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}