feat(auth): enhance session management with proactive token refresh and error handling
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
|
||||
/// An interceptor that adds the Firebase Auth ID token to the Authorization header.
|
||||
/// An interceptor that adds the Firebase Auth ID token to the Authorization
|
||||
/// header and retries once on 401 with a force-refreshed token.
|
||||
///
|
||||
/// Skips unauthenticated auth endpoints (sign-in, sign-up, phone/start) since
|
||||
/// the user has no Firebase session yet. Sign-out, session, and phone/verify
|
||||
@@ -14,6 +15,9 @@ class AuthInterceptor extends Interceptor {
|
||||
'/auth/staff/phone/start',
|
||||
];
|
||||
|
||||
/// Tracks whether a 401 retry is in progress to prevent infinite loops.
|
||||
bool _isRetrying = false;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
@@ -27,16 +31,48 @@ class AuthInterceptor extends Interceptor {
|
||||
if (!skipAuth) {
|
||||
final User? user = FirebaseAuth.instance.currentUser;
|
||||
if (user != null) {
|
||||
try {
|
||||
final String? token = await user.getIdToken();
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
return handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
// Retry once with a force-refreshed token on 401 Unauthorized.
|
||||
if (err.response?.statusCode == 401 && !_isRetrying) {
|
||||
final bool skipAuth = _unauthenticatedPaths.any(
|
||||
(String path) => err.requestOptions.path.contains(path),
|
||||
);
|
||||
|
||||
if (!skipAuth) {
|
||||
final User? user = FirebaseAuth.instance.currentUser;
|
||||
if (user != null) {
|
||||
_isRetrying = true;
|
||||
try {
|
||||
final String? freshToken = await user.getIdToken(true);
|
||||
if (freshToken != null) {
|
||||
// Retry the original request with the refreshed token.
|
||||
err.requestOptions.headers['Authorization'] =
|
||||
'Bearer $freshToken';
|
||||
final Response<dynamic> response =
|
||||
await Dio().fetch<dynamic>(err.requestOptions);
|
||||
return handler.resolve(response);
|
||||
}
|
||||
} catch (_) {
|
||||
// Force-refresh or retry failed — fall through to original error.
|
||||
} finally {
|
||||
_isRetrying = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,12 +129,39 @@ mixin SessionHandlerMixin {
|
||||
/// extract the role from the response.
|
||||
Future<String?> fetchUserRole(String userId);
|
||||
|
||||
/// Ensures the Firebase auth token is valid and refreshes if needed.
|
||||
/// Retries up to 3 times with exponential backoff before emitting error.
|
||||
Future<void> ensureSessionValid() async {
|
||||
final firebase_auth.User? user = auth.currentUser;
|
||||
if (user == null) return;
|
||||
/// Handle user sign-in event.
|
||||
Future<void> _handleSignIn(firebase_auth.User user) async {
|
||||
try {
|
||||
_emitSessionState(SessionState.loading());
|
||||
|
||||
if (_allowedRoles.isNotEmpty) {
|
||||
final String? userRole = await fetchUserRole(user.uid);
|
||||
|
||||
if (userRole == null) {
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_allowedRoles.contains(userRole)) {
|
||||
await auth.signOut();
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Proactively refresh the token if it expires soon.
|
||||
await _ensureSessionValid(user);
|
||||
|
||||
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
||||
} catch (e) {
|
||||
_emitSessionState(SessionState.error(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures the Firebase auth token is valid and refreshes if it expires
|
||||
/// within [_refreshThreshold]. Retries up to 3 times with exponential
|
||||
/// backoff before emitting an error state.
|
||||
Future<void> _ensureSessionValid(firebase_auth.User user) async {
|
||||
final DateTime now = DateTime.now();
|
||||
if (_lastTokenRefreshTime != null) {
|
||||
final Duration timeSinceLastCheck = now.difference(
|
||||
@@ -189,40 +216,6 @@ mixin SessionHandlerMixin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle user sign-in event.
|
||||
Future<void> _handleSignIn(firebase_auth.User user) async {
|
||||
try {
|
||||
_emitSessionState(SessionState.loading());
|
||||
|
||||
if (_allowedRoles.isNotEmpty) {
|
||||
final String? userRole = await fetchUserRole(user.uid);
|
||||
|
||||
if (userRole == null) {
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_allowedRoles.contains(userRole)) {
|
||||
await auth.signOut();
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final firebase_auth.IdTokenResult idToken =
|
||||
await user.getIdTokenResult();
|
||||
if (idToken.expirationTime != null &&
|
||||
idToken.expirationTime!.difference(DateTime.now()) <
|
||||
const Duration(minutes: 5)) {
|
||||
await user.getIdTokenResult(true);
|
||||
}
|
||||
|
||||
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
||||
} catch (e) {
|
||||
_emitSessionState(SessionState.error(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle user sign-out event.
|
||||
void handleSignOut() {
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
|
||||
Reference in New Issue
Block a user