···
print(' No nonce in response');
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');
205
+
print('⚠️ DPoP nonce error in response (status ${response.statusCode})');
206
+
print(' Is token endpoint: $isTokenEndpoint');
209
+
if (isTokenEndpoint) {
210
+
// Don't retry token endpoint - just pass through with nonce cached
212
+
print(' NOT retrying token endpoint (nonce cached for next attempt)');
214
+
handler.next(response);
218
+
// For non-token endpoints, retry is safe
220
+
print('🔄 Retrying request with fresh nonce');
225
+
response.requestOptions.headers['Authorization'] as String?;
227
+
if (authHeader != null && authHeader.startsWith('DPoP ')) {
228
+
ath = await options.sha256(authHeader.substring(5));
233
+
final htm = response.requestOptions.method;
234
+
final htu = _buildHtu(uri.toString());
236
+
final nextProof = await _buildProof(
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,
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.
259
+
final retryResponse = await dio.requestUri(
261
+
options: retryOptions,
262
+
data: response.requestOptions.data,
265
+
handler.resolve(retryResponse);
267
+
} catch (retryError) {
269
+
print('❌ Retry failed: $retryError');
271
+
// If retry fails, return the original response
272
+
handler.next(response);
···
// Clone request options and update DPoP header
388
+
// Note: We preserve validateStatus to match original request behavior
final retryOptions = Options(
method: response.requestOptions.method,
headers: {...response.requestOptions.headers, 'DPoP': nextProof},
392
+
validateStatus: response.requestOptions.validateStatus,
314
-
// Retry the request
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.
316
-
final retryResponse = await dio.request(
317
-
response.requestOptions.path,
401
+
final retryResponse = await dio.requestUri(
data: response.requestOptions.data,
320
-
queryParameters: response.requestOptions.queryParameters,
handler.resolve(retryResponse);
···
/// Checks if a response indicates a "use_dpop_nonce" error.
430
-
/// There are two error formats depending on server type:
514
+
/// There are multiple error formats depending on server implementation:
/// 1. Resource Server (RFC 6750): 401 with WWW-Authenticate header
/// WWW-Authenticate: DPoP error="use_dpop_nonce"
···
/// 2. Authorization Server: 400 with JSON body
/// {"error": "use_dpop_nonce"}
522
+
/// 3. Resource Server (JSON variant): 401 with JSON body
523
+
/// {"error": "use_dpop_nonce"}
/// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
/// - https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
Future<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"');
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"')) {
452
-
// Check authorization server error format (400 + JSON error)
453
-
if (isAuthServer == null || isAuthServer == true) {
454
-
if (response.statusCode == 400) {
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';
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) {
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';
467
-
// Invalid JSON or response too large, not a use_dpop_nonce error
554
+
// Invalid JSON or response too large, not a use_dpop_nonce error