this repo has no description
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});