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