A Deno-compatible AT Protocol OAuth client that serves as a drop-in replacement for @atproto/oauth-client-node
at main 236 lines 6.1 kB view raw
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}