this repo has no description
1import { afterEach, beforeEach, describe, expect, it, vi } 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}/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: { kty: "EC", crv: "P-256", x: "test", y: "test", d: "test" }, 355 publicJwk: { kty: "EC", crv: "P-256", x: "test", y: "test" }, 356 thumbprint: "test-thumb", 357 createdAt: Date.now() - 25 * 60 * 60 * 1000, 358 }; 359 localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored)); 360 361 const loaded = await loadDPoPKey(); 362 363 expect(loaded).toBeNull(); 364 expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 365 }); 366 367 it("returns null and clears storage for invalid data", async () => { 368 localStorage.setItem(DPOP_KEY_STORAGE, "not-valid-json"); 369 370 const loaded = await loadDPoPKey(); 371 372 expect(loaded).toBeNull(); 373 expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 374 }); 375 }); 376 377 describe("clearDPoPKey", () => { 378 it("removes key from localStorage", async () => { 379 const keyPair = await generateDPoPKeyPair(); 380 await saveDPoPKey(keyPair); 381 expect(localStorage.getItem(DPOP_KEY_STORAGE)).not.toBeNull(); 382 383 clearDPoPKey(); 384 385 expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 386 }); 387 388 it("does not throw when nothing to clear", () => { 389 expect(() => clearDPoPKey()).not.toThrow(); 390 }); 391 }); 392 }); 393 394 describe("prepareWebAuthnCreationOptions", () => { 395 it("decodes challenge from base64url", () => { 396 const options = { 397 publicKey: { 398 challenge: "dGVzdC1jaGFsbGVuZ2U", 399 user: { 400 id: "dXNlci1pZA", 401 name: "test@example.com", 402 displayName: "Test User", 403 }, 404 excludeCredentials: [], 405 rp: { name: "Test" }, 406 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 407 }, 408 }; 409 410 const prepared = prepareWebAuthnCreationOptions(options); 411 412 expect(prepared.challenge).toBeInstanceOf(Uint8Array); 413 expect(new TextDecoder().decode(prepared.challenge as Uint8Array)).toBe( 414 "test-challenge", 415 ); 416 }); 417 418 it("decodes user.id from base64url", () => { 419 const options = { 420 publicKey: { 421 challenge: "Y2hhbGxlbmdl", 422 user: { 423 id: "dXNlci1pZA", 424 name: "test@example.com", 425 displayName: "Test User", 426 }, 427 excludeCredentials: [], 428 rp: { name: "Test" }, 429 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 430 }, 431 }; 432 433 const prepared = prepareWebAuthnCreationOptions(options); 434 435 expect(prepared.user?.id).toBeInstanceOf(Uint8Array); 436 expect(new TextDecoder().decode(prepared.user?.id as Uint8Array)).toBe( 437 "user-id", 438 ); 439 }); 440 441 it("decodes excludeCredentials ids from base64url", () => { 442 const options = { 443 publicKey: { 444 challenge: "Y2hhbGxlbmdl", 445 user: { 446 id: "dXNlcg", 447 name: "test@example.com", 448 displayName: "Test User", 449 }, 450 excludeCredentials: [ 451 { id: "Y3JlZDE", type: "public-key" }, 452 { id: "Y3JlZDI", type: "public-key" }, 453 ], 454 rp: { name: "Test" }, 455 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 456 }, 457 }; 458 459 const prepared = prepareWebAuthnCreationOptions(options); 460 461 expect(prepared.excludeCredentials).toHaveLength(2); 462 expect( 463 new TextDecoder().decode( 464 prepared.excludeCredentials![0].id as Uint8Array, 465 ), 466 ).toBe("cred1"); 467 expect( 468 new TextDecoder().decode( 469 prepared.excludeCredentials![1].id as Uint8Array, 470 ), 471 ).toBe("cred2"); 472 }); 473 474 it("handles empty excludeCredentials", () => { 475 const options = { 476 publicKey: { 477 challenge: "Y2hhbGxlbmdl", 478 user: { 479 id: "dXNlcg", 480 name: "test@example.com", 481 displayName: "Test User", 482 }, 483 rp: { name: "Test" }, 484 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 485 }, 486 }; 487 488 const prepared = prepareWebAuthnCreationOptions(options); 489 490 expect(prepared.excludeCredentials).toEqual([]); 491 }); 492 493 it("preserves other user properties", () => { 494 const options = { 495 publicKey: { 496 challenge: "Y2hhbGxlbmdl", 497 user: { 498 id: "dXNlcg", 499 name: "test@example.com", 500 displayName: "Test User", 501 }, 502 excludeCredentials: [], 503 rp: { name: "Test" }, 504 pubKeyCredParams: [{ type: "public-key", alg: -7 }], 505 }, 506 }; 507 508 const prepared = prepareWebAuthnCreationOptions(options); 509 510 expect(prepared.user?.name).toBe("test@example.com"); 511 expect(prepared.user?.displayName).toBe("Test User"); 512 }); 513 }); 514});