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