this repo has no description
at main 16 kB view raw
1import { beforeEach, describe, expect, it } from "vitest"; 2import { 3 base64UrlDecode, 4 base64UrlEncode, 5 buildOAuthAuthorizationUrl, 6 clearDPoPKey, 7 generateDPoPKeyPair, 8 generateOAuthState, 9 generatePKCE, 10 getMigrationOAuthClientId, 11 getMigrationOAuthRedirectUri, 12 loadDPoPKey, 13 prepareWebAuthnCreationOptions, 14 saveDPoPKey, 15} from "../../lib/migration/atproto-client"; 16import type { OAuthServerMetadata } from "../../lib/migration/types"; 17 18const DPOP_KEY_STORAGE = "migration_dpop_key"; 19 20describe("migration/atproto-client", () => { 21 beforeEach(() => { 22 localStorage.removeItem(DPOP_KEY_STORAGE); 23 }); 24 25 describe("base64UrlEncode", () => { 26 it("encodes empty buffer", () => { 27 const result = base64UrlEncode(new Uint8Array([])); 28 expect(result).toBe(""); 29 }); 30 31 it("encodes simple data", () => { 32 const data = new TextEncoder().encode("hello"); 33 const result = base64UrlEncode(data); 34 expect(result).toBe("aGVsbG8"); 35 }); 36 37 it("uses URL-safe characters (no +, /, or =)", () => { 38 const data = new Uint8Array([251, 255, 254]); 39 const result = base64UrlEncode(data); 40 expect(result).not.toContain("+"); 41 expect(result).not.toContain("/"); 42 expect(result).not.toContain("="); 43 }); 44 45 it("replaces + with -", () => { 46 const data = new Uint8Array([251]); 47 const result = base64UrlEncode(data); 48 expect(result).toContain("-"); 49 }); 50 51 it("replaces / with _", () => { 52 const data = new Uint8Array([255]); 53 const result = base64UrlEncode(data); 54 expect(result).toContain("_"); 55 }); 56 57 it("accepts ArrayBuffer", () => { 58 const arrayBuffer = new ArrayBuffer(4); 59 const view = new Uint8Array(arrayBuffer); 60 view[0] = 116; // t 61 view[1] = 101; // e 62 view[2] = 115; // s 63 view[3] = 116; // t 64 const result = base64UrlEncode(arrayBuffer); 65 expect(result).toBe("dGVzdA"); 66 }); 67 }); 68 69 describe("base64UrlDecode", () => { 70 it("decodes empty string", () => { 71 const result = base64UrlDecode(""); 72 expect(result.length).toBe(0); 73 }); 74 75 it("decodes URL-safe base64", () => { 76 const result = base64UrlDecode("aGVsbG8"); 77 expect(new TextDecoder().decode(result)).toBe("hello"); 78 }); 79 80 it("handles - and _ characters", () => { 81 const encoded = base64UrlEncode(new Uint8Array([251, 255, 254])); 82 const decoded = base64UrlDecode(encoded); 83 expect(decoded).toEqual(new Uint8Array([251, 255, 254])); 84 }); 85 86 it("is inverse of base64UrlEncode", () => { 87 const original = new Uint8Array([0, 1, 2, 255, 254, 253]); 88 const encoded = base64UrlEncode(original); 89 const decoded = base64UrlDecode(encoded); 90 expect(decoded).toEqual(original); 91 }); 92 93 it("handles missing padding", () => { 94 const result = base64UrlDecode("YQ"); 95 expect(new TextDecoder().decode(result)).toBe("a"); 96 }); 97 }); 98 99 describe("generateOAuthState", () => { 100 it("generates a non-empty string", () => { 101 const state = generateOAuthState(); 102 expect(state).toBeTruthy(); 103 expect(typeof state).toBe("string"); 104 }); 105 106 it("generates URL-safe characters only", () => { 107 const state = generateOAuthState(); 108 expect(state).toMatch(/^[A-Za-z0-9_-]+$/); 109 }); 110 111 it("generates different values each time", () => { 112 const state1 = generateOAuthState(); 113 const state2 = generateOAuthState(); 114 expect(state1).not.toBe(state2); 115 }); 116 }); 117 118 describe("generatePKCE", () => { 119 it("generates code_verifier and code_challenge", async () => { 120 const pkce = await generatePKCE(); 121 expect(pkce.codeVerifier).toBeTruthy(); 122 expect(pkce.codeChallenge).toBeTruthy(); 123 }); 124 125 it("generates URL-safe code_verifier", async () => { 126 const pkce = await generatePKCE(); 127 expect(pkce.codeVerifier).toMatch(/^[A-Za-z0-9_-]+$/); 128 }); 129 130 it("generates URL-safe code_challenge", async () => { 131 const pkce = await generatePKCE(); 132 expect(pkce.codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/); 133 }); 134 135 it("code_challenge is SHA-256 hash of code_verifier", async () => { 136 const pkce = await generatePKCE(); 137 138 const encoder = new TextEncoder(); 139 const data = encoder.encode(pkce.codeVerifier); 140 const digest = await crypto.subtle.digest("SHA-256", data); 141 const expectedChallenge = base64UrlEncode(new Uint8Array(digest)); 142 143 expect(pkce.codeChallenge).toBe(expectedChallenge); 144 }); 145 146 it("generates different values each time", async () => { 147 const pkce1 = await generatePKCE(); 148 const pkce2 = await generatePKCE(); 149 expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier); 150 expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge); 151 }); 152 }); 153 154 describe("buildOAuthAuthorizationUrl", () => { 155 const mockMetadata: OAuthServerMetadata = { 156 issuer: "https://bsky.social", 157 authorization_endpoint: "https://bsky.social/oauth/authorize", 158 token_endpoint: "https://bsky.social/oauth/token", 159 scopes_supported: ["atproto"], 160 response_types_supported: ["code"], 161 grant_types_supported: ["authorization_code"], 162 code_challenge_methods_supported: ["S256"], 163 dpop_signing_alg_values_supported: ["ES256"], 164 }; 165 166 it("builds authorization URL with required parameters", () => { 167 const url = buildOAuthAuthorizationUrl(mockMetadata, { 168 clientId: "https://example.com/oauth/client-metadata.json", 169 redirectUri: "https://example.com/migrate", 170 codeChallenge: "abc123", 171 state: "state123", 172 }); 173 174 const parsed = new URL(url); 175 expect(parsed.origin).toBe("https://bsky.social"); 176 expect(parsed.pathname).toBe("/oauth/authorize"); 177 expect(parsed.searchParams.get("response_type")).toBe("code"); 178 expect(parsed.searchParams.get("client_id")).toBe( 179 "https://example.com/oauth/client-metadata.json", 180 ); 181 expect(parsed.searchParams.get("redirect_uri")).toBe( 182 "https://example.com/migrate", 183 ); 184 expect(parsed.searchParams.get("code_challenge")).toBe("abc123"); 185 expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); 186 expect(parsed.searchParams.get("state")).toBe("state123"); 187 }); 188 189 it("includes default scope when not specified", () => { 190 const url = buildOAuthAuthorizationUrl(mockMetadata, { 191 clientId: "client", 192 redirectUri: "redirect", 193 codeChallenge: "challenge", 194 state: "state", 195 }); 196 197 const parsed = new URL(url); 198 expect(parsed.searchParams.get("scope")).toBe("atproto"); 199 }); 200 201 it("includes custom scope when specified", () => { 202 const url = buildOAuthAuthorizationUrl(mockMetadata, { 203 clientId: "client", 204 redirectUri: "redirect", 205 codeChallenge: "challenge", 206 state: "state", 207 scope: "atproto identity:*", 208 }); 209 210 const parsed = new URL(url); 211 expect(parsed.searchParams.get("scope")).toBe("atproto identity:*"); 212 }); 213 214 it("includes dpop_jkt when specified", () => { 215 const url = buildOAuthAuthorizationUrl(mockMetadata, { 216 clientId: "client", 217 redirectUri: "redirect", 218 codeChallenge: "challenge", 219 state: "state", 220 dpopJkt: "dpop-thumbprint-123", 221 }); 222 223 const parsed = new URL(url); 224 expect(parsed.searchParams.get("dpop_jkt")).toBe("dpop-thumbprint-123"); 225 }); 226 227 it("includes login_hint when specified", () => { 228 const url = buildOAuthAuthorizationUrl(mockMetadata, { 229 clientId: "client", 230 redirectUri: "redirect", 231 codeChallenge: "challenge", 232 state: "state", 233 loginHint: "alice.bsky.social", 234 }); 235 236 const parsed = new URL(url); 237 expect(parsed.searchParams.get("login_hint")).toBe("alice.bsky.social"); 238 }); 239 240 it("omits optional params when not specified", () => { 241 const url = buildOAuthAuthorizationUrl(mockMetadata, { 242 clientId: "client", 243 redirectUri: "redirect", 244 codeChallenge: "challenge", 245 state: "state", 246 }); 247 248 const parsed = new URL(url); 249 expect(parsed.searchParams.has("dpop_jkt")).toBe(false); 250 expect(parsed.searchParams.has("login_hint")).toBe(false); 251 }); 252 }); 253 254 describe("getMigrationOAuthClientId", () => { 255 it("returns client metadata URL based on origin", () => { 256 const clientId = getMigrationOAuthClientId(); 257 expect(clientId).toBe( 258 `${globalThis.location.origin}/oauth/client-metadata.json`, 259 ); 260 }); 261 }); 262 263 describe("getMigrationOAuthRedirectUri", () => { 264 it("returns migrate path based on origin", () => { 265 const redirectUri = getMigrationOAuthRedirectUri(); 266 expect(redirectUri).toBe(`${globalThis.location.origin}/app/migrate`); 267 }); 268 }); 269 270 describe("DPoP key management", () => { 271 describe("generateDPoPKeyPair", () => { 272 it("generates a valid key pair", async () => { 273 const keyPair = await generateDPoPKeyPair(); 274 275 expect(keyPair.privateKey).toBeDefined(); 276 expect(keyPair.publicKey).toBeDefined(); 277 expect(keyPair.jwk).toBeDefined(); 278 expect(keyPair.thumbprint).toBeDefined(); 279 }); 280 281 it("generates ES256 (P-256) keys", async () => { 282 const keyPair = await generateDPoPKeyPair(); 283 284 expect(keyPair.jwk.kty).toBe("EC"); 285 expect(keyPair.jwk.crv).toBe("P-256"); 286 expect(keyPair.jwk.x).toBeDefined(); 287 expect(keyPair.jwk.y).toBeDefined(); 288 }); 289 290 it("generates URL-safe thumbprint", async () => { 291 const keyPair = await generateDPoPKeyPair(); 292 293 expect(keyPair.thumbprint).toMatch(/^[A-Za-z0-9_-]+$/); 294 }); 295 296 it("generates different keys each time", async () => { 297 const keyPair1 = await generateDPoPKeyPair(); 298 const keyPair2 = await generateDPoPKeyPair(); 299 300 expect(keyPair1.thumbprint).not.toBe(keyPair2.thumbprint); 301 }); 302 }); 303 304 describe("saveDPoPKey", () => { 305 it("saves key pair to localStorage", async () => { 306 const keyPair = await generateDPoPKeyPair(); 307 308 await saveDPoPKey(keyPair); 309 310 expect(localStorage.getItem(DPOP_KEY_STORAGE)).not.toBeNull(); 311 }); 312 313 it("stores private and public JWK", async () => { 314 const keyPair = await generateDPoPKeyPair(); 315 316 await saveDPoPKey(keyPair); 317 318 const stored = JSON.parse(localStorage.getItem(DPOP_KEY_STORAGE)!); 319 expect(stored.privateJwk).toBeDefined(); 320 expect(stored.publicJwk).toBeDefined(); 321 expect(stored.thumbprint).toBe(keyPair.thumbprint); 322 }); 323 324 it("stores creation timestamp", async () => { 325 const before = Date.now(); 326 const keyPair = await generateDPoPKeyPair(); 327 await saveDPoPKey(keyPair); 328 const after = Date.now(); 329 330 const stored = JSON.parse(localStorage.getItem(DPOP_KEY_STORAGE)!); 331 expect(stored.createdAt).toBeGreaterThanOrEqual(before); 332 expect(stored.createdAt).toBeLessThanOrEqual(after); 333 }); 334 }); 335 336 describe("loadDPoPKey", () => { 337 it("returns null when no key stored", async () => { 338 const keyPair = await loadDPoPKey(); 339 expect(keyPair).toBeNull(); 340 }); 341 342 it("loads stored key pair", async () => { 343 const original = await generateDPoPKeyPair(); 344 await saveDPoPKey(original); 345 346 const loaded = await loadDPoPKey(); 347 348 expect(loaded).not.toBeNull(); 349 expect(loaded!.thumbprint).toBe(original.thumbprint); 350 }); 351 352 it("returns null and clears storage for expired key (> 24 hours)", async () => { 353 const stored = { 354 privateJwk: { 355 kty: "EC", 356 crv: "P-256", 357 x: "test", 358 y: "test", 359 d: "test", 360 }, 361 publicJwk: { kty: "EC", crv: "P-256", x: "test", y: "test" }, 362 thumbprint: "test-thumb", 363 createdAt: Date.now() - 25 * 60 * 60 * 1000, 364 }; 365 localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored)); 366 367 const loaded = await loadDPoPKey(); 368 369 expect(loaded).toBeNull(); 370 expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 371 }); 372 373 it("returns null and clears storage for invalid data", async () => { 374 localStorage.setItem(DPOP_KEY_STORAGE, "not-valid-json"); 375 376 const loaded = await loadDPoPKey(); 377 378 expect(loaded).toBeNull(); 379 expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 380 }); 381 }); 382 383 describe("clearDPoPKey", () => { 384 it("removes key from localStorage", async () => { 385 const keyPair = await generateDPoPKeyPair(); 386 await saveDPoPKey(keyPair); 387 expect(localStorage.getItem(DPOP_KEY_STORAGE)).not.toBeNull(); 388 389 clearDPoPKey(); 390 391 expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 392 }); 393 394 it("does not throw when nothing to clear", () => { 395 expect(() => clearDPoPKey()).not.toThrow(); 396 }); 397 }); 398 }); 399 400 describe("prepareWebAuthnCreationOptions", () => { 401 it("decodes challenge from base64url", () => { 402 const options = { 403 publicKey: { 404 challenge: "dGVzdC1jaGFsbGVuZ2U", 405 user: { 406 id: "dXNlci1pZA", 407 name: "test@example.com", 408 displayName: "Test User", 409 }, 410 excludeCredentials: [], 411 rp: { name: "Test" }, 412 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 413 }, 414 }; 415 416 const prepared = prepareWebAuthnCreationOptions(options); 417 418 expect(prepared.challenge).toBeInstanceOf(Uint8Array); 419 expect(new TextDecoder().decode(prepared.challenge as Uint8Array)).toBe( 420 "test-challenge", 421 ); 422 }); 423 424 it("decodes user.id from base64url", () => { 425 const options = { 426 publicKey: { 427 challenge: "Y2hhbGxlbmdl", 428 user: { 429 id: "dXNlci1pZA", 430 name: "test@example.com", 431 displayName: "Test User", 432 }, 433 excludeCredentials: [], 434 rp: { name: "Test" }, 435 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 436 }, 437 }; 438 439 const prepared = prepareWebAuthnCreationOptions(options); 440 441 expect(prepared.user?.id).toBeInstanceOf(Uint8Array); 442 expect(new TextDecoder().decode(prepared.user?.id as Uint8Array)).toBe( 443 "user-id", 444 ); 445 }); 446 447 it("decodes excludeCredentials ids from base64url", () => { 448 const options = { 449 publicKey: { 450 challenge: "Y2hhbGxlbmdl", 451 user: { 452 id: "dXNlcg", 453 name: "test@example.com", 454 displayName: "Test User", 455 }, 456 excludeCredentials: [ 457 { id: "Y3JlZDE", type: "public-key" }, 458 { id: "Y3JlZDI", type: "public-key" }, 459 ], 460 rp: { name: "Test" }, 461 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 462 }, 463 }; 464 465 const prepared = prepareWebAuthnCreationOptions(options); 466 467 expect(prepared.excludeCredentials).toHaveLength(2); 468 expect( 469 new TextDecoder().decode( 470 prepared.excludeCredentials![0].id as Uint8Array, 471 ), 472 ).toBe("cred1"); 473 expect( 474 new TextDecoder().decode( 475 prepared.excludeCredentials![1].id as Uint8Array, 476 ), 477 ).toBe("cred2"); 478 }); 479 480 it("handles empty excludeCredentials", () => { 481 const options = { 482 publicKey: { 483 challenge: "Y2hhbGxlbmdl", 484 user: { 485 id: "dXNlcg", 486 name: "test@example.com", 487 displayName: "Test User", 488 }, 489 rp: { name: "Test" }, 490 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 491 }, 492 }; 493 494 const prepared = prepareWebAuthnCreationOptions(options); 495 496 expect(prepared.excludeCredentials).toEqual([]); 497 }); 498 499 it("preserves other user properties", () => { 500 const options = { 501 publicKey: { 502 challenge: "Y2hhbGxlbmdl", 503 user: { 504 id: "dXNlcg", 505 name: "test@example.com", 506 displayName: "Test User", 507 }, 508 excludeCredentials: [], 509 rp: { name: "Test" }, 510 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 511 }, 512 }; 513 514 const prepared = prepareWebAuthnCreationOptions(options); 515 516 expect(prepared.user?.name).toBe("test@example.com"); 517 expect(prepared.user?.displayName).toBe("Test User"); 518 }); 519 }); 520});