How to handle authentication in Flutter with go_router and Firebase Authentication when already on a route?
P

4

10

In my Flutter application, I'm using the go_router package to manage routes and Firebase Authentication for user authentication. I have several screens that require users to be authenticated to access, such as account management, transactions, and details.

Currently, I have implemented redirects with go_router to ensure that unauthenticated users are redirected correctly. However, I face a challenge when the user is already on one of these screens and logs out or the session expires.

I'm considering using a BlocListener to detect changes in the authentication state on each screen, but this seems to lead to code duplication. I also have a Stream that notifies changes in the authentication state, thanks to Firebase Authentication, and updates the contex.isUserSignIn variable.

What would be the best practice for handling logout or session expiration events in Flutter with go_router and Firebase Authentication efficiently?

Polack answered 7/10, 2023 at 11:37 Comment(0)
T
16

I’ve been struggling with the exact same question and have researched several alternatives and options.

TL;DR: Option 3 is my preferred choice, which uses the GoRouter.refresh() method at the main() level to dynamically update the GoRouter state based on events from the auth stream.

Option 1: Follow the go_router async_redirection.dart example

See here for the example: https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/async_redirection.dart

This wraps the top level app, typically MyApp (as returned by main() in runApp() ) in an InheritedNotifer widget, which they call StreamAuthScope and which creates a dependency between the notifier StreamAuthNotifier and go_router's parsing pipeline. This in turn will rebuild MyApp (or App in the example) when the auth status changes (as communicated by StreamAuthNotifier via notifyListeners()).

I implemented a similar model based on the Provider package where the ChangeProviderNotifier replaces StreamAuthScope and wraps the top level MyApp returned by main(). However this doesn’t allow the creation of a monitored Provider.of<> inside the GoRouter( redirect: ) enclosure. To solve this I created a getRouter function that passed in isUserSignIn which was monitored with a Provider.of<> in the main body of MyApp but before the build function. This works but feels cumbersome and causes the main MyApp to be rebuilt each time auth status changes. If desired, I’m sure you could do something similar with a BLoC model in place of Provider.

Option 2: Use GoRouter’s refreshListenable: parameter

This is based on this go_router redirection.dart example: https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/redirection.dart

In the question you mentioned you have a stream that notifies auth state changes. You can wrap this in a class with extends ChangeNotifier to make it Listenable. Then in the constructor you can instantiate and monitor the stream with .listen, and in the enclosure issue a notifyListerners() each time there is an auth state change (probably each time there is a stream event). In my case I called this class AuthNotifier This can then be used as the listenable with GoRouter’s refreshListenable: parameter simply as: refreshListenable: AuthNotifier()

Example AuthNotifier class

class AuthNotifier extends ChangeNotifier {
  AuthNotifier() {
    // Continuously monitor for authStateChanges
    // per: https://firebase.google.com/docs/auth/flutter/start#authstatechanges
    _subscription =
        FirebaseAuth.instance.authStateChanges().listen((User? user) {
          // if user != null there is a user logged in, however
          // we can just monitor for auth state change and notify
          notifyListeners();
        });
  } // End AuthNotifier constructor

  late final StreamSubscription<dynamic> _subscription;

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }
}

Note: to avoid multiple streams being created and monitored, you need to ensure this constructor is only called once in your app (in this case as part of GoRouter’s refreshListenable:), or else modify it to be a singleton.

Option 3: Use GoRouter’s .refresh() method

A similar, but more direct approach to option 2 is to use GoRouter’s .refresh() method. This directly calls an internal notifyListerners() that refreshes the GoRouter configuration. We can use a similar class to the AuthNotifier above but we don’t need extends ChangeNotifier and would call router.refresh() in place of notifyListeners(), where router is your GoRouter() configuration. This new class would be instantiated in main().

Given its so simple (2-3 lines of code), we can also skip the class definition and instantiation and implement the functionality directly in the main() body, as follows:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  // Listen for Auth changes and .refresh the GoRouter [router]
  FirebaseAuth.instance.authStateChanges().listen((User? user) {
    router.refresh();
  });

  runApp(
    const MyApp(),
  );
}

Since this appears to be the most direct and simplest solution, it is my preferred solution and the one I have implemented. However there is a lot of confusing and dated information out there and I don’t feel I have enough experience to claim it as any sort of 'best practice', so will leave that for others to judge and comment.

I hope all this helps you and others as it’s taken me a long time with work out these various options and wade through the wide range of materials and options out there. I feel there is a definitely an opportunity to improve the official go_router documentation in this area !

Thenna answered 8/11, 2023 at 20:58 Comment(2)
Very nice contribution! Helped me a lot :)Aircrewman
Thanks a lot for the description! I posted an example to illustrate the first approach, for which you didn't include code snippets.Mcnully
A
7

To elaborate on Arik_E's answer, here is my implementation:

My main.dart file:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  // Listen for Auth changes and .refresh the GoRouter [router]
  GoRouter router = RoutingService().router;
  FirebaseAuth.instance.authStateChanges().listen((User? user) {
    router.refresh();
  });

  runApp(App(router: router));
}

class App extends StatelessWidget {
  const App({super.key, required this.router});
  final GoRouter router;

  @override
  Widget build(BuildContext context) => MaterialApp.router(
        routerConfig: router,
      );
}

And I have a class which contains my router:

class RoutingService {
  final router = GoRouter(
    routes: <GoRoute>[
      GoRoute(
        path: MainMenuPage.route,
        builder: (BuildContext context, GoRouterState state) =>
            const MainMenuPage(),
      ),
      GoRoute(
        path: LoginPage.route,
        builder: (BuildContext context, GoRouterState state) =>
            const LoginPage(),
      ),
    ],

    // redirect to the login page if the user is not logged in
    redirect: (BuildContext context, GoRouterState state) async {
      final bool loggedIn = FirebaseAuth.instance.currentUser != null;
      final bool loggingIn = state.matchedLocation == LoginPage.route;
      if (!loggedIn) return LoginPage.route;
      if (loggingIn) return MainMenuPage.route;
      // no need to redirect at all
      return null;
    },
  );
}

In my MainMenuPage and LoginPage, I have the route defined so I can reuse it. Just add static const route = '/yourRouteName' to the widget.

Aircrewman answered 25/2 at 14:28 Comment(3)
concise and to the point, thanksFactor
Nice. Note that the user can be non-null and Anonymous (not really logged in). So I think this is better: final bool loggedIn = FirebaseAuth.instance.currentUser != null && !FirebaseAuth.instance.currentUser!.isAnonymous;Carmelinacarmelita
@linus But this logic is preventing a user from navigating to signup from the login screen if a user is not authenticated. How can we fix that?Cougar
M
1

To elaborate on Option 1 from @Arik_E which only uses Provider, as I didn't want to introduce the bloc pattern just for this purpose, here is an example home.dart. (In my case I'm trying to make my AuthService work with graphql_flutter instead of Firebase):

getRouterConfig(authService) {
  return GoRouter(
    initialLocation: '/auth',
    redirect: (BuildContext context, GoRouterState state) async {
      // Note how we don't need to do this anymore, which wouldn't work.
      // var authService = context.watch<AuthService>();

      if (authService.auth == null) {
        return '/auth';
      } else if (state.matchedLocation == '/auth') {
        return '/';
      } else {
        return null;
      }
    },
    routes: [
      GoRoute(
          path: '/',
          builder: (context, state) =>
              const MyHomePage(title: 'My Home Page'),
          routes: [
            GoRoute(
              path: 'tasks',
              builder: (context, state) => TasksView(),
            ),
            GoRoute(
              path: 'auth',
              builder: (context, state) => LoginView(),
            ),
          ]),
    ],
  );
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await initHiveForFlutter();

  runApp(MultiProvider(providers: [
    ChangeNotifierProvider<AuthService>(
        create: (context) => AuthService(),),
    ProxyProvider<AuthService, GraphQLClientModel>(
        update: (context, authService, graphQLClientModel) =>
            GraphQLClientModel(authService)),
  ], child: const MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    var client = context.watch<GraphQLClientModel>().clientNotifier;
    var authService = context.watch<AuthService>();
    return GraphQLProvider(
        client: client,
        child: MaterialApp.router(
          title: 'My App',
          routerConfig: getRouterConfig(authService),
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
            useMaterial3: true,
          ),
        ));
  }
}

class MyHomePage extends StatefulWidget {
  // ... Build your home page
}
Mcnully answered 4/5 at 20:45 Comment(0)
O
0

I found a better solution, I wrote an article about it. https://omasuaku.medium.com/seamless-auto-redirect-authentication-with-firebase-riverpod-generator-and-gorouter-39eaf72ec578

The solution is

We must create a ChangeNotifier that update the value so that GoRouter can refresh whenever the authentication state changes using GoRouter’s refreshListenable.

This is an exemple using riverpod.

part 'router.g.dart';

final routerKey = GlobalKey<NavigatorState>();

@Riverpod(keepAlive: true)
GoRouter router(RouterRef ref) {
  final user = ref.watch(currentUserNotifierProvider);


  return GoRouter(
    navigatorKey: routerKey,
    refreshListenable: user,
    initialLocation: AuthRoute().location,
    routes: $appRoutes,
    redirect: (context, state) {
      final isLogged = user.asyncValue.value != null;

      if (state.uri.path == AuthRoute().location && isLogged) return HomeRoute().location;

      if (!isLogged) return AuthRoute().location;

      return null;
    },
  );
}
Octogenarian answered 8/9 at 5:19 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.