Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -531,7 +531,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.0.3"
version: "0.0.4"
matcher:
dependency: transitive
description:
Expand Down
45 changes: 45 additions & 0 deletions lib/src/routing/magic_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -208,12 +209,51 @@ 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, 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]. 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).\n$stackTrace',
);
return null;
}
}
Comment thread
Copilot marked this conversation as resolved.

/// Convert route definitions to GoRouter routes.
List<RouteBase> _buildRoutes() {
final goRoutes = <RouteBase>[];
Expand Down Expand Up @@ -715,6 +755,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();
Expand Down
166 changes: 166 additions & 0 deletions test/routing/router_auth_refresh_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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');
});

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 <String, dynamic>{'token': 'tok'}, _fakeUser());
await tester.pumpAndSettle();

expect(MagicRouter.instance.currentPath, '/');
});
}

/// 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<void> 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<void> 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<String> get fillable => ['id', 'name'];
}

_FakeUser _fakeUser() {
final user = _FakeUser();
user.fill({'id': 1, 'name': 'Alice'});
user.exists = true;
return user;
}
Loading