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