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