Files

139 lines
4.1 KiB
Dart

import 'package:dio/dio.dart';
import '../storage/token_storage.dart';
class ApiInterceptor extends Interceptor {
final TokenStorage tokenStorage;
final Dio dio;
final Duration refreshFailureCooldown;
Future<bool> Function()? onTokenRefresh;
Future<void> Function()? onAuthFailure;
Future<bool>? _refreshFuture;
Future<void>? _authFailureFuture;
DateTime? _refreshBlockedUntil;
static const _retriedRequestKey = '_auth_retry_once';
static const _refreshPathSuffix = '/api/v1/auth/sessions/refresh';
ApiInterceptor({
required this.tokenStorage,
required this.dio,
this.refreshFailureCooldown = const Duration(seconds: 5),
this.onTokenRefresh,
});
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await tokenStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final requestOptions = err.requestOptions;
final isUnauthorized = err.response?.statusCode == 401;
final shouldHandleUnauthorized =
isUnauthorized && _isAuthenticatedRequest(requestOptions);
if (err.response?.statusCode == 401 &&
onTokenRefresh != null &&
!_shouldSkipRefresh(requestOptions)) {
final refreshed = await _refreshTokenSingleflight();
if (refreshed) {
final token = await tokenStorage.getAccessToken();
if (token != null) {
final retryHeaders = Map<String, dynamic>.from(requestOptions.headers)
..['Authorization'] = 'Bearer $token';
final retryExtra = Map<String, dynamic>.from(requestOptions.extra)
..[_retriedRequestKey] = true;
final retryOptions = requestOptions.copyWith(
headers: retryHeaders,
extra: retryExtra,
);
try {
final response = await dio.fetch(retryOptions);
handler.resolve(response);
return;
} on DioException {
// Retry failed, proceed with original error.
}
}
} else if (shouldHandleUnauthorized) {
await _notifyAuthFailureSingleflight();
}
} else if (shouldHandleUnauthorized && _shouldSkipRefresh(requestOptions)) {
await _notifyAuthFailureSingleflight();
}
handler.next(err);
}
bool _isAuthenticatedRequest(RequestOptions options) {
return options.headers['Authorization'] != null;
}
Future<void> _notifyAuthFailureSingleflight() {
final existing = _authFailureFuture;
if (existing != null) {
return existing;
}
final callback = onAuthFailure;
if (callback == null) {
return Future<void>.value();
}
final future = callback().whenComplete(() {
_authFailureFuture = null;
});
_authFailureFuture = future;
return future;
}
bool _shouldSkipRefresh(RequestOptions options) {
final blockedUntil = _refreshBlockedUntil;
if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) {
return true;
}
return _normalizePath(options.path) == _refreshPathSuffix ||
options.extra[_retriedRequestKey] == true;
}
String _normalizePath(String rawPath) {
final parsed = Uri.tryParse(rawPath);
if (parsed == null) {
return rawPath.replaceFirst(RegExp(r'/+$'), '');
}
final normalized = parsed.path.replaceFirst(RegExp(r'/+$'), '');
return normalized.isEmpty ? '/' : normalized;
}
Future<bool> _refreshTokenSingleflight() {
final inflight = _refreshFuture;
if (inflight != null) {
return inflight;
}
final future = onTokenRefresh!().catchError((_) => false).whenComplete(() {
_refreshFuture = null;
});
_refreshFuture = future;
return future.then((refreshed) {
if (refreshed) {
_refreshBlockedUntil = null;
} else {
_refreshBlockedUntil = DateTime.now().add(refreshFailureCooldown);
}
return refreshed;
});
}
void reset() {
_refreshFuture = null;
_authFailureFuture = null;
_refreshBlockedUntil = null;
}
}