alf: the atproto Latency Fabric alf.fly.dev/
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 204 lines 6.6 kB view raw
1// ABOUTME: Inbound authentication - verifies PDS Bearer tokens from clients 2// Validates ATProto access tokens by fetching JWKS from the issuing PDS 3 4import crypto from 'crypto'; 5import * as jose from 'jose'; 6import { createLogger } from './logger.js'; 7 8const logger = createLogger('Auth'); 9 10export interface VerifiedUser { 11 did: string; 12} 13 14// Cache of JWKS sets keyed by PDS base URL 15const jwksSetsCache = new Map<string, ReturnType<typeof jose.createRemoteJWKSet>>(); 16 17function getJwksSet(pdsUrl: string): ReturnType<typeof jose.createRemoteJWKSet> { 18 const cached = jwksSetsCache.get(pdsUrl); 19 if (cached) return cached; 20 21 // ATProto PDS OAuth JWKS endpoint 22 const jwksUri = new URL('/oauth/jwks', pdsUrl); 23 const jwksSet = jose.createRemoteJWKSet(jwksUri); 24 jwksSetsCache.set(pdsUrl, jwksSet); 25 return jwksSet; 26} 27 28/** 29 * Clear the JWKS cache (used in tests) 30 */ 31export function clearJwksCache(): void { 32 jwksSetsCache.clear(); 33} 34 35/** 36 * Extract Bearer token from Authorization header 37 */ 38export function extractBearerToken(authHeader: string | undefined): string { 39 if (!authHeader) { 40 throw new Error('Missing Authorization header'); 41 } 42 43 const parts = authHeader.split(' '); 44 if (parts.length !== 2 || (parts[0] !== 'Bearer' && parts[0] !== 'DPoP')) { 45 throw new Error('Invalid Authorization header format - expected "Bearer <token>" or "DPoP <token>"'); 46 } 47 48 return parts[1]; 49} 50 51/** 52 * Verify a PDS Bearer token and extract the user's DID. 53 * 54 * For ATProto OAuth tokens (asymmetric): performs full JWKS signature verification. 55 * For legacy HS256 tokens: validates expiry and extracts sub (pragmatic dev fallback). 56 * 57 * @param token JWT Bearer token from the PDS 58 * @param pdsUrl Base URL of the PDS (used to fetch JWKS) 59 * @returns The verified user's DID 60 */ 61export async function verifyBearerToken( 62 token: string, 63 pdsUrl: string, 64): Promise<VerifiedUser> { 65 // Decode without verification to inspect the token 66 let header: jose.ProtectedHeaderParameters; 67 try { 68 header = jose.decodeProtectedHeader(token); 69 } catch { 70 throw new Error('Invalid JWT: cannot decode header'); 71 } 72 73 // Check for symmetric algorithms (legacy createSession tokens) 74 if (header.alg === 'HS256' || header.alg === 'HS384' || header.alg === 'HS512') { 75 logger.warn('Received legacy HS256 token - falling back to expiry-only validation', { 76 alg: header.alg, 77 }); 78 79 const payload = jose.decodeJwt(token); 80 if (!payload.sub) { 81 throw new Error('Missing sub claim in JWT'); 82 } 83 const now = Math.floor(Date.now() / 1000); 84 if (payload.exp && now > payload.exp) { 85 throw new Error('JWT token expired'); 86 } 87 return { did: payload.sub }; 88 } 89 90 // Asymmetric token - full JWKS verification 91 const jwks = getJwksSet(pdsUrl); 92 93 let payload: jose.JWTPayload; 94 try { 95 const result = await jose.jwtVerify(token, jwks); 96 payload = result.payload; 97 } catch (err) { 98 const message = err instanceof Error ? err.message : String(err); 99 throw new Error(`JWT verification failed: ${message}`); 100 } 101 102 if (!payload.sub) { 103 throw new Error('Missing sub claim in verified JWT'); 104 } 105 106 return { did: payload.sub }; 107} 108 109/** 110 * Verify a DPoP-bound access token using the DPoP proof. 111 * 112 * Does not require a JWKS endpoint — the DPoP proof header embeds the public 113 * key and the access token binds to it via cnf.jkt. Together they prove the 114 * caller holds the DPoP private key and presented the correct access token. 115 */ 116export async function verifyDpopBoundToken( 117 accessToken: string, 118 dpopProof: string, 119): Promise<VerifiedUser> { 120 // Decode access token payload without signature verification. 121 // We trust sub/exp/cnf because we verify the DPoP binding below. 122 let atPayload: jose.JWTPayload; 123 try { 124 atPayload = jose.decodeJwt(accessToken); 125 } catch { 126 throw new Error('Invalid access token: cannot decode payload'); 127 } 128 129 if (!atPayload.sub) throw new Error('Missing sub claim in access token'); 130 131 const now = Math.floor(Date.now() / 1000); 132 if (atPayload.exp && now > atPayload.exp) throw new Error('Access token expired'); 133 134 const cnf = atPayload.cnf as Record<string, string> | undefined; 135 if (!cnf?.jkt) throw new Error('Missing cnf.jkt in access token (not a DPoP-bound token)'); 136 137 // Extract the public key embedded in the DPoP proof header 138 let dpopHeader: jose.ProtectedHeaderParameters; 139 try { 140 dpopHeader = jose.decodeProtectedHeader(dpopProof); 141 } catch { 142 throw new Error('Invalid DPoP proof: cannot decode header'); 143 } 144 145 const dpopJwk = dpopHeader.jwk as jose.JWK | undefined; 146 if (!dpopJwk) throw new Error('Missing jwk in DPoP proof header'); 147 148 // Verify the DPoP key thumbprint matches cnf.jkt in the access token 149 const keyThumbprint = await jose.calculateJwkThumbprint(dpopJwk); 150 if (keyThumbprint !== cnf.jkt) { 151 throw new Error('DPoP key thumbprint does not match cnf.jkt in access token'); 152 } 153 154 // Verify the DPoP proof signature using the embedded public key 155 const dpopPublicKey = await jose.importJWK(dpopJwk); 156 let dpopPayload: jose.JWTPayload; 157 try { 158 const result = await jose.jwtVerify(dpopProof, dpopPublicKey, { typ: 'dpop+jwt' }); 159 dpopPayload = result.payload; 160 } catch (err) { 161 throw new Error(`DPoP proof verification failed: ${err instanceof Error ? err.message : String(err)}`); 162 } 163 164 // Verify the DPoP proof is fresh (prevent replay) 165 if (typeof dpopPayload.iat === 'number' && now - dpopPayload.iat > 60) { 166 throw new Error('DPoP proof too old'); 167 } 168 169 // Verify ath: DPoP proof must be cryptographically bound to this access token 170 const tokenHash = crypto.createHash('sha256').update(accessToken).digest('base64url'); 171 if (dpopPayload.ath !== tokenHash) { 172 throw new Error('DPoP ath claim does not match access token hash'); 173 } 174 175 return { did: atPayload.sub }; 176} 177 178/** 179 * Extract and verify a Bearer token from the Authorization header. 180 * Returns the verified user's DID. 181 */ 182export async function verifyRequestAuth( 183 authHeader: string | undefined, 184 pdsUrl: string, 185): Promise<VerifiedUser> { 186 const token = extractBearerToken(authHeader); 187 return verifyBearerToken(token, pdsUrl); 188} 189 190/** 191 * Extract the PDS URL from a JWT's iss claim. 192 * Falls back to a default PDS URL if the iss is not a URL. 193 */ 194export function extractPdsUrlFromToken(token: string, defaultPdsUrl: string): string { 195 try { 196 const payload = jose.decodeJwt(token); 197 if (payload.iss && payload.iss.startsWith('http')) { 198 return payload.iss; 199 } 200 } catch { 201 // fall through 202 } 203 return defaultPdsUrl; 204}