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}