From 4cf6494eea2c463373a57cc57f86326f46eac3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 10 Jul 2026 23:39:49 +0300 Subject: [PATCH 1/6] feat(routing): re-run redirects on auth-state change Wire the GoRouter refreshListenable to the auth guard stateNotifier (resolved defensively, null when auth is unbound) so a passive 401 -> logout ejects the user from a protected screen to login. Adds router_auth_refresh_test covering the eject + the resting-on-login no-loop case. --- lib/src/routing/magic_router.dart | 38 ++++++ test/routing/router_auth_refresh_test.dart | 144 +++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 test/routing/router_auth_refresh_test.dart diff --git a/lib/src/routing/magic_router.dart b/lib/src/routing/magic_router.dart index 9c896a3..f3026b2 100644 --- a/lib/src/routing/magic_router.dart +++ b/lib/src/routing/magic_router.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import '../http/kernel.dart'; import '../http/middleware/magic_middleware.dart'; +import '../facades/auth.dart'; import '../facades/log.dart'; import 'route_definition.dart'; import 'title_manager.dart'; @@ -208,12 +209,49 @@ class MagicRouter { observers: _observers, routes: _buildRoutes(), redirect: _handleRedirect, + refreshListenable: _resolveAuthRefreshListenable(), onException: (context, state, router) { Log.warning('Route not found: ${state.uri}'); }, ); } + /// Resolve the auth guard's state notifier as the router's refresh signal. + /// + /// GoRouter re-runs [_handleRedirect] whenever the returned [Listenable] + /// notifies. The default guard's `stateNotifier` bumps on every login, + /// logout, and session restore, so a passive 401 (expired or revoked token) + /// that triggers a logout re-evaluates the redirect chain and ejects the + /// user from a protected screen to the login route, with no explicit + /// navigation call. + /// + /// This only adds a re-evaluation trigger: it does not force any redirect. + /// The middleware chain still decides the target, returning `null` (allow) + /// once the user is on the correct side, so a logged-out user resting on the + /// login route does not loop. + /// + /// Resolution is defensive. The router may be built before auth is fully + /// configured (no `auth` binding in the container, missing guard config), in + /// which case reaching the notifier throws. We then return `null` (no refresh + /// signal) rather than crashing router construction, mirroring the tolerance + /// in [AuthServiceProvider.boot]. + Listenable? _resolveAuthRefreshListenable() { + try { + return Auth.stateNotifier; + } catch (e) { + // Use debugPrint, not the Log facade: this runs at router-build time, + // which can precede the container binding of the log service (and of + // auth itself). Resolving Log here would throw the very "service not + // registered" error we are guarding against, mirroring the + // container-free warning in [setInitialLocation]. + debugPrint( + 'MagicRouter: auth state notifier unavailable; redirects will not ' + 're-run on auth-state changes ($e).', + ); + return null; + } + } + /// Convert route definitions to GoRouter routes. List _buildRoutes() { final goRoutes = []; diff --git a/test/routing/router_auth_refresh_test.dart b/test/routing/router_auth_refresh_test.dart new file mode 100644 index 0000000..19686c1 --- /dev/null +++ b/test/routing/router_auth_refresh_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +/// Tests for wiring the GoRouter `refreshListenable` to the auth guard's +/// `stateNotifier`. +/// +/// Background: a passive 401 (expired or revoked token) routes through +/// `AuthInterceptor.onError`, which logs the user out when the refresh fails. +/// Logout bumps the guard's `stateNotifier`. With the notifier wired as the +/// router's refresh signal, GoRouter re-runs its redirect chain on that bump, +/// ejecting the user from a protected screen to the login route without an +/// explicit navigation call. +/// +/// These tests read live auth state inside the middleware (rather than a fixed +/// boolean), so they exercise the actual re-evaluation trigger and prove the +/// eject happens purely from a state change. +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + setUp(() { + MagicApp.reset(); + Magic.flush(); + TitleManager.reset(); + MagicRouter.reset(); + Kernel.flush(); + Gate.manager.flush(); + Log.fake(); + }); + + tearDown(() { + Auth.unfake(); + }); + + testWidgets( + 'a passive logout re-runs redirects and ejects to /login', + (tester) async { + // 1. Start authenticated: the protected route is reachable. + Auth.fake(user: _fakeUser()); + Kernel.register('auth', () => _LiveAuthGuard()); + Kernel.register('guest', () => _LiveGuestGuard()); + + MagicRoute.page('/', () => const Text('dashboard')).middleware(['auth']); + MagicRoute.page( + '/login', + () => const Text('login'), + ).middleware(['guest']); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/'); + + // 2. Simulate the passive 401 -> AuthInterceptor logout. This only bumps + // the guard's stateNotifier; there is no explicit navigation here. + await Auth.logout(); + await tester.pumpAndSettle(); + + // 3. The router re-evaluated the redirect chain off the state change and + // ejected the now-guest user to the login route. + expect(MagicRouter.instance.currentPath, '/login'); + }, + ); + + testWidgets( + 'a logged-out user resting on /login does not loop', + (tester) async { + // Guest (no user) sitting on the guest route: the guest middleware must + // return null (allow), so the refresh-driven re-evaluation stays put and + // go_router never exceeds its redirect budget. + Auth.fake(); + Kernel.register('auth', () => _LiveAuthGuard()); + Kernel.register('guest', () => _LiveGuestGuard()); + + MagicRoute.page('/', () => const Text('dashboard')).middleware(['auth']); + MagicRoute.page( + '/login', + () => const Text('login'), + ).middleware(['guest']); + + MagicRouter.instance.setInitialLocation('/login'); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/login'); + + // A spurious state bump (e.g. a failed restore) must not start a loop. + Auth.stateNotifier.value++; + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/login'); + }, + ); +} + +/// Auth guard reading live auth state: redirects a guest off protected routes. +class _LiveAuthGuard extends MagicMiddleware { + @override + String? redirectTarget(String location) { + if (Auth.guest && location != '/login') return '/login'; + return null; + } + + @override + Future handle(void Function() next) async => next(); +} + +/// Guest guard reading live auth state: redirects an authenticated user home. +class _LiveGuestGuard extends MagicMiddleware { + @override + String? redirectTarget(String location) { + if (Auth.check() && location != '/') return '/'; + return null; + } + + @override + Future handle(void Function() next) async => next(); +} + +/// Minimal authenticated user for the fake auth manager. +class _FakeUser extends Model with Authenticatable { + @override + String get table => 'users'; + + @override + String get resource => 'users'; + + @override + List get fillable => ['id', 'name']; +} + +_FakeUser _fakeUser() { + final user = _FakeUser(); + user.fill({'id': 1, 'name': 'Alice'}); + user.exists = true; + return user; +} From 45225bdb2c888b0ff0b72dc4ba9026c97153acb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sat, 18 Jul 2026 02:23:26 +0300 Subject: [PATCH 2/6] docs(changelog): note the auth-state redirect refresh --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe6be4..2fc191e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ All notable changes to this project will be documented in this file. - [ ] `example/` updated when the change touches the canonical consumer scaffold - [ ] `flutter test` green; `dart analyze` clean; `dart format` no diff; `dart pub publish --dry-run` no blocking errors +### Added + +- **`MagicRouter` now re-runs its redirect chain when auth state changes, not only on navigation.** The router evaluated its guards (the `'auth'` / `'guest'` redirects) only while resolving a route, so a login or logout that happened while the user was already sitting on a page (a token expiry, a background sign-out, a successful login on the auth screen) did not move them off a now-forbidden route until the next manual navigation. The router now listens to the auth guard's state notifier and refreshes `routerConfig` on a change, so an auth transition re-evaluates redirects immediately (an expired session bounces to login; a login leaves the guest-only auth screen). Consumers with no bound `auth` guard are unaffected (the notifier is absent and the listener is a no-op). Touches `lib/src/routing/magic_router.dart`; covered by `test/routing/router_auth_refresh_test.dart`. + ## [0.0.4] - 2026-07-08 ### Added From 6fc343089eb37b641031f1bbc7c4c3bf2de90914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sat, 18 Jul 2026 02:47:09 +0300 Subject: [PATCH 3/6] style: dart format --- test/routing/router_auth_refresh_test.dart | 120 ++++++++++----------- 1 file changed, 56 insertions(+), 64 deletions(-) diff --git a/test/routing/router_auth_refresh_test.dart b/test/routing/router_auth_refresh_test.dart index 19686c1..4d812ca 100644 --- a/test/routing/router_auth_refresh_test.dart +++ b/test/routing/router_auth_refresh_test.dart @@ -34,70 +34,62 @@ void main() { Auth.unfake(); }); - testWidgets( - 'a passive logout re-runs redirects and ejects to /login', - (tester) async { - // 1. Start authenticated: the protected route is reachable. - Auth.fake(user: _fakeUser()); - Kernel.register('auth', () => _LiveAuthGuard()); - Kernel.register('guest', () => _LiveGuestGuard()); - - MagicRoute.page('/', () => const Text('dashboard')).middleware(['auth']); - MagicRoute.page( - '/login', - () => const Text('login'), - ).middleware(['guest']); - - await tester.pumpWidget( - MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), - ); - await tester.pumpAndSettle(); - - expect(MagicRouter.instance.currentPath, '/'); - - // 2. Simulate the passive 401 -> AuthInterceptor logout. This only bumps - // the guard's stateNotifier; there is no explicit navigation here. - await Auth.logout(); - await tester.pumpAndSettle(); - - // 3. The router re-evaluated the redirect chain off the state change and - // ejected the now-guest user to the login route. - expect(MagicRouter.instance.currentPath, '/login'); - }, - ); - - testWidgets( - 'a logged-out user resting on /login does not loop', - (tester) async { - // Guest (no user) sitting on the guest route: the guest middleware must - // return null (allow), so the refresh-driven re-evaluation stays put and - // go_router never exceeds its redirect budget. - Auth.fake(); - Kernel.register('auth', () => _LiveAuthGuard()); - Kernel.register('guest', () => _LiveGuestGuard()); - - MagicRoute.page('/', () => const Text('dashboard')).middleware(['auth']); - MagicRoute.page( - '/login', - () => const Text('login'), - ).middleware(['guest']); - - MagicRouter.instance.setInitialLocation('/login'); - - await tester.pumpWidget( - MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), - ); - await tester.pumpAndSettle(); - - expect(MagicRouter.instance.currentPath, '/login'); - - // A spurious state bump (e.g. a failed restore) must not start a loop. - Auth.stateNotifier.value++; - await tester.pumpAndSettle(); - - expect(MagicRouter.instance.currentPath, '/login'); - }, - ); + testWidgets('a passive logout re-runs redirects and ejects to /login', ( + tester, + ) async { + // 1. Start authenticated: the protected route is reachable. + Auth.fake(user: _fakeUser()); + Kernel.register('auth', () => _LiveAuthGuard()); + Kernel.register('guest', () => _LiveGuestGuard()); + + MagicRoute.page('/', () => const Text('dashboard')).middleware(['auth']); + MagicRoute.page('/login', () => const Text('login')).middleware(['guest']); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/'); + + // 2. Simulate the passive 401 -> AuthInterceptor logout. This only bumps + // the guard's stateNotifier; there is no explicit navigation here. + await Auth.logout(); + await tester.pumpAndSettle(); + + // 3. The router re-evaluated the redirect chain off the state change and + // ejected the now-guest user to the login route. + expect(MagicRouter.instance.currentPath, '/login'); + }); + + testWidgets('a logged-out user resting on /login does not loop', ( + tester, + ) async { + // Guest (no user) sitting on the guest route: the guest middleware must + // return null (allow), so the refresh-driven re-evaluation stays put and + // go_router never exceeds its redirect budget. + Auth.fake(); + Kernel.register('auth', () => _LiveAuthGuard()); + Kernel.register('guest', () => _LiveGuestGuard()); + + MagicRoute.page('/', () => const Text('dashboard')).middleware(['auth']); + MagicRoute.page('/login', () => const Text('login')).middleware(['guest']); + + MagicRouter.instance.setInitialLocation('/login'); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/login'); + + // A spurious state bump (e.g. a failed restore) must not start a loop. + Auth.stateNotifier.value++; + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/login'); + }); } /// Auth guard reading live auth state: redirects a guest off protected routes. From d2177ef984b0ce2e9364d3c938f33eeeb13eb0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sat, 18 Jul 2026 02:59:37 +0300 Subject: [PATCH 4/6] fix(routing): dispose the GoRouter on reset to release the refresh listener Address PR review: reset() dropped _router without disposing it, leaking the GoRouter's internal subscription to the auth refreshListenable (the orphaned router kept reacting to auth changes). Dispose it before nulling the reference. --- example/pubspec.lock | 6 +++--- lib/src/routing/magic_router.dart | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index e230157..d038cd1 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -329,10 +329,10 @@ packages: dependency: transitive description: name: fluttersdk_wind - sha256: "9cf3c6045d7f1391148a37726e09d8b76c20ddc787481b013c3f8768b99ad62b" + sha256: "6e45c03595e7b42cd2b1467d7aae0b6290e5f486f486b9434c65497e29f362af" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.2.0" fluttersdk_wind_diagnostics_contracts: dependency: transitive description: @@ -531,7 +531,7 @@ packages: path: ".." relative: true source: path - version: "0.0.3" + version: "0.0.4" matcher: dependency: transitive description: diff --git a/lib/src/routing/magic_router.dart b/lib/src/routing/magic_router.dart index f3026b2..709ff5a 100644 --- a/lib/src/routing/magic_router.dart +++ b/lib/src/routing/magic_router.dart @@ -753,6 +753,11 @@ class MagicRouter { _instance?._router?.routerDelegate.removeListener( _instance!._onRouteChanged, ); + // Dispose the GoRouter so it releases its internal subscription to the + // refreshListenable (the auth state notifier). Dropping the reference alone + // would leak that listener on the long-lived notifier, and the orphaned + // router would keep reacting to auth changes. + _instance?._router?.dispose(); _instance?._routes.clear(); _instance?._layouts.clear(); _instance?._observers.clear(); From 1109e6f82cf644517094f60ec1d5e96a4a6849f0 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 20 Jul 2026 11:46:14 +0300 Subject: [PATCH 5/6] fix(routing): log the stack trace when auth-notifier resolution fails The defensive catch that tolerates a router built before auth is bound logged only the exception message. A misconfiguration (missing guard, wrong binding order) is hard to trace from the message alone, so include the stack trace in the debugPrint. --- lib/src/routing/magic_router.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/routing/magic_router.dart b/lib/src/routing/magic_router.dart index 709ff5a..233cdd8 100644 --- a/lib/src/routing/magic_router.dart +++ b/lib/src/routing/magic_router.dart @@ -238,15 +238,17 @@ class MagicRouter { Listenable? _resolveAuthRefreshListenable() { try { return Auth.stateNotifier; - } catch (e) { + } catch (e, stackTrace) { // Use debugPrint, not the Log facade: this runs at router-build time, // which can precede the container binding of the log service (and of // auth itself). Resolving Log here would throw the very "service not // registered" error we are guarding against, mirroring the - // container-free warning in [setInitialLocation]. + // container-free warning in [setInitialLocation]. The stack trace is + // included: a misconfiguration here (missing guard, wrong binding order) + // is otherwise hard to trace back to its origin from the message alone. debugPrint( 'MagicRouter: auth state notifier unavailable; redirects will not ' - 're-run on auth-state changes ($e).', + 're-run on auth-state changes ($e).\n$stackTrace', ); return null; } From ade9d85854a4622c2d44a92130d74f338269bdfa Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 20 Jul 2026 11:46:14 +0300 Subject: [PATCH 6/6] test(routing): cover the login-direction redirect The suite covered the logout eject and the no-loop case but not the login half the changelog claims. Add a guest-on-/login -> login -> redirect-to-/ test so both directions of the auth transition are locked. --- test/routing/router_auth_refresh_test.dart | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/routing/router_auth_refresh_test.dart b/test/routing/router_auth_refresh_test.dart index 4d812ca..d2940ba 100644 --- a/test/routing/router_auth_refresh_test.dart +++ b/test/routing/router_auth_refresh_test.dart @@ -90,6 +90,36 @@ void main() { expect(MagicRouter.instance.currentPath, '/login'); }); + + testWidgets('a login on /login re-runs redirects and leaves for /', ( + tester, + ) async { + // The other half of the auth transition: a guest resting on the guest-only + // login route logs in, and the refresh-driven re-evaluation should send the + // now-authenticated user home with no explicit navigation call. + Auth.fake(); + Kernel.register('auth', () => _LiveAuthGuard()); + Kernel.register('guest', () => _LiveGuestGuard()); + + MagicRoute.page('/', () => const Text('dashboard')).middleware(['auth']); + MagicRoute.page('/login', () => const Text('login')).middleware(['guest']); + + MagicRouter.instance.setInitialLocation('/login'); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/login'); + + // Log in: the guard's stateNotifier bumps, the router re-evaluates, and the + // guest guard now sends the authenticated user home. + await Auth.login(const {'token': 'tok'}, _fakeUser()); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/'); + }); } /// Auth guard reading live auth state: redirects a guest off protected routes.