From 764359a84b4e0fb713392a89e4b261767e531652 Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 28 Mar 2024 14:45:13 +0700 Subject: [PATCH] Fix mobile app force logout with multiples request in onError queue --- .../authorization_interceptors.dart | 65 ++++--- .../authorization_interceptor_test.dart | 162 +++++++++++++++++- 2 files changed, 200 insertions(+), 27 deletions(-) diff --git a/lib/features/login/data/network/interceptors/authorization_interceptors.dart b/lib/features/login/data/network/interceptors/authorization_interceptors.dart index ea6e219a41..1e75a46e33 100644 --- a/lib/features/login/data/network/interceptors/authorization_interceptors.dart +++ b/lib/features/login/data/network/interceptors/authorization_interceptors.dart @@ -49,11 +49,11 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { _token = newToken; _configOIDC = newConfig; _authenticationType = AuthenticationType.oidc; - log('AuthorizationInterceptors::setTokenAndAuthorityOidc: TOKEN_INITIAL = $newToken'); + log('AuthorizationInterceptors::setTokenAndAuthorityOidc: INITIAL_TOKEN = ${newToken?.token} | EXPIRED_TIME = ${newToken?.expiredTime}'); } void _updateNewToken(TokenOIDC newToken) { - log('AuthorizationInterceptors::_updateNewToken: NEW_TOKEN = $newToken'); + log('AuthorizationInterceptors::_updateNewToken: NEW_TOKEN = ${newToken.token} | EXPIRED_TIME = ${newToken.expiredTime}'); _token = newToken; } @@ -83,13 +83,17 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { @override void onError(DioError err, ErrorInterceptorHandler handler) async { - logError('AuthorizationInterceptors::onError(): DIO_ERROR = $err | METHOD = ${err.requestOptions.method}'); + logError('AuthorizationInterceptors::onError(): TOKEN = ${_token?.expiredTime} | DIO_ERROR = $err | METHOD = ${err.requestOptions.method}'); try { - if (validateToRefreshToken(responseStatusCode: err.response?.statusCode)) { + final requestOptions = err.requestOptions; + final extraInRequest = requestOptions.extra; + bool isRetryRequest = false; + + if (validateToRefreshToken( + responseStatusCode: err.response?.statusCode, + tokenOIDC: _token + )) { log('AuthorizationInterceptors::onError:_validateToRefreshToken'); - final requestOptions = err.requestOptions; - final extraInRequest = requestOptions.extra; - final newTokenOidc = PlatformInfo.isIOS ? await _handleRefreshTokenOnIOSPlatform() : await _handleRefreshTokenOnOtherPlatform(); @@ -101,6 +105,18 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { _updateNewToken(newTokenOidc); + isRetryRequest = true; + } else if (validateToRetryTheRequestWithNewToken( + authHeader: requestOptions.headers[HttpHeaders.authorizationHeader], + tokenOIDC: _token + )) { + log('AuthorizationInterceptors::onError:validateToRetryTheRequestWithNewToken'); + isRetryRequest = true; + } else { + return super.onError(err, handler); + } + + if (isRetryRequest) { if (extraInRequest.containsKey(FileUploader.uploadAttachmentExtraKey)) { log('AuthorizationInterceptors::onError: Perform upload attachment request'); final uploadExtra = extraInRequest[FileUploader.uploadAttachmentExtraKey]; @@ -147,24 +163,33 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { } } - bool _isTokenExpired() => _token?.isExpired == true; + bool _isTokenExpired(TokenOIDC? tokenOIDC) => tokenOIDC?.isExpired == true; bool _isAuthenticationOidcValid() => _authenticationType == AuthenticationType.oidc && _configOIDC != null; - bool _isTokenNotEmpty() => _token?.token.isNotEmpty == true; + bool _isTokenNotEmpty(TokenOIDC? tokenOIDC) => tokenOIDC?.token.isNotEmpty == true; - bool _isRefreshTokenNotEmpty() => _token?.refreshToken.isNotEmpty == true; + bool _isRefreshTokenNotEmpty(TokenOIDC? tokenOIDC) => tokenOIDC?.refreshToken.isNotEmpty == true; - bool validateToRefreshToken({int? responseStatusCode}) { - if (responseStatusCode == 401 && - _isAuthenticationOidcValid() && - _isTokenNotEmpty() && - _isRefreshTokenNotEmpty() && - _isTokenExpired() - ) { - return true; - } - return false; + bool validateToRefreshToken({ + required int? responseStatusCode, + required TokenOIDC? tokenOIDC + }) { + return responseStatusCode == 401 + && _isAuthenticationOidcValid() + && _isTokenNotEmpty(tokenOIDC) + && _isRefreshTokenNotEmpty(tokenOIDC) + && _isTokenExpired(tokenOIDC); + } + + bool validateToRetryTheRequestWithNewToken({ + required String? authHeader, + required TokenOIDC? tokenOIDC + }) { + return authHeader != null + && _isTokenNotEmpty(tokenOIDC) + && !_isTokenExpired(tokenOIDC) + && !authHeader.contains(tokenOIDC!.token); } String _getAuthorizationAsBasicHeader(String? authorization) => 'Basic $authorization'; diff --git a/test/features/interceptor/authorization_interceptor_test.dart b/test/features/interceptor/authorization_interceptor_test.dart index d17cb32601..b614309c53 100644 --- a/test/features/interceptor/authorization_interceptor_test.dart +++ b/test/features/interceptor/authorization_interceptor_test.dart @@ -12,6 +12,7 @@ import 'package:tmail_ui_user/features/login/data/local/account_cache_manager.da import 'package:tmail_ui_user/features/login/data/local/token_oidc_cache_manager.dart'; import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart'; import 'package:tmail_ui_user/features/login/data/network/interceptors/authorization_interceptors.dart'; +import 'package:tmail_ui_user/features/login/domain/exceptions/authentication_exception.dart'; import 'package:tmail_ui_user/features/login/domain/extensions/oidc_configuration_extensions.dart'; import 'package:tmail_ui_user/main/utils/ios_sharing_manager.dart'; @@ -58,7 +59,7 @@ void main() { }; final baseOption = BaseOptions(headers: headers); dio = Dio(baseOption) - ..options.baseUrl = baseUrl; + ..options.baseUrl = baseUrl;; authenticationClient = MockAuthenticationClientBase(); tokenOidcCacheManager = MockTokenOidcCacheManager(); @@ -85,7 +86,10 @@ void main() { newToken: OIDCFixtures.tokenOidcExpiredTime, newConfig: OIDCFixtures.oidcConfiguration); - final result = authorizationInterceptors.validateToRefreshToken(responseStatusCode: responseStatusCode401); + final result = authorizationInterceptors.validateToRefreshToken( + responseStatusCode: responseStatusCode401, + tokenOIDC: OIDCFixtures.tokenOidcExpiredTime, + ); expect(result, true); }); @@ -95,7 +99,10 @@ void main() { newToken: OIDCFixtures.tokenOidcExpiredTime, newConfig: OIDCFixtures.oidcConfiguration); - final result = authorizationInterceptors.validateToRefreshToken(responseStatusCode: responseStatusCode500); + final result = authorizationInterceptors.validateToRefreshToken( + responseStatusCode: responseStatusCode500, + tokenOIDC: OIDCFixtures.tokenOidcExpiredTime, + ); expect(result, false); }); @@ -105,7 +112,10 @@ void main() { newToken: OIDCFixtures.tokenOidcExpiredTime, newConfig: null); - final result = authorizationInterceptors.validateToRefreshToken(responseStatusCode: responseStatusCode401); + final result = authorizationInterceptors.validateToRefreshToken( + responseStatusCode: responseStatusCode401, + tokenOIDC: OIDCFixtures.tokenOidcExpiredTime, + ); expect(result, false); }); @@ -115,7 +125,10 @@ void main() { newToken: OIDCFixtures.tokenOidcExpiredTimeAndTokenEmpty, newConfig: OIDCFixtures.oidcConfiguration); - final result = authorizationInterceptors.validateToRefreshToken(responseStatusCode: responseStatusCode401); + final result = authorizationInterceptors.validateToRefreshToken( + responseStatusCode: responseStatusCode401, + tokenOIDC: OIDCFixtures.tokenOidcExpiredTimeAndTokenEmpty, + ); expect(result, false); }); @@ -125,7 +138,10 @@ void main() { newToken: OIDCFixtures.tokenOidcExpiredTimeAndRefreshTokenEmpty, newConfig: OIDCFixtures.oidcConfiguration); - final result = authorizationInterceptors.validateToRefreshToken(responseStatusCode: responseStatusCode401); + final result = authorizationInterceptors.validateToRefreshToken( + responseStatusCode: responseStatusCode401, + tokenOIDC: OIDCFixtures.tokenOidcExpiredTimeAndRefreshTokenEmpty, + ); expect(result, false); }); @@ -135,7 +151,10 @@ void main() { newToken: OIDCFixtures.newTokenOidc, newConfig: OIDCFixtures.oidcConfiguration); - final result = authorizationInterceptors.validateToRefreshToken(responseStatusCode: responseStatusCode401); + final result = authorizationInterceptors.validateToRefreshToken( + responseStatusCode: responseStatusCode401, + tokenOIDC: OIDCFixtures.newTokenOidc, + ); expect(result, false); }); @@ -222,6 +241,135 @@ void main() { }); }); + group('AuthorizationInterceptor: multiple requests queued on onError', () { + final requestOneDioError401 = DioError( + error: {'message': 'Token Expired'}, + requestOptions: RequestOptions(path: '$baseUrl/1', method: 'POST'), + response: Response( + statusCode: responseStatusCode401, + requestOptions: RequestOptions(path: '$baseUrl/1') + ), + type: DioErrorType.badResponse, + ); + + final requestTwoDioError401 = DioError( + error: {'message': 'Token Expired'}, + requestOptions: RequestOptions( + path: '$baseUrl/2', + method: 'POST', + headers: {HttpHeaders.authorizationHeader: 'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}'} + ), + response: Response( + statusCode: responseStatusCode401, + requestOptions: RequestOptions(path: '$baseUrl/2') + ), + type: DioErrorType.badResponse, + ); + + test('GIVEN 2 requests have token expired\n' + 'AND Request 1 refresh token then execute succeeded\n' + 'THEN Request 2 must use new token to execute request', () async { + + authorizationInterceptors.setTokenAndAuthorityOidc( + newToken: OIDCFixtures.tokenOidcExpiredTime, + newConfig: OIDCFixtures.oidcConfiguration); + + dioAdapter.onPost( + '$baseUrl/1', + (server) => server.throws(responseStatusCode401, requestOneDioError401) + ); + + dioAdapter.onPost( + '$baseUrl/2', + (server) => server.throws(responseStatusCode401, requestTwoDioError401) + ); + + when(authenticationClient.refreshingTokensOIDC( + OIDCFixtures.oidcConfiguration.clientId, + OIDCFixtures.oidcConfiguration.redirectUrl, + OIDCFixtures.oidcConfiguration.discoveryUrl, + OIDCFixtures.oidcConfiguration.scopes, + OIDCFixtures.tokenOidcExpiredTime.refreshToken + )).thenAnswer((_) async { + dioAdapter.onPost( + '$baseUrl/1', + (server) => server.reply(responseStatusCode200, dataRequestSuccessfully) + ); + dioAdapter.onPost( + '$baseUrl/2', + (server) => server.reply(responseStatusCode200, dataRequestSuccessfully) + ); + return OIDCFixtures.newTokenOidc; + }); + + when(accountCacheManager.getCurrentAccount()).thenAnswer((_) async => AccountFixtures.aliceAccount); + when(accountCacheManager.deleteCurrentAccount(AccountFixtures.aliceAccount.id)).thenAnswer((_) async {}); + + final responses = await Future.wait([ + dio.post('$baseUrl/1',), + dio.post('$baseUrl/2',) + ]); + + verify(authenticationClient.refreshingTokensOIDC( + OIDCFixtures.oidcConfiguration.clientId, + OIDCFixtures.oidcConfiguration.redirectUrl, + OIDCFixtures.oidcConfiguration.discoveryUrl, + OIDCFixtures.oidcConfiguration.scopes, + OIDCFixtures.tokenOidcExpiredTime.refreshToken + )).called(1); + + expect(responses.length, equals(2)); + expect(responses[0].statusCode, equals(HttpStatus.ok)); + expect(responses[0].requestOptions.headers[HttpHeaders.authorizationHeader], equals('Bearer ${OIDCFixtures.newTokenOidc.token}')); + + expect(responses[1].statusCode, equals(HttpStatus.ok)); + expect(responses[1].requestOptions.headers[HttpHeaders.authorizationHeader], equals('Bearer ${OIDCFixtures.newTokenOidc.token}')); + }); + + test('GIVEN 2 requests have token expired\n' + 'AND Request 1 refresh token then execute failed\n' + 'THEN Request 2 can not execute', () async { + + authorizationInterceptors.setTokenAndAuthorityOidc( + newToken: OIDCFixtures.tokenOidcExpiredTime, + newConfig: OIDCFixtures.oidcConfiguration); + + dioAdapter.onPost( + '$baseUrl/1', + (server) => server.throws(responseStatusCode401, requestOneDioError401) + ); + + dioAdapter.onPost( + '$baseUrl/2', + (server) => server.throws(responseStatusCode401, requestTwoDioError401) + ); + + when(authenticationClient.refreshingTokensOIDC( + OIDCFixtures.oidcConfiguration.clientId, + OIDCFixtures.oidcConfiguration.redirectUrl, + OIDCFixtures.oidcConfiguration.discoveryUrl, + OIDCFixtures.oidcConfiguration.scopes, + OIDCFixtures.tokenOidcExpiredTime.refreshToken + )).thenAnswer((_) async { + throw AccessTokenInvalidException(); + }); + + when(accountCacheManager.getCurrentAccount()).thenAnswer((_) async => AccountFixtures.aliceAccount); + when(accountCacheManager.deleteCurrentAccount(AccountFixtures.aliceAccount.id)).thenAnswer((_) async {}); + + expect( + () async => await Future.wait([ + dio.post('$baseUrl/1',), + dio.post('$baseUrl/2',) + ]), + throwsA(predicate( + (dioError) => dioError.error is AccessTokenInvalidException)) + ); + + verifyZeroInteractions(authenticationClient); + }); + }); + tearDown(() { dioAdapter.close(); dio.close();