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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ 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

- **`MagicMiddleware.redirectTarget(String location)` for pre-build redirect guards.** Redirect-style guards (auth / guest) can now return a redirect target synchronously, evaluated inside the router's `redirect` callback BEFORE any page builds. Previously the only way to redirect was an imperative `MagicRoute.to()` inside `handle()`, which runs post-mount and remounts the destination view, recreating its form state on every mount (the login-double-mount bug). `_handleRedirect` now evaluates every matched route's global + route middleware `redirectTarget` and returns the first non-null target. The default returns `null`, and `handle()` now defaults to `next()`, so a redirect-only guard overrides just `redirectTarget`. Fully backward compatible: existing `handle()`-based guards keep working. Touches `lib/src/http/middleware/magic_middleware.dart`, `lib/src/routing/magic_router.dart`; adds `test/routing/redirect_guard_mount_test.dart` (asserts the destination mounts exactly once, including through a layout ShellRoute).

### Fixed

- **`Crypt` now accepts the `base64:` app key that `key:generate` produces.** `key:generate` writes `APP_KEY=base64:<base64 of 32 random bytes>`, but `EncryptionServiceProvider` required `app.key` to be a raw 32-character string and threw `App Key must be 32 characters for AES-256` on the generated key, so `Crypt.encrypt`/`decrypt` were unusable out of the box. Added `MagicEncrypter.fromAppKey(appKey)` which base64-decodes a `base64:`-prefixed key to its 32 bytes (and still accepts a raw 32-character key); `EncryptionServiceProvider` now binds through it. Touches `lib/src/encryption/magic_encrypter.dart`, `lib/src/encryption/encryption_service_provider.dart`; adds three `fromAppKey` cases to `test/encryption/magic_encrypter_test.dart`.
- **`MagicStatefulView` now calls the controller's `onInit()` lifecycle hook.** `MagicStatefulViewState.initState` listened to the controller and called the VIEW's own `onInit()` hook, but never invoked the CONTROLLER's `onInit()`, despite the documented contract. A controller that bootstraps in `onInit` (initial data load, table creation, subscriptions) silently never ran it when backed by a `MagicStatefulView`, so the screen rendered against uninitialized state (e.g. a query against a table the controller's `onInit` was supposed to create). It now calls `_controller.onInit()` guarded by `MagicController.initialized`, so a `SimpleMagicController` that already initialized in its constructor is not double-initialized and a singleton controller reused across re-mounts initializes exactly once per lifetime. Touches `lib/src/ui/magic_view.dart`; adds `test/ui/magic_view_controller_oninit_test.dart`.
- **Auth no longer warns on every boot of a fresh app.** `AuthServiceProvider.boot()` logged a `userFactory not registered` warning (blaming provider order) whenever no userFactory was set, even for apps with no stored session to restore. It now only warns when a stored session actually exists (`Auth.hasToken()`) but cannot be rebuilt; a fresh app or a logged-out user stays quiet (debug-level). The stored-session check is guarded so a misconfigured Auth (for example, no Vault registered) cannot crash boot from this warning-verbosity path. Touches `lib/src/auth/auth_service_provider.dart`; adds three cases to `test/auth/auth_test.dart`.

### Changed
Expand Down
20 changes: 12 additions & 8 deletions doc/basics/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,24 @@ import 'package:magic/magic.dart';

class EnsureAuthenticated extends MagicMiddleware {
@override
Future<void> handle(void Function() next) async {
if (Auth.check()) {
next(); // Allow navigation to proceed
} else {
MagicRoute.to('/login'); // Redirect to login
}
String? redirectTarget(String location) {
if (!Auth.check() && location != '/login') return '/login';
return null; // Authenticated, or already on /login: allow.
}
}
```

As you can see, if the user is not authenticated, the middleware will redirect the user to the login screen. If the user is authenticated, the request is passed further into the application by calling `next()`.
As you can see, if the user is not authenticated, the middleware returns the login path and the user is redirected to the login screen. If the user is authenticated, it returns `null` and the request proceeds.

### Redirect guards vs blocking guards

Middleware has two hooks, and you pick the one that matches the intent:

- **`redirectTarget(String location)`** for redirect-style guards (auth, guest, role gates). It is evaluated synchronously in the router's `redirect` callback BEFORE the route builds, so the destination view mounts exactly once. Return a path to redirect to, or `null` to allow. Always return `null` when `location` already equals the target, otherwise the redirect loops.
- **`handle(void Function() next)`** for async, non-redirecting concerns (logging, a blocked-state screen). Call `next()` to allow, skip it to block. Both hooks default to allow, so override only the one you need.

> [!IMPORTANT]
> If you do NOT call `next()`, navigation will be blocked. Always ensure you either call `next()` or redirect the user.
> Prefer `redirectTarget` for redirects. Redirecting with an imperative `MagicRoute.to()` from inside `handle()` runs after the route has already mounted, so it remounts the destination view and recreates its state.

### Async Operations

Expand Down
9 changes: 3 additions & 6 deletions doc/security/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,12 +329,9 @@ Create a `guest` middleware to redirect authenticated users:
```dart
class RedirectIfAuthenticated extends MagicMiddleware {
@override
Future<void> handle(void Function() next) async {
if (Auth.check()) {
MagicRoute.to('/dashboard');
} else {
next();
}
String? redirectTarget(String location) {
if (Auth.check() && location != '/dashboard') return '/dashboard';
return null;
}
}
```
Expand Down
22 changes: 10 additions & 12 deletions lib/src/encryption/encryption_service_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ class EncryptionServiceProvider extends ServiceProvider {

/// Bootstrap the encryption services.
///
/// This method checks for the presence of a valid 32-character `app.key`
/// in the configuration. If the key is missing or invalid, it registers
/// a factory that throws a clear exception when the [Crypt] facade is accessed.
/// This method checks for the presence of an `app.key` in the configuration.
/// If the key is missing, it registers a factory that throws a clear exception
/// when the [Crypt] facade is accessed.
///
/// If the key is valid, it binds the [MagicEncrypter] as a singleton.
/// Otherwise it binds a lazy [MagicEncrypter] built via
/// [MagicEncrypter.fromAppKey], which accepts both a `base64:`-prefixed key
/// (as produced by `magic key:generate`) and a raw 32-character key, and
/// throws a clear error on access if the resolved key is not 32 bytes.
@override
Future<void> boot() async {
final key = Config.get<String>('app.key');
Expand All @@ -32,13 +35,8 @@ class EncryptionServiceProvider extends ServiceProvider {
return;
}

if (key.length != 32) {
app.singleton('encrypter', () {
throw Exception('App Key must be 32 characters for AES-256.');
});
return;
}

app.singleton('encrypter', () => MagicEncrypter(key));
// Lazy: an invalid key surfaces a clear error when Crypt is first used,
// not during boot. fromAppKey handles both base64: and raw 32-char keys.
app.singleton('encrypter', () => MagicEncrypter.fromAppKey(key));
}
}
36 changes: 36 additions & 0 deletions lib/src/encryption/magic_encrypter.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:encrypt/encrypt.dart';

import 'exceptions.dart';
Expand Down Expand Up @@ -25,6 +28,39 @@ class MagicEncrypter {
}
}

/// Build an encrypter from an already-resolved [Key].
MagicEncrypter._fromKey(Key key)
: _encrypter = Encrypter(AES(key, mode: AESMode.cbc));

/// Build an encrypter from a Laravel-style `app.key`.
///
/// Accepts either a `base64:`-prefixed key (as produced by
/// `magic key:generate`, which base64-encodes 32 random bytes) or a raw
/// 32-character string. Throws when the resolved key is not 32 bytes, so the
/// generator output and the encrypter stay compatible.
factory MagicEncrypter.fromAppKey(String appKey) {
if (appKey.startsWith('base64:')) {
final Uint8List bytes;
try {
bytes = base64.decode(appKey.substring('base64:'.length));
} on FormatException catch (e) {
// base64.decode throws a terse low-level FormatException; rethrow with
// an actionable message since every app key now flows through here.
throw Exception(
'App Key has a "base64:" prefix but the value is not valid base64 '
'(${e.message}). Re-run `magic key:generate`.',
);
}
if (bytes.length != 32) {
throw Exception(
'App Key must decode to 32 bytes for AES-256 (got ${bytes.length}).',
);
}
return MagicEncrypter._fromKey(Key(bytes));
}
return MagicEncrypter(appKey);
}

/// Encrypt the given value.
///
/// This method generates a fresh, secure random 16-byte IV (Initialization Vector)
Expand Down
33 changes: 7 additions & 26 deletions lib/src/http/middleware/authorize_middleware.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'package:magic/src/http/middleware/magic_middleware.dart';
import 'package:magic/src/facades/gate.dart';
import 'package:magic/src/facades/route.dart';

/// Authorization Middleware.
///
Expand All @@ -21,26 +20,9 @@ import 'package:magic/src/facades/route.dart';
/// .middleware(['auth', 'can:edit-post']);
/// ```
///
/// ## With Route Arguments
///
/// When you need to check authorization against a model, pass the model
/// from the route parameters:
///
/// ```dart
/// class EditPostMiddleware extends MagicMiddleware {
/// @override
/// Future<void> handle(void Function() next) async {
/// final postId = MagicRoute.param('id');
/// final post = await Post.find(postId);
///
/// if (Gate.allows('edit-post', post)) {
/// next();
/// } else {
/// MagicRoute.to('/unauthorized');
/// }
/// }
/// }
/// ```
/// Gating resolves in the router's `redirect` callback (pre-build) via
/// [redirectTarget], so a denied route never builds and the unauthorized
/// destination mounts exactly once.
class AuthorizeMiddleware extends MagicMiddleware {
/// The ability to check.
final String ability;
Expand All @@ -63,11 +45,10 @@ class AuthorizeMiddleware extends MagicMiddleware {
});

@override
Future<void> handle(void Function() next) async {
if (Gate.allows(ability, arguments)) {
next();
} else {
MagicRoute.to(unauthorizedRoute);
String? redirectTarget(String location) {
if (!Gate.allows(ability, arguments) && location != unauthorizedRoute) {
return unauthorizedRoute;
}
return null;
}
}
66 changes: 56 additions & 10 deletions lib/src/http/middleware/magic_middleware.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,32 @@
/// Middleware intercepts navigation and can allow, block, or redirect.
/// This mimics Laravel's `$next($request)` pattern.
///
/// ## Usage
/// ## Redirect-style guards
///
/// Override [redirectTarget] so the redirect resolves BEFORE the route builds.
/// The destination view then mounts exactly once.
///
/// ```dart
/// class EnsureAuthenticated extends MagicMiddleware {
/// @override
/// String? redirectTarget(String location) {
/// if (!Auth.check() && location != '/login') return '/login';
/// return null;
/// }
/// }
/// ```
///
/// ## Blocking / async guards
///
/// Override [handle] for async, non-redirecting concerns. Call `next()` to
/// allow navigation; skip it to block.
///
/// ```dart
/// class LogNavigation extends MagicMiddleware {
/// @override
/// Future<void> handle(void Function() next) async {
/// if (AuthService.isLoggedIn) {
/// next(); // Allow navigation
/// } else {
/// MagicRoute.to('/login'); // Redirect
/// }
/// Log.info('navigating');
/// next();
/// }
/// }
/// ```
Expand All @@ -29,22 +44,53 @@
/// .middleware(['auth']);
/// ```
abstract class MagicMiddleware {
/// Resolve a redirect target for [location] BEFORE the route builds.
///
/// This is the redirect-guard hook. Return a path to redirect to, or `null`
/// to allow the navigation. It is evaluated synchronously inside the router's
/// `redirect` callback, so a redirect-style guard resolves before any page
/// is built and the destination view mounts exactly once. Prefer this over
/// an imperative `MagicRoute.to()` inside [handle]: redirecting from [handle]
/// runs post-mount and remounts the destination.
///
/// Always return `null` when [location] already equals the target, otherwise
/// the redirect loops.
///
/// ```dart
/// class EnsureAuthenticated extends MagicMiddleware {
/// @override
/// String? redirectTarget(String location) {
/// if (!Auth.check() && location != '/login') return '/login';
/// return null;
/// }
/// }
/// ```
///
/// The default returns `null` (no redirect), so non-redirecting middleware
/// only need to implement [handle].
String? redirectTarget(String location) => null;

/// Handle the navigation request.
///
/// Call [next] to allow navigation to proceed.
/// If [next] is NOT called, navigation stops (redirect can happen inside).
/// If [next] is NOT called, navigation stops.
///
/// Use this for async, non-redirecting concerns (logging, feature gating
/// that shows a blocked state). For redirect-style guards, override
/// [redirectTarget] instead so the redirect resolves pre-build.
///
/// The default allows navigation, so a redirect-only guard can override
/// just [redirectTarget] and leave this alone.
///
/// ```dart
/// @override
/// Future<void> handle(void Function() next) async {
/// if (canProceed) {
/// next();
/// } else {
/// MagicRoute.to('/login');
/// }
/// }
/// ```
Future<void> handle(void Function() next);
Future<void> handle(void Function() next) async => next();
}

/// A simple middleware that always allows navigation.
Expand Down
45 changes: 37 additions & 8 deletions lib/src/routing/magic_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,6 @@ class MagicRouter {
/// Whether the router has been built.
bool _isBuilt = false;

/// Pending redirect from async middleware.
String? _pendingRedirect;

/// Saved intended URL for redirect-after-login pattern.
String? _intendedUrl;

Expand Down Expand Up @@ -391,12 +388,44 @@ class MagicRouter {
}

/// Handle global redirects (sync only).
///
/// Runs before any page builds. Redirect-style guards drive it: every
/// global + route middleware's [MagicMiddleware.redirectTarget] is
/// evaluated for the matched location and the first non-null target wins.
/// Resolving redirects here (pre-build) instead of imperatively from a
/// guard widget keeps the destination view mounting exactly once.
String? _handleRedirect(BuildContext context, GoRouterState state) {
// Check for pending redirect from middleware
if (_pendingRedirect != null) {
final redirect = _pendingRedirect;
_pendingRedirect = null;
return redirect;
// Evaluate redirect-style guards synchronously, pre-build.
final location = state.matchedLocation;
final route = _resolveRoute(state);
final middlewares = <MagicMiddleware>[
...Kernel.globalMiddleware,
if (route != null) ...Kernel.resolveAll(route.middlewares),
];
for (final middleware in middlewares) {
final target = middleware.redirectTarget(location);
if (target != null && target != location) {
return target;
}
}
return null;
}

/// Resolve the [RouteDefinition] whose pattern matches [state].
///
/// Searches top-level routes and every layout's children. Matches on the
/// configured full path pattern (`state.fullPath`), so it works for static
/// and parameterized routes alike. Returns `null` when no route matches.
RouteDefinition? _resolveRoute(GoRouterState state) {
final fullPath = state.fullPath;
if (fullPath == null) return null;
for (final route in _routes) {
if (route.fullPath == fullPath) return route;
}
for (final layout in _layouts) {
for (final child in layout.children) {
if (child.fullPath == fullPath) return child;
}
}
return null;
}
Expand Down
8 changes: 8 additions & 0 deletions lib/src/ui/magic_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ abstract class MagicStatefulViewState<
_controller.addListener(_onControllerChanged);
// Auto-clear validation errors when new view initializes (Laravel-like)
_clearValidationErrors();
// Run the controller's onInit lifecycle hook the first time it backs a
// view (data bootstrap, table creation, initial load live here). Guarded by
// `initialized` so a SimpleMagicController that already ran onInit in its
// constructor is not initialized twice, and a singleton controller reused
// across re-mounts runs onInit exactly once per lifetime.
if (!_controller.initialized) {
_controller.onInit();
}
onInit();
}

Expand Down
Loading
Loading