A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
1/**
2 * DPoP (Demonstrating Proof of Possession) implementation for AT Protocol
3 * Uses Web Crypto API for Deno compatibility
4 */
5
6import { exportJWK, SignJWT } from "@panva/jose";
7import { DPoPError } from "./errors.ts";
8
9/** Module-level nonce cache: maps origin to latest DPoP nonce */
10const nonceCache = new Map<string, string>();
11
12/** Get cached nonce for a URL's origin */
13export function getCachedNonce(url: string): string | undefined {
14 return nonceCache.get(new URL(url).origin);
15}
16
17/** Update nonce cache from a response's DPoP-Nonce header */
18export function updateNonceCache(url: string, response: Response): void {
19 const nonce = response.headers.get("DPoP-Nonce");
20 if (nonce) {
21 nonceCache.set(new URL(url).origin, nonce);
22 }
23}
24
25export interface DPoPKeyPair {
26 privateKey: CryptoKey;
27 publicKey: CryptoKey;
28 privateKeyJWK: JsonWebKey;
29 publicKeyJWK: JsonWebKey;
30}
31
32/**
33 * Generate ES256 key pair for DPoP operations
34 */
35export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
36 try {
37 const keyPair = await crypto.subtle.generateKey(
38 {
39 name: "ECDSA",
40 namedCurve: "P-256",
41 },
42 true, // extractable
43 ["sign", "verify"],
44 );
45
46 // Export keys as JWK
47 const publicKeyJWK = await exportJWK(keyPair.publicKey);
48 const privateKeyJWK = await exportJWK(keyPair.privateKey);
49
50 return {
51 privateKey: keyPair.privateKey,
52 publicKey: keyPair.publicKey,
53 privateKeyJWK,
54 publicKeyJWK,
55 };
56 } catch (error) {
57 throw new DPoPError("Failed to generate DPoP key pair", error as Error);
58 }
59}
60
61/**
62 * Generate DPoP proof JWT
63 */
64export async function generateDPoPProof(
65 method: string,
66 url: string,
67 privateKey: CryptoKey,
68 publicKeyJWK: JsonWebKey,
69 accessToken?: string,
70 nonce?: string,
71): Promise<string> {
72 try {
73 // Normalize htu per RFC 9449: strip query and fragment
74 const htuUrl = new URL(url);
75 const htu = `${htuUrl.origin}${htuUrl.pathname}`;
76
77 // Create DPoP JWT payload
78 const payload: Record<string, unknown> = {
79 jti: crypto.randomUUID(),
80 htm: method,
81 htu,
82 iat: Math.floor(Date.now() / 1000),
83 exp: Math.floor(Date.now() / 1000) + (5 * 60), // Expires in 5 minutes
84 };
85
86 if (accessToken) {
87 // Hash access token for ath claim
88 const encoder = new TextEncoder();
89 const data = encoder.encode(accessToken);
90 const digest = await crypto.subtle.digest("SHA-256", data);
91 payload.ath = btoa(String.fromCharCode(...new Uint8Array(digest)))
92 .replace(/[+/]/g, (match) => match === "+" ? "-" : "_")
93 .replace(/=/g, "");
94 }
95
96 if (nonce) {
97 payload.nonce = nonce;
98 }
99
100 // Sign JWT using Web Crypto
101 const dpopProof = await new SignJWT(payload)
102 .setProtectedHeader({
103 typ: "dpop+jwt",
104 alg: "ES256",
105 jwk: publicKeyJWK,
106 })
107 .sign(privateKey);
108
109 return dpopProof;
110 } catch (error) {
111 throw new DPoPError("Failed to generate DPoP proof", error as Error);
112 }
113}
114
115/**
116 * Import private key from JWK for DPoP operations
117 */
118export async function importPrivateKeyFromJWK(
119 privateKeyJWK: JsonWebKey,
120): Promise<CryptoKey> {
121 try {
122 // Validate required JWK fields for EC private key
123 if (
124 typeof privateKeyJWK.kty !== "string" ||
125 typeof privateKeyJWK.crv !== "string" ||
126 typeof privateKeyJWK.x !== "string" ||
127 typeof privateKeyJWK.y !== "string" ||
128 typeof privateKeyJWK.d !== "string"
129 ) {
130 throw new Error("Invalid JWK format: missing required EC private key fields");
131 }
132
133 // Clean JWK to remove any conflicting key_ops that might have been added by exportJWK
134 const cleanJWK: JsonWebKey = {
135 kty: privateKeyJWK.kty,
136 crv: privateKeyJWK.crv,
137 x: privateKeyJWK.x,
138 y: privateKeyJWK.y,
139 d: privateKeyJWK.d,
140 };
141
142 return await crypto.subtle.importKey(
143 "jwk",
144 cleanJWK,
145 {
146 name: "ECDSA",
147 namedCurve: "P-256",
148 },
149 false, // not extractable
150 ["sign"],
151 );
152 } catch (error) {
153 throw new DPoPError("Failed to import private key from JWK", error as Error);
154 }
155}
156
157/**
158 * Make authenticated DPoP request with automatic nonce handling
159 */
160export async function makeDPoPRequest(
161 method: string,
162 url: string,
163 accessToken: string,
164 privateKey: CryptoKey,
165 publicKeyJWK: JsonWebKey,
166 body?: string,
167 headers: HeadersInit = {},
168): Promise<Response> {
169 try {
170 // Check nonce cache for this origin
171 const cachedNonce = getCachedNonce(url);
172
173 // Generate initial DPoP proof (with cached nonce if available)
174 let dpopProof = await generateDPoPProof(
175 method,
176 url,
177 privateKey,
178 publicKeyJWK,
179 accessToken,
180 cachedNonce,
181 );
182
183 const requestHeaders: HeadersInit = {
184 "Authorization": `DPoP ${accessToken}`,
185 "DPoP": dpopProof,
186 "Content-Type": "application/json",
187 ...headers,
188 };
189
190 const fetchOptions: RequestInit = {
191 method,
192 headers: requestHeaders,
193 };
194 if (body) {
195 fetchOptions.body = body;
196 }
197
198 let response = await fetch(url, fetchOptions);
199
200 // Always update nonce cache from response
201 updateNonceCache(url, response);
202
203 // Handle DPoP nonce challenge
204 if (response.status === 401) {
205 const dpopNonce = response.headers.get("DPoP-Nonce");
206 if (dpopNonce) {
207 // Generate new proof with nonce
208 dpopProof = await generateDPoPProof(
209 method,
210 url,
211 privateKey,
212 publicKeyJWK,
213 accessToken,
214 dpopNonce,
215 );
216
217 (requestHeaders as Record<string, string>)["DPoP"] = dpopProof;
218
219 const retryOptions: RequestInit = {
220 method,
221 headers: requestHeaders,
222 };
223 if (body) {
224 retryOptions.body = body;
225 }
226
227 response = await fetch(url, retryOptions);
228 updateNonceCache(url, response);
229 }
230 }
231
232 return response;
233 } catch (error) {
234 throw new DPoPError("Failed to make DPoP request", error as Error);
235 }
236}