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 describeServer(): Promise<ServerDescription> {
192 return this.xrpc<ServerDescription>("com.atproto.server.describeServer");
193 }
194
195 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 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(): Promise<void> {
376 apiLog(
377 "POST",
378 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`,
379 );
380 const start = Date.now();
381 try {
382 await this.xrpc("com.atproto.server.deactivateAccount", {
383 httpMethod: "POST",
384 });
385 apiLog(
386 "POST",
387 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount COMPLETE`,
388 {
389 durationMs: Date.now() - start,
390 success: true,
391 },
392 );
393 } catch (e) {
394 const err = e as Error & { error?: string; status?: number };
395 apiLog(
396 "POST",
397 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount FAILED`,
398 {
399 durationMs: Date.now() - start,
400 error: err.message,
401 errorCode: err.error,
402 status: err.status,
403 },
404 );
405 throw e;
406 }
407 }
408
409 async checkAccountStatus(): Promise<AccountStatus> {
410 return this.xrpc("com.atproto.server.checkAccountStatus");
411 }
412
413 async resolveHandle(handle: string): Promise<{ did: string }> {
414 return this.xrpc("com.atproto.identity.resolveHandle", {
415 params: { handle },
416 });
417 }
418
419 async loginDeactivated(
420 identifier: string,
421 password: string,
422 ): Promise<Session> {
423 const session = await this.xrpc<Session>(
424 "com.atproto.server.createSession",
425 {
426 httpMethod: "POST",
427 body: { identifier, password, allowDeactivated: true },
428 },
429 );
430 this.accessToken = session.accessJwt;
431 return session;
432 }
433
434 async checkEmailVerified(identifier: string): Promise<boolean> {
435 const result = await this.xrpc<{ verified: boolean }>(
436 "_checkEmailVerified",
437 {
438 httpMethod: "POST",
439 body: { identifier },
440 },
441 );
442 return result.verified;
443 }
444
445 async verifyToken(
446 token: string,
447 identifier: string,
448 ): Promise<
449 { success: boolean; did: string; purpose: string; channel: string }
450 > {
451 return this.xrpc("_account.verifyToken", {
452 httpMethod: "POST",
453 body: { token, identifier },
454 });
455 }
456
457 async resendMigrationVerification(): Promise<void> {
458 await this.xrpc("com.atproto.server.resendMigrationVerification", {
459 httpMethod: "POST",
460 });
461 }
462
463 async createPasskeyAccount(
464 params: CreatePasskeyAccountParams,
465 serviceToken?: string,
466 ): Promise<PasskeyAccountSetup> {
467 const headers: Record<string, string> = {
468 "Content-Type": "application/json",
469 };
470 if (serviceToken) {
471 headers["Authorization"] = `Bearer ${serviceToken}`;
472 }
473
474 const res = await fetch(
475 `${this.baseUrl}/xrpc/_account.createPasskeyAccount`,
476 {
477 method: "POST",
478 headers,
479 body: JSON.stringify(params),
480 },
481 );
482
483 if (!res.ok) {
484 const err = await res.json().catch(() => ({
485 error: "Unknown",
486 message: res.statusText,
487 }));
488 const error = new Error(err.message || err.error || res.statusText) as
489 & Error
490 & {
491 status: number;
492 error: string;
493 };
494 error.status = res.status;
495 error.error = err.error;
496 throw error;
497 }
498
499 return res.json();
500 }
501
502 async startPasskeyRegistrationForSetup(
503 did: string,
504 setupToken: string,
505 friendlyName?: string,
506 ): Promise<StartPasskeyRegistrationResponse> {
507 return this.xrpc("_account.startPasskeyRegistrationForSetup", {
508 httpMethod: "POST",
509 body: { did, setupToken, friendlyName },
510 });
511 }
512
513 async completePasskeySetup(
514 did: string,
515 setupToken: string,
516 passkeyCredential: unknown,
517 passkeyFriendlyName?: string,
518 ): Promise<CompletePasskeySetupResponse> {
519 return this.xrpc("_account.completePasskeySetup", {
520 httpMethod: "POST",
521 body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
522 });
523 }
524}
525
526export async function getOAuthServerMetadata(
527 pdsUrl: string,
528): Promise<OAuthServerMetadata | null> {
529 try {
530 const directUrl = `${pdsUrl}/.well-known/oauth-authorization-server`;
531 const directRes = await fetch(directUrl);
532 if (directRes.ok) {
533 return directRes.json();
534 }
535
536 const protectedResourceUrl =
537 `${pdsUrl}/.well-known/oauth-protected-resource`;
538 const protectedRes = await fetch(protectedResourceUrl);
539 if (!protectedRes.ok) {
540 return null;
541 }
542
543 const protectedMetadata = await protectedRes.json();
544 const authServers = protectedMetadata.authorization_servers;
545 if (!authServers || authServers.length === 0) {
546 return null;
547 }
548
549 const authServerUrl = `${
550 authServers[0]
551 }/.well-known/oauth-authorization-server`;
552 const authServerRes = await fetch(authServerUrl);
553 if (!authServerRes.ok) {
554 return null;
555 }
556
557 return authServerRes.json();
558 } catch {
559 return null;
560 }
561}
562
563export async function generatePKCE(): Promise<{
564 codeVerifier: string;
565 codeChallenge: string;
566}> {
567 const array = new Uint8Array(32);
568 crypto.getRandomValues(array);
569 const codeVerifier = base64UrlEncode(array);
570
571 const encoder = new TextEncoder();
572 const data = encoder.encode(codeVerifier);
573 const digest = await crypto.subtle.digest("SHA-256", data);
574 const codeChallenge = base64UrlEncode(new Uint8Array(digest));
575
576 return { codeVerifier, codeChallenge };
577}
578
579export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
580 const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
581 let binary = "";
582 for (let i = 0; i < bytes.length; i++) {
583 binary += String.fromCharCode(bytes[i]);
584 }
585 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
586 /=+$/,
587 "",
588 );
589}
590
591export function base64UrlDecode(base64url: string): Uint8Array {
592 const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
593 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
594 const binary = atob(padded);
595 const bytes = new Uint8Array(binary.length);
596 for (let i = 0; i < binary.length; i++) {
597 bytes[i] = binary.charCodeAt(i);
598 }
599 return bytes;
600}
601
602export function prepareWebAuthnCreationOptions(
603 options: { publicKey: Record<string, unknown> },
604): PublicKeyCredentialCreationOptions {
605 const pk = options.publicKey;
606 return {
607 ...pk,
608 challenge: base64UrlDecode(pk.challenge as string),
609 user: {
610 ...(pk.user as Record<string, unknown>),
611 id: base64UrlDecode((pk.user as Record<string, unknown>).id as string),
612 },
613 excludeCredentials:
614 ((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map(
615 (cred) => ({
616 ...cred,
617 id: base64UrlDecode(cred.id as string),
618 }),
619 ),
620 } as PublicKeyCredentialCreationOptions;
621}
622
623async function computeAccessTokenHash(accessToken: string): Promise<string> {
624 const encoder = new TextEncoder();
625 const data = encoder.encode(accessToken);
626 const hash = await crypto.subtle.digest("SHA-256", data);
627 return base64UrlEncode(new Uint8Array(hash));
628}
629
630export function generateOAuthState(): string {
631 const array = new Uint8Array(16);
632 crypto.getRandomValues(array);
633 return base64UrlEncode(array);
634}
635
636export function buildOAuthAuthorizationUrl(
637 metadata: OAuthServerMetadata,
638 params: {
639 clientId: string;
640 redirectUri: string;
641 codeChallenge: string;
642 state: string;
643 scope?: string;
644 dpopJkt?: string;
645 loginHint?: string;
646 },
647): string {
648 const url = new URL(metadata.authorization_endpoint);
649 url.searchParams.set("response_type", "code");
650 url.searchParams.set("client_id", params.clientId);
651 url.searchParams.set("redirect_uri", params.redirectUri);
652 url.searchParams.set("code_challenge", params.codeChallenge);
653 url.searchParams.set("code_challenge_method", "S256");
654 url.searchParams.set("state", params.state);
655 url.searchParams.set("scope", params.scope ?? "atproto");
656 if (params.dpopJkt) {
657 url.searchParams.set("dpop_jkt", params.dpopJkt);
658 }
659 if (params.loginHint) {
660 url.searchParams.set("login_hint", params.loginHint);
661 }
662 return url.toString();
663}
664
665export async function initiateOAuthWithPAR(
666 metadata: OAuthServerMetadata,
667 params: {
668 clientId: string;
669 redirectUri: string;
670 codeChallenge: string;
671 state: string;
672 scope?: string;
673 dpopJkt?: string;
674 loginHint?: string;
675 },
676): Promise<string> {
677 if (!metadata.pushed_authorization_request_endpoint) {
678 return buildOAuthAuthorizationUrl(metadata, params);
679 }
680
681 const body = new URLSearchParams({
682 response_type: "code",
683 client_id: params.clientId,
684 redirect_uri: params.redirectUri,
685 code_challenge: params.codeChallenge,
686 code_challenge_method: "S256",
687 state: params.state,
688 scope: params.scope ?? "atproto",
689 });
690
691 if (params.dpopJkt) {
692 body.set("dpop_jkt", params.dpopJkt);
693 }
694 if (params.loginHint) {
695 body.set("login_hint", params.loginHint);
696 }
697
698 const res = await fetch(metadata.pushed_authorization_request_endpoint, {
699 method: "POST",
700 headers: { "Content-Type": "application/x-www-form-urlencoded" },
701 body: body.toString(),
702 });
703
704 if (!res.ok) {
705 const err = await res.json().catch(() => ({
706 error: "par_error",
707 error_description: res.statusText,
708 }));
709 throw new Error(err.error_description || err.error || "PAR request failed");
710 }
711
712 const { request_uri } = await res.json();
713
714 const authUrl = new URL(metadata.authorization_endpoint);
715 authUrl.searchParams.set("client_id", params.clientId);
716 authUrl.searchParams.set("request_uri", request_uri);
717 return authUrl.toString();
718}
719
720export async function exchangeOAuthCode(
721 metadata: OAuthServerMetadata,
722 params: {
723 code: string;
724 codeVerifier: string;
725 clientId: string;
726 redirectUri: string;
727 dpopKeyPair?: DPoPKeyPair;
728 },
729): Promise<OAuthTokenResponse> {
730 const body = new URLSearchParams({
731 grant_type: "authorization_code",
732 code: params.code,
733 code_verifier: params.codeVerifier,
734 client_id: params.clientId,
735 redirect_uri: params.redirectUri,
736 });
737
738 const makeRequest = async (nonce?: string): Promise<Response> => {
739 const headers: Record<string, string> = {
740 "Content-Type": "application/x-www-form-urlencoded",
741 };
742
743 if (params.dpopKeyPair) {
744 const dpopProof = await createDPoPProof(
745 params.dpopKeyPair,
746 "POST",
747 metadata.token_endpoint,
748 nonce,
749 );
750 headers["DPoP"] = dpopProof;
751 }
752
753 return fetch(metadata.token_endpoint, {
754 method: "POST",
755 headers,
756 body: body.toString(),
757 });
758 };
759
760 let res = await makeRequest();
761
762 if (!res.ok) {
763 const err = await res.json().catch(() => ({
764 error: "token_error",
765 error_description: res.statusText,
766 }));
767
768 if (err.error === "use_dpop_nonce" && params.dpopKeyPair) {
769 const dpopNonce = res.headers.get("DPoP-Nonce");
770 if (dpopNonce) {
771 res = await makeRequest(dpopNonce);
772 if (!res.ok) {
773 const retryErr = await res.json().catch(() => ({
774 error: "token_error",
775 error_description: res.statusText,
776 }));
777 throw new Error(
778 retryErr.error_description || retryErr.error ||
779 "Token exchange failed",
780 );
781 }
782 return res.json();
783 }
784 }
785
786 throw new Error(
787 err.error_description || err.error || "Token exchange failed",
788 );
789 }
790
791 return res.json();
792}
793
794export async function resolveDidDocument(did: string): Promise<DidDocument> {
795 if (did.startsWith("did:plc:")) {
796 const res = await fetch(`https://plc.directory/${did}`);
797 if (!res.ok) {
798 throw new Error(`Failed to resolve DID: ${res.statusText}`);
799 }
800 return res.json();
801 }
802
803 if (did.startsWith("did:web:")) {
804 const domain = did.slice(8).replace(/%3A/g, ":");
805 const url = domain.includes("/")
806 ? `https://${domain}/did.json`
807 : `https://${domain}/.well-known/did.json`;
808
809 const res = await fetch(url);
810 if (!res.ok) {
811 throw new Error(`Failed to resolve DID: ${res.statusText}`);
812 }
813 return res.json();
814 }
815
816 throw new Error(`Unsupported DID method: ${did}`);
817}
818
819export async function resolvePdsUrl(
820 handleOrDid: string,
821): Promise<{ did: string; pdsUrl: string }> {
822 let did: string | undefined;
823
824 if (handleOrDid.startsWith("did:")) {
825 did = handleOrDid;
826 } else {
827 const handle = handleOrDid.replace(/^@/, "");
828
829 if (handle.endsWith(".bsky.social")) {
830 const res = await fetch(
831 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${
832 encodeURIComponent(handle)
833 }`,
834 );
835 if (!res.ok) {
836 throw new Error(`Failed to resolve handle: ${res.statusText}`);
837 }
838 const data = await res.json();
839 did = data.did;
840 } else {
841 const dnsRes = await fetch(
842 `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`,
843 );
844 if (dnsRes.ok) {
845 const dnsData = await dnsRes.json();
846 const txtRecords = dnsData.Answer ?? [];
847 for (const record of txtRecords) {
848 const txt = record.data?.replace(/"/g, "") ?? "";
849 if (txt.startsWith("did=")) {
850 did = txt.slice(4);
851 break;
852 }
853 }
854 }
855
856 if (!did) {
857 const wellKnownRes = await fetch(
858 `https://${handle}/.well-known/atproto-did`,
859 );
860 if (wellKnownRes.ok) {
861 did = (await wellKnownRes.text()).trim();
862 }
863 }
864
865 if (!did) {
866 throw new Error(`Could not resolve handle: ${handle}`);
867 }
868 }
869 }
870
871 if (!did) {
872 throw new Error("Could not resolve DID");
873 }
874
875 const didDoc = await resolveDidDocument(did);
876
877 const pdsService = didDoc.service?.find(
878 (s: { type: string }) => s.type === "AtprotoPersonalDataServer",
879 );
880
881 if (!pdsService) {
882 throw new Error("No PDS service found in DID document");
883 }
884
885 return { did, pdsUrl: pdsService.serviceEndpoint };
886}
887
888export function createLocalClient(): AtprotoClient {
889 return new AtprotoClient(globalThis.location.origin);
890}
891
892export function getMigrationOAuthClientId(): string {
893 return `${globalThis.location.origin}/oauth/client-metadata.json`;
894}
895
896export function getMigrationOAuthRedirectUri(): string {
897 return `${globalThis.location.origin}/app/migrate`;
898}
899
900export interface DPoPKeyPair {
901 privateKey: CryptoKey;
902 publicKey: CryptoKey;
903 jwk: JsonWebKey;
904 thumbprint: string;
905}
906
907const DPOP_KEY_STORAGE = "migration_dpop_key";
908const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
909
910export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
911 const keyPair = await crypto.subtle.generateKey(
912 {
913 name: "ECDSA",
914 namedCurve: "P-256",
915 },
916 true,
917 ["sign", "verify"],
918 );
919
920 const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
921 const thumbprint = await computeJwkThumbprint(publicJwk);
922
923 return {
924 privateKey: keyPair.privateKey,
925 publicKey: keyPair.publicKey,
926 jwk: publicJwk,
927 thumbprint,
928 };
929}
930
931async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
932 const thumbprintInput = JSON.stringify({
933 crv: jwk.crv,
934 kty: jwk.kty,
935 x: jwk.x,
936 y: jwk.y,
937 });
938
939 const encoder = new TextEncoder();
940 const data = encoder.encode(thumbprintInput);
941 const hash = await crypto.subtle.digest("SHA-256", data);
942 return base64UrlEncode(new Uint8Array(hash));
943}
944
945export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise<void> {
946 const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
947 const stored = {
948 privateJwk,
949 publicJwk: keyPair.jwk,
950 thumbprint: keyPair.thumbprint,
951 createdAt: Date.now(),
952 };
953 localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored));
954}
955
956export async function loadDPoPKey(): Promise<DPoPKeyPair | null> {
957 const stored = localStorage.getItem(DPOP_KEY_STORAGE);
958 if (!stored) return null;
959
960 try {
961 const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored);
962
963 if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) {
964 localStorage.removeItem(DPOP_KEY_STORAGE);
965 return null;
966 }
967
968 const privateKey = await crypto.subtle.importKey(
969 "jwk",
970 privateJwk,
971 { name: "ECDSA", namedCurve: "P-256" },
972 true,
973 ["sign"],
974 );
975
976 const publicKey = await crypto.subtle.importKey(
977 "jwk",
978 publicJwk,
979 { name: "ECDSA", namedCurve: "P-256" },
980 true,
981 ["verify"],
982 );
983
984 return { privateKey, publicKey, jwk: publicJwk, thumbprint };
985 } catch {
986 localStorage.removeItem(DPOP_KEY_STORAGE);
987 return null;
988 }
989}
990
991export function clearDPoPKey(): void {
992 localStorage.removeItem(DPOP_KEY_STORAGE);
993}
994
995export async function createDPoPProof(
996 keyPair: DPoPKeyPair,
997 httpMethod: string,
998 httpUri: string,
999 nonce?: string,
1000 accessTokenHash?: string,
1001): Promise<string> {
1002 const header = {
1003 typ: "dpop+jwt",
1004 alg: "ES256",
1005 jwk: {
1006 kty: keyPair.jwk.kty,
1007 crv: keyPair.jwk.crv,
1008 x: keyPair.jwk.x,
1009 y: keyPair.jwk.y,
1010 },
1011 };
1012
1013 const payload: Record<string, unknown> = {
1014 jti: crypto.randomUUID(),
1015 htm: httpMethod,
1016 htu: httpUri,
1017 iat: Math.floor(Date.now() / 1000),
1018 };
1019
1020 if (nonce) {
1021 payload.nonce = nonce;
1022 }
1023
1024 if (accessTokenHash) {
1025 payload.ath = accessTokenHash;
1026 }
1027
1028 const headerB64 = base64UrlEncode(
1029 new TextEncoder().encode(JSON.stringify(header)),
1030 );
1031 const payloadB64 = base64UrlEncode(
1032 new TextEncoder().encode(JSON.stringify(payload)),
1033 );
1034
1035 const signingInput = `${headerB64}.${payloadB64}`;
1036 const signature = await crypto.subtle.sign(
1037 { name: "ECDSA", hash: "SHA-256" },
1038 keyPair.privateKey,
1039 new TextEncoder().encode(signingInput),
1040 );
1041
1042 const signatureB64 = base64UrlEncode(new Uint8Array(signature));
1043 return `${headerB64}.${payloadB64}.${signatureB64}`;
1044}