Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun
at main 239 lines 7.1 kB view raw
1import { beforeEach, describe, expect, it, vi } from "vitest"; 2import { 3 checkForOAuthCallback, 4 clearOAuthCallbackParams, 5 generateCodeChallenge, 6 generateCodeVerifier, 7 generateState, 8 saveOAuthState, 9} from "../lib/oauth.ts"; 10 11describe("OAuth utilities", () => { 12 beforeEach(() => { 13 sessionStorage.clear(); 14 vi.restoreAllMocks(); 15 }); 16 17 describe("generateState", () => { 18 it("generates a 64-character hex string", () => { 19 const state = generateState(); 20 expect(state).toMatch(/^[0-9a-f]{64}$/); 21 }); 22 23 it("generates unique values", () => { 24 const states = new Set( 25 Array.from({ length: 100 }, () => generateState()), 26 ); 27 expect(states.size).toBe(100); 28 }); 29 }); 30 31 describe("generateCodeVerifier", () => { 32 it("generates a 64-character hex string", () => { 33 const verifier = generateCodeVerifier(); 34 expect(verifier).toMatch(/^[0-9a-f]{64}$/); 35 }); 36 37 it("generates unique values", () => { 38 const verifiers = new Set( 39 Array.from({ length: 100 }, () => generateCodeVerifier()), 40 ); 41 expect(verifiers.size).toBe(100); 42 }); 43 }); 44 45 describe("generateCodeChallenge", () => { 46 it("generates a base64url-encoded SHA-256 hash", async () => { 47 const verifier = "test-verifier-12345"; 48 const challenge = await generateCodeChallenge(verifier); 49 50 expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); 51 expect(challenge).not.toContain("+"); 52 expect(challenge).not.toContain("/"); 53 expect(challenge).not.toContain("="); 54 }); 55 56 it("produces consistent output for same input", async () => { 57 const verifier = "consistent-test-verifier"; 58 const challenge1 = await generateCodeChallenge(verifier); 59 const challenge2 = await generateCodeChallenge(verifier); 60 61 expect(challenge1).toBe(challenge2); 62 }); 63 64 it("produces different output for different inputs", async () => { 65 const challenge1 = await generateCodeChallenge("verifier-1"); 66 const challenge2 = await generateCodeChallenge("verifier-2"); 67 68 expect(challenge1).not.toBe(challenge2); 69 }); 70 71 it("produces correct S256 challenge", async () => { 72 const challenge = await generateCodeChallenge( 73 "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", 74 ); 75 expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); 76 }); 77 }); 78 79 describe("saveOAuthState", () => { 80 it("stores state and verifier in sessionStorage", () => { 81 saveOAuthState({ state: "test-state", codeVerifier: "test-verifier" }); 82 83 expect(sessionStorage.getItem("tranquil_pds_oauth_state")).toBe( 84 "test-state", 85 ); 86 expect(sessionStorage.getItem("tranquil_pds_oauth_verifier")).toBe( 87 "test-verifier", 88 ); 89 }); 90 }); 91 92 describe("checkForOAuthCallback", () => { 93 it("returns null when no code/state in URL", () => { 94 Object.defineProperty(globalThis.location, "search", { 95 value: "", 96 writable: true, 97 configurable: true, 98 }); 99 Object.defineProperty(globalThis.location, "pathname", { 100 value: "/app/", 101 writable: true, 102 configurable: true, 103 }); 104 105 expect(checkForOAuthCallback()).toBeNull(); 106 }); 107 108 it("returns code and state when present in URL", () => { 109 Object.defineProperty(globalThis.location, "search", { 110 value: "?code=auth-code-123&state=state-456", 111 writable: true, 112 configurable: true, 113 }); 114 Object.defineProperty(globalThis.location, "pathname", { 115 value: "/app/", 116 writable: true, 117 configurable: true, 118 }); 119 120 const result = checkForOAuthCallback(); 121 expect(result).toEqual({ code: "auth-code-123", state: "state-456" }); 122 }); 123 124 it("returns null on migrate path even with code/state", () => { 125 Object.defineProperty(globalThis.location, "search", { 126 value: "?code=auth-code-123&state=state-456", 127 writable: true, 128 configurable: true, 129 }); 130 Object.defineProperty(globalThis.location, "pathname", { 131 value: "/app/migrate", 132 writable: true, 133 configurable: true, 134 }); 135 136 expect(checkForOAuthCallback()).toBeNull(); 137 }); 138 139 it("returns null when only code is present", () => { 140 Object.defineProperty(globalThis.location, "search", { 141 value: "?code=auth-code-123", 142 writable: true, 143 configurable: true, 144 }); 145 Object.defineProperty(globalThis.location, "pathname", { 146 value: "/app/", 147 writable: true, 148 configurable: true, 149 }); 150 151 expect(checkForOAuthCallback()).toBeNull(); 152 }); 153 154 it("returns null when only state is present", () => { 155 Object.defineProperty(globalThis.location, "search", { 156 value: "?state=state-456", 157 writable: true, 158 configurable: true, 159 }); 160 Object.defineProperty(globalThis.location, "pathname", { 161 value: "/app/", 162 writable: true, 163 configurable: true, 164 }); 165 166 expect(checkForOAuthCallback()).toBeNull(); 167 }); 168 }); 169 170 describe("clearOAuthCallbackParams", () => { 171 it("removes query params from URL", () => { 172 const replaceStateSpy = vi.spyOn(globalThis.history, "replaceState"); 173 174 Object.defineProperty(globalThis.location, "href", { 175 value: "http://localhost:3000/app/?code=123&state=456", 176 writable: true, 177 configurable: true, 178 }); 179 180 clearOAuthCallbackParams(); 181 182 expect(replaceStateSpy).toHaveBeenCalled(); 183 const callArgs = replaceStateSpy.mock.calls[0]; 184 expect(callArgs[0]).toEqual({}); 185 expect(callArgs[1]).toBe(""); 186 const urlString = callArgs[2] as string; 187 expect(urlString).toBe("http://localhost:3000/app/"); 188 expect(urlString).not.toContain("?"); 189 expect(urlString).not.toContain("code="); 190 expect(urlString).not.toContain("state="); 191 }); 192 }); 193}); 194 195describe("DPoP proof generation", () => { 196 it("base64url encoding produces valid output", async () => { 197 const testData = new Uint8Array([72, 101, 108, 108, 111]); 198 const _buffer = testData.buffer; 199 200 const binary = Array.from(testData, (byte) => String.fromCharCode(byte)) 201 .join(""); 202 const base64url = btoa(binary) 203 .replace(/\+/g, "-") 204 .replace(/\//g, "_") 205 .replace(/=+$/, ""); 206 207 expect(base64url).toBe("SGVsbG8"); 208 expect(base64url).not.toContain("+"); 209 expect(base64url).not.toContain("/"); 210 expect(base64url).not.toContain("="); 211 }); 212 213 it("JWK thumbprint uses correct key ordering for EC keys", () => { 214 const jwk = { 215 kty: "EC", 216 crv: "P-256", 217 x: "test-x", 218 y: "test-y", 219 }; 220 221 const canonical = JSON.stringify({ 222 crv: jwk.crv, 223 kty: jwk.kty, 224 x: jwk.x, 225 y: jwk.y, 226 }); 227 228 expect(canonical).toBe( 229 '{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}', 230 ); 231 232 const keys = Object.keys(JSON.parse(canonical)); 233 expect(keys).toEqual(["crv", "kty", "x", "y"]); 234 235 for (let i = 1; i < keys.length; i++) { 236 expect(keys[i - 1] < keys[i]).toBe(true); 237 } 238 }); 239});