feat: Implement role-based session management and refactor authentication flow

This commit is contained in:
Achintha Isuru
2026-02-17 15:10:10 -05:00
parent be40614274
commit 8ce37d2306
13 changed files with 138 additions and 210 deletions

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:flutter/material.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -159,6 +160,20 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin {
clearCache();
}
@override
Future<String?> fetchUserRole(String userId) async {
try {
final fdc.QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables>
response = await executeProtected(
() => connector.getUserById(id: userId).execute(),
);
return response.data.user?.userRole;
} catch (e) {
debugPrint('Failed to fetch user role: $e');
return null;
}
}
/// Dispose all resources (call on app shutdown).
Future<void> dispose() async {
await disposeSessionHandler();

View File

@@ -47,9 +47,25 @@ mixin SessionHandlerMixin {
final StreamController<SessionState> _sessionStateController =
StreamController<SessionState>.broadcast();
/// Last emitted session state (for late subscribers).
SessionState? _lastSessionState;
/// Public stream for listening to session state changes.
Stream<SessionState> get onSessionStateChanged =>
_sessionStateController.stream;
/// Late subscribers will immediately receive the last emitted state.
Stream<SessionState> get onSessionStateChanged {
// Create a custom stream that emits the last state before forwarding new events
return _createStreamWithLastState();
}
/// Creates a stream that emits the last state before subscribing to new events.
Stream<SessionState> _createStreamWithLastState() async* {
// If we have a last state, emit it immediately to late subscribers
if (_lastSessionState != null) {
yield _lastSessionState!;
}
// Then forward all subsequent events
yield* _sessionStateController.stream;
}
/// Last token refresh timestamp to avoid excessive checks.
DateTime? _lastTokenRefreshTime;
@@ -66,8 +82,13 @@ mixin SessionHandlerMixin {
/// Firebase Auth instance (to be provided by implementing class).
firebase_auth.FirebaseAuth get auth;
/// List of allowed roles for this app (to be set during initialization).
List<String> _allowedRoles = <String>[];
/// Initialize the auth state listener (call once on app startup).
void initializeAuthListener() {
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
_allowedRoles = allowedRoles;
// Cancel any existing subscription first
_authStateSubscription?.cancel();
@@ -86,6 +107,25 @@ mixin SessionHandlerMixin {
);
}
/// Validates if user has one of the allowed roles.
/// Returns true if user role is in allowed roles, false otherwise.
Future<bool> validateUserRole(
String userId,
List<String> allowedRoles,
) async {
try {
final String? userRole = await fetchUserRole(userId);
return userRole != null && allowedRoles.contains(userRole);
} catch (e) {
debugPrint('Failed to validate user role: $e');
return false;
}
}
/// Fetches user role from Data Connect.
/// To be implemented by concrete class.
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 {
@@ -111,8 +151,8 @@ mixin SessionHandlerMixin {
while (retryCount < maxRetries) {
try {
// Get token result (doesn't fetch from network unless needed)
final firebase_auth.IdTokenResult idToken =
await user.getIdTokenResult();
final firebase_auth.IdTokenResult idToken = await user
.getIdTokenResult();
// Extract expiration time
final DateTime? expiryTime = idToken.expirationTime;
@@ -152,7 +192,9 @@ mixin SessionHandlerMixin {
final Duration backoffDuration = Duration(
seconds: 1 << (retryCount - 1), // 2^(retryCount-1)
);
debugPrint('Retrying token validation in ${backoffDuration.inSeconds}s');
debugPrint(
'Retrying token validation in ${backoffDuration.inSeconds}s',
);
await Future<void>.delayed(backoffDuration);
}
}
@@ -163,6 +205,19 @@ mixin SessionHandlerMixin {
try {
_emitSessionState(SessionState.loading());
// Validate role if allowed roles are specified
if (_allowedRoles.isNotEmpty) {
final bool isAuthorized = await validateUserRole(
user.uid,
_allowedRoles,
);
if (!isAuthorized) {
await auth.signOut();
_emitSessionState(SessionState.unauthenticated());
return;
}
}
// Get fresh token to validate session
final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult();
if (idToken.expirationTime != null &&
@@ -186,6 +241,7 @@ mixin SessionHandlerMixin {
/// Emit session state update.
void _emitSessionState(SessionState state) {
_lastSessionState = state;
if (!_sessionStateController.isClosed) {
_sessionStateController.add(state);
}