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}