diff --git a/docs/adr/0031-fix-refresh-token-with-oidc.md b/docs/adr/0031-fix-refresh-token-with-oidc.md new file mode 100644 index 0000000000..3e28cf293b --- /dev/null +++ b/docs/adr/0031-fix-refresh-token-with-oidc.md @@ -0,0 +1,29 @@ +# 30. Fix refresh token with OIDC using `QueuedInterceptor` + +Date: 2023-09-11 + +## Status + +- Issue: + +[1974](https://github.com/linagora/tmail-flutter/issues/1974) + +## Context + +- Requests still return `401` after retrieving a new token. The application automatically logs out + +## Root cause + +- When executing tasks concurrently, they are pushed into the `Queue` along with the old `header` values (the old authentication is retained). +So when the first request receives a `401` error and tries to get a new token, it will be updated with the new authentication header value. + +## Decision + +- Use `QueuedInterceptor` to serialize `requests/responses/errors` before they enter the interceptor. +If there are multiple concurrent requests, the request is added to a queue before entering the interceptor. +Only one request at a time enters the interceptor, and after that request is processed by the interceptor, the next request will enter the interceptor. +- Try to make a `retry` request up to 3 times when receiving a `401` error. Aims to update the new token value on `memmory` to requests in the `queue`. + +## Consequences + +- The following `requests` were completed successfully. The application is not automatically logged out diff --git a/lib/features/login/data/network/config/authorization_interceptors.dart b/lib/features/login/data/network/config/authorization_interceptors.dart index d016923474..9a7bdfac67 100644 --- a/lib/features/login/data/network/config/authorization_interceptors.dart +++ b/lib/features/login/data/network/config/authorization_interceptors.dart @@ -15,6 +15,9 @@ import 'package:tmail_ui_user/features/login/data/network/authentication_client/ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { + static const int _maxRetryCount = 3; + static const String RETRY_KEY = 'Retry'; + final Dio _dio; final AuthenticationClientBase _authenticationClient; final TokenOidcCacheManager _tokenOidcCacheManager; @@ -41,7 +44,6 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { _token = newToken; _configOIDC = newConfig; _authenticationType = AuthenticationType.oidc; - log('AuthorizationInterceptors::setToken(): newToken: $newToken | configOIDC: $_configOIDC'); } void _updateNewToken(Token newToken) { @@ -54,6 +56,8 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + log('AuthorizationInterceptors::onRequest():DATA: ${options.data}'); + log('AuthorizationInterceptors::onRequest():TOKEN_HASHCODE_CURRENT: ${_token?.token.hashCode}'); switch(_authenticationType) { case AuthenticationType.basic: if (_authorization != null) { @@ -73,77 +77,87 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { @override void onError(DioError err, ErrorInterceptorHandler handler) async { - logError('AuthorizationInterceptors::onError():dioType: ${err.type} | statusCode: ${err.response?.statusCode} | message: ${err.message} | statusMessage: ${err.response?.statusMessage}'); - try { - if (_validateToRefreshToken(err)) { - log('AuthorizationInterceptors::onError:RefreshTokenCalled:configOIDC: $_configOIDC | refreshTokenCurrent: ${_token?.refreshToken}'); - final newToken = await _authenticationClient.refreshingTokensOIDC( - _configOIDC!.clientId, - _configOIDC!.redirectUrl, - _configOIDC!.discoveryUrl, - _configOIDC!.scopes, - _token!.refreshToken - ); - - final accountCurrent = await _accountCacheManager.getSelectedAccount(); - - await _accountCacheManager.deleteSelectedAccount(_token!.tokenIdHash); - - await Future.wait([ - _tokenOidcCacheManager.persistOneTokenOidc(newToken), - _accountCacheManager.setSelectedAccount( - PersonalAccount( - newToken.tokenIdHash, - AuthenticationType.oidc, - isSelected: true, - accountId: accountCurrent.accountId, - apiUrl: accountCurrent.apiUrl, - userName: accountCurrent.userName - ) + logError('AuthorizationInterceptors::onError(): $err'); + logError('AuthorizationInterceptors::onError():TOKEN_HASHCODE_CURRENT: ${_token?.token.hashCode}'); + + final requestOptions = err.requestOptions; + final extraInRequest = requestOptions.extra; + var retries = extraInRequest[RETRY_KEY] ?? 0; + + if (_validateToRefreshToken(err)) { + log('AuthorizationInterceptors::onError:>> _validateToRefreshToken'); + final newToken = await _authenticationClient.refreshingTokensOIDC( + _configOIDC!.clientId, + _configOIDC!.redirectUrl, + _configOIDC!.discoveryUrl, + _configOIDC!.scopes, + _token!.refreshToken + ); + + final accountCurrent = await _accountCacheManager.getSelectedAccount(); + + await _accountCacheManager.deleteSelectedAccount(_token!.tokenIdHash); + + await Future.wait([ + _tokenOidcCacheManager.persistOneTokenOidc(newToken), + _accountCacheManager.setSelectedAccount( + PersonalAccount( + newToken.tokenIdHash, + AuthenticationType.oidc, + isSelected: true, + accountId: accountCurrent.accountId, + apiUrl: accountCurrent.apiUrl, + userName: accountCurrent.userName ) - ]); - log('AuthorizationInterceptors::onError():NewToken: $newToken'); - _updateNewToken(newToken.toToken()); - - final requestOptions = err.requestOptions; - requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(newToken.token); - - final response = await _dio.fetch(requestOptions); - return handler.resolve(response); - } else { - super.onError(err, handler); - } - } catch (e) { - logError('AuthorizationInterceptors::onError():Exception: $e'); + ) + ]); + _updateNewToken(newToken.toToken()); + + final requestOptions = err.requestOptions; + requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(newToken.token); + + final response = await _dio.fetch(requestOptions); + return handler.resolve(response); + } else if (_validateToRetry(err, retries)) { + log('AuthorizationInterceptors::onError:>> _validateToRetry | retries: $retries'); + retries++; + + final requestOptions = err.requestOptions; + requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token); + requestOptions.extra = {RETRY_KEY: retries}; + + final response = await _dio.fetch(requestOptions); + return handler.resolve(response); + } else { super.onError(err, handler); } } - bool _isTokenExpired() { - if (_token?.isExpired == true) { - log('AuthorizationInterceptors::_isTokenExpired(): TOKE_EXPIRED'); - return true; - } - return false; - } + bool _isTokenExpired() => _token?.isExpired == true; + + bool _isAuthenticationOidcValid() => _authenticationType == AuthenticationType.oidc && _configOIDC != null; + + bool _isTokenNotEmpty() => _token?.token.isNotEmpty == true; - bool _isAuthenticationOidcValid() { - if (_authenticationType == AuthenticationType.oidc && - _configOIDC != null && - _token != null) { - log('AuthorizationInterceptors::_isAuthenticationOidcValid()'); + bool _isRefreshTokenNotEmpty() => _token?.refreshToken.isNotEmpty == true; + + bool _validateToRefreshToken(DioError dioError) { + if (dioError.response?.statusCode == 401 && + _isAuthenticationOidcValid() && + _isRefreshTokenNotEmpty() && + _isTokenExpired() + ) { return true; } return false; } - bool _isRefreshTokenNotEmpty() => _token != null && _token!.refreshToken.isNotEmpty; - - bool _validateToRefreshToken(DioError dioError) { - if (_isTokenExpired() && - (dioError.response == null || dioError.response?.statusCode == 401) && - _isRefreshTokenNotEmpty() && - _isAuthenticationOidcValid()) { + bool _validateToRetry(DioError dioError, int retryCount) { + if (dioError.type == DioErrorType.badResponse && + dioError.response?.statusCode == 401 && + _isTokenNotEmpty() && + retryCount < _maxRetryCount + ) { return true; } return false;