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