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 // Check for nonce errors in successful responses (when validateStatus: true)
199 // This handles the case where Dio returns 401 as a successful response
200 if (nextNonce != null && await _isUseDpopNonceError(response, options.isAuthServer)) {
201 final isTokenEndpoint =
202 uri.path.contains('/token') || uri.path.endsWith('/token');
203
204 if (kDebugMode) {
205 print('⚠️ DPoP nonce error in response (status ${response.statusCode})');
206 print(' Is token endpoint: $isTokenEndpoint');
207 }
208
209 if (isTokenEndpoint) {
210 // Don't retry token endpoint - just pass through with nonce cached
211 if (kDebugMode) {
212 print(' NOT retrying token endpoint (nonce cached for next attempt)');
213 }
214 handler.next(response);
215 return;
216 }
217
218 // For non-token endpoints, retry is safe
219 if (kDebugMode) {
220 print('🔄 Retrying request with fresh nonce');
221 }
222
223 try {
224 final authHeader =
225 response.requestOptions.headers['Authorization'] as String?;
226 final String? ath;
227 if (authHeader != null && authHeader.startsWith('DPoP ')) {
228 ath = await options.sha256(authHeader.substring(5));
229 } else {
230 ath = null;
231 }
232
233 final htm = response.requestOptions.method;
234 final htu = _buildHtu(uri.toString());
235
236 final nextProof = await _buildProof(
237 options.key,
238 alg,
239 htm,
240 htu,
241 nextNonce,
242 ath,
243 );
244
245 // Clone request options and update DPoP header
246 // Note: We preserve validateStatus to match original request behavior
247 final retryOptions = Options(
248 method: response.requestOptions.method,
249 headers: {...response.requestOptions.headers, 'DPoP': nextProof},
250 validateStatus: response.requestOptions.validateStatus,
251 );
252
253 // DESIGN NOTE: We create a fresh Dio instance for retry to avoid
254 // re-triggering this interceptor (which would cause infinite loops).
255 // This means base options (timeouts, etc.) are not preserved, but
256 // this is acceptable for DPoP nonce retry scenarios which should be fast.
257 // If this becomes an issue, we could inject a Dio factory function.
258 final dio = Dio();
259 final retryResponse = await dio.requestUri(
260 uri,
261 options: retryOptions,
262 data: response.requestOptions.data,
263 );
264
265 handler.resolve(retryResponse);
266 return;
267 } catch (retryError) {
268 if (kDebugMode) {
269 print('❌ Retry failed: $retryError');
270 }
271 // If retry fails, return the original response
272 handler.next(response);
273 return;
274 }
275 }
276
277 handler.next(response);
278 } catch (e) {
279 handler.reject(
280 DioException(
281 requestOptions: response.requestOptions,
282 response: response,
283 error: 'Failed to process DPoP nonce: $e',
284 type: DioExceptionType.unknown,
285 ),
286 );
287 }
288 },
289 onError: (error, handler) async {
290 final response = error.response;
291 if (response == null) {
292 handler.next(error);
293 return;
294 }
295
296 final uri = response.requestOptions.uri;
297
298 if (kDebugMode && uri.path.contains('/token')) {
299 print('🔴 DPoP interceptor onError triggered');
300 print(' URL: ${uri.path}');
301 print(' Status: ${response.statusCode}');
302 print(
303 ' Has validateStatus: ${response.requestOptions.validateStatus != null}',
304 );
305 }
306
307 // Check for DPoP-Nonce in error response
308 final nextNonce = response.headers.value('dpop-nonce');
309
310 if (nextNonce != null) {
311 // Extract origin
312 final origin =
313 '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
314
315 // Store the fresh nonce for future requests
316 try {
317 await options.nonces.set(origin, nextNonce);
318 if (kDebugMode && uri.path.contains('/token')) {
319 print(' Cached nonce: ${nextNonce.substring(0, 20)}...');
320 }
321 } catch (_) {
322 // Ignore nonce storage errors
323 }
324
325 // Check if this is a "use_dpop_nonce" error
326 final isNonceError = await _isUseDpopNonceError(
327 response,
328 options.isAuthServer,
329 );
330
331 if (kDebugMode && uri.path.contains('/token')) {
332 print(' Is use_dpop_nonce error: $isNonceError');
333 }
334
335 if (isNonceError) {
336 // IMPORTANT: Do NOT retry for token endpoint!
337 // Retrying the token exchange can consume the authorization code,
338 // causing "Invalid code" errors on the retry.
339 //
340 // Instead, we rely on pre-fetching the nonce before critical operations
341 // (like authorization code exchange) to ensure we have a valid nonce
342 // from the start.
343 //
344 // We still cache the nonce for future requests, but we don't retry
345 // this particular request.
346 final isTokenEndpoint =
347 uri.path.contains('/token') || uri.path.endsWith('/token');
348
349 if (kDebugMode && isTokenEndpoint) {
350 print('⚠️ DPoP nonce error on token endpoint - NOT retrying');
351 print(' Cached fresh nonce for future requests');
352 }
353
354 if (isTokenEndpoint) {
355 // Don't retry - just pass through the error with the nonce cached
356 handler.next(error);
357 return;
358 }
359
360 // For non-token endpoints, retry is safe
361 if (kDebugMode) {
362 print('🔄 DPoP retry for non-token endpoint: ${uri.path}');
363 }
364
365 try {
366 final authHeader =
367 response.requestOptions.headers['Authorization'] as String?;
368 final String? ath;
369 if (authHeader != null && authHeader.startsWith('DPoP ')) {
370 ath = await options.sha256(authHeader.substring(5));
371 } else {
372 ath = null;
373 }
374
375 final htm = response.requestOptions.method;
376 final htu = _buildHtu(uri.toString());
377
378 final nextProof = await _buildProof(
379 options.key,
380 alg,
381 htm,
382 htu,
383 nextNonce,
384 ath,
385 );
386
387 // Clone request options and update DPoP header
388 // Note: We preserve validateStatus to match original request behavior
389 final retryOptions = Options(
390 method: response.requestOptions.method,
391 headers: {...response.requestOptions.headers, 'DPoP': nextProof},
392 validateStatus: response.requestOptions.validateStatus,
393 );
394
395 // DESIGN NOTE: We create a fresh Dio instance for retry to avoid
396 // re-triggering this interceptor (which would cause infinite loops).
397 // This means base options (timeouts, etc.) are not preserved, but
398 // this is acceptable for DPoP nonce retry scenarios which should be fast.
399 // If this becomes an issue, we could inject a Dio factory function.
400 final dio = Dio();
401 final retryResponse = await dio.requestUri(
402 uri,
403 options: retryOptions,
404 data: response.requestOptions.data,
405 );
406
407 handler.resolve(retryResponse);
408 return;
409 } catch (retryError) {
410 // If retry fails, return the retry error
411 if (retryError is DioException) {
412 handler.next(retryError);
413 } else {
414 handler.next(
415 DioException(
416 requestOptions: response.requestOptions,
417 error: retryError,
418 type: DioExceptionType.unknown,
419 ),
420 );
421 }
422 return;
423 }
424 }
425 }
426
427 if (kDebugMode && uri.path.contains('/token')) {
428 print('🔴 DPoP interceptor passing error through (no retry)');
429 }
430
431 handler.next(error);
432 },
433 );
434}
435
436/// Strips query string and fragment from URL.
437///
438/// Per RFC 9449, the htu (HTTP URI) claim must not include query or fragment.
439///
440/// See: https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6
441String _buildHtu(String url) {
442 final fragmentIndex = url.indexOf('#');
443 final queryIndex = url.indexOf('?');
444
445 final int end;
446 if (fragmentIndex == -1) {
447 end = queryIndex;
448 } else if (queryIndex == -1) {
449 end = fragmentIndex;
450 } else {
451 end = fragmentIndex < queryIndex ? fragmentIndex : queryIndex;
452 }
453
454 return end == -1 ? url : url.substring(0, end);
455}
456
457/// Builds a DPoP proof JWT.
458///
459/// The proof is a JWT with:
460/// - Header: typ="dpop+jwt", alg, jwk (public key)
461/// - Payload: iat, jti, htm, htu, nonce?, ath?
462///
463/// See: https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
464Future<String> _buildProof(
465 Key key,
466 String alg,
467 String htm,
468 String htu,
469 String? nonce,
470 String? ath,
471) async {
472 final jwk = key.bareJwk;
473 if (jwk == null) {
474 throw StateError('Only asymmetric keys can be used for DPoP proofs');
475 }
476
477 final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
478
479 // Create header
480 final header = {'alg': alg, 'typ': 'dpop+jwt', 'jwk': jwk};
481
482 // Create payload
483 final payload = {
484 'iat': now,
485 // Random jti to prevent replay attacks
486 // Any collision will cause server rejection, which is acceptable
487 'jti': DateTime.now().microsecondsSinceEpoch.toString(),
488 'htm': htm,
489 'htu': htu,
490 if (nonce != null) 'nonce': nonce,
491 if (ath != null) 'ath': ath,
492 };
493
494 if (kDebugMode && htu.contains('/token')) {
495 print('🔐 Creating DPoP proof for token request:');
496 print(' htm: $htm');
497 print(' htu: $htu');
498 print(' nonce: ${nonce ?? "none"}');
499 print(' ath: ${ath ?? "none"}');
500 print(' jwk keys: ${jwk?.keys.toList()}');
501 }
502
503 final jwt = await key.createJwt(header, payload);
504
505 if (kDebugMode && htu.contains('/token')) {
506 print(' ✅ DPoP proof created: ${jwt.substring(0, 50)}...');
507 }
508
509 return jwt;
510}
511
512/// Checks if a response indicates a "use_dpop_nonce" error.
513///
514/// There are multiple error formats depending on server implementation:
515///
516/// 1. Resource Server (RFC 6750): 401 with WWW-Authenticate header
517/// WWW-Authenticate: DPoP error="use_dpop_nonce"
518///
519/// 2. Authorization Server: 400 with JSON body
520/// {"error": "use_dpop_nonce"}
521///
522/// 3. Resource Server (JSON variant): 401 with JSON body
523/// {"error": "use_dpop_nonce"}
524///
525/// See:
526/// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
527/// - https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
528Future<bool> _isUseDpopNonceError(Response response, bool? isAuthServer) async {
529 // Check WWW-Authenticate header format (401 + header)
530 if (response.statusCode == 401) {
531 final wwwAuth = response.headers.value('www-authenticate');
532 if (wwwAuth != null && wwwAuth.startsWith('DPoP')) {
533 if (wwwAuth.contains('error="use_dpop_nonce"')) {
534 return true;
535 }
536 }
537 }
538
539 // Check JSON body format (400 or 401 + JSON)
540 // Some servers use 401 + JSON instead of WWW-Authenticate header
541 if (response.statusCode == 400 || response.statusCode == 401) {
542 try {
543 final data = response.data;
544 if (data is Map<String, dynamic>) {
545 return data['error'] == 'use_dpop_nonce';
546 } else if (data is String) {
547 // Try to parse as JSON
548 final json = jsonDecode(data);
549 if (json is Map<String, dynamic>) {
550 return json['error'] == 'use_dpop_nonce';
551 }
552 }
553 } catch (_) {
554 // Invalid JSON or response too large, not a use_dpop_nonce error
555 return false;
556 }
557 }
558
559 return false;
560}
561
562/// Negotiates the algorithm to use for DPoP proofs.
563///
564/// If supportedAlgs is provided, uses the first algorithm that the key supports.
565/// Otherwise, uses the key's first algorithm.
566///
567/// Throws if the key doesn't support any of the server's algorithms.
568String _negotiateAlg(Key key, List<String>? supportedAlgs) {
569 if (supportedAlgs != null) {
570 // Use order of supportedAlgs as preference
571 for (final alg in supportedAlgs) {
572 if (key.algorithms.contains(alg)) {
573 return alg;
574 }
575 }
576 throw StateError(
577 'Key does not match any algorithm supported by the server. '
578 'Key supports: ${key.algorithms}, server supports: $supportedAlgs',
579 );
580 }
581
582 // No server preference, use key's first algorithm
583 if (key.algorithms.isEmpty) {
584 throw StateError('Key does not support any algorithms');
585 }
586
587 return key.algorithms.first;
588}