March 12, 2024 - 6 min
Handling Authentication State With go_router and Riverpod
Started using go_router for navigation and Riverpod for state management in your project, but struggling how to properly set up and handle authentication state and make sure some routes can be accessed only when logged in?
Our choice for state management in Flutter development is Riverpod and recently we have decided to give go_router a go as our navigation solution. Till now we have used many packages for navigation in our applications, including Navigator 1.0, auto_route and Beamer but neither of them fulfilled our needs completely and at the same time remained simple enough not to have to spend too much time understanding how to work with it or how to add a new route when necessary.
There comes go_router which has official Flutter team support and it is pretty simple to get started with. However, handling the application’s main authentication state in cooperation with Riverpod turned out to be a challenge and go_router’s official documentation and resources on the Internet didn’t provide all the answers to our questions so we decided to share our solution to it.
Handling authentication state requirements
For starters we need to define what we want to achieve and then we will see how to do it. In each moment our authentication state will have one of the following values:
- Initial state – AuthStateInitial
- Authentication in progress – AuthStateAuthenticating
- Authenticated state – AuthStateAuthenticated (usually can hold User object)
- Unauthenticated state – AuthStateUnauthenticated
import 'package:equatable/equatable.dart';
sealed class AuthState extends Equatable {
const AuthState();
}
final class AuthStateInitial extends AuthState {
const AuthStateInitial();
@override
List<Object?> get props => [];
}
final class AuthStateAuthenticating extends AuthState {
const AuthStateAuthenticating();
@override
List<Object?> get props => [];
}
final class AuthStateAuthenticated extends AuthState {
const AuthStateAuthenticated();
@override
List<Object?> get props => [];
}
final class AuthStateUnauthenticated extends AuthState {
const AuthStateUnauthenticated();
@override
List<Object?> get props => [];
}
This is how it will look like in the code, sealed class AuthState with four classes extending it.
We then need a method to be called immediately after the application has been started to find out whether the user is already authenticated or not and update the authentication state accordingly.
Along with the method to update our authentication state at app start we want to be able to handle navigation redirects to decide whether the user should be able to access some page or not based on his authentication status. In other words we want to “protect” some routes and leave some routes open for everyone.
AuthNotifier with authentication state implementation
The requirements mentioned above will be met through our AuthNotifier class which will hold authentication state and extend Riverpod’s Notifier class. In order for AuthNotifier to be compatible with go_router and to be able to notify go_router to refresh every time AuthNotifier’s state changes, AuthNotifier must implement the Listenable protocol. By doing that, the whole AuthNotifier instance can be passed as refreshListenable parameter in GoRouter’s constructor.
If you would like to use the good old StateNotifier instead of the newer Notifier class, you won’t be able to because StateNotifier can’t implement Listenable. To see the differences between StateNotifier and Notifier and how to switch to a newer solution, visit our previous blog post (Migrating From StateNotifier to Notifier in Riverpod 2.0 With Unit Tests).
To implement the Listenable protocol, our AuthNotifier needs to override addListener(VoidCallback listener) and removeListener(VoidCallback listener) methods. The idea is to call the listener provided in addListener method when the authentication state changes.
That way go_router will be informed the AuthState change has occurred and redirect (which we will see below) to the exact page we want. Here we can see how our basic AuthNotifier looks so far.
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:my_app/features/auth/data/repository/auth_repository.dart';
import 'package:my_app/features/auth/domain/notifiers/auth_state.dart';
final authNotifierProvider = NotifierProvider<AuthNotifier, AuthState>(
() => AuthNotifier(),
);
class AuthNotifier extends Notifier<AuthState> implements Listenable {
late AuthRepository _authRepository;
VoidCallback? _routerListener;
@override
AuthState build() {
_authRepository = ref.watch(authRepositoryProvider);
return const AuthStateInitial();
}
@override
void addListener(VoidCallback listener) => _routerListener = listener;
@override
void removeListener(VoidCallback listener) => _routerListener = null;
}
Checking authentication state when applications starts
As we mentioned in the requirements above, we need a method that will check if the user has been authenticated, update authentication state accordingly and refresh go_router through a Listenable listener callback.
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:my_app/features/auth/data/repository/auth_repository.dart';
import 'package:my_app/features/auth/domain/notifiers/auth_state.dart';
final authNotifierProvider = NotifierProvider<AuthNotifier, AuthState>(
() => AuthNotifier()..checkIfAuthenticated(),
);
class AuthNotifier extends Notifier<AuthState> implements Listenable {
late AuthRepository _authRepository;
VoidCallback? _routerListener;
@override
AuthState build() {
_authRepository = ref.watch(authRepositoryProvider);
return const AuthStateInitial();
}
Future<void> checkIfAuthenticated() async {
state = const AuthState.authenticating();
final result = await _authRepository.checkIfAuthenticated();
result.fold(
(failure) {
state = const AuthStateUnauthenticated();
_routerListener?.call();
},
(_) {
state = const AuthStateAuthenticated();
_routerListener?.call();
},
);
}
@override
void addListener(VoidCallback listener) => _routerListener = listener;
@override
void removeListener(VoidCallback listener) => _routerListener = null;
}
As shown in the code snippet above, AuthNotifier has dependency on AuthRepository and when instantiated, Provider immediately calls AuthNotifier’s checkIfAuthenticated() method which then calls AuthRepository’s checkIfAuthenticated() method where the check if the user is already logged in can be made and based on the result of this method, AuthNotifier updates its state to AuthStateUnauthenticated or AuthStateAuthenticated and triggers go_router’s listener callback.
Handling redirects
To complete our requirements we are still missing one piece of the puzzle, maybe the most important one. After updating AuthNotifier’s authentication state, go_router needs to know which page to show to the user if he was successfully logged in or not and also on any subsequent redirect whether the user should be able to access that certain route or not.
To accomplish this, our AuthNotifier will have another method which will be called simply redirect with two input parameters, GoRouterState and bool showErrorIfNonExistentRoute and it will return optional String, some route name if the user should be redirected somewhere else (e.g. to LoginPage route if he doesn’t have the access for certain route) or null if the user is allowed to access that route.
Our use case will be that the user not being logged in can access only login, register or few similar pages and won’t be able to access all the other pages without being authenticated.
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:my_app/common/domain/providers/go_router_provider.dart';
import 'package:my_app/features/auth/data/repository/auth_repository.dart';
import 'package:my_app/features/auth/domain/notifiers/auth_state.dart';
import 'package:my_app/features/home/presentation/home_page.dart';
import 'package:my_app/features/login/presentation/login_page.dart';
final authNotifierProvider = NotifierProvider<AuthNotifier, AuthState>(
() => AuthNotifier()..checkIfAuthenticated(),
);
class AuthNotifier extends Notifier<AuthState> implements Listenable {
late AuthRepository _authRepository;
VoidCallback? _routerListener;
@override
AuthState build() {
_authRepository = ref.watch(authRepositoryProvider);
return const AuthStateInitial();
}
Future<void> checkIfAuthenticated() async {
state = const AuthState.authenticating();
final result = await _authRepository.checkIfAuthenticated();
result.fold(
(failure) {
state = const AuthStateUnauthenticated();
_routerListener?.call();
},
(_) {
state = const AuthStateAuthenticated();
_routerListener?.call();
},
);
}
String? redirect({
required GoRouterState goRouterState,
required bool showErrorIfNonExistentRoute,
}) {
final isAuthenticating = switch (state) {
AuthStateInitial() || AuthStateAuthenticating() => true,
_ => false,
};
if (isAuthenticating) return null;
final isLoggedIn =
switch (state) { AuthStateAuthenticated() => true, _ => false };
final loggingIn = goRouterState.matchedLocation == LoginPage.routeName;
if (loggingIn) {
if (isLoggedIn) return HomePage.routeName;
return null;
}
final routeExists = _routeExists(goRouterState.matchedLocation);
final loginRoutes =
goRouterState.matchedLocation.startsWith(LoginPage.routeName);
if (isLoggedIn && routeExists) {
return loginRoutes ? HomePage.routeName : null;
}
return loginRoutes || (showErrorIfNonExistentRoute && !routeExists)
? null
: LoginPage.routeName;
}
@override
void addListener(VoidCallback listener) => _routerListener = listener;
@override
void removeListener(VoidCallback listener) => _routerListener = null;
// temporary solution https://github.com/flutter/flutter/issues/117514
bool _routeExists(String route) {
try {
return ref
.read(goRouterProvider)
.configuration
.findMatch(route)
.matches
.isNotEmpty;
} catch (err) {
return false;
}
}
}
Here you can see updated AuthNotifier with the redirect method which at first checks whether the authentication state is in initial or authenticating status and if it is, it just does nothing (the redirect method will be called again when the state gets updated to authenticated or unauthenticated). Otherwise it checks if the user is logged in and if logging in is happening right now. If both is true, the user is redirected to the HomePage route which is our root page being shown when the user is authenticated.
If the user is currently not logging in, the method checks if the route exists via _routeExists method (at the moment, there is no more convenient way to get this information, for more info check this) and if the current redirect should happen to one of the login routes. Based on this information, if the user is logged in and the route exists, he will be allowed to access the desired route or be redirected to the HomePage route if the current redirect is to one of the login routes.
The last check relates to the case when the user is not logged in or the route doesn’t exist, based on the showErrorIfNonExistentRoute the redirect method will either let the user access that login route or non existent route (for which you can setup your 404 page through GoRouter’s constructor, by using errorBuilder or errorPageBuilder parameter), otherwise redirect it to LoginPage route.
Redirect method written like that allows us to handle all the cases regarding our authentication state, where to redirect the user when the application is started, what to do after being logged in or logged out and whether the route can be accessed or not based on the current authentication status.
Instantiating GoRouter
Now when we have all required pieces implemented we can glue them together and instantiate GoRouter.
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:my_app/features/auth/domain/notifiers/auth_notifier.dart';
import 'package:my_app/features/home/presentation/home_page.dart';
import 'package:my_app/features/login/presentation/login_page.dart';
final goRouterProvider = Provider<GoRouter>((ref) {
final authNotifier = ref.read(authNotifierProvider.notifier);
return GoRouter(
debugLogDiagnostics: kDebugMode,
initialLocation: HomePage.routeName,
routes: [
GoRoute(
path: HomePage.routeName,
builder: (context, state) => const HomePage(),
),
GoRoute(
path: LoginPage.routeName,
builder: (context, state) => const LoginPage(),
),
],
refreshListenable: authNotifier,
redirect: (context, state) => authNotifier.redirect(
goRouterState: state,
showErrorIfNonExistentRoute: true,
),
);
});
Here you can see the Provider which provides GoRouter object and references AuthNotifier via its provider and passes it to GoRouter’s constructor as a refreshListenable object and also calls its redirect method each time GoRouter needs to redirect and decide where to navigate the user.
Conclusion
In the few chapters, you saw how to handle authentication state in our application and how to integrate it with routing. First we wrote a few requirements that needed to be met and then step by step wrote a solution for connecting go_router and Riverpod to complete our initial challenge.
P.S. We didn’t show how to write login and logout methods in AuthNotifier but they should have almost the same logic as in checkIfAuthenticated() method, at first the state should be updated to AuthStateAuthenticating and after calling and getting AuthRepository’s login or logout result, the state should be updated to either AuthStateUnauthenticated or AuthStateAuthenticated, depending if you are writing login or logout method and if that method finished successfully or not.
Give Kudos by sharing the post!