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:dio/dio.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.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
|
/// Skips unauthenticated auth endpoints (sign-in, sign-up, phone/start) since
|
||||||
/// the user has no Firebase session yet. Sign-out, session, and phone/verify
|
/// 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',
|
'/auth/staff/phone/start',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Tracks whether a 401 retry is in progress to prevent infinite loops.
|
||||||
|
bool _isRetrying = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onRequest(
|
Future<void> onRequest(
|
||||||
RequestOptions options,
|
RequestOptions options,
|
||||||
@@ -27,16 +31,48 @@ class AuthInterceptor extends Interceptor {
|
|||||||
if (!skipAuth) {
|
if (!skipAuth) {
|
||||||
final User? user = FirebaseAuth.instance.currentUser;
|
final User? user = FirebaseAuth.instance.currentUser;
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
try {
|
|
||||||
final String? token = await user.getIdToken();
|
final String? token = await user.getIdToken();
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return handler.next(options);
|
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.
|
/// extract the role from the response.
|
||||||
Future<String?> fetchUserRole(String userId);
|
Future<String?> fetchUserRole(String userId);
|
||||||
|
|
||||||
/// Ensures the Firebase auth token is valid and refreshes if needed.
|
/// Handle user sign-in event.
|
||||||
/// Retries up to 3 times with exponential backoff before emitting error.
|
Future<void> _handleSignIn(firebase_auth.User user) async {
|
||||||
Future<void> ensureSessionValid() async {
|
try {
|
||||||
final firebase_auth.User? user = auth.currentUser;
|
_emitSessionState(SessionState.loading());
|
||||||
if (user == null) return;
|
|
||||||
|
|
||||||
|
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();
|
final DateTime now = DateTime.now();
|
||||||
if (_lastTokenRefreshTime != null) {
|
if (_lastTokenRefreshTime != null) {
|
||||||
final Duration timeSinceLastCheck = now.difference(
|
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.
|
/// Handle user sign-out event.
|
||||||
void handleSignOut() {
|
void handleSignOut() {
|
||||||
_emitSessionState(SessionState.unauthenticated());
|
_emitSessionState(SessionState.unauthenticated());
|
||||||
|
|||||||
Reference in New Issue
Block a user