From cc4e2664b6f787381f5b6359f18940e8b2286777 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 11:09:12 -0400 Subject: [PATCH] feat(auth): enhance session management with proactive token refresh and error handling --- .../inspectors/auth_interceptor.dart | 52 +++++++++++--- .../mixins/session_handler_mixin.dart | 71 +++++++++---------- 2 files changed, 76 insertions(+), 47 deletions(-) diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart index b52849b7..5cbe0d1c 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -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 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 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 response = + await Dio().fetch(err.requestOptions); + return handler.resolve(response); + } + } catch (_) { + // Force-refresh or retry failed — fall through to original error. + } finally { + _isRetrying = false; + } + } + } + } + return handler.next(err); + } } diff --git a/apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart b/apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart index 6c37d595..afb14c0a 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/mixins/session_handler_mixin.dart @@ -129,12 +129,39 @@ mixin SessionHandlerMixin { /// extract the role from the response. Future 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 ensureSessionValid() async { - final firebase_auth.User? user = auth.currentUser; - if (user == null) return; + /// Handle user sign-in event. + Future _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 _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 _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());