fix: critical DPoP key persistence and retry bugs

CRITICAL FIXES:
- [P0] Use requestUri instead of request path in DPoP retry (fetch_dpop.dart)
- Was using relative path, now uses absolute URI
- Prevents endpoint resolution failures during retry

- [CRITICAL] Fix token refresh to preserve DPoP key (session_getter.dart:293)
- Was using undefined newDpopKey.bareJwk variable
- Now correctly preserves storedSession.dpopKey

- [CRITICAL] Fix onStoreError to use stored key for revocation (session_getter.dart:178)
- Was generating new key instead of using stored key
- Now properly restores key with FlutterKey.fromJwk()

- Remove duplicate dpopKey declaration (session_getter.dart:234)

- Add automatic nonce retry in onResponse handler (fetch_dpop.dart)
- Handles 401 responses when validateStatus: true
- Implements same retry logic as onError handler

These fixes ensure DPoP keys persist correctly across the entire
OAuth lifecycle, preventing "DPoP proof does not match JKT" errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+140 -43
packages
atproto_oauth_flutter
lib
+113 -27
packages/atproto_oauth_flutter/lib/src/dpop/fetch_dpop.dart
···
print(' No nonce in response');
}
+
// Check for nonce errors in successful responses (when validateStatus: true)
+
// This handles the case where Dio returns 401 as a successful response
+
if (nextNonce != null && await _isUseDpopNonceError(response, options.isAuthServer)) {
+
final isTokenEndpoint =
+
uri.path.contains('/token') || uri.path.endsWith('/token');
+
+
if (kDebugMode) {
+
print('⚠️ DPoP nonce error in response (status ${response.statusCode})');
+
print(' Is token endpoint: $isTokenEndpoint');
+
}
+
+
if (isTokenEndpoint) {
+
// Don't retry token endpoint - just pass through with nonce cached
+
if (kDebugMode) {
+
print(' NOT retrying token endpoint (nonce cached for next attempt)');
+
}
+
handler.next(response);
+
return;
+
}
+
+
// For non-token endpoints, retry is safe
+
if (kDebugMode) {
+
print('🔄 Retrying request with fresh nonce');
+
}
+
+
try {
+
final authHeader =
+
response.requestOptions.headers['Authorization'] as String?;
+
final String? ath;
+
if (authHeader != null && authHeader.startsWith('DPoP ')) {
+
ath = await options.sha256(authHeader.substring(5));
+
} else {
+
ath = null;
+
}
+
+
final htm = response.requestOptions.method;
+
final htu = _buildHtu(uri.toString());
+
+
final nextProof = await _buildProof(
+
options.key,
+
alg,
+
htm,
+
htu,
+
nextNonce,
+
ath,
+
);
+
+
// Clone request options and update DPoP header
+
// Note: We preserve validateStatus to match original request behavior
+
final retryOptions = Options(
+
method: response.requestOptions.method,
+
headers: {...response.requestOptions.headers, 'DPoP': nextProof},
+
validateStatus: response.requestOptions.validateStatus,
+
);
+
+
// DESIGN NOTE: We create a fresh Dio instance for retry to avoid
+
// re-triggering this interceptor (which would cause infinite loops).
+
// This means base options (timeouts, etc.) are not preserved, but
+
// this is acceptable for DPoP nonce retry scenarios which should be fast.
+
// If this becomes an issue, we could inject a Dio factory function.
+
final dio = Dio();
+
final retryResponse = await dio.requestUri(
+
uri,
+
options: retryOptions,
+
data: response.requestOptions.data,
+
);
+
+
handler.resolve(retryResponse);
+
return;
+
} catch (retryError) {
+
if (kDebugMode) {
+
print('❌ Retry failed: $retryError');
+
}
+
// If retry fails, return the original response
+
handler.next(response);
+
return;
+
}
+
}
+
handler.next(response);
} catch (e) {
handler.reject(
···
);
// Clone request options and update DPoP header
+
// Note: We preserve validateStatus to match original request behavior
final retryOptions = Options(
method: response.requestOptions.method,
headers: {...response.requestOptions.headers, 'DPoP': nextProof},
+
validateStatus: response.requestOptions.validateStatus,
);
-
// Retry the request
+
// DESIGN NOTE: We create a fresh Dio instance for retry to avoid
+
// re-triggering this interceptor (which would cause infinite loops).
+
// This means base options (timeouts, etc.) are not preserved, but
+
// this is acceptable for DPoP nonce retry scenarios which should be fast.
+
// If this becomes an issue, we could inject a Dio factory function.
final dio = Dio();
-
final retryResponse = await dio.request(
-
response.requestOptions.path,
+
final retryResponse = await dio.requestUri(
+
uri,
options: retryOptions,
data: response.requestOptions.data,
-
queryParameters: response.requestOptions.queryParameters,
);
handler.resolve(retryResponse);
···
/// Checks if a response indicates a "use_dpop_nonce" error.
///
-
/// There are two error formats depending on server type:
+
/// 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"}
///
+
/// 3. Resource Server (JSON variant): 401 with JSON body
+
/// {"error": "use_dpop_nonce"}
+
///
/// See:
/// - 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 {
-
// Check resource server error format (401 + WWW-Authenticate)
-
if (isAuthServer == null || isAuthServer == false) {
-
if (response.statusCode == 401) {
-
final wwwAuth = response.headers.value('www-authenticate');
-
if (wwwAuth != null && wwwAuth.startsWith('DPoP')) {
-
return wwwAuth.contains('error="use_dpop_nonce"');
+
// Check WWW-Authenticate header format (401 + header)
+
if (response.statusCode == 401) {
+
final wwwAuth = response.headers.value('www-authenticate');
+
if (wwwAuth != null && wwwAuth.startsWith('DPoP')) {
+
if (wwwAuth.contains('error="use_dpop_nonce"')) {
+
return true;
}
}
}
-
// Check authorization server error format (400 + JSON error)
-
if (isAuthServer == null || isAuthServer == true) {
-
if (response.statusCode == 400) {
-
try {
-
final data = response.data;
-
if (data is Map<String, dynamic>) {
-
return data['error'] == 'use_dpop_nonce';
-
} else if (data is String) {
-
// Try to parse as JSON
-
final json = jsonDecode(data);
-
if (json is Map<String, dynamic>) {
-
return json['error'] == 'use_dpop_nonce';
-
}
+
// Check JSON body format (400 or 401 + JSON)
+
// Some servers use 401 + JSON instead of WWW-Authenticate header
+
if (response.statusCode == 400 || response.statusCode == 401) {
+
try {
+
final data = response.data;
+
if (data is Map<String, dynamic>) {
+
return data['error'] == 'use_dpop_nonce';
+
} else if (data is String) {
+
// Try to parse as JSON
+
final json = jsonDecode(data);
+
if (json is Map<String, dynamic>) {
+
return json['error'] == 'use_dpop_nonce';
}
-
} catch (_) {
-
// Invalid JSON or response too large, not a use_dpop_nonce error
-
return false;
}
+
} catch (_) {
+
// Invalid JSON or response too large, not a use_dpop_nonce error
+
return false;
}
}
+27 -16
packages/atproto_oauth_flutter/lib/src/session/session_getter.dart
···
import '../oauth/client_auth.dart' show ClientAuthMethod;
import '../oauth/oauth_server_agent.dart';
import '../oauth/oauth_server_factory.dart';
+
import '../platform/flutter_key.dart';
import '../runtime/runtime.dart';
import '../util.dart';
import 'oauth_session.dart';
···
? ClientAuthMethod.fromJson(authMethodValue)
: (authMethodValue as String?) ?? 'legacy';
-
// Generate new DPoP key for revocation
-
// (stored key is serialized and can't be directly used)
-
final dpopKeyAlgs = ['ES256', 'RS256'];
-
final newDpopKey = await runtime.generateKey(dpopKeyAlgs);
+
// Restore DPoP key from session for revocation
+
// CRITICAL FIX: Use the stored key instead of generating a new one
+
// This ensures DPoP proofs match the token binding
+
final dpopKey = FlutterKey.fromJwk(
+
session.dpopKey as Map<String, dynamic>,
+
);
// If the token data cannot be stored, let's revoke it
final server = await serverFactory.fromIssuer(
session.tokenSet.iss,
authMethod,
-
newDpopKey,
+
dpopKey,
);
await server.revoke(
session.tokenSet.refreshToken ??
···
// access (which, normally, should not happen if a proper runtime lock
// was provided).
-
final dpopKey = storedSession.dpopKey;
// authMethod can be a Map (serialized ClientAuthMethod) or String ('legacy')
final authMethodValue = storedSession.authMethod;
final authMethod =
···
// always possible. If no lock implementation is provided, we will use
// the store to check if a concurrent refresh occurred.
-
// TODO: Key Persistence Workaround
-
// The storedSession.dpopKey is a Map<String, dynamic> (serialized JWK),
-
// but OAuthServerFactory.fromIssuer() expects a Key object.
-
// Until Key serialization is implemented (see runtime_implementation.dart),
-
// we generate a new DPoP key for each session refresh.
-
// This works but means tokens are bound to new keys, requiring refresh.
-
final dpopKeyAlgs = ['ES256', 'RS256']; // Supported DPoP algorithms
-
final newDpopKey = await _runtime.generateKey(dpopKeyAlgs);
+
// Restore dpopKey from stored private JWK with error handling
+
// CRITICAL FIX: Use the stored key instead of generating a new one
+
// This ensures DPoP proofs match the token binding during refresh
+
final FlutterKey dpopKey;
+
try {
+
dpopKey = FlutterKey.fromJwk(
+
storedSession.dpopKey as Map<String, dynamic>,
+
);
+
} catch (e) {
+
// If key is corrupted, the session is unusable - force re-authentication
+
throw TokenRefreshError(
+
sub,
+
'Corrupted DPoP key in stored session: $e. Re-authentication required.',
+
);
+
}
final server = await _serverFactory.fromIssuer(
tokenSet.iss,
authMethod,
-
newDpopKey,
+
dpopKey,
);
// Because refresh tokens can only be used once, we must not use the
···
throw TokenRefreshError(sub, 'Token set sub mismatch');
}
+
// CRITICAL FIX: Preserve the stored DPoP key (full private JWK)
+
// This ensures the same key is used across token refreshes
return Session(
-
dpopKey: newDpopKey.bareJwk ?? {},
+
dpopKey: storedSession.dpopKey,
tokenSet: newTokenSet,
authMethod: server.authMethod.toJson(),
);