Role-Based Authentication

This guide shows how to implement role-based authentication in your Flutter app using Firebase Auth and Flutter Bloc.

Extending the Auth State

First, let’s modify the auth state to include user roles:

part of 'auth_bloc.dart';


class AuthState with _$AuthState {
  const factory AuthState.signedOut() = _SignedOut;
  const factory AuthState.signedIn({
    required User user,
    required Map<String, dynamic> claims,
  }) = _SignedIn;
}

Extending the Auth Bloc

Now, let’s update the auth bloc to handle role-based authentication:

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc({
    required IAuthService authService,
    required FirebaseAnalytics analytics,
    required FirebaseCrashlytics crashlytics,
    required FacebookAppEvents facebookEvents,
  })  : _authService = authService,
        _analytics = analytics,
        _crashlytics = crashlytics,
        _facebookEvents = facebookEvents,
        super(const AuthState.signedOut()) {
    // Listen to auth state changes
    _authStateStreamSubscription = _authService.authStateChanges.listen((user) async {
      if (user == null) {
        add(const AuthEvent.signOut());
      } else {
        // Get user claims when signing in
        final tokenResult = await user.getIdTokenResult();
        add(AuthEvent.signedInwithProvider(
          user: user,
          claims: tokenResult.claims ?? {},
        ));
      }
    });

    // Handle Sign Out
    on<_SignOut>((event, emit) async {
      debugPrint('DEBUG: AuthStateChange: Sign Out');
      emit(const AuthState.signedOut());
      
      // Handle Initial logout
      final isConfigured = await Purchases.isConfigured;
      if (isConfigured) {
        final isAnon = await Purchases.isAnonymous;
        if (!isAnon) {
          await Purchases.logOut();
        }
      }
    });

    // Handle Sign In
    on<_SignedInWithProvider>((event, emit) async {
      final token = await event.user.getIdToken();
      final user = event.user;
      log('DEBUG: AuthStateChange: Sign In: $token');
      
      emit(AuthState.signedIn(user: user, claims: event.claims));
      
      // Set up user tracking across services
      await _crashlytics.setUserIdentifier(user.uid);
      await _analytics.setUserId(id: user.uid);
      await _facebookEvents.setUserID(user.uid);
      await Purchases.logIn(user.uid);

      debugPrint(
        [
          'DEBUG: user details ${user.photoURL}, ${user.displayName},',
          ' ${user.email} ',
        ].join(),
      );
    });
  }

  // ... rest of the bloc implementation
}

Extending the Auth Events

Update the auth events to include claims:

part of 'auth_bloc.dart';


class AuthEvent with _$AuthEvent {
  const factory AuthEvent.signedInwithProvider({
    required User user,
    required Map<String, dynamic> claims,
  }) = _SignedInWithProvider;
  const factory AuthEvent.signOut() = _SignOut;
}

Using Role-Based Authentication

Checking User Roles

You can check user roles in your UI or business logic:

// In a widget
BlocBuilder<AuthBloc, AuthState>(
  builder: (context, state) {
    return state.maybeWhen(
      signedIn: (user, claims) {
        if (claims['admin'] == true) {
          return AdminDashboard();
        } else if (claims['premium'] == true) {
          return PremiumDashboard();
        } else {
          return RegularDashboard();
        }
      },
      orElse: () => const LoginScreen(),
    );
  },
);

Role-Based Navigation

You can implement role-based navigation in your router:

class AppRouter {
  final authBloc = getIt<AuthBloc>();

  GoRouter getRouter() {
    return GoRouter(
      initialLocation: '/',
      redirect: (context, state) {
        final authState = authBloc.state;
        
        // Handle public routes
        final isPublicRoute = state.matchedLocation == '/login' || 
                            state.matchedLocation == '/signup';
        
        if (isPublicRoute && authState is _SignedIn) {
          return '/';
        }
        
        // Handle protected routes
        if (!isPublicRoute && authState is! _SignedIn) {
          return '/login';
        }
        
        // Handle role-based routes
        if (authState is _SignedIn) {
          final claims = authState.claims;
          
          // Admin routes
          if (state.matchedLocation.startsWith('/admin') && 
              claims['admin'] != true) {
            return '/';
          }
          
          // Premium routes
          if (state.matchedLocation.startsWith('/premium') && 
              claims['premium'] != true) {
            return '/';
          }
        }
        
        return null;
      },
      routes: [
        // ... your routes
      ],
    );
  }
}

Setting Custom Claims

To set custom claims for a user, you’ll need to use Firebase Admin SDK on your backend:

// Firebase Admin SDK (Node.js)
admin.auth().setCustomUserClaims(uid, {
  admin: true,
  premium: true,
  // ... other claims
});

Best Practices

  1. Cache Claims: Claims are cached in the ID token, so they don’t need to be fetched on every request.

  2. Security Rules: Use Firebase Security Rules to enforce role-based access:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /premium-content/{document=**} {
      allow read: if request.auth.token.premium == true;
    }
    
    match /admin/{document=**} {
      allow read, write: if request.auth.token.admin == true;
    }
  }
}
  1. Error Handling: Always handle cases where claims might be missing or invalid.

  2. UI Feedback: Provide clear feedback to users when they try to access restricted content.

  3. Testing: Test your role-based logic thoroughly, especially edge cases.