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 let binary = ""; 604 for (let i = 0; i < bytes.length; i++) { 605 binary += String.fromCharCode(bytes[i]); 606 } 607 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 608 /=+$/, 609 "", 610 ); 611} 612 613export function base64UrlDecode(base64url: string): Uint8Array { 614 const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); 615 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); 616 const binary = atob(padded); 617 const bytes = new Uint8Array(binary.length); 618 for (let i = 0; i < binary.length; i++) { 619 bytes[i] = binary.charCodeAt(i); 620 } 621 return bytes; 622} 623 624export function prepareWebAuthnCreationOptions( 625 options: { publicKey: Record<string, unknown> }, 626): PublicKeyCredentialCreationOptions { 627 const pk = options.publicKey; 628 return { 629 ...pk, 630 challenge: base64UrlDecode(pk.challenge as string), 631 user: { 632 ...(pk.user as Record<string, unknown>), 633 id: base64UrlDecode((pk.user as Record<string, unknown>).id as string), 634 }, 635 excludeCredentials: 636 ((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map( 637 (cred) => ({ 638 ...cred, 639 id: base64UrlDecode(cred.id as string), 640 }), 641 ), 642 } as PublicKeyCredentialCreationOptions; 643} 644 645async function computeAccessTokenHash(accessToken: string): Promise<string> { 646 const encoder = new TextEncoder(); 647 const data = encoder.encode(accessToken); 648 const hash = await crypto.subtle.digest("SHA-256", data); 649 return base64UrlEncode(new Uint8Array(hash)); 650} 651 652export function generateOAuthState(): string { 653 const array = new Uint8Array(16); 654 crypto.getRandomValues(array); 655 return base64UrlEncode(array); 656} 657 658export function buildOAuthAuthorizationUrl( 659 metadata: OAuthServerMetadata, 660 params: { 661 clientId: string; 662 redirectUri: string; 663 codeChallenge: string; 664 state: string; 665 scope?: string; 666 dpopJkt?: string; 667 loginHint?: string; 668 }, 669): string { 670 const url = new URL(metadata.authorization_endpoint); 671 url.searchParams.set("response_type", "code"); 672 url.searchParams.set("client_id", params.clientId); 673 url.searchParams.set("redirect_uri", params.redirectUri); 674 url.searchParams.set("code_challenge", params.codeChallenge); 675 url.searchParams.set("code_challenge_method", "S256"); 676 url.searchParams.set("state", params.state); 677 url.searchParams.set("scope", params.scope ?? "atproto"); 678 if (params.dpopJkt) { 679 url.searchParams.set("dpop_jkt", params.dpopJkt); 680 } 681 if (params.loginHint) { 682 url.searchParams.set("login_hint", params.loginHint); 683 } 684 return url.toString(); 685} 686 687export async function initiateOAuthWithPAR( 688 metadata: OAuthServerMetadata, 689 params: { 690 clientId: string; 691 redirectUri: string; 692 codeChallenge: string; 693 state: string; 694 scope?: string; 695 dpopJkt?: string; 696 loginHint?: string; 697 }, 698): Promise<string> { 699 if (!metadata.pushed_authorization_request_endpoint) { 700 return buildOAuthAuthorizationUrl(metadata, params); 701 } 702 703 const body = new URLSearchParams({ 704 response_type: "code", 705 client_id: params.clientId, 706 redirect_uri: params.redirectUri, 707 code_challenge: params.codeChallenge, 708 code_challenge_method: "S256", 709 state: params.state, 710 scope: params.scope ?? "atproto", 711 }); 712 713 if (params.dpopJkt) { 714 body.set("dpop_jkt", params.dpopJkt); 715 } 716 if (params.loginHint) { 717 body.set("login_hint", params.loginHint); 718 } 719 720 const res = await fetch(metadata.pushed_authorization_request_endpoint, { 721 method: "POST", 722 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 723 body: body.toString(), 724 }); 725 726 if (!res.ok) { 727 const err = await res.json().catch(() => ({ 728 error: "par_error", 729 error_description: res.statusText, 730 })); 731 throw new Error(err.error_description || err.error || "PAR request failed"); 732 } 733 734 const { request_uri } = await res.json(); 735 736 const authUrl = new URL(metadata.authorization_endpoint); 737 authUrl.searchParams.set("client_id", params.clientId); 738 authUrl.searchParams.set("request_uri", request_uri); 739 return authUrl.toString(); 740} 741 742export async function exchangeOAuthCode( 743 metadata: OAuthServerMetadata, 744 params: { 745 code: string; 746 codeVerifier: string; 747 clientId: string; 748 redirectUri: string; 749 dpopKeyPair?: DPoPKeyPair; 750 }, 751): Promise<OAuthTokenResponse> { 752 const body = new URLSearchParams({ 753 grant_type: "authorization_code", 754 code: params.code, 755 code_verifier: params.codeVerifier, 756 client_id: params.clientId, 757 redirect_uri: params.redirectUri, 758 }); 759 760 const makeRequest = async (nonce?: string): Promise<Response> => { 761 const headers: Record<string, string> = { 762 "Content-Type": "application/x-www-form-urlencoded", 763 }; 764 765 if (params.dpopKeyPair) { 766 const dpopProof = await createDPoPProof( 767 params.dpopKeyPair, 768 "POST", 769 metadata.token_endpoint, 770 nonce, 771 ); 772 headers["DPoP"] = dpopProof; 773 } 774 775 return fetch(metadata.token_endpoint, { 776 method: "POST", 777 headers, 778 body: body.toString(), 779 }); 780 }; 781 782 let res = await makeRequest(); 783 784 if (!res.ok) { 785 const err = await res.json().catch(() => ({ 786 error: "token_error", 787 error_description: res.statusText, 788 })); 789 790 if (err.error === "use_dpop_nonce" && params.dpopKeyPair) { 791 const dpopNonce = res.headers.get("DPoP-Nonce"); 792 if (dpopNonce) { 793 res = await makeRequest(dpopNonce); 794 if (!res.ok) { 795 const retryErr = await res.json().catch(() => ({ 796 error: "token_error", 797 error_description: res.statusText, 798 })); 799 throw new Error( 800 retryErr.error_description || retryErr.error || 801 "Token exchange failed", 802 ); 803 } 804 return res.json(); 805 } 806 } 807 808 throw new Error( 809 err.error_description || err.error || "Token exchange failed", 810 ); 811 } 812 813 return res.json(); 814} 815 816export async function resolveDidDocument(did: string): Promise<DidDocument> { 817 if (did.startsWith("did:plc:")) { 818 const res = await fetch(`https://plc.directory/${did}`); 819 if (!res.ok) { 820 throw new Error(`Failed to resolve DID: ${res.statusText}`); 821 } 822 return res.json(); 823 } 824 825 if (did.startsWith("did:web:")) { 826 const domain = did.slice(8).replace(/%3A/g, ":"); 827 const url = domain.includes("/") 828 ? `https://${domain}/did.json` 829 : `https://${domain}/.well-known/did.json`; 830 831 const res = await fetch(url); 832 if (!res.ok) { 833 throw new Error(`Failed to resolve DID: ${res.statusText}`); 834 } 835 return res.json(); 836 } 837 838 throw new Error(`Unsupported DID method: ${did}`); 839} 840 841export async function resolvePdsUrl( 842 handleOrDid: string, 843): Promise<{ did: string; pdsUrl: string }> { 844 let did: string | undefined; 845 846 if (handleOrDid.startsWith("did:")) { 847 did = handleOrDid; 848 } else { 849 const handle = handleOrDid.replace(/^@/, ""); 850 851 if (handle.endsWith(".bsky.social")) { 852 const res = await fetch( 853 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ 854 encodeURIComponent(handle) 855 }`, 856 ); 857 if (!res.ok) { 858 throw new Error(`Failed to resolve handle: ${res.statusText}`); 859 } 860 const data = await res.json(); 861 did = data.did; 862 } else { 863 const dnsRes = await fetch( 864 `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`, 865 ); 866 if (dnsRes.ok) { 867 const dnsData = await dnsRes.json(); 868 const txtRecords = dnsData.Answer ?? []; 869 for (const record of txtRecords) { 870 const txt = record.data?.replace(/"/g, "") ?? ""; 871 if (txt.startsWith("did=")) { 872 did = txt.slice(4); 873 break; 874 } 875 } 876 } 877 878 if (!did) { 879 const wellKnownRes = await fetch( 880 `https://${handle}/.well-known/atproto-did`, 881 ); 882 if (wellKnownRes.ok) { 883 did = (await wellKnownRes.text()).trim(); 884 } 885 } 886 887 if (!did) { 888 throw new Error(`Could not resolve handle: ${handle}`); 889 } 890 } 891 } 892 893 if (!did) { 894 throw new Error("Could not resolve DID"); 895 } 896 897 const didDoc = await resolveDidDocument(did); 898 899 const pdsService = didDoc.service?.find( 900 (s: { type: string }) => s.type === "AtprotoPersonalDataServer", 901 ); 902 903 if (!pdsService) { 904 throw new Error("No PDS service found in DID document"); 905 } 906 907 return { did, pdsUrl: pdsService.serviceEndpoint }; 908} 909 910export function createLocalClient(): AtprotoClient { 911 return new AtprotoClient(globalThis.location.origin); 912} 913 914export function getMigrationOAuthClientId(): string { 915 return `${globalThis.location.origin}/oauth/client-metadata.json`; 916} 917 918export function getMigrationOAuthRedirectUri(): string { 919 return `${globalThis.location.origin}/app/migrate`; 920} 921 922export interface DPoPKeyPair { 923 privateKey: CryptoKey; 924 publicKey: CryptoKey; 925 jwk: JsonWebKey; 926 thumbprint: string; 927} 928 929const DPOP_KEY_STORAGE = "migration_dpop_key"; 930const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000; 931 932export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { 933 const keyPair = await crypto.subtle.generateKey( 934 { 935 name: "ECDSA", 936 namedCurve: "P-256", 937 }, 938 true, 939 ["sign", "verify"], 940 ); 941 942 const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 943 const thumbprint = await computeJwkThumbprint(publicJwk); 944 945 return { 946 privateKey: keyPair.privateKey, 947 publicKey: keyPair.publicKey, 948 jwk: publicJwk, 949 thumbprint, 950 }; 951} 952 953async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> { 954 const thumbprintInput = JSON.stringify({ 955 crv: jwk.crv, 956 kty: jwk.kty, 957 x: jwk.x, 958 y: jwk.y, 959 }); 960 961 const encoder = new TextEncoder(); 962 const data = encoder.encode(thumbprintInput); 963 const hash = await crypto.subtle.digest("SHA-256", data); 964 return base64UrlEncode(new Uint8Array(hash)); 965} 966 967export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise<void> { 968 const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); 969 const stored = { 970 privateJwk, 971 publicJwk: keyPair.jwk, 972 thumbprint: keyPair.thumbprint, 973 createdAt: Date.now(), 974 }; 975 localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored)); 976} 977 978export async function loadDPoPKey(): Promise<DPoPKeyPair | null> { 979 const stored = localStorage.getItem(DPOP_KEY_STORAGE); 980 if (!stored) return null; 981 982 try { 983 const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored); 984 985 if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) { 986 localStorage.removeItem(DPOP_KEY_STORAGE); 987 return null; 988 } 989 990 const privateKey = await crypto.subtle.importKey( 991 "jwk", 992 privateJwk, 993 { name: "ECDSA", namedCurve: "P-256" }, 994 true, 995 ["sign"], 996 ); 997 998 const publicKey = await crypto.subtle.importKey( 999 "jwk", 1000 publicJwk, 1001 { name: "ECDSA", namedCurve: "P-256" }, 1002 true, 1003 ["verify"], 1004 ); 1005 1006 return { privateKey, publicKey, jwk: publicJwk, thumbprint }; 1007 } catch { 1008 localStorage.removeItem(DPOP_KEY_STORAGE); 1009 return null; 1010 } 1011} 1012 1013export function clearDPoPKey(): void { 1014 localStorage.removeItem(DPOP_KEY_STORAGE); 1015} 1016 1017export async function createDPoPProof( 1018 keyPair: DPoPKeyPair, 1019 httpMethod: string, 1020 httpUri: string, 1021 nonce?: string, 1022 accessTokenHash?: string, 1023): Promise<string> { 1024 const header = { 1025 typ: "dpop+jwt", 1026 alg: "ES256", 1027 jwk: { 1028 kty: keyPair.jwk.kty, 1029 crv: keyPair.jwk.crv, 1030 x: keyPair.jwk.x, 1031 y: keyPair.jwk.y, 1032 }, 1033 }; 1034 1035 const payload: Record<string, unknown> = { 1036 jti: crypto.randomUUID(), 1037 htm: httpMethod, 1038 htu: httpUri, 1039 iat: Math.floor(Date.now() / 1000), 1040 }; 1041 1042 if (nonce) { 1043 payload.nonce = nonce; 1044 } 1045 1046 if (accessTokenHash) { 1047 payload.ath = accessTokenHash; 1048 } 1049 1050 const headerB64 = base64UrlEncode( 1051 new TextEncoder().encode(JSON.stringify(header)), 1052 ); 1053 const payloadB64 = base64UrlEncode( 1054 new TextEncoder().encode(JSON.stringify(payload)), 1055 ); 1056 1057 const signingInput = `${headerB64}.${payloadB64}`; 1058 const signature = await crypto.subtle.sign( 1059 { name: "ECDSA", hash: "SHA-256" }, 1060 keyPair.privateKey, 1061 new TextEncoder().encode(signingInput), 1062 ); 1063 1064 const signatureB64 = base64UrlEncode(new Uint8Array(signature)); 1065 return `${headerB64}.${payloadB64}.${signatureB64}`; 1066}