this repo has no description
1import type {
2 AccountStatus,
3 BlobRef,
4 CompletePasskeySetupResponse,
5 CreateAccountParams,
6 CreatePasskeyAccountParams,
7 DidCredentials,
8 DidDocument,
9 OAuthServerMetadata,
10 OAuthTokenResponse,
11 PasskeyAccountSetup,
12 PlcOperation,
13 Preferences,
14 ServerDescription,
15 Session,
16 StartPasskeyRegistrationResponse,
17} from "./types";
18
19function apiLog(
20 method: string,
21 endpoint: string,
22 data?: Record<string, unknown>,
23) {
24 const timestamp = new Date().toISOString();
25 const msg = `[API ${timestamp}] ${method} ${endpoint}`;
26 if (data) {
27 console.log(msg, JSON.stringify(data, null, 2));
28 } else {
29 console.log(msg);
30 }
31}
32
33export class AtprotoClient {
34 private baseUrl: string;
35 private accessToken: string | null = null;
36 private dpopKeyPair: DPoPKeyPair | null = null;
37 private dpopNonce: string | null = null;
38
39 constructor(pdsUrl: string) {
40 this.baseUrl = pdsUrl.replace(/\/$/, "");
41 }
42
43 setAccessToken(token: string | null) {
44 this.accessToken = token;
45 }
46
47 getAccessToken(): string | null {
48 return this.accessToken;
49 }
50
51 setDPoPKeyPair(keyPair: DPoPKeyPair | null) {
52 this.dpopKeyPair = keyPair;
53 }
54
55 private async xrpc<T>(
56 method: string,
57 options?: {
58 httpMethod?: "GET" | "POST";
59 params?: Record<string, string>;
60 body?: unknown;
61 authToken?: string;
62 rawBody?: Uint8Array | Blob;
63 contentType?: string;
64 },
65 ): Promise<T> {
66 const {
67 httpMethod = "GET",
68 params,
69 body,
70 authToken,
71 rawBody,
72 contentType,
73 } = options ?? {};
74
75 let url = `${this.baseUrl}/xrpc/${method}`;
76 if (params) {
77 const searchParams = new URLSearchParams(params);
78 url += `?${searchParams}`;
79 }
80
81 const makeRequest = async (nonce?: string): Promise<Response> => {
82 const headers: Record<string, string> = {};
83 const token = authToken ?? this.accessToken;
84 if (token) {
85 if (this.dpopKeyPair) {
86 headers["Authorization"] = `DPoP ${token}`;
87 const tokenHash = await computeAccessTokenHash(token);
88 const dpopProof = await createDPoPProof(
89 this.dpopKeyPair,
90 httpMethod,
91 url.split("?")[0],
92 nonce,
93 tokenHash,
94 );
95 headers["DPoP"] = dpopProof;
96 } else {
97 headers["Authorization"] = `Bearer ${token}`;
98 }
99 }
100
101 let requestBody: BodyInit | undefined;
102 if (rawBody) {
103 headers["Content-Type"] = contentType ?? "application/octet-stream";
104 requestBody = rawBody;
105 } else if (body) {
106 headers["Content-Type"] = "application/json";
107 requestBody = JSON.stringify(body);
108 } else if (httpMethod === "POST") {
109 headers["Content-Type"] = "application/json";
110 }
111
112 return fetch(url, {
113 method: httpMethod,
114 headers,
115 body: requestBody,
116 });
117 };
118
119 let res = await makeRequest(this.dpopNonce ?? undefined);
120
121 if (!res.ok && this.dpopKeyPair) {
122 const dpopNonce = res.headers.get("DPoP-Nonce");
123 if (dpopNonce && dpopNonce !== this.dpopNonce) {
124 this.dpopNonce = dpopNonce;
125 res = await makeRequest(dpopNonce);
126 }
127 }
128
129 if (!res.ok) {
130 const err = await res.json().catch(() => ({
131 error: "Unknown",
132 message: res.statusText,
133 }));
134 const error = new Error(err.message || err.error || res.statusText) as Error & {
135 status: number;
136 error: string;
137 };
138 error.status = res.status;
139 error.error = err.error;
140 throw error;
141 }
142
143 const newNonce = res.headers.get("DPoP-Nonce");
144 if (newNonce) {
145 this.dpopNonce = newNonce;
146 }
147
148 const responseContentType = res.headers.get("content-type") ?? "";
149 if (responseContentType.includes("application/json")) {
150 return res.json();
151 }
152 return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T;
153 }
154
155 async login(
156 identifier: string,
157 password: string,
158 authFactorToken?: string,
159 ): Promise<Session> {
160 const body: Record<string, string> = { identifier, password };
161 if (authFactorToken) {
162 body.authFactorToken = authFactorToken;
163 }
164
165 const session = await this.xrpc<Session>(
166 "com.atproto.server.createSession",
167 {
168 httpMethod: "POST",
169 body,
170 },
171 );
172
173 this.accessToken = session.accessJwt;
174 return session;
175 }
176
177 async refreshSession(refreshJwt: string): Promise<Session> {
178 const session = await this.xrpc<Session>(
179 "com.atproto.server.refreshSession",
180 {
181 httpMethod: "POST",
182 authToken: refreshJwt,
183 },
184 );
185 this.accessToken = session.accessJwt;
186 return session;
187 }
188
189 async describeServer(): Promise<ServerDescription> {
190 return this.xrpc<ServerDescription>("com.atproto.server.describeServer");
191 }
192
193 async getServiceAuth(
194 aud: string,
195 lxm?: string,
196 ): Promise<{ token: string }> {
197 const params: Record<string, string> = { aud };
198 if (lxm) {
199 params.lxm = lxm;
200 }
201 return this.xrpc("com.atproto.server.getServiceAuth", { params });
202 }
203
204 async getRepo(did: string): Promise<Uint8Array> {
205 return this.xrpc("com.atproto.sync.getRepo", {
206 params: { did },
207 });
208 }
209
210 async listBlobs(
211 did: string,
212 cursor?: string,
213 limit = 100,
214 ): Promise<{ cids: string[]; cursor?: string }> {
215 const params: Record<string, string> = { did, limit: String(limit) };
216 if (cursor) {
217 params.cursor = cursor;
218 }
219 return this.xrpc("com.atproto.sync.listBlobs", { params });
220 }
221
222 async getBlob(did: string, cid: string): Promise<Uint8Array> {
223 return this.xrpc("com.atproto.sync.getBlob", {
224 params: { did, cid },
225 });
226 }
227
228 async uploadBlob(
229 data: Uint8Array,
230 mimeType: string,
231 ): Promise<{ blob: BlobRef }> {
232 return this.xrpc("com.atproto.repo.uploadBlob", {
233 httpMethod: "POST",
234 rawBody: data,
235 contentType: mimeType,
236 });
237 }
238
239 async getPreferences(): Promise<Preferences> {
240 return this.xrpc("app.bsky.actor.getPreferences");
241 }
242
243 async putPreferences(preferences: Preferences): Promise<void> {
244 await this.xrpc("app.bsky.actor.putPreferences", {
245 httpMethod: "POST",
246 body: preferences,
247 });
248 }
249
250 async createAccount(
251 params: CreateAccountParams,
252 serviceToken?: string,
253 ): Promise<Session> {
254 const headers: Record<string, string> = {
255 "Content-Type": "application/json",
256 };
257 if (serviceToken) {
258 headers["Authorization"] = `Bearer ${serviceToken}`;
259 }
260
261 const res = await fetch(
262 `${this.baseUrl}/xrpc/com.atproto.server.createAccount`,
263 {
264 method: "POST",
265 headers,
266 body: JSON.stringify(params),
267 },
268 );
269
270 if (!res.ok) {
271 const err = await res.json().catch(() => ({
272 error: "Unknown",
273 message: res.statusText,
274 }));
275 const error = new Error(err.message || err.error || res.statusText) as Error & {
276 status: number;
277 error: string;
278 };
279 error.status = res.status;
280 error.error = err.error;
281 throw error;
282 }
283
284 const session = (await res.json()) as Session;
285 this.accessToken = session.accessJwt;
286 return session;
287 }
288
289 async importRepo(car: Uint8Array): Promise<void> {
290 await this.xrpc("com.atproto.repo.importRepo", {
291 httpMethod: "POST",
292 rawBody: car,
293 contentType: "application/vnd.ipld.car",
294 });
295 }
296
297 async listMissingBlobs(
298 cursor?: string,
299 limit = 100,
300 ): Promise<
301 { blobs: Array<{ cid: string; recordUri: string }>; cursor?: string }
302 > {
303 const params: Record<string, string> = { limit: String(limit) };
304 if (cursor) {
305 params.cursor = cursor;
306 }
307 return this.xrpc("com.atproto.repo.listMissingBlobs", { params });
308 }
309
310 async requestPlcOperationSignature(): Promise<void> {
311 await this.xrpc("com.atproto.identity.requestPlcOperationSignature", {
312 httpMethod: "POST",
313 });
314 }
315
316 async signPlcOperation(params: {
317 token?: string;
318 rotationKeys?: string[];
319 alsoKnownAs?: string[];
320 verificationMethods?: { atproto?: string };
321 services?: { atproto_pds?: { type: string; endpoint: string } };
322 }): Promise<{ operation: PlcOperation }> {
323 return this.xrpc("com.atproto.identity.signPlcOperation", {
324 httpMethod: "POST",
325 body: params,
326 });
327 }
328
329 async submitPlcOperation(operation: PlcOperation): Promise<void> {
330 apiLog(
331 "POST",
332 `${this.baseUrl}/xrpc/com.atproto.identity.submitPlcOperation`,
333 {
334 operationType: operation.type,
335 operationPrev: operation.prev,
336 },
337 );
338 const start = Date.now();
339 await this.xrpc("com.atproto.identity.submitPlcOperation", {
340 httpMethod: "POST",
341 body: { operation },
342 });
343 apiLog(
344 "POST",
345 `${this.baseUrl}/xrpc/com.atproto.identity.submitPlcOperation COMPLETE`,
346 {
347 durationMs: Date.now() - start,
348 },
349 );
350 }
351
352 async getRecommendedDidCredentials(): Promise<DidCredentials> {
353 return this.xrpc("com.atproto.identity.getRecommendedDidCredentials");
354 }
355
356 async activateAccount(): Promise<void> {
357 apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.activateAccount`);
358 const start = Date.now();
359 await this.xrpc("com.atproto.server.activateAccount", {
360 httpMethod: "POST",
361 });
362 apiLog(
363 "POST",
364 `${this.baseUrl}/xrpc/com.atproto.server.activateAccount COMPLETE`,
365 {
366 durationMs: Date.now() - start,
367 },
368 );
369 }
370
371 async deactivateAccount(migratingTo?: string): Promise<void> {
372 apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, {
373 migratingTo,
374 });
375 const start = Date.now();
376 try {
377 const body: { migratingTo?: string } = {};
378 if (migratingTo) {
379 body.migratingTo = migratingTo;
380 }
381 await this.xrpc("com.atproto.server.deactivateAccount", {
382 httpMethod: "POST",
383 body,
384 });
385 apiLog(
386 "POST",
387 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount COMPLETE`,
388 {
389 durationMs: Date.now() - start,
390 success: true,
391 migratingTo,
392 },
393 );
394 } catch (e) {
395 const err = e as Error & { error?: string; status?: number };
396 apiLog(
397 "POST",
398 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount FAILED`,
399 {
400 durationMs: Date.now() - start,
401 error: err.message,
402 errorCode: err.error,
403 status: err.status,
404 migratingTo,
405 },
406 );
407 throw e;
408 }
409 }
410
411 async checkAccountStatus(): Promise<AccountStatus> {
412 return this.xrpc("com.atproto.server.checkAccountStatus");
413 }
414
415 async getMigrationStatus(): Promise<{
416 did: string;
417 didType: string;
418 migrated: boolean;
419 migratedToPds?: string;
420 migratedAt?: string;
421 }> {
422 return this.xrpc("com.tranquil.account.getMigrationStatus");
423 }
424
425 async updateMigrationForwarding(pdsUrl: string): Promise<{
426 success: boolean;
427 migratedToPds: string;
428 migratedAt: string;
429 }> {
430 return this.xrpc("com.tranquil.account.updateMigrationForwarding", {
431 httpMethod: "POST",
432 body: { pdsUrl },
433 });
434 }
435
436 async clearMigrationForwarding(): Promise<{ success: boolean }> {
437 return this.xrpc("com.tranquil.account.clearMigrationForwarding", {
438 httpMethod: "POST",
439 });
440 }
441
442 async resolveHandle(handle: string): Promise<{ did: string }> {
443 return this.xrpc("com.atproto.identity.resolveHandle", {
444 params: { handle },
445 });
446 }
447
448 async loginDeactivated(
449 identifier: string,
450 password: string,
451 ): Promise<Session> {
452 const session = await this.xrpc<Session>(
453 "com.atproto.server.createSession",
454 {
455 httpMethod: "POST",
456 body: { identifier, password, allowDeactivated: true },
457 },
458 );
459 this.accessToken = session.accessJwt;
460 return session;
461 }
462
463 async verifyToken(
464 token: string,
465 identifier: string,
466 ): Promise<
467 { success: boolean; did: string; purpose: string; channel: string }
468 > {
469 return this.xrpc("com.tranquil.account.verifyToken", {
470 httpMethod: "POST",
471 body: { token, identifier },
472 });
473 }
474
475 async resendMigrationVerification(): Promise<void> {
476 await this.xrpc("com.atproto.server.resendMigrationVerification", {
477 httpMethod: "POST",
478 });
479 }
480
481 async createPasskeyAccount(
482 params: CreatePasskeyAccountParams,
483 serviceToken?: string,
484 ): Promise<PasskeyAccountSetup> {
485 const headers: Record<string, string> = {
486 "Content-Type": "application/json",
487 };
488 if (serviceToken) {
489 headers["Authorization"] = `Bearer ${serviceToken}`;
490 }
491
492 const res = await fetch(
493 `${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`,
494 {
495 method: "POST",
496 headers,
497 body: JSON.stringify(params),
498 },
499 );
500
501 if (!res.ok) {
502 const err = await res.json().catch(() => ({
503 error: "Unknown",
504 message: res.statusText,
505 }));
506 const error = new Error(err.message || err.error || res.statusText) as Error & {
507 status: number;
508 error: string;
509 };
510 error.status = res.status;
511 error.error = err.error;
512 throw error;
513 }
514
515 return res.json();
516 }
517
518 async startPasskeyRegistrationForSetup(
519 did: string,
520 setupToken: string,
521 friendlyName?: string,
522 ): Promise<StartPasskeyRegistrationResponse> {
523 return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
524 httpMethod: "POST",
525 body: { did, setupToken, friendlyName },
526 });
527 }
528
529 async completePasskeySetup(
530 did: string,
531 setupToken: string,
532 passkeyCredential: unknown,
533 passkeyFriendlyName?: string,
534 ): Promise<CompletePasskeySetupResponse> {
535 return this.xrpc("com.tranquil.account.completePasskeySetup", {
536 httpMethod: "POST",
537 body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
538 });
539 }
540}
541
542export async function getOAuthServerMetadata(
543 pdsUrl: string,
544): Promise<OAuthServerMetadata | null> {
545 try {
546 const directUrl = `${pdsUrl}/.well-known/oauth-authorization-server`;
547 const directRes = await fetch(directUrl);
548 if (directRes.ok) {
549 return directRes.json();
550 }
551
552 const protectedResourceUrl = `${pdsUrl}/.well-known/oauth-protected-resource`;
553 const protectedRes = await fetch(protectedResourceUrl);
554 if (!protectedRes.ok) {
555 return null;
556 }
557
558 const protectedMetadata = await protectedRes.json();
559 const authServers = protectedMetadata.authorization_servers;
560 if (!authServers || authServers.length === 0) {
561 return null;
562 }
563
564 const authServerUrl = `${authServers[0]}/.well-known/oauth-authorization-server`;
565 const authServerRes = await fetch(authServerUrl);
566 if (!authServerRes.ok) {
567 return null;
568 }
569
570 return authServerRes.json();
571 } catch {
572 return null;
573 }
574}
575
576export async function generatePKCE(): Promise<{
577 codeVerifier: string;
578 codeChallenge: string;
579}> {
580 const array = new Uint8Array(32);
581 crypto.getRandomValues(array);
582 const codeVerifier = base64UrlEncode(array);
583
584 const encoder = new TextEncoder();
585 const data = encoder.encode(codeVerifier);
586 const digest = await crypto.subtle.digest("SHA-256", data);
587 const codeChallenge = base64UrlEncode(new Uint8Array(digest));
588
589 return { codeVerifier, codeChallenge };
590}
591
592export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
593 const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
594 let binary = "";
595 for (let i = 0; i < bytes.length; i++) {
596 binary += String.fromCharCode(bytes[i]);
597 }
598 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
599}
600
601export function base64UrlDecode(base64url: string): Uint8Array {
602 const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
603 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
604 const binary = atob(padded);
605 const bytes = new Uint8Array(binary.length);
606 for (let i = 0; i < binary.length; i++) {
607 bytes[i] = binary.charCodeAt(i);
608 }
609 return bytes;
610}
611
612export function prepareWebAuthnCreationOptions(
613 options: { publicKey: Record<string, unknown> },
614): PublicKeyCredentialCreationOptions {
615 const pk = options.publicKey;
616 return {
617 ...pk,
618 challenge: base64UrlDecode(pk.challenge as string),
619 user: {
620 ...(pk.user as Record<string, unknown>),
621 id: base64UrlDecode((pk.user as Record<string, unknown>).id as string),
622 },
623 excludeCredentials:
624 ((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map(
625 (cred) => ({
626 ...cred,
627 id: base64UrlDecode(cred.id as string),
628 }),
629 ),
630 } as PublicKeyCredentialCreationOptions;
631}
632
633async function computeAccessTokenHash(accessToken: string): Promise<string> {
634 const encoder = new TextEncoder();
635 const data = encoder.encode(accessToken);
636 const hash = await crypto.subtle.digest("SHA-256", data);
637 return base64UrlEncode(new Uint8Array(hash));
638}
639
640export function generateOAuthState(): string {
641 const array = new Uint8Array(16);
642 crypto.getRandomValues(array);
643 return base64UrlEncode(array);
644}
645
646export function buildOAuthAuthorizationUrl(
647 metadata: OAuthServerMetadata,
648 params: {
649 clientId: string;
650 redirectUri: string;
651 codeChallenge: string;
652 state: string;
653 scope?: string;
654 dpopJkt?: string;
655 loginHint?: string;
656 },
657): string {
658 const url = new URL(metadata.authorization_endpoint);
659 url.searchParams.set("response_type", "code");
660 url.searchParams.set("client_id", params.clientId);
661 url.searchParams.set("redirect_uri", params.redirectUri);
662 url.searchParams.set("code_challenge", params.codeChallenge);
663 url.searchParams.set("code_challenge_method", "S256");
664 url.searchParams.set("state", params.state);
665 url.searchParams.set("scope", params.scope ?? "atproto");
666 if (params.dpopJkt) {
667 url.searchParams.set("dpop_jkt", params.dpopJkt);
668 }
669 if (params.loginHint) {
670 url.searchParams.set("login_hint", params.loginHint);
671 }
672 return url.toString();
673}
674
675export async function exchangeOAuthCode(
676 metadata: OAuthServerMetadata,
677 params: {
678 code: string;
679 codeVerifier: string;
680 clientId: string;
681 redirectUri: string;
682 dpopKeyPair?: DPoPKeyPair;
683 },
684): Promise<OAuthTokenResponse> {
685 const body = new URLSearchParams({
686 grant_type: "authorization_code",
687 code: params.code,
688 code_verifier: params.codeVerifier,
689 client_id: params.clientId,
690 redirect_uri: params.redirectUri,
691 });
692
693 const makeRequest = async (nonce?: string): Promise<Response> => {
694 const headers: Record<string, string> = {
695 "Content-Type": "application/x-www-form-urlencoded",
696 };
697
698 if (params.dpopKeyPair) {
699 const dpopProof = await createDPoPProof(
700 params.dpopKeyPair,
701 "POST",
702 metadata.token_endpoint,
703 nonce,
704 );
705 headers["DPoP"] = dpopProof;
706 }
707
708 return fetch(metadata.token_endpoint, {
709 method: "POST",
710 headers,
711 body: body.toString(),
712 });
713 };
714
715 let res = await makeRequest();
716
717 if (!res.ok) {
718 const err = await res.json().catch(() => ({
719 error: "token_error",
720 error_description: res.statusText,
721 }));
722
723 if (err.error === "use_dpop_nonce" && params.dpopKeyPair) {
724 const dpopNonce = res.headers.get("DPoP-Nonce");
725 if (dpopNonce) {
726 res = await makeRequest(dpopNonce);
727 if (!res.ok) {
728 const retryErr = await res.json().catch(() => ({
729 error: "token_error",
730 error_description: res.statusText,
731 }));
732 throw new Error(
733 retryErr.error_description || retryErr.error || "Token exchange failed",
734 );
735 }
736 return res.json();
737 }
738 }
739
740 throw new Error(err.error_description || err.error || "Token exchange failed");
741 }
742
743 return res.json();
744}
745
746export async function resolveDidDocument(did: string): Promise<DidDocument> {
747 if (did.startsWith("did:plc:")) {
748 const res = await fetch(`https://plc.directory/${did}`);
749 if (!res.ok) {
750 throw new Error(`Failed to resolve DID: ${res.statusText}`);
751 }
752 return res.json();
753 }
754
755 if (did.startsWith("did:web:")) {
756 const domain = did.slice(8).replace(/%3A/g, ":");
757 const url = domain.includes("/")
758 ? `https://${domain}/did.json`
759 : `https://${domain}/.well-known/did.json`;
760
761 const res = await fetch(url);
762 if (!res.ok) {
763 throw new Error(`Failed to resolve DID: ${res.statusText}`);
764 }
765 return res.json();
766 }
767
768 throw new Error(`Unsupported DID method: ${did}`);
769}
770
771export async function resolvePdsUrl(
772 handleOrDid: string,
773): Promise<{ did: string; pdsUrl: string }> {
774 let did: string | undefined;
775
776 if (handleOrDid.startsWith("did:")) {
777 did = handleOrDid;
778 } else {
779 const handle = handleOrDid.replace(/^@/, "");
780
781 if (handle.endsWith(".bsky.social")) {
782 const res = await fetch(
783 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${
784 encodeURIComponent(handle)
785 }`,
786 );
787 if (!res.ok) {
788 throw new Error(`Failed to resolve handle: ${res.statusText}`);
789 }
790 const data = await res.json();
791 did = data.did;
792 } else {
793 const dnsRes = await fetch(
794 `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`,
795 );
796 if (dnsRes.ok) {
797 const dnsData = await dnsRes.json();
798 const txtRecords = dnsData.Answer ?? [];
799 for (const record of txtRecords) {
800 const txt = record.data?.replace(/"/g, "") ?? "";
801 if (txt.startsWith("did=")) {
802 did = txt.slice(4);
803 break;
804 }
805 }
806 }
807
808 if (!did) {
809 const wellKnownRes = await fetch(
810 `https://${handle}/.well-known/atproto-did`,
811 );
812 if (wellKnownRes.ok) {
813 did = (await wellKnownRes.text()).trim();
814 }
815 }
816
817 if (!did) {
818 throw new Error(`Could not resolve handle: ${handle}`);
819 }
820 }
821 }
822
823 if (!did) {
824 throw new Error("Could not resolve DID");
825 }
826
827 const didDoc = await resolveDidDocument(did);
828
829 const pdsService = didDoc.service?.find(
830 (s: { type: string }) => s.type === "AtprotoPersonalDataServer",
831 );
832
833 if (!pdsService) {
834 throw new Error("No PDS service found in DID document");
835 }
836
837 return { did, pdsUrl: pdsService.serviceEndpoint };
838}
839
840export function createLocalClient(): AtprotoClient {
841 return new AtprotoClient(globalThis.location.origin);
842}
843
844export function getMigrationOAuthClientId(): string {
845 return `${globalThis.location.origin}/oauth/client-metadata.json`;
846}
847
848export function getMigrationOAuthRedirectUri(): string {
849 return `${globalThis.location.origin}/migrate`;
850}
851
852export interface DPoPKeyPair {
853 privateKey: CryptoKey;
854 publicKey: CryptoKey;
855 jwk: JsonWebKey;
856 thumbprint: string;
857}
858
859const DPOP_KEY_STORAGE = "migration_dpop_key";
860const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
861
862export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
863 const keyPair = await crypto.subtle.generateKey(
864 {
865 name: "ECDSA",
866 namedCurve: "P-256",
867 },
868 true,
869 ["sign", "verify"],
870 );
871
872 const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
873 const thumbprint = await computeJwkThumbprint(publicJwk);
874
875 return {
876 privateKey: keyPair.privateKey,
877 publicKey: keyPair.publicKey,
878 jwk: publicJwk,
879 thumbprint,
880 };
881}
882
883async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
884 const thumbprintInput = JSON.stringify({
885 crv: jwk.crv,
886 kty: jwk.kty,
887 x: jwk.x,
888 y: jwk.y,
889 });
890
891 const encoder = new TextEncoder();
892 const data = encoder.encode(thumbprintInput);
893 const hash = await crypto.subtle.digest("SHA-256", data);
894 return base64UrlEncode(new Uint8Array(hash));
895}
896
897export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise<void> {
898 const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
899 const stored = {
900 privateJwk,
901 publicJwk: keyPair.jwk,
902 thumbprint: keyPair.thumbprint,
903 createdAt: Date.now(),
904 };
905 localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored));
906}
907
908export async function loadDPoPKey(): Promise<DPoPKeyPair | null> {
909 const stored = localStorage.getItem(DPOP_KEY_STORAGE);
910 if (!stored) return null;
911
912 try {
913 const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored);
914
915 if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) {
916 localStorage.removeItem(DPOP_KEY_STORAGE);
917 return null;
918 }
919
920 const privateKey = await crypto.subtle.importKey(
921 "jwk",
922 privateJwk,
923 { name: "ECDSA", namedCurve: "P-256" },
924 true,
925 ["sign"],
926 );
927
928 const publicKey = await crypto.subtle.importKey(
929 "jwk",
930 publicJwk,
931 { name: "ECDSA", namedCurve: "P-256" },
932 true,
933 ["verify"],
934 );
935
936 return { privateKey, publicKey, jwk: publicJwk, thumbprint };
937 } catch {
938 localStorage.removeItem(DPOP_KEY_STORAGE);
939 return null;
940 }
941}
942
943export function clearDPoPKey(): void {
944 localStorage.removeItem(DPOP_KEY_STORAGE);
945}
946
947export async function createDPoPProof(
948 keyPair: DPoPKeyPair,
949 httpMethod: string,
950 httpUri: string,
951 nonce?: string,
952 accessTokenHash?: string,
953): Promise<string> {
954 const header = {
955 typ: "dpop+jwt",
956 alg: "ES256",
957 jwk: {
958 kty: keyPair.jwk.kty,
959 crv: keyPair.jwk.crv,
960 x: keyPair.jwk.x,
961 y: keyPair.jwk.y,
962 },
963 };
964
965 const payload: Record<string, unknown> = {
966 jti: crypto.randomUUID(),
967 htm: httpMethod,
968 htu: httpUri,
969 iat: Math.floor(Date.now() / 1000),
970 };
971
972 if (nonce) {
973 payload.nonce = nonce;
974 }
975
976 if (accessTokenHash) {
977 payload.ath = accessTokenHash;
978 }
979
980 const headerB64 = base64UrlEncode(
981 new TextEncoder().encode(JSON.stringify(header)),
982 );
983 const payloadB64 = base64UrlEncode(
984 new TextEncoder().encode(JSON.stringify(payload)),
985 );
986
987 const signingInput = `${headerB64}.${payloadB64}`;
988 const signature = await crypto.subtle.sign(
989 { name: "ECDSA", hash: "SHA-256" },
990 keyPair.privateKey,
991 new TextEncoder().encode(signingInput),
992 );
993
994 const signatureB64 = base64UrlEncode(new Uint8Array(signature));
995 return `${headerB64}.${payloadB64}.${signatureB64}`;
996}