import 'package:logging/logging.dart'; import 'package:pshared/api/errors/authorization_failed.dart'; import 'package:pshared/api/errors/unauthorized.dart'; import 'package:pshared/api/requests/tokens/access_refresh.dart'; import 'package:pshared/api/requests/tokens/refresh_rotate.dart'; import 'package:pshared/api/responses/account.dart'; import 'package:pshared/api/responses/error/server.dart'; import 'package:pshared/api/responses/login.dart'; import 'package:pshared/api/responses/token.dart'; import 'package:pshared/config/constants.dart'; import 'package:pshared/service/authorization/circuit_breaker.dart'; import 'package:pshared/service/authorization/retry_helper.dart'; import 'package:pshared/service/authorization/storage.dart'; import 'package:pshared/service/authorization/token_mutex.dart'; import 'package:pshared/service/device_id.dart'; import 'package:pshared/service/services.dart'; import 'package:pshared/utils/exception.dart'; import 'package:pshared/utils/http/requests.dart'; class TokenService { static final _logger = Logger('service.authorization.token'); static const String _objectType = Services.account; static bool _isTokenExpiringSoon(TokenData token, Duration duration) { return token.expiration.isBefore(DateTime.now().add(duration)); } static Future getAccessToken() async { TokenData token = await AuthorizationStorage.getAccessToken(); if (_isTokenExpiringSoon(token, const Duration(hours: 4))) { // Use mutex to prevent concurrent refresh operations final refreshedToken = await TokenRefreshMutex().executeRefresh(() async { return (await refreshAccessToken()).accessToken.token; }); return refreshedToken; } return token.token; } static Future _updateTokens(LoginResponse response) async { await AuthorizationStorage.updateToken(response.accessToken); await AuthorizationStorage.updateRefreshToken(response.refreshToken); } static Future refreshAccessToken() async { _logger.fine('Refreshing access token...'); final deviceId = await DeviceIdManager.getDeviceId(); final refresh = await AuthorizationStorage.getRefreshToken(); if (_isTokenExpiringSoon(refresh, const Duration(days: 7))) { return await _rotateRefreshToken(); } final response = await getPOSTResponse( _objectType, '/refresh', AccessTokenRefreshRequest( deviceId: deviceId, clientId: Constants.clientId, token: refresh.token, ).toJson(), ); final accountResp = AccountResponse.fromJson(response); await AuthorizationStorage.updateToken(accountResp.accessToken); return accountResp; } static Future _rotateRefreshToken() async { _logger.fine('Rotating refresh token...'); final refresh = await AuthorizationStorage.getRefreshToken(); if (refresh.expiration.isBefore(DateTime.now())) throw ErrorUnauthorized(); final deviceId = await DeviceIdManager.getDeviceId(); final response = await getPOSTResponse( _objectType, '/rotate', RotateRefreshTokenRequest( token: refresh.token, clientId: Constants.clientId, deviceId: deviceId, ).toJson(), ); final loginResponse = LoginResponse.fromJson(response); await _updateTokens(loginResponse); return loginResponse; } /// Enhanced method to handle unexpected 401 errors with fallback logic static Future handleUnauthorized() async { _logger.warning('Handling unexpected 401 unauthorized error'); return AuthCircuitBreaker.execute(() async { return RetryHelper.withExponentialBackoff( () async { try { // Try refresh first (faster) final currentRefresh = await AuthorizationStorage.getRefreshToken(); if (!_isTokenExpiringSoon(currentRefresh, const Duration(days: 1))) { _logger.fine('Attempting access token refresh for 401 recovery'); await TokenRefreshMutex().executeRefresh(() async { await refreshAccessToken(); return 'refreshed'; }); return; } // Fallback to rotation if refresh token expiring soon _logger.fine('Attempting refresh token rotation for 401 recovery'); await TokenRefreshMutex().executeRotation(() async { await _rotateRefreshToken(); }); } catch (e) { _logger.severe('Token recovery failed: $e'); throw AuthenticationFailedException('Token recovery failed', toException(e)); } }, maxRetries: 2, shouldRetry: (error) { // Only retry on network errors, not auth errors return RetryHelper.isRetryableError(error) && !_isAuthError(error); }, ); }); } /// Enhanced getAccessToken with better error handling static Future getAccessTokenSafe() async { return AuthCircuitBreaker.execute(() async { return RetryHelper.withExponentialBackoff( () async { TokenData token = await AuthorizationStorage.getAccessToken(); if (_isTokenExpiringSoon(token, const Duration(hours: 4))) { // Use mutex to prevent concurrent refresh operations final refreshedToken = await TokenRefreshMutex().executeRefresh(() async { return (await refreshAccessToken()).accessToken.token; }); return refreshedToken; } return token.token; }, maxRetries: 2, shouldRetry: (error) => RetryHelper.isRetryableError(error), ); }); } /// Check if error is authentication-related (non-retryable) static bool _isAuthError(Exception error) { if (error is ErrorUnauthorized || error is AuthenticationFailedException) { return true; } if (error is ErrorResponse && error.code == 401) { return true; } final errorString = error.toString().toLowerCase(); return errorString.contains('unauthorized') || errorString.contains('401') || errorString.contains('authentication') || errorString.contains('token'); } /// Get circuit breaker status for debugging static Map getAuthStatus() { return { 'circuitBreaker': AuthCircuitBreaker.getStatus(), 'tokenMutex': TokenRefreshMutex().getStatus(), 'timestamp': DateTime.now().toIso8601String(), }; } }