diff --git a/lib/features/login/data/network/interceptors/authorization_interceptors.dart b/lib/features/login/data/network/interceptors/authorization_interceptors.dart index 5387e965a..31829a571 100644 --- a/lib/features/login/data/network/interceptors/authorization_interceptors.dart +++ b/lib/features/login/data/network/interceptors/authorization_interceptors.dart @@ -179,36 +179,18 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { } if (isRetryRequest) { - if (extraInRequest.containsKey(FileUploader.uploadAttachmentExtraKey)) { - log('AuthorizationInterceptors::onError: Retry upload request with TokenId = ${_token?.tokenIdHash}'); - final uploadExtra = extraInRequest[FileUploader.uploadAttachmentExtraKey]; - - requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token); - // Mark as attempted to prevent infinite retry loops - requestOptions.extra[_refreshAttemptedKey] = true; - - final newOptions = Options( - method: requestOptions.method, - headers: requestOptions.headers, - extra: requestOptions.extra, - ); - - final response = await _dio.request( - requestOptions.path, - data: _getDataUploadRequest(uploadExtra), - queryParameters: requestOptions.queryParameters, - options: newOptions, - ); - - return handler.resolve(response); - } else { - log('AuthorizationInterceptors::onError: Retry request with TokenId = ${_token?.tokenIdHash}'); - 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); + try { + final response = await _retryRequest(requestOptions, extraInRequest); return handler.resolve(response); + } catch (retryError) { + logError( + 'AuthorizationInterceptors::onError: ' + 'Retry failed with error=$retryError', + ); + return super.onError( + err.copyWith(error: retryError), + handler, + ); } } else { return super.onError(err, handler); @@ -379,6 +361,44 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper { return _invokeRefreshTokenFromServer(); } + Future _retryRequest( + RequestOptions requestOptions, + Map extraInRequest, + ) { + requestOptions.headers[HttpHeaders.authorizationHeader] = + _getTokenAsBearerHeader(_token!.token); + requestOptions.extra[_refreshAttemptedKey] = true; + + final retryDio = _createRetryDio(); + + if (extraInRequest.containsKey(FileUploader.uploadAttachmentExtraKey)) { + log('AuthorizationInterceptors::_retryRequest: ' + 'Retry upload request with TokenId = ${_token?.tokenIdHash}'); + final uploadExtra = + extraInRequest[FileUploader.uploadAttachmentExtraKey]; + + return retryDio.request( + requestOptions.path, + data: _getDataUploadRequest(uploadExtra), + queryParameters: requestOptions.queryParameters, + options: Options( + method: requestOptions.method, + headers: requestOptions.headers, + extra: requestOptions.extra, + ), + ); + } else { + log('AuthorizationInterceptors::_retryRequest: ' + 'Retry request with TokenId = ${_token?.tokenIdHash}'); + return retryDio.fetch(requestOptions); + } + } + + /// Creates a separate Dio instance without interceptors for retry requests. + /// This avoids deadlock when retrying inside [onError] of [QueuedInterceptorsWrapper]. + Dio _createRetryDio() => Dio(_dio.options) + ..httpClientAdapter = _dio.httpClientAdapter; + void clear() { _authorization = null; _token = null; diff --git a/test/features/interceptor/authorization_interceptor_test.dart b/test/features/interceptor/authorization_interceptor_test.dart index fbbc0f3a5..dcad0ef24 100644 --- a/test/features/interceptor/authorization_interceptor_test.dart +++ b/test/features/interceptor/authorization_interceptor_test.dart @@ -1335,6 +1335,218 @@ void main() { ); }); + // ============================================================ + // onError: retry fails (separate Dio error handling) + // ============================================================ + group('onError: retry fails (separate Dio error handling)', () { + test( + 'WHEN refresh succeeds but retry with new token gets 401\n' + 'THEN error is propagated via retry catch block\n' + 'AND no deadlock occurs', + () async { + authorizationInterceptors.setTokenAndAuthorityOidc( + newToken: OIDCFixtures.tokenOidcExpiredTime, + newConfig: OIDCFixtures.oidcConfiguration, + ); + + // Old token → 401 + dioAdapter.onPost( + baseUrl, + (server) => server.throws(responseStatusCode401, makeDioError401()), + headers: { + HttpHeaders.authorizationHeader: + 'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}', + }, + ); + // Retry with new token → also 401 + dioAdapter.onPost( + baseUrl, + (server) => server.throws( + responseStatusCode401, + makeDioError401(), + ), + headers: { + HttpHeaders.authorizationHeader: + 'Bearer ${OIDCFixtures.newTokenOidc.token}', + }, + ); + + when(authenticationClient.refreshingTokensOIDC( + OIDCFixtures.oidcConfiguration.clientId, + OIDCFixtures.oidcConfiguration.redirectUrl, + OIDCFixtures.oidcConfiguration.discoveryUrl, + OIDCFixtures.oidcConfiguration.scopes, + OIDCFixtures.tokenOidcExpiredTime.refreshToken, + )).thenAnswer((_) async => OIDCFixtures.newTokenOidc); + stubAccountCache(); + + await expectLater( + () => dio.post(baseUrl), + throwsA(isA()), + ); + + // Refresh was called once + verify(authenticationClient.refreshingTokensOIDC( + OIDCFixtures.oidcConfiguration.clientId, + OIDCFixtures.oidcConfiguration.redirectUrl, + OIDCFixtures.oidcConfiguration.discoveryUrl, + OIDCFixtures.oidcConfiguration.scopes, + OIDCFixtures.tokenOidcExpiredTime.refreshToken, + )).called(1); + }, + ); + + test( + 'WHEN refresh succeeds but retry gets 500\n' + 'THEN error is propagated\n' + 'AND OIDC state is preserved', + () async { + authorizationInterceptors.setTokenAndAuthorityOidc( + newToken: OIDCFixtures.tokenOidcExpiredTime, + newConfig: OIDCFixtures.oidcConfiguration, + ); + + final dioError500 = DioException( + error: {'message': 'Internal Server Error'}, + requestOptions: RequestOptions(path: baseUrl, method: 'POST'), + response: Response( + statusCode: responseStatusCode500, + requestOptions: RequestOptions(path: baseUrl), + ), + type: DioExceptionType.badResponse, + ); + + // Old token → 401 + dioAdapter.onPost( + baseUrl, + (server) => server.throws(responseStatusCode401, makeDioError401()), + headers: { + HttpHeaders.authorizationHeader: + 'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}', + }, + ); + // Retry with new token → 500 + dioAdapter.onPost( + baseUrl, + (server) => server.throws(responseStatusCode500, dioError500), + headers: { + HttpHeaders.authorizationHeader: + 'Bearer ${OIDCFixtures.newTokenOidc.token}', + }, + ); + + when(authenticationClient.refreshingTokensOIDC( + OIDCFixtures.oidcConfiguration.clientId, + OIDCFixtures.oidcConfiguration.redirectUrl, + OIDCFixtures.oidcConfiguration.discoveryUrl, + OIDCFixtures.oidcConfiguration.scopes, + OIDCFixtures.tokenOidcExpiredTime.refreshToken, + )).thenAnswer((_) async => OIDCFixtures.newTokenOidc); + stubAccountCache(); + + await expectLater( + () => dio.post(baseUrl), + throwsA(isA()), + ); + + // OIDC state should NOT be cleared (only 400 clears state) + expect( + authorizationInterceptors.authenticationType, + AuthenticationType.oidc, + ); + }, + ); + + test( + 'GIVEN 2 concurrent requests\n' + 'WHEN Request 1 refreshes and retries successfully\n' + 'AND Request 2 retries with new token but gets 500\n' + 'THEN Request 1 succeeds\n' + 'AND Request 2 error is propagated', + () async { + authorizationInterceptors.setTokenAndAuthorityOidc( + newToken: OIDCFixtures.tokenOidcExpiredTime, + newConfig: OIDCFixtures.oidcConfiguration, + ); + + final dioError500 = DioException( + error: {'message': 'Internal Server Error'}, + requestOptions: RequestOptions(path: '$baseUrl/2', method: 'POST'), + response: Response( + statusCode: responseStatusCode500, + requestOptions: RequestOptions(path: '$baseUrl/2'), + ), + type: DioExceptionType.badResponse, + ); + + // Request 1: old token → 401 + dioAdapter.onPost( + '$baseUrl/1', + (server) => server.reply( + responseStatusCode401, + {'error': 'Unauthorized'}, + ), + headers: { + HttpHeaders.authorizationHeader: + 'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}', + }, + ); + // Request 1 retry: new token → 200 + dioAdapter.onPost( + '$baseUrl/1', + (server) => + server.reply(responseStatusCode200, dataRequestSuccessfully), + headers: { + HttpHeaders.authorizationHeader: + 'Bearer ${OIDCFixtures.newTokenOidc.token}', + }, + ); + + // Request 2: old token → 401 + dioAdapter.onPost( + '$baseUrl/2', + (server) => server.reply( + responseStatusCode401, + {'error': 'Unauthorized'}, + ), + headers: { + HttpHeaders.authorizationHeader: + 'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}', + }, + ); + // Request 2 retry: new token → 500 + dioAdapter.onPost( + '$baseUrl/2', + (server) => server.throws(responseStatusCode500, dioError500), + headers: { + HttpHeaders.authorizationHeader: + 'Bearer ${OIDCFixtures.newTokenOidc.token}', + }, + ); + + when(authenticationClient.refreshingTokensOIDC( + OIDCFixtures.oidcConfiguration.clientId, + OIDCFixtures.oidcConfiguration.redirectUrl, + OIDCFixtures.oidcConfiguration.discoveryUrl, + OIDCFixtures.oidcConfiguration.scopes, + OIDCFixtures.tokenOidcExpiredTime.refreshToken, + )).thenAnswer((_) async => OIDCFixtures.newTokenOidc); + stubAccountCache(); + + final future1 = dio.post('$baseUrl/1'); + final future2 = dio.post('$baseUrl/2'); + + final response1 = await future1; + expect(response1.statusCode, responseStatusCode200); + + await expectLater( + () => future2, + throwsA(isA()), + ); + }, + ); + }); + // ============================================================ // onError: token duplicate prevents infinite loop // ============================================================