Skip to content

Commit

Permalink
Fix mobile app force logout with multiples request in onError queue
Browse files Browse the repository at this point in the history
  • Loading branch information
dab246 authored and hoangdat committed May 2, 2024
1 parent 133691e commit 764359a
Show file tree
Hide file tree
Showing 2 changed files with 200 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
Expand All @@ -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];
Expand Down Expand Up @@ -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';
Expand Down
162 changes: 155 additions & 7 deletions test/features/interceptor/authorization_interceptor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -58,7 +59,7 @@ void main() {
};
final baseOption = BaseOptions(headers: headers);
dio = Dio(baseOption)
..options.baseUrl = baseUrl;
..options.baseUrl = baseUrl;;

authenticationClient = MockAuthenticationClientBase();
tokenOidcCacheManager = MockTokenOidcCacheManager();
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand Down Expand Up @@ -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) => dioError.error is AccessTokenInvalidException))
);

verifyZeroInteractions(authenticationClient);
});
});

tearDown(() {
dioAdapter.close();
dio.close();
Expand Down

0 comments on commit 764359a

Please sign in to comment.