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