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 async describeServer(): Promise<ServerDescription> { 192 return this.xrpc<ServerDescription>("com.atproto.server.describeServer"); 193 } 194 195 async 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 async 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(migratingTo?: string): Promise<void> { 376 apiLog( 377 "POST", 378 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, 379 { 380 migratingTo, 381 }, 382 ); 383 const start = Date.now(); 384 try { 385 const body: { migratingTo?: string } = {}; 386 if (migratingTo) { 387 body.migratingTo = migratingTo; 388 } 389 await this.xrpc("com.atproto.server.deactivateAccount", { 390 httpMethod: "POST", 391 body, 392 }); 393 apiLog( 394 "POST", 395 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount COMPLETE`, 396 { 397 durationMs: Date.now() - start, 398 success: true, 399 migratingTo, 400 }, 401 ); 402 } catch (e) { 403 const err = e as Error & { error?: string; status?: number }; 404 apiLog( 405 "POST", 406 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount FAILED`, 407 { 408 durationMs: Date.now() - start, 409 error: err.message, 410 errorCode: err.error, 411 status: err.status, 412 migratingTo, 413 }, 414 ); 415 throw e; 416 } 417 } 418 419 async checkAccountStatus(): Promise<AccountStatus> { 420 return this.xrpc("com.atproto.server.checkAccountStatus"); 421 } 422 423 async getMigrationStatus(): Promise<{ 424 did: string; 425 didType: string; 426 migrated: boolean; 427 migratedToPds?: string; 428 migratedAt?: string; 429 }> { 430 return this.xrpc("com.tranquil.account.getMigrationStatus"); 431 } 432 433 async updateMigrationForwarding(pdsUrl: string): Promise<{ 434 success: boolean; 435 migratedToPds: string; 436 migratedAt: string; 437 }> { 438 return this.xrpc("com.tranquil.account.updateMigrationForwarding", { 439 httpMethod: "POST", 440 body: { pdsUrl }, 441 }); 442 } 443 444 async clearMigrationForwarding(): Promise<{ success: boolean }> { 445 return this.xrpc("com.tranquil.account.clearMigrationForwarding", { 446 httpMethod: "POST", 447 }); 448 } 449 450 async resolveHandle(handle: string): Promise<{ did: string }> { 451 return this.xrpc("com.atproto.identity.resolveHandle", { 452 params: { handle }, 453 }); 454 } 455 456 async loginDeactivated( 457 identifier: string, 458 password: string, 459 ): Promise<Session> { 460 const session = await this.xrpc<Session>( 461 "com.atproto.server.createSession", 462 { 463 httpMethod: "POST", 464 body: { identifier, password, allowDeactivated: true }, 465 }, 466 ); 467 this.accessToken = session.accessJwt; 468 return session; 469 } 470 471 async verifyToken( 472 token: string, 473 identifier: string, 474 ): Promise< 475 { success: boolean; did: string; purpose: string; channel: string } 476 > { 477 return this.xrpc("com.tranquil.account.verifyToken", { 478 httpMethod: "POST", 479 body: { token, identifier }, 480 }); 481 } 482 483 async resendMigrationVerification(): Promise<void> { 484 await this.xrpc("com.atproto.server.resendMigrationVerification", { 485 httpMethod: "POST", 486 }); 487 } 488 489 async createPasskeyAccount( 490 params: CreatePasskeyAccountParams, 491 serviceToken?: string, 492 ): Promise<PasskeyAccountSetup> { 493 const headers: Record<string, string> = { 494 "Content-Type": "application/json", 495 }; 496 if (serviceToken) { 497 headers["Authorization"] = `Bearer ${serviceToken}`; 498 } 499 500 const res = await fetch( 501 `${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`, 502 { 503 method: "POST", 504 headers, 505 body: JSON.stringify(params), 506 }, 507 ); 508 509 if (!res.ok) { 510 const err = await res.json().catch(() => ({ 511 error: "Unknown", 512 message: res.statusText, 513 })); 514 const error = new Error(err.message || err.error || res.statusText) as 515 & Error 516 & { 517 status: number; 518 error: string; 519 }; 520 error.status = res.status; 521 error.error = err.error; 522 throw error; 523 } 524 525 return res.json(); 526 } 527 528 async startPasskeyRegistrationForSetup( 529 did: string, 530 setupToken: string, 531 friendlyName?: string, 532 ): Promise<StartPasskeyRegistrationResponse> { 533 return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 534 httpMethod: "POST", 535 body: { did, setupToken, friendlyName }, 536 }); 537 } 538 539 async completePasskeySetup( 540 did: string, 541 setupToken: string, 542 passkeyCredential: unknown, 543 passkeyFriendlyName?: string, 544 ): Promise<CompletePasskeySetupResponse> { 545 return this.xrpc("com.tranquil.account.completePasskeySetup", { 546 httpMethod: "POST", 547 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 548 }); 549 } 550} 551 552export async function getOAuthServerMetadata( 553 pdsUrl: string, 554): Promise<OAuthServerMetadata | null> { 555 try { 556 const directUrl = `${pdsUrl}/.well-known/oauth-authorization-server`; 557 const directRes = await fetch(directUrl); 558 if (directRes.ok) { 559 return directRes.json(); 560 } 561 562 const protectedResourceUrl = 563 `${pdsUrl}/.well-known/oauth-protected-resource`; 564 const protectedRes = await fetch(protectedResourceUrl); 565 if (!protectedRes.ok) { 566 return null; 567 } 568 569 const protectedMetadata = await protectedRes.json(); 570 const authServers = protectedMetadata.authorization_servers; 571 if (!authServers || authServers.length === 0) { 572 return null; 573 } 574 575 const authServerUrl = `${ 576 authServers[0] 577 }/.well-known/oauth-authorization-server`; 578 const authServerRes = await fetch(authServerUrl); 579 if (!authServerRes.ok) { 580 return null; 581 } 582 583 return authServerRes.json(); 584 } catch { 585 return null; 586 } 587} 588 589export async function generatePKCE(): Promise<{ 590 codeVerifier: string; 591 codeChallenge: string; 592}> { 593 const array = new Uint8Array(32); 594 crypto.getRandomValues(array); 595 const codeVerifier = base64UrlEncode(array); 596 597 const encoder = new TextEncoder(); 598 const data = encoder.encode(codeVerifier); 599 const digest = await crypto.subtle.digest("SHA-256", data); 600 const codeChallenge = base64UrlEncode(new Uint8Array(digest)); 601 602 return { codeVerifier, codeChallenge }; 603} 604 605export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string { 606 const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; 607 let binary = ""; 608 for (let i = 0; i < bytes.length; i++) { 609 binary += String.fromCharCode(bytes[i]); 610 } 611 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 612 /=+$/, 613 "", 614 ); 615} 616 617export function base64UrlDecode(base64url: string): Uint8Array { 618 const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); 619 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); 620 const binary = atob(padded); 621 const bytes = new Uint8Array(binary.length); 622 for (let i = 0; i < binary.length; i++) { 623 bytes[i] = binary.charCodeAt(i); 624 } 625 return bytes; 626} 627 628export function prepareWebAuthnCreationOptions( 629 options: { publicKey: Record<string, unknown> }, 630): PublicKeyCredentialCreationOptions { 631 const pk = options.publicKey; 632 return { 633 ...pk, 634 challenge: base64UrlDecode(pk.challenge as string), 635 user: { 636 ...(pk.user as Record<string, unknown>), 637 id: base64UrlDecode((pk.user as Record<string, unknown>).id as string), 638 }, 639 excludeCredentials: 640 ((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map( 641 (cred) => ({ 642 ...cred, 643 id: base64UrlDecode(cred.id as string), 644 }), 645 ), 646 } as PublicKeyCredentialCreationOptions; 647} 648 649async function computeAccessTokenHash(accessToken: string): Promise<string> { 650 const encoder = new TextEncoder(); 651 const data = encoder.encode(accessToken); 652 const hash = await crypto.subtle.digest("SHA-256", data); 653 return base64UrlEncode(new Uint8Array(hash)); 654} 655 656export function generateOAuthState(): string { 657 const array = new Uint8Array(16); 658 crypto.getRandomValues(array); 659 return base64UrlEncode(array); 660} 661 662export function buildOAuthAuthorizationUrl( 663 metadata: OAuthServerMetadata, 664 params: { 665 clientId: string; 666 redirectUri: string; 667 codeChallenge: string; 668 state: string; 669 scope?: string; 670 dpopJkt?: string; 671 loginHint?: string; 672 }, 673): string { 674 const url = new URL(metadata.authorization_endpoint); 675 url.searchParams.set("response_type", "code"); 676 url.searchParams.set("client_id", params.clientId); 677 url.searchParams.set("redirect_uri", params.redirectUri); 678 url.searchParams.set("code_challenge", params.codeChallenge); 679 url.searchParams.set("code_challenge_method", "S256"); 680 url.searchParams.set("state", params.state); 681 url.searchParams.set("scope", params.scope ?? "atproto"); 682 if (params.dpopJkt) { 683 url.searchParams.set("dpop_jkt", params.dpopJkt); 684 } 685 if (params.loginHint) { 686 url.searchParams.set("login_hint", params.loginHint); 687 } 688 return url.toString(); 689} 690 691export async function exchangeOAuthCode( 692 metadata: OAuthServerMetadata, 693 params: { 694 code: string; 695 codeVerifier: string; 696 clientId: string; 697 redirectUri: string; 698 dpopKeyPair?: DPoPKeyPair; 699 }, 700): Promise<OAuthTokenResponse> { 701 const body = new URLSearchParams({ 702 grant_type: "authorization_code", 703 code: params.code, 704 code_verifier: params.codeVerifier, 705 client_id: params.clientId, 706 redirect_uri: params.redirectUri, 707 }); 708 709 const makeRequest = async (nonce?: string): Promise<Response> => { 710 const headers: Record<string, string> = { 711 "Content-Type": "application/x-www-form-urlencoded", 712 }; 713 714 if (params.dpopKeyPair) { 715 const dpopProof = await createDPoPProof( 716 params.dpopKeyPair, 717 "POST", 718 metadata.token_endpoint, 719 nonce, 720 ); 721 headers["DPoP"] = dpopProof; 722 } 723 724 return fetch(metadata.token_endpoint, { 725 method: "POST", 726 headers, 727 body: body.toString(), 728 }); 729 }; 730 731 let res = await makeRequest(); 732 733 if (!res.ok) { 734 const err = await res.json().catch(() => ({ 735 error: "token_error", 736 error_description: res.statusText, 737 })); 738 739 if (err.error === "use_dpop_nonce" && params.dpopKeyPair) { 740 const dpopNonce = res.headers.get("DPoP-Nonce"); 741 if (dpopNonce) { 742 res = await makeRequest(dpopNonce); 743 if (!res.ok) { 744 const retryErr = await res.json().catch(() => ({ 745 error: "token_error", 746 error_description: res.statusText, 747 })); 748 throw new Error( 749 retryErr.error_description || retryErr.error || 750 "Token exchange failed", 751 ); 752 } 753 return res.json(); 754 } 755 } 756 757 throw new Error( 758 err.error_description || err.error || "Token exchange failed", 759 ); 760 } 761 762 return res.json(); 763} 764 765export async function resolveDidDocument(did: string): Promise<DidDocument> { 766 if (did.startsWith("did:plc:")) { 767 const res = await fetch(`https://plc.directory/${did}`); 768 if (!res.ok) { 769 throw new Error(`Failed to resolve DID: ${res.statusText}`); 770 } 771 return res.json(); 772 } 773 774 if (did.startsWith("did:web:")) { 775 const domain = did.slice(8).replace(/%3A/g, ":"); 776 const url = domain.includes("/") 777 ? `https://${domain}/did.json` 778 : `https://${domain}/.well-known/did.json`; 779 780 const res = await fetch(url); 781 if (!res.ok) { 782 throw new Error(`Failed to resolve DID: ${res.statusText}`); 783 } 784 return res.json(); 785 } 786 787 throw new Error(`Unsupported DID method: ${did}`); 788} 789 790export async function resolvePdsUrl( 791 handleOrDid: string, 792): Promise<{ did: string; pdsUrl: string }> { 793 let did: string | undefined; 794 795 if (handleOrDid.startsWith("did:")) { 796 did = handleOrDid; 797 } else { 798 const handle = handleOrDid.replace(/^@/, ""); 799 800 if (handle.endsWith(".bsky.social")) { 801 const res = await fetch( 802 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ 803 encodeURIComponent(handle) 804 }`, 805 ); 806 if (!res.ok) { 807 throw new Error(`Failed to resolve handle: ${res.statusText}`); 808 } 809 const data = await res.json(); 810 did = data.did; 811 } else { 812 const dnsRes = await fetch( 813 `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`, 814 ); 815 if (dnsRes.ok) { 816 const dnsData = await dnsRes.json(); 817 const txtRecords = dnsData.Answer ?? []; 818 for (const record of txtRecords) { 819 const txt = record.data?.replace(/"/g, "") ?? ""; 820 if (txt.startsWith("did=")) { 821 did = txt.slice(4); 822 break; 823 } 824 } 825 } 826 827 if (!did) { 828 const wellKnownRes = await fetch( 829 `https://${handle}/.well-known/atproto-did`, 830 ); 831 if (wellKnownRes.ok) { 832 did = (await wellKnownRes.text()).trim(); 833 } 834 } 835 836 if (!did) { 837 throw new Error(`Could not resolve handle: ${handle}`); 838 } 839 } 840 } 841 842 if (!did) { 843 throw new Error("Could not resolve DID"); 844 } 845 846 const didDoc = await resolveDidDocument(did); 847 848 const pdsService = didDoc.service?.find( 849 (s: { type: string }) => s.type === "AtprotoPersonalDataServer", 850 ); 851 852 if (!pdsService) { 853 throw new Error("No PDS service found in DID document"); 854 } 855 856 return { did, pdsUrl: pdsService.serviceEndpoint }; 857} 858 859export function createLocalClient(): AtprotoClient { 860 return new AtprotoClient(globalThis.location.origin); 861} 862 863export function getMigrationOAuthClientId(): string { 864 return `${globalThis.location.origin}/oauth/client-metadata.json`; 865} 866 867export function getMigrationOAuthRedirectUri(): string { 868 return `${globalThis.location.origin}/migrate`; 869} 870 871export interface DPoPKeyPair { 872 privateKey: CryptoKey; 873 publicKey: CryptoKey; 874 jwk: JsonWebKey; 875 thumbprint: string; 876} 877 878const DPOP_KEY_STORAGE = "migration_dpop_key"; 879const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000; 880 881export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { 882 const keyPair = await crypto.subtle.generateKey( 883 { 884 name: "ECDSA", 885 namedCurve: "P-256", 886 }, 887 true, 888 ["sign", "verify"], 889 ); 890 891 const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 892 const thumbprint = await computeJwkThumbprint(publicJwk); 893 894 return { 895 privateKey: keyPair.privateKey, 896 publicKey: keyPair.publicKey, 897 jwk: publicJwk, 898 thumbprint, 899 }; 900} 901 902async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> { 903 const thumbprintInput = JSON.stringify({ 904 crv: jwk.crv, 905 kty: jwk.kty, 906 x: jwk.x, 907 y: jwk.y, 908 }); 909 910 const encoder = new TextEncoder(); 911 const data = encoder.encode(thumbprintInput); 912 const hash = await crypto.subtle.digest("SHA-256", data); 913 return base64UrlEncode(new Uint8Array(hash)); 914} 915 916export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise<void> { 917 const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); 918 const stored = { 919 privateJwk, 920 publicJwk: keyPair.jwk, 921 thumbprint: keyPair.thumbprint, 922 createdAt: Date.now(), 923 }; 924 localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored)); 925} 926 927export async function loadDPoPKey(): Promise<DPoPKeyPair | null> { 928 const stored = localStorage.getItem(DPOP_KEY_STORAGE); 929 if (!stored) return null; 930 931 try { 932 const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored); 933 934 if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) { 935 localStorage.removeItem(DPOP_KEY_STORAGE); 936 return null; 937 } 938 939 const privateKey = await crypto.subtle.importKey( 940 "jwk", 941 privateJwk, 942 { name: "ECDSA", namedCurve: "P-256" }, 943 true, 944 ["sign"], 945 ); 946 947 const publicKey = await crypto.subtle.importKey( 948 "jwk", 949 publicJwk, 950 { name: "ECDSA", namedCurve: "P-256" }, 951 true, 952 ["verify"], 953 ); 954 955 return { privateKey, publicKey, jwk: publicJwk, thumbprint }; 956 } catch { 957 localStorage.removeItem(DPOP_KEY_STORAGE); 958 return null; 959 } 960} 961 962export function clearDPoPKey(): void { 963 localStorage.removeItem(DPOP_KEY_STORAGE); 964} 965 966export async function createDPoPProof( 967 keyPair: DPoPKeyPair, 968 httpMethod: string, 969 httpUri: string, 970 nonce?: string, 971 accessTokenHash?: string, 972): Promise<string> { 973 const header = { 974 typ: "dpop+jwt", 975 alg: "ES256", 976 jwk: { 977 kty: keyPair.jwk.kty, 978 crv: keyPair.jwk.crv, 979 x: keyPair.jwk.x, 980 y: keyPair.jwk.y, 981 }, 982 }; 983 984 const payload: Record<string, unknown> = { 985 jti: crypto.randomUUID(), 986 htm: httpMethod, 987 htu: httpUri, 988 iat: Math.floor(Date.now() / 1000), 989 }; 990 991 if (nonce) { 992 payload.nonce = nonce; 993 } 994 995 if (accessTokenHash) { 996 payload.ath = accessTokenHash; 997 } 998 999 const headerB64 = base64UrlEncode( 1000 new TextEncoder().encode(JSON.stringify(header)), 1001 ); 1002 const payloadB64 = base64UrlEncode( 1003 new TextEncoder().encode(JSON.stringify(payload)), 1004 ); 1005 1006 const signingInput = `${headerB64}.${payloadB64}`; 1007 const signature = await crypto.subtle.sign( 1008 { name: "ECDSA", hash: "SHA-256" }, 1009 keyPair.privateKey, 1010 new TextEncoder().encode(signingInput), 1011 ); 1012 1013 const signatureB64 = base64UrlEncode(new Uint8Array(signature)); 1014 return `${headerB64}.${payloadB64}.${signatureB64}`; 1015}