fix infinitive loop when 401 before expired token

This commit is contained in:
Dat H. Pham
2026-02-09 13:52:34 +07:00
committed by Dat H. Pham
parent 8a1d237686
commit 8fe935ac30
3 changed files with 1195 additions and 306 deletions
@@ -21,6 +21,7 @@ import 'package:tmail_ui_user/main/exceptions/remote_exception.dart';
import 'package:tmail_ui_user/main/utils/ios_sharing_manager.dart'; import 'package:tmail_ui_user/main/utils/ios_sharing_manager.dart';
class AuthorizationInterceptors extends QueuedInterceptorsWrapper { class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
static const String _refreshAttemptedKey = '_authInterceptorRefreshAttempted';
final Dio _dio; final Dio _dio;
final AuthenticationClientBase _authenticationClient; final AuthenticationClientBase _authenticationClient;
@@ -90,10 +91,23 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
final extraInRequest = requestOptions.extra; final extraInRequest = requestOptions.extra;
bool isRetryRequest = false; bool isRetryRequest = false;
if (validateToRefreshToken( // Check if this request has already attempted a refresh/retry
final hasAttemptedRefresh = extraInRequest[_refreshAttemptedKey] == true;
// FIRST: Check if token was already updated by another request in the queue
// If so, just retry with the new token - no refresh needed
// But skip if we've already attempted (to prevent infinite loops)
if (!hasAttemptedRefresh && validateToRetryTheRequestWithNewToken(
authHeader: requestOptions.headers[HttpHeaders.authorizationHeader],
tokenOIDC: _token
)) {
log('AuthorizationInterceptors::onError: Request using old token, retry with updated token');
isRetryRequest = true;
} else if (!hasAttemptedRefresh && validateToRefreshToken(
responseStatusCode: err.response?.statusCode, responseStatusCode: err.response?.statusCode,
tokenOIDC: _token tokenOIDC: _token
)) { )) {
// SECOND: Check if we should attempt to refresh the token
try { try {
log('AuthorizationInterceptors::onError: Perform get New Token'); log('AuthorizationInterceptors::onError: Perform get New Token');
final newTokenOidc = PlatformInfo.isIOS final newTokenOidc = PlatformInfo.isIOS
@@ -112,6 +126,8 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
await _iosSharingManager.saveKeyChainSharingSession(personalAccount); await _iosSharingManager.saveKeyChainSharingSession(personalAccount);
} }
// Mark that we've attempted refresh for this request
requestOptions.extra[_refreshAttemptedKey] = true;
isRetryRequest = true; isRetryRequest = true;
} on DioException catch (refreshError, st) { } on DioException catch (refreshError, st) {
if (refreshError.response?.statusCode == 400) { if (refreshError.response?.statusCode == 400) {
@@ -135,12 +151,6 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
return super.onError(err, handler); return super.onError(err, handler);
} }
} else if (validateToRetryTheRequestWithNewToken(
authHeader: requestOptions.headers[HttpHeaders.authorizationHeader],
tokenOIDC: _token
)) {
log('AuthorizationInterceptors::onError: Request using old token');
isRetryRequest = true;
} else { } else {
logTrace( logTrace(
'AuthorizationInterceptors::onError: ' 'AuthorizationInterceptors::onError: '
@@ -148,6 +158,7 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
'statusCode = ${err.response?.statusCode} | ' 'statusCode = ${err.response?.statusCode} | '
'authType = $_authenticationType | ' 'authType = $_authenticationType | '
'hasConfig = ${_configOIDC != null} | ' 'hasConfig = ${_configOIDC != null} | '
'hasAttemptedRefresh = $hasAttemptedRefresh | '
'url = ${err.requestOptions.uri}', 'url = ${err.requestOptions.uri}',
webConsoleEnabled: true, webConsoleEnabled: true,
); );
@@ -160,10 +171,13 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
final uploadExtra = extraInRequest[FileUploader.uploadAttachmentExtraKey]; final uploadExtra = extraInRequest[FileUploader.uploadAttachmentExtraKey];
requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token); requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token);
// Mark as attempted to prevent infinite retry loops
requestOptions.extra[_refreshAttemptedKey] = true;
final newOptions = Options( final newOptions = Options(
method: requestOptions.method, method: requestOptions.method,
headers: requestOptions.headers, headers: requestOptions.headers,
extra: requestOptions.extra,
); );
final response = await _dio.request( final response = await _dio.request(
@@ -177,6 +191,8 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
} else { } else {
log('AuthorizationInterceptors::onError: Retry request with TokenId = ${_token?.tokenIdHash}'); log('AuthorizationInterceptors::onError: Retry request with TokenId = ${_token?.tokenIdHash}');
requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token); requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token);
// Mark as attempted to prevent infinite retry loops
requestOptions.extra[_refreshAttemptedKey] = true;
final response = await _dio.fetch(requestOptions); final response = await _dio.fetch(requestOptions);
return handler.resolve(response); return handler.resolve(response);
@@ -232,13 +248,14 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
final isLoginWithOIDC = _isAuthenticationOidcValid(); final isLoginWithOIDC = _isAuthenticationOidcValid();
final hasAccessToken = _isTokenNotEmpty(tokenOIDC); final hasAccessToken = _isTokenNotEmpty(tokenOIDC);
final hasRefreshToken = _isRefreshTokenNotEmpty(tokenOIDC); final hasRefreshToken = _isRefreshTokenNotEmpty(tokenOIDC);
final isExpired = _isTokenExpired(tokenOIDC);
// Note: We removed isExpired check. If server returns 401, we trust it
// and attempt refresh regardless of local expiry time. This handles cases
// where server clock is ahead or token was revoked server-side.
final canProceedRefresh = isStatusCode401 && final canProceedRefresh = isStatusCode401 &&
isLoginWithOIDC && isLoginWithOIDC &&
hasAccessToken && hasAccessToken &&
hasRefreshToken && hasRefreshToken;
isExpired;
logTrace( logTrace(
'AuthorizationInterceptors::validateToRefreshToken: ' 'AuthorizationInterceptors::validateToRefreshToken: '
@@ -246,9 +263,6 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
'isLoginWithOIDC = $isLoginWithOIDC | ' 'isLoginWithOIDC = $isLoginWithOIDC | '
'hasAccessToken = $hasAccessToken | ' 'hasAccessToken = $hasAccessToken | '
'hasRefreshToken = $hasRefreshToken | ' 'hasRefreshToken = $hasRefreshToken | '
'isExpired = $isExpired | '
'expiredTime = ${tokenOIDC?.expiredTime} | '
'now = ${DateTime.now()} | '
'canProceedRefresh = $canProceedRefresh', 'canProceedRefresh = $canProceedRefresh',
webConsoleEnabled: true, webConsoleEnabled: true,
); );
@@ -264,6 +278,9 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
final isTokenUpdated = final isTokenUpdated =
tokenOIDC != null && authHeader?.contains(tokenOIDC.token) != true; tokenOIDC != null && authHeader?.contains(tokenOIDC.token) != true;
// Note: We don't check isTokenExpired here. If another request already
// refreshed the token, we should retry with the new token regardless of
// its expiry status. The key check is isTokenUpdated.
final shouldRetry = final shouldRetry =
hasAuthHeader && hasAccessToken && isTokenStillValid && isTokenUpdated; hasAuthHeader && hasAccessToken && isTokenStillValid && isTokenUpdated;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -28,6 +28,13 @@ class OIDCFixtures {
'test456', 'test456',
expiredTime: DateTime.now().add(const Duration(days: 1))); expiredTime: DateTime.now().add(const Duration(days: 1)));
/// Token that is NOT expired yet - for testing 401 before expiry scenario
static final tokenOidcNotExpiredYet = TokenOIDC(
'valid_token_123',
TokenId('valid_token_123'),
'valid_refresh_456',
expiredTime: DateTime.now().add(const Duration(hours: 1)));
static final oidcConfiguration = OIDCConfiguration( static final oidcConfiguration = OIDCConfiguration(
authority: 'https://example.com', authority: 'https://example.com',
clientId: 'client-id', clientId: 'client-id',