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