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
1 change: 1 addition & 0 deletions .claude/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ You are preparing a new release for the **Magic Starter** Flutter plugin. Follow
3. **Tests** — All tests must pass (see context above). If failing, STOP and report.
4. **Analyzer** — Zero issues required (see context above). If issues, STOP and report.
5. **Version** — Determine the new version from $ARGUMENTS or auto-increment.
6. **Agent-facing reference** — Update `../magic/skills/magic-framework/references/plugin-starter.md` against this release and move its first-line stamp to the new version. That file, not this repo's `CLAUDE.md`, is what an agent adopting this package reads: `.pubignore` keeps `CLAUDE.md` and `.claude/` out of the published archive. It lives in another repository, so nothing here forces it; `test/skill_reference_stamp_test.dart` catches a stale stamp when a sibling checkout exists and skips when it does not, which is why this line is here too.

### Phase 2: Version Bump

Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

All notable changes to this project will be documented in this file.

## [Unreleased]
## [0.0.1-alpha.27] - 2026-09-09

### Added
- **A deep link that lands on a signed-out device now survives the login bounce.** `EnsureAuthenticated.redirectTarget` records the requested location via `MagicRouter.setIntendedUrl` before bouncing an unauthenticated visitor to login, and a new `NavigatesRoutes.navigateHome` reads it back with `pullIntendedUrl` once they authenticate, falling back to `MagicStarterConfig.homeRoute()` when no intent was stored or the stored value is not an in-app path. Nothing is recorded for the login route itself or for any other guest-only auth route (register, forgot-password, reset-password, two-factor-challenge, otp), since a bounced visitor cannot use one of those as a destination either. All five post-auth navigations (login, register auto-login, two-factor challenge, OTP verification, guest login) now call `navigateHome()` instead of navigating straight to the home route. Known limit: `redirectTarget` only ever sees `state.matchedLocation`, which carries no query string, so a recorded intent loses any `?token=...` the original link carried.

**An intent belongs to the session that asked for it, and ending that session discards it.** Signing out flips the auth state, which re-runs go_router's redirects while the app is still on the protected route, so `EnsureAuthenticated` writes that route down as somewhere to return to. Nobody asked for it: a sign-out on `/teams/settings` would otherwise send the NEXT person who signs in on that device straight there, and the account-deletion path would send them to a deleted account's settings. `MagicStarterServiceProvider` now listens to `Auth.stateNotifier` and discards the intent whenever the state goes to signed-out, which is the one funnel all three logouts pass through: the two a user asks for, and the one the app performs on its own when magic's `AuthInterceptor` fails a token refresh, which no call-site clear can reach. In this provider rather than in `SessionScopeSync`, which listens to the same notifier: that one is opt-in and nothing in this package calls `attach()`, so an app that never adopted `SessionScopedController` would have had no clear at all. This provider boots in every starter app, so no host action is needed. The clear is deferred by a microtask because the whole record path is synchronous and clearing inline would run before the redirect that writes the value. A host route with an ASYNC `redirect` records after that microtask and is not covered.

## [0.0.1-alpha.26] - 2026-09-03

Expand Down
2 changes: 1 addition & 1 deletion doc/basics/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ await MagicStarterAuthController.instance.doLogin(

The login flow has three possible outcomes:

1. **Success** — the controller extracts `token` and `user` from the nested `data` key, calls `Auth.login()`, sets success state, and navigates to `MagicStarterConfig.homeRoute()`.
1. **Success** — the controller extracts `token` and `user` from the nested `data` key, calls `Auth.login()`, sets success state, and calls `navigateHome()`. That is the intended url a bounced deep link recorded, read once through `MagicRouter.pullIntendedUrl()`, and `MagicStarterConfig.homeRoute()` only as the fallback when nothing was recorded or the recorded value is not an in-app path. An intent belongs to the session that asked for it: `MagicStarterServiceProvider` listens to `Auth.stateNotifier` and discards it whenever the state goes to signed-out, so it never reaches the next person on the device. That covers the sign-out a user asks for, the account deletion, and the one the app performs on its own when a token refresh fails, since all three go through the same notifier. No host wiring is needed; the provider boots in every starter app.
2. **Two-factor required** — the controller detects the challenge flag and navigates to `MagicStarterConfig.twoFactorChallengeRoute()` with the encrypted token as a query parameter. No login occurs yet.
3. **Failure** — `handleApiError()` sets the error state with a localized fallback message.

Expand Down
2 changes: 1 addition & 1 deletion lib/src/cli/starter_artisan_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import 'commands/magic_starter_uninstall_command.dart';
/// both and fails when they disagree, so a banner cannot drift behind a release
/// the way the two hand-written `'0.0.1'` literals did: they were written before
/// the first alpha and were still claiming 0.0.1 twenty-four releases later.
const String magicStarterVersion = '0.0.1-alpha.26';
const String magicStarterVersion = '0.0.1-alpha.27';

/// Magic Starter's contribution to the host application's artisan registry.
///
Expand Down
37 changes: 37 additions & 0 deletions lib/src/http/controllers/concerns/navigates_routes.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:magic/magic.dart';

import '../../../configuration/magic_starter_config.dart';

/// Shared navigation helper for Magic Starter controllers.
///
/// Provides a safe [navigateTo] method that checks for navigator context
Expand All @@ -17,4 +19,39 @@ mixin NavigatesRoutes {

MagicRoute.to(path, query: query);
}

/// Navigate to wherever a signed-in user should land after authenticating.
///
/// This is the ONLY post-auth navigation seam: every controller action
/// that follows a successful login, registration, two-factor challenge,
/// OTP verification or guest login must call this instead of navigating
/// to [MagicStarterConfig.homeRoute] directly, or the deep-link-through-
/// login flow below silently keeps sending everyone home.
///
/// Pulls the URL `EnsureAuthenticated` recorded before bouncing the user
/// to login (see [MagicRouter.pullIntendedUrl], a one-time read) and
/// targets it when it is a well-formed in-app path (non-null, leading
/// slash). Any other value, including no stored intent at all, falls
/// back to [MagicStarterConfig.homeRoute]. The leading-slash check is
/// defence in depth: `setIntendedUrl` is only ever called with a router
/// location internally, so this guards against a value that should not
/// be reachable rather than one that is.
void navigateHome() {
if (MagicRouter.instance.navigatorKey.currentContext == null) return;

final String? intended = MagicRouter.instance.pullIntendedUrl();
// `//host/path` is protocol-relative: it starts with a slash and is a
// different ORIGIN, so the leading-slash test alone lets it through.
// Nothing reachable can store one today, since only a router location is
// ever recorded, which is exactly why the check is cheap to keep.
final bool hasValidIntent =
intended != null &&
intended.startsWith('/') &&
!intended.startsWith('//');
final String target = hasValidIntent
? intended
: MagicStarterConfig.homeRoute();

navigateTo(target);
}
}
10 changes: 7 additions & 3 deletions lib/src/http/controllers/magic_starter_auth_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ class MagicStarterAuthController extends MagicController
// 3. Authenticate the user and navigate home.
await Auth.login({'token': token}, MagicStarter.createUser(userData));
setSuccess(true);
navigateTo(MagicStarterConfig.homeRoute());
navigateHome();
} on TimeoutException catch (e, stackTrace) {
Log.error(
'[MagicStarterAuthController.doLogin] Timeout: $e\n$stackTrace',
Expand Down Expand Up @@ -186,7 +186,7 @@ class MagicStarterAuthController extends MagicController
// 3. Auto-login when the server returns credentials immediately.
await Auth.login({'token': token}, MagicStarter.createUser(userData));
setSuccess(true);
navigateTo(MagicStarterConfig.homeRoute());
navigateHome();
return;
}

Expand Down Expand Up @@ -321,7 +321,7 @@ class MagicStarterAuthController extends MagicController
// 2. Log the user in and navigate to home.
await Auth.login({'token': token}, MagicStarter.createUser(userData));
setSuccess(true);
navigateTo(MagicStarterConfig.homeRoute());
navigateHome();
} catch (e, stackTrace) {
Log.error(
'[MagicStarterAuthController.doTwoFactorChallenge] $e\n$stackTrace',
Expand Down Expand Up @@ -352,6 +352,10 @@ class MagicStarterAuthController extends MagicController

// 2. Clear authentication tokens and navigate to login.
await Auth.logout();

// The intended url this sign-out just recorded is dropped by
// `SessionScopeSync`, which listens to the one notifier all three logout
// paths pass through, including the passive 401 one no call site can see.
navigateTo(MagicStarterConfig.loginRoute());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import 'dart:math';
import 'package:magic/magic.dart';

import 'concerns/navigates_routes.dart';
import '../../configuration/magic_starter_config.dart';
import '../../facades/magic_starter.dart';
import '../../models/magic_starter_auth_user.dart';

Expand Down Expand Up @@ -85,7 +84,7 @@ class MagicStarterGuestAuthController extends MagicController
setSuccess(true);

// 5. Navigate home.
navigateTo(MagicStarterConfig.homeRoute());
navigateHome();
} catch (e, stackTrace) {
Log.error(
'[MagicStarterGuestAuthController.doGuestLogin] $e\n$stackTrace',
Expand Down
2 changes: 1 addition & 1 deletion lib/src/http/controllers/magic_starter_otp_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class MagicStarterOtpController extends MagicController

// 3. Navigate home on successful authentication.
setSuccess(data);
navigateTo(MagicStarterConfig.homeRoute());
navigateHome();
} catch (e, stackTrace) {
Log.error('[MagicStarterOtpController.verifyOtp] $e\n$stackTrace');
setError(trans('errors.unexpected'));
Expand Down
58 changes: 55 additions & 3 deletions lib/src/middleware/ensure_authenticated.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,73 @@ import '../configuration/magic_starter_config.dart';
/// `redirect` callback before any page builds, so an unauthenticated boot
/// lands on the login route and the gated page never mounts.
///
/// Before bouncing to login it records [location] via
/// [MagicRouter.setIntendedUrl], so `NavigatesRoutes.navigateHome` can send
/// the user back there once they authenticate. Nothing is recorded for the
/// login route itself (nothing to return to) or for any other guest-only
/// route registered in `auth_routes.dart` (register, forgot-password,
/// reset-password, two-factor-challenge, otp): a visitor bounced off one of
/// those cannot use it as a post-login destination either.
///
/// **Known limit**: [redirectTarget] receives `state.matchedLocation` (see
/// `magic_router.dart`'s `_handleRedirect`), which carries no query string,
/// so a recorded intent loses any `?token=...` the original link carried.
///
/// ```dart
/// MagicRoute.group(
/// middleware: ['auth'],
/// routes: () { /* protected routes */ },
/// );
/// ```
class EnsureAuthenticated extends MagicMiddleware {
/// Path suffixes (under the configured auth prefix) that are themselves
/// guest-only screens: see `auth_routes.dart` for the route registrations
/// this mirrors.
static const List<String> _guestRouteSuffixes = <String>[
'/register',
'/forgot-password',
'/reset-password',
'/two-factor-challenge',
'/otp',
];

@override
String? redirectTarget(String location) {
if (Auth.check()) return null;

// Guard the login route itself so the redirect can never loop: go_router
// raises after more than five successive redirects.
final String login = MagicStarterConfig.loginRoute();
if (!Auth.check() && location != login) {
return login;
if (location == login) return null;

if (!_isGuestRoute(location)) {
MagicRouter.instance.setIntendedUrl(location);
}
return null;

return login;
}

/// Whether [location] is one of the guest-only auth routes registered in
/// `auth_routes.dart` (the login route itself is handled by the caller).
///
/// Unreachable through THIS package's own route table, and kept anyway.
/// `auth_routes.dart:19` registers the whole auth group under
/// `middleware: ['guest']`, so this middleware never sees `/auth/register`
/// or its siblings there. What it is for is a host that applies `auth`
/// globally, over a shell route wrapping everything, which the middleware
/// being public API makes a supported configuration rather than a
/// hypothetical: without this, such an app would bounce a visitor off
/// `/auth/register`, record it, and send them back to a guest-only route
/// after they sign in.
///
/// Named here because a reviewer asked three times whether the branch was
/// dead. It is dead for us and live for an adopter, and the two tests over
/// it call [redirectTarget] directly, so nothing shows the router reaching
/// it.
bool _isGuestRoute(String location) {
final String authPrefix = MagicStarterConfig.authPrefix();
return _guestRouteSuffixes.any(
(suffix) => location == '$authPrefix$suffix',
);
}
}
78 changes: 78 additions & 0 deletions lib/src/providers/magic_starter_service_provider.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/widgets.dart';
import 'package:magic/magic.dart';

Expand Down Expand Up @@ -54,6 +56,82 @@ class MagicStarterServiceProvider extends ServiceProvider {
// 2. If not, register 'indigo' as the fallback primary color.
// 3. Emit info log to notify about the fallback.
_bootPrimaryColorFallback();

_forgetIntendedUrlOnSignOut();
}

/// The notifier [_forgetIntendedUrlOnSignOut] is currently subscribed to.
///
/// Held rather than resolved again at removal time for the same reason
/// `SessionScopeSync` holds its own: `Auth.stateNotifier` resolves through
/// the container, so re-binding the guard hands back a DIFFERENT notifier and
/// unsubscribing through the facade would leave this listener on the old one.
///
/// Compared by identity rather than treated as a one-way latch, which is what
/// it was first written as and what a review caught. A latch never cleared,
/// so a second boot after a re-bind returned early and left the subscription
/// on a notifier nobody bumps any more: no clear at all, and a test suite
/// where the assertion holds only because an earlier test happened to attach
/// first. It failed under `--test-randomize-ordering-seed=1` and passed in
/// declaration order, which is the worst way for it to be wrong.
static ValueNotifier<int>? _authState;

/// Discards the intended url when the session that recorded it ends.
///
/// [EnsureAuthenticated] records a protected route before bouncing to login,
/// so `navigateHome()` can send the visitor back to it afterwards. Signing
/// out flips the auth state, which re-runs go_router's redirects while the
/// app is STILL on that route, so the sign-out records it too. Nobody asked
/// for that: it would send the next person who signs in on this device to the
/// previous one's page, and on the account-deletion path to a deleted
/// account's settings.
///
/// Hung off the auth notifier rather than off each `Auth.logout()` call site,
/// which is where this first landed and covers only the logouts a user asks
/// for. The one that matters most is the one the app performs on its own:
/// magic's `AuthInterceptor` calls `Auth.logout()` when a token refresh fails
/// (`auth_interceptor.dart:77`) and `AuthServiceProvider` installs it
/// unconditionally, so a session that simply EXPIRES on a protected route
/// took that path. The notifier is the one funnel all three pass through.
///
/// Here rather than in `SessionScopeSync`, which listens to the same notifier
/// and was the second thing tried: that class is OPT-IN and nothing in this
/// package calls `attach()`, so an app that never adopted
/// `SessionScopedController` would have had no clear at all. This provider
/// boots in every starter app.
///
/// Deferred by a microtask because the whole record path (`stateNotifier` ->
/// `GoRouteInformationProvider.notifyListeners` -> parse -> redirect) is
/// synchronous: clearing inline would run before the redirect that writes the
/// value. Known limit: a host route with an ASYNC `redirect` records after
/// the microtask and is not covered. Read-and-discard because
/// `pullIntendedUrl` is the one-time read and `MagicRouter` exposes no
/// separate clear.
void _forgetIntendedUrlOnSignOut() {
final ValueNotifier<int> notifier = Auth.stateNotifier;
if (identical(_authState, notifier)) return;

// Moves rather than adds. Booting twice against the same notifier is the
// no-op above; booting against a NEW one has to take the subscription with
// it, or the listener sits on a notifier nothing bumps.
_authState?.removeListener(_forgetIntendedUrl);
_authState = notifier..addListener(_forgetIntendedUrl);
}

/// The listener itself, a named static so [_forgetIntendedUrlOnSignOut] can
/// remove it: `removeListener` matches by identity and a fresh closure never
/// equals the one that was added.
static void _forgetIntendedUrl() {
if (Auth.check()) return;

scheduleMicrotask(() {
// Re-checked inside the microtask: by the time it runs the state may
// have moved again, and clearing after a login would eat the deep link
// the intent exists to serve.
if (Auth.check()) return;

MagicRouter.instance.pullIntendedUrl();
});
}

/// Registers Gate abilities that control profile section visibility.
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: magic_starter
description: Starter kit for Magic Framework. Auth, Profile, Teams, Notifications — 14 opt-in features with overridable views.
version: 0.0.1-alpha.26
version: 0.0.1-alpha.27
homepage: https://magic.fluttersdk.com/starter
documentation: https://magic.fluttersdk.com/packages/starter/getting-started/installation
repository: https://github.com/fluttersdk/magic_starter
Expand Down
Loading