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