Main coves client
1import 'dart:async';
2import 'dart:convert';
3
4import 'package:dio/dio.dart';
5import 'package:flutter/foundation.dart' hide Key;
6
7import '../runtime/runtime_implementation.dart';
8
9/// A simple key-value store interface for storing DPoP nonces.
10///
11/// This is a simplified Dart version of @atproto-labs/simple-store.
12/// Implementations can use:
13/// - In-memory Map (for testing)
14/// - SharedPreferences (for persistence)
15/// - Secure storage (for sensitive data)
16abstract class SimpleStore<K, V> {
17 /// Get a value by key. Returns null if not found.
18 FutureOr<V?> get(K key);
19
20 /// Set a value for a key.
21 FutureOr<void> set(K key, V value);
22
23 /// Delete a value by key.
24 FutureOr<void> del(K key);
25
26 /// Clear all values (optional).
27 FutureOr<void> clear();
28}
29
30/// In-memory implementation of SimpleStore for DPoP nonces.
31///
32/// This is used as the default nonce store. Nonces are ephemeral and
33/// don't need to be persisted across app restarts.
34class InMemoryStore<K, V> implements SimpleStore<K, V> {
35 final Map<K, V> _store = {};
36
37 @override
38 V? get(K key) => _store[key];
39
40 @override
41 void set(K key, V value) => _store[key] = value;
42
43 @override
44 void del(K key) => _store.remove(key);
45
46 @override
47 void clear() => _store.clear();
48}
49
50/// Options for configuring the DPoP fetch wrapper.
51class DpopFetchWrapperOptions {
52 /// The cryptographic key used to sign DPoP proofs.
53 final Key key;
54
55 /// Store for caching DPoP nonces per origin.
56 final SimpleStore<String, String> nonces;
57
58 /// List of algorithms supported by the server (optional).
59 /// If not provided, the key's first algorithm will be used.
60 final List<String>? supportedAlgs;
61
62 /// Function to compute SHA-256 hash (required for DPoP).
63 /// Should return base64url-encoded hash.
64 final Future<String> Function(String input) sha256;
65
66 /// Whether the target server is an authorization server (true)
67 /// or resource server (false).
68 ///
69 /// This affects how "use_dpop_nonce" errors are detected:
70 /// - Authorization servers return 400 with JSON error
71 /// - Resource servers return 401 with WWW-Authenticate header
72 ///
73 /// If null, both patterns will be checked.
74 final bool? isAuthServer;
75
76 const DpopFetchWrapperOptions({
77 required this.key,
78 required this.nonces,
79 this.supportedAlgs,
80 required this.sha256,
81 this.isAuthServer,
82 });
83}
84
85/// Creates a Dio interceptor that adds DPoP (Demonstrating Proof of Possession)
86/// headers to HTTP requests.
87///
88/// DPoP is a security mechanism that binds access tokens to cryptographic keys,
89/// preventing token theft and replay attacks. It works by:
90///
91/// 1. Creating a JWT proof signed with a private key
92/// 2. Including the proof in a DPoP header
93/// 3. Including the access token hash (ath) in the proof
94/// 4. Handling nonce-based replay protection
95///
96/// The interceptor automatically:
97/// - Generates DPoP proofs for each request
98/// - Caches and reuses server-provided nonces
99/// - Retries requests when server requires a fresh nonce
100/// - Handles both authorization and resource server error formats
101///
102/// See: https://datatracker.ietf.org/doc/html/rfc9449
103///
104/// Example:
105/// ```dart
106/// final dio = Dio();
107/// final options = DpopFetchWrapperOptions(
108/// key: myKey,
109/// nonces: InMemoryStore(),
110/// sha256: runtime.sha256,
111/// );
112/// dio.interceptors.add(createDpopInterceptor(options));
113/// ```
114Interceptor createDpopInterceptor(DpopFetchWrapperOptions options) {
115 // Negotiate algorithm once at creation time
116 final alg = _negotiateAlg(options.key, options.supportedAlgs);
117
118 return InterceptorsWrapper(
119 onRequest: (requestOptions, handler) async {
120 try {
121 // Extract authorization header for ath calculation
122 final authHeader = requestOptions.headers['Authorization'] as String?;
123 final String? ath;
124 if (authHeader != null && authHeader.startsWith('DPoP ')) {
125 ath = await options.sha256(authHeader.substring(5));
126 } else {
127 ath = null;
128 }
129
130 final uri = requestOptions.uri;
131 final origin =
132 '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
133
134 final htm = requestOptions.method;
135 final htu = _buildHtu(uri.toString());
136
137 // Try to get cached nonce for this origin
138 String? initNonce;
139 try {
140 initNonce = await options.nonces.get(origin);
141 } catch (_) {
142 // Ignore nonce retrieval errors
143 }
144
145 // Build and add DPoP proof
146 final initProof = await _buildProof(
147 options.key,
148 alg,
149 htm,
150 htu,
151 initNonce,
152 ath,
153 );
154 requestOptions.headers['DPoP'] = initProof;
155
156 handler.next(requestOptions);
157 } catch (e) {
158 handler.reject(
159 DioException(
160 requestOptions: requestOptions,
161 error: 'Failed to create DPoP proof: $e',
162 type: DioExceptionType.unknown,
163 ),
164 );
165 }
166 },
167 onResponse: (response, handler) async {
168 try {
169 final uri = response.requestOptions.uri;
170
171 if (kDebugMode && uri.path.contains('/token')) {
172 print('🟢 DPoP interceptor onResponse triggered');
173 print(' URL: ${uri.path}');
174 print(' Status: ${response.statusCode}');
175 }
176
177 // Check for DPoP-Nonce header in response
178 final nextNonce = response.headers.value('dpop-nonce');
179
180 if (nextNonce != null) {
181 // Extract origin from request
182 final origin =
183 '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
184
185 // Store the fresh nonce for future requests
186 try {
187 await options.nonces.set(origin, nextNonce);
188 if (kDebugMode && uri.path.contains('/token')) {
189 print(' Cached nonce: ${nextNonce.substring(0, 20)}...');
190 }
191 } catch (_) {
192 // Ignore nonce storage errors
193 }
194 } else if (kDebugMode && uri.path.contains('/token')) {
195 print(' No nonce in response');
196 }
197
198 handler.next(response);
199 } catch (e) {
200 handler.reject(
201 DioException(
202 requestOptions: response.requestOptions,
203 response: response,
204 error: 'Failed to process DPoP nonce: $e',
205 type: DioExceptionType.unknown,
206 ),
207 );
208 }
209 },
210 onError: (error, handler) async {
211 final response = error.response;
212 if (response == null) {
213 handler.next(error);
214 return;
215 }
216
217 final uri = response.requestOptions.uri;
218
219 if (kDebugMode && uri.path.contains('/token')) {
220 print('🔴 DPoP interceptor onError triggered');
221 print(' URL: ${uri.path}');
222 print(' Status: ${response.statusCode}');
223 print(
224 ' Has validateStatus: ${response.requestOptions.validateStatus != null}',
225 );
226 }
227
228 // Check for DPoP-Nonce in error response
229 final nextNonce = response.headers.value('dpop-nonce');
230
231 if (nextNonce != null) {
232 // Extract origin
233 final origin =
234 '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
235
236 // Store the fresh nonce for future requests
237 try {
238 await options.nonces.set(origin, nextNonce);
239 if (kDebugMode && uri.path.contains('/token')) {
240 print(' Cached nonce: ${nextNonce.substring(0, 20)}...');
241 }
242 } catch (_) {
243 // Ignore nonce storage errors
244 }
245
246 // Check if this is a "use_dpop_nonce" error
247 final isNonceError = await _isUseDpopNonceError(
248 response,
249 options.isAuthServer,
250 );
251
252 if (kDebugMode && uri.path.contains('/token')) {
253 print(' Is use_dpop_nonce error: $isNonceError');
254 }
255
256 if (isNonceError) {
257 // IMPORTANT: Do NOT retry for token endpoint!
258 // Retrying the token exchange can consume the authorization code,
259 // causing "Invalid code" errors on the retry.
260 //
261 // Instead, we rely on pre-fetching the nonce before critical operations
262 // (like authorization code exchange) to ensure we have a valid nonce
263 // from the start.
264 //
265 // We still cache the nonce for future requests, but we don't retry
266 // this particular request.
267 final isTokenEndpoint =
268 uri.path.contains('/token') || uri.path.endsWith('/token');
269
270 if (kDebugMode && isTokenEndpoint) {
271 print('⚠️ DPoP nonce error on token endpoint - NOT retrying');
272 print(' Cached fresh nonce for future requests');
273 }
274
275 if (isTokenEndpoint) {
276 // Don't retry - just pass through the error with the nonce cached
277 handler.next(error);
278 return;
279 }
280
281 // For non-token endpoints, retry is safe
282 if (kDebugMode) {
283 print('🔄 DPoP retry for non-token endpoint: ${uri.path}');
284 }
285
286 try {
287 final authHeader =
288 response.requestOptions.headers['Authorization'] as String?;
289 final String? ath;
290 if (authHeader != null && authHeader.startsWith('DPoP ')) {
291 ath = await options.sha256(authHeader.substring(5));
292 } else {
293 ath = null;
294 }
295
296 final htm = response.requestOptions.method;
297 final htu = _buildHtu(uri.toString());
298
299 final nextProof = await _buildProof(
300 options.key,
301 alg,
302 htm,
303 htu,
304 nextNonce,
305 ath,
306 );
307
308 // Clone request options and update DPoP header
309 final retryOptions = Options(
310 method: response.requestOptions.method,
311 headers: {...response.requestOptions.headers, 'DPoP': nextProof},
312 );
313
314 // Retry the request
315 final dio = Dio();
316 final retryResponse = await dio.request(
317 response.requestOptions.path,
318 options: retryOptions,
319 data: response.requestOptions.data,
320 queryParameters: response.requestOptions.queryParameters,
321 );
322
323 handler.resolve(retryResponse);
324 return;
325 } catch (retryError) {
326 // If retry fails, return the retry error
327 if (retryError is DioException) {
328 handler.next(retryError);
329 } else {
330 handler.next(
331 DioException(
332 requestOptions: response.requestOptions,
333 error: retryError,
334 type: DioExceptionType.unknown,
335 ),
336 );
337 }
338 return;
339 }
340 }
341 }
342
343 if (kDebugMode && uri.path.contains('/token')) {
344 print('🔴 DPoP interceptor passing error through (no retry)');
345 }
346
347 handler.next(error);
348 },
349 );
350}
351
352/// Strips query string and fragment from URL.
353///
354/// Per RFC 9449, the htu (HTTP URI) claim must not include query or fragment.
355///
356/// See: https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6
357String _buildHtu(String url) {
358 final fragmentIndex = url.indexOf('#');
359 final queryIndex = url.indexOf('?');
360
361 final int end;
362 if (fragmentIndex == -1) {
363 end = queryIndex;
364 } else if (queryIndex == -1) {
365 end = fragmentIndex;
366 } else {
367 end = fragmentIndex < queryIndex ? fragmentIndex : queryIndex;
368 }
369
370 return end == -1 ? url : url.substring(0, end);
371}
372
373/// Builds a DPoP proof JWT.
374///
375/// The proof is a JWT with:
376/// - Header: typ="dpop+jwt", alg, jwk (public key)
377/// - Payload: iat, jti, htm, htu, nonce?, ath?
378///
379/// See: https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
380Future<String> _buildProof(
381 Key key,
382 String alg,
383 String htm,
384 String htu,
385 String? nonce,
386 String? ath,
387) async {
388 final jwk = key.bareJwk;
389 if (jwk == null) {
390 throw StateError('Only asymmetric keys can be used for DPoP proofs');
391 }
392
393 final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
394
395 // Create header
396 final header = {'alg': alg, 'typ': 'dpop+jwt', 'jwk': jwk};
397
398 // Create payload
399 final payload = {
400 'iat': now,
401 // Random jti to prevent replay attacks
402 // Any collision will cause server rejection, which is acceptable
403 'jti': DateTime.now().microsecondsSinceEpoch.toString(),
404 'htm': htm,
405 'htu': htu,
406 if (nonce != null) 'nonce': nonce,
407 if (ath != null) 'ath': ath,
408 };
409
410 if (kDebugMode && htu.contains('/token')) {
411 print('🔐 Creating DPoP proof for token request:');
412 print(' htm: $htm');
413 print(' htu: $htu');
414 print(' nonce: ${nonce ?? "none"}');
415 print(' ath: ${ath ?? "none"}');
416 print(' jwk keys: ${jwk?.keys.toList()}');
417 }
418
419 final jwt = await key.createJwt(header, payload);
420
421 if (kDebugMode && htu.contains('/token')) {
422 print(' ✅ DPoP proof created: ${jwt.substring(0, 50)}...');
423 }
424
425 return jwt;
426}
427
428/// Checks if a response indicates a "use_dpop_nonce" error.
429///
430/// There are two error formats depending on server type:
431///
432/// 1. Resource Server (RFC 6750): 401 with WWW-Authenticate header
433/// WWW-Authenticate: DPoP error="use_dpop_nonce"
434///
435/// 2. Authorization Server: 400 with JSON body
436/// {"error": "use_dpop_nonce"}
437///
438/// See:
439/// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
440/// - https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
441Future<bool> _isUseDpopNonceError(Response response, bool? isAuthServer) async {
442 // Check resource server error format (401 + WWW-Authenticate)
443 if (isAuthServer == null || isAuthServer == false) {
444 if (response.statusCode == 401) {
445 final wwwAuth = response.headers.value('www-authenticate');
446 if (wwwAuth != null && wwwAuth.startsWith('DPoP')) {
447 return wwwAuth.contains('error="use_dpop_nonce"');
448 }
449 }
450 }
451
452 // Check authorization server error format (400 + JSON error)
453 if (isAuthServer == null || isAuthServer == true) {
454 if (response.statusCode == 400) {
455 try {
456 final data = response.data;
457 if (data is Map<String, dynamic>) {
458 return data['error'] == 'use_dpop_nonce';
459 } else if (data is String) {
460 // Try to parse as JSON
461 final json = jsonDecode(data);
462 if (json is Map<String, dynamic>) {
463 return json['error'] == 'use_dpop_nonce';
464 }
465 }
466 } catch (_) {
467 // Invalid JSON or response too large, not a use_dpop_nonce error
468 return false;
469 }
470 }
471 }
472
473 return false;
474}
475
476/// Negotiates the algorithm to use for DPoP proofs.
477///
478/// If supportedAlgs is provided, uses the first algorithm that the key supports.
479/// Otherwise, uses the key's first algorithm.
480///
481/// Throws if the key doesn't support any of the server's algorithms.
482String _negotiateAlg(Key key, List<String>? supportedAlgs) {
483 if (supportedAlgs != null) {
484 // Use order of supportedAlgs as preference
485 for (final alg in supportedAlgs) {
486 if (key.algorithms.contains(alg)) {
487 return alg;
488 }
489 }
490 throw StateError(
491 'Key does not match any algorithm supported by the server. '
492 'Key supports: ${key.algorithms}, server supports: $supportedAlgs',
493 );
494 }
495
496 // No server preference, use key's first algorithm
497 if (key.algorithms.isEmpty) {
498 throw StateError('Key does not support any algorithms');
499 }
500
501 return key.algorithms.first;
502}