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