Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
180 lines
6.4 KiB
Dart
180 lines
6.4 KiB
Dart
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<String> 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<void> _updateTokens(LoginResponse response) async {
|
|
await AuthorizationStorage.updateToken(response.accessToken);
|
|
await AuthorizationStorage.updateRefreshToken(response.refreshToken);
|
|
}
|
|
|
|
static Future<AccountResponse> 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<LoginResponse> _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<void> 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<String> 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<String, dynamic> getAuthStatus() {
|
|
return {
|
|
'circuitBreaker': AuthCircuitBreaker.getStatus(),
|
|
'tokenMutex': TokenRefreshMutex().getStatus(),
|
|
'timestamp': DateTime.now().toIso8601String(),
|
|
};
|
|
}
|
|
|
|
}
|