Authentication: Sign Up, Login & Session Persistence
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
AuthStateto the widget tree.
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();
}
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
redirectcallback inGoRouterthat readsref.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 returnnullto halt navigation.
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 aScaffoldMessenger.of(context).showSnackBar(...)witherror.toString(). - Keep the form fields pre-filled on error so users can correct only the mistake.
- Disable the submit button while
AsyncLoadingto 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.