alf: the atproto Latency Fabric
alf.fly.dev/
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}