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}