feat(auth): enhance session management with proactive token refresh and error handling

This commit is contained in:
Achintha Isuru
2026-03-17 11:09:12 -04:00
parent ba5bf8e1d7
commit cc4e2664b6
2 changed files with 76 additions and 47 deletions

View File

@@ -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;
final String? token = await user.getIdToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
}
}
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);
}
}

View File

@@ -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());