Capstone: Real-World Flutter Project

Authentication: Sign Up, Login & Session Persistence

16 min Lesson 3 of 10

Authentication: Sign Up, Login & Session Persistence

A production Flutter app almost always requires a secure, reliable authentication layer. In this lesson you will wire up Firebase Authentication (email/password) to your capstone project using a clean repository + use-case architecture, persist the authenticated session so users stay logged in across app restarts, and surface meaningful error feedback in the UI.

Architecture Overview

Rather than calling Firebase SDK methods directly from widgets, we separate concerns into three layers:

  • AuthRepository — abstracts the data source (Firebase). Can be swapped or mocked in tests.
  • Use Cases — thin business-logic wrappers: SignUpUseCase, SignInUseCase, SignOutUseCase, GetCurrentUserUseCase.
  • AuthNotifier (Riverpod) — state holder that calls use cases and exposes AuthState to the widget tree.
Note: This pattern mirrors Domain-Driven Design. It keeps widgets ignorant of Firebase, making future migrations (e.g., switching to a custom backend) trivial.

Setting Up the AuthRepository

Define an abstract interface and a concrete Firebase implementation:

auth_repository.dart

import 'package:firebase_auth/firebase_auth.dart';

// Abstract contract — widgets depend on this, never on the concrete class
abstract class AuthRepository {
  Stream<User?> get authStateChanges;
  Future<User> signUp({required String email, required String password});
  Future<User> signIn({required String email, required String password});
  Future<void> signOut();
  User? get currentUser;
}

// Concrete Firebase implementation
class FirebaseAuthRepository implements AuthRepository {
  final FirebaseAuth _auth;

  FirebaseAuthRepository({FirebaseAuth? auth})
      : _auth = auth ?? FirebaseAuth.instance;

  @override
  Stream<User?> get authStateChanges => _auth.authStateChanges();

  @override
  User? get currentUser => _auth.currentUser;

  @override
  Future<User> signUp({
    required String email,
    required String password,
  }) async {
    final credential = await _auth.createUserWithEmailAndPassword(
      email: email,
      password: password,
    );
    return credential.user!;
  }

  @override
  Future<User> signIn({
    required String email,
    required String password,
  }) async {
    final credential = await _auth.signInWithEmailAndPassword(
      email: email,
      password: password,
    );
    return credential.user!;
  }

  @override
  Future<void> signOut() => _auth.signOut();
}
Tip: Injecting FirebaseAuth via the constructor (with a default of FirebaseAuth.instance) makes unit-testing easy — pass a mock instance in tests without touching production code.

Use Cases

Each use case is a single-responsibility class with one call() method. They translate raw Firebase exceptions into friendly domain errors using a Result type or Either (or simply rethrow mapped exceptions):

sign_in_use_case.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'auth_repository.dart';

class SignInUseCase {
  final AuthRepository _repo;
  SignInUseCase(this._repo);

  Future<User> call({
    required String email,
    required String password,
  }) async {
    try {
      return await _repo.signIn(email: email, password: password);
    } on FirebaseAuthException catch (e) {
      // Map FirebaseAuthException codes to readable messages
      throw _mapError(e.code);
    }
  }

  String _mapError(String code) {
    switch (code) {
      case 'user-not-found':
        return 'No account found for that email address.';
      case 'wrong-password':
        return 'Incorrect password. Please try again.';
      case 'invalid-email':
        return 'The email address is not valid.';
      case 'user-disabled':
        return 'This account has been disabled.';
      case 'too-many-requests':
        return 'Too many attempts. Please wait and try again.';
      default:
        return 'Sign-in failed. Please try again.';
    }
  }
}

Session Persistence

Firebase SDK persists the auth token to the device's secure storage by default on mobile. On app launch you only need to check authStateChanges() — if a token exists and is still valid, Firebase emits the User object immediately before any network call completes. Wire this into a Riverpod StreamProvider:

auth_providers.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_repository.dart';
import 'firebase_auth_repository.dart';

// Provide the repository (override in tests)
final authRepositoryProvider = Provider<AuthRepository>(
  (ref) => FirebaseAuthRepository(),
);

// Stream-based session persistence: emits User? on every auth change
final authStateProvider = StreamProvider<User?>((ref) {
  return ref.watch(authRepositoryProvider).authStateChanges;
});

// Notifier for sign-up / sign-in / sign-out actions
class AuthNotifier extends AsyncNotifier<User?> {
  late AuthRepository _repo;

  @override
  Future<User?> build() async {
    _repo = ref.watch(authRepositoryProvider);
    return _repo.currentUser;
  }

  Future<void> signIn(String email, String password) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(
      () => SignInUseCase(_repo)(email: email, password: password),
    );
  }

  Future<void> signUp(String email, String password) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(
      () => SignUpUseCase(_repo)(email: email, password: password),
    );
  }

  Future<void> signOut() async {
    state = const AsyncLoading();
    await _repo.signOut();
    state = const AsyncData(null);
  }
}

final authNotifierProvider =
    AsyncNotifierProvider<AuthNotifier, User?>(AuthNotifier.new);

Routing Based on Auth State

Listen to authStateProvider in your router (e.g., go_router) to redirect unauthenticated users to the login screen and authenticated users past it:

  • Use a redirect callback in GoRouter that reads ref.watch(authStateProvider).
  • When the stream emits null, redirect to /login.
  • When it emits a User, redirect to /home.
  • While the stream is loading (AsyncLoading), render a splash screen and return null to halt navigation.
Warning: Never store passwords or raw Firebase ID tokens in SharedPreferences. Firebase handles token storage securely in the platform keychain/keystore. Only store non-sensitive preferences (like theme choice) in SharedPreferences.

Error Handling in the UI

Display errors using AsyncValue.when on the notifier state. An AsyncError carries the mapped exception message from the use case, ready to display in a SnackBar or inline form text:

  • Use ref.listen(authNotifierProvider, ...) to react to state changes without rebuilding the whole widget.
  • On AsyncError, show a ScaffoldMessenger.of(context).showSnackBar(...) with error.toString().
  • Keep the form fields pre-filled on error so users can correct only the mistake.
  • Disable the submit button while AsyncLoading to prevent double-submission.

Summary

You now have a complete, production-grade authentication flow. The repository isolates Firebase from business logic. Use cases translate SDK exceptions into user-readable messages. A StreamProvider feeds Firebase's built-in token persistence into the widget tree automatically. And a go_router redirect guards every route without duplicating auth checks in individual screens. This architecture scales cleanly as you add social login, biometrics, or a different backend later.