Authentication
Magic provides a frontend-focused authentication system with secure token storage, user caching, automatic token refresh, and support for multiple configurable guards.
- Introduction
- Quick Start
- Configuration
- The Auth Facade
- Guards
- Reactive Auth State
- Auto Token Refresh
- Protecting Routes
- Login & Logout
- Testing
Introduction
Magic provides a simple, frontend-focused authentication system with user caching and automatic token refresh. Like Laravel's Auth system, it's built around the concept of "guards" that define how users are authenticated.
Key Features
| Feature | Description |
|---|---|
| User Caching | Instant restore from secure cache, then sync from API |
| Auto Token Refresh | 401 response → refresh token → retry original request |
| Multiple Guards | Support for Bearer, Basic, API Key, or custom guards |
| Secure Storage | Tokens stored in platform secure storage (Keychain/Keystore) |
Quick Start
// 1. Register user factory (tells Magic how to create User from API data)
Auth.registerModel(User.fromMap);
// 2. Login after API call
final response = await Http.post('/login', data: {
'email': email,
'password': password,
});
if (response.successful) {
final user = User.fromMap(response['data']['user']);
await Auth.login({
'token': response['data']['token'],
'refresh_token': response['data']['refresh_token'],
}, user);
MagicRoute.to('/dashboard');
}
// 3. Check authentication anywhere
if (Auth.check()) {
final user = Auth.user();
print('Welcome, ${user?.name}');
}
// 4. Logout
await Auth.logout();
MagicRoute.to('/login');
Configuration
Create lib/config/auth.dart:
Map get authConfig => {
'auth': {
'defaults': {
'guard': 'api',
},
'guards': {
'api': {
'driver': 'bearer',
},
},
'endpoints': {
'user': '/api/user',
'refresh': '/api/auth/refresh',
},
'token': {
'key': 'auth_token',
'header': 'Authorization',
'prefix': 'Bearer',
},
'auto_refresh': true,
},
};
Register in your config and add AuthServiceProvider:
'providers': [
(app) => AuthServiceProvider(app),
// ...
],
The Auth Facade
The Auth facade provides convenient access to authentication functionality:
// Check if user is authenticated
Auth.check() // bool
// Check if user is a guest (not authenticated)
Auth.guest() // bool
// Get the authenticated user
Auth.user() // User?
// Get user ID
Auth.id() // dynamic
// Login
await Auth.login(tokenData, user)
// Logout
await Auth.logout()
// Restore session from cache
await Auth.restore()
// Manually refresh token
await Auth.refreshToken()
// Token management
await Auth.hasToken() // bool
await Auth.getToken() // String?
// Resolve a guard by name (default guard when name is omitted)
Auth.guard() // Guard
Auth.guard('api') // Guard (named)
// Notifier that bumps on every auth state change
Auth.stateNotifier // ValueNotifier
Guards
Guards define how users are authenticated. Each guard implements the Guard contract:
abstract class Guard {
Future login(Map data, Authenticatable user);
Future logout();
bool check();
bool get guest;
T? user();
dynamic id();
void setUser(Authenticatable user);
Future hasToken();
Future getToken();
Future refreshToken();
Future restore();
/// Bumped on every auth state change (login, logout, restore).
ValueNotifier get stateNotifier;
}
Built-in Guards
| Guard | Login Data | Use Case |
|---|---|---|
BearerTokenGuard |
token, refresh_token |
JWT/OAuth APIs |
BasicAuthGuard |
username, password |
Basic HTTP Auth |
ApiKeyGuard |
api_key |
API Key authentication |
Custom Guards
Create custom guards by extending BaseGuard:
class MyGuard extends BaseGuard {
MyGuard() : super(
userEndpoint: '/api/me',
refreshEndpoint: '/api/refresh',
userFactory: (data) => User.fromMap(data),
);
@override
Future login(Map data, Authenticatable user) async {
await storeToken(data['token'], data['refresh_token']);
await cacheUser(user);
setUser(user);
}
}
// Register in your auth config
Auth.manager.extend('myguard', (config) => MyGuard());
Firebase Guard Example
class FirebaseGuard extends BaseGuard {
final _auth = firebase.FirebaseAuth.instance;
FirebaseGuard() : super(userFactory: (data) => User.fromMap(data));
@override
Future login(Map data, Authenticatable user) async {
final idToken = await _auth.currentUser?.getIdToken();
if (idToken != null) await storeToken(idToken);
await cacheUser(user);
setUser(user);
}
@override
Future restore() async {
// Try cached user first (instant UI)
final cached = await loadCachedUser();
if (cached != null) setUser(cached);
// Then verify with Firebase
final fbUser = _auth.currentUser;
if (fbUser == null) {
await logout();
return;
}
final token = await fbUser.getIdToken();
if (token != null) await storeToken(token);
final user = userFactory!({
'id': fbUser.uid,
'email': fbUser.email,
'name': fbUser.displayName,
});
setUser(user);
await cacheUser(user);
}
@override
Future logout() async {
await _auth.signOut();
await super.logout();
}
}
Reactive Auth State
Every guard exposes a ValueNotifier that increments on every auth state change (setUser(), logout(), and session restore). Use it to reactively rebuild UI without manual state management.
// Using ListenableBuilder (Flutter built-in)
ListenableBuilder(
listenable: Auth.guard.stateNotifier,
builder: (context, _) {
if (Auth.check()) {
return Text('Hello, ${Auth.user()?.name}');
}
return const Text('Not logged in');
},
)
With MagicBuilder the pattern is identical; pass stateNotifier as the listenable:
MagicBuilder(
listenable: Auth.guard.stateNotifier,
builder: (context, _) => Auth.check()
? const DashboardView()
: const LoginView(),
)
setUser() is called internally by login(), restore(), and any custom guard that calls setUser(user) directly. Each call bumps stateNotifier.value by 1, triggering all registered listeners.
Auto Token Refresh
When auto_refresh is enabled, Magic automatically handles 401 responses:
- Original request fails with 401
- Interceptor calls
Auth.refreshToken() - If refresh succeeds, original request is retried with new token
- If refresh fails, user is logged out
The auth interceptor is built into AuthServiceProvider and works automatically when configured.
// Manual token refresh
final success = await Auth.refreshToken();
if (!success) {
await Auth.logout();
MagicRoute.to('/login');
}
Protecting Routes
Use the auth middleware to protect routes:
// Single route
MagicRoute.page('/dashboard', () => DashboardView())
.middleware(['auth']);
// Route group
MagicRoute.group(
middleware: ['auth'],
routes: () {
MagicRoute.page('/dashboard', () => DashboardView());
MagicRoute.page('/profile', () => ProfileView());
MagicRoute.page('/settings', () => SettingsView());
},
);
Create a guest middleware to redirect authenticated users:
class RedirectIfAuthenticated extends MagicMiddleware {
@override
String? redirectTarget(String location) {
if (Auth.check() && location != '/dashboard') return '/dashboard';
return null;
}
}
Login & Logout
Login Flow
class AuthController extends MagicController with ValidatesRequests {
Future login(Map data) async {
clearErrors();
final response = await Http.post('/login', data: data);
if (response.successful) {
final user = User.fromMap(response['data']['user']);
await Auth.login({
'token': response['data']['token'],
'refresh_token': response['data']['refresh_token'],
}, user);
Magic.success('Success', 'Welcome back!');
MagicRoute.to('/dashboard');
} else {
handleApiError(response, fallback: 'Invalid credentials');
}
}
}
Logout Flow
Future logout() async {
// Optionally notify backend
await Http.post('/logout');
// Clear local auth state
await Auth.logout();
Magic.info('Logged Out', 'See you next time!');
MagicRoute.to('/login');
}
Restoring Session on App Start
In your main.dart:
void main() async {
await Magic.init(...);
// Restore auth session from cache
await Auth.restore();
runApp(MagicApplication(...));
}
This instantly restores the cached user for a fast startup, then syncs with the API in the background.
If userFactory is not set on the guard, the cache load and API sync steps are skipped gracefully (no error is thrown). Set userFactory via Auth.manager.setUserFactory() (or pass it to BaseGuard's constructor) during the boot phase to enable full session restore.
Testing
Replace the real auth manager with a FakeAuthManager using Auth.fake(). The fake routes all guard operations through an in-memory guard so tests run without platform channels or a real backend.
import 'package:magic/testing.dart';
void main() {
setUp(() {
MagicApp.reset();
Magic.flush();
});
tearDown(() => Auth.unfake());
test('login sets authenticated state', () async {
final fake = Auth.fake();
await Auth.login({'token': 'abc'}, myUser);
fake.assertLoggedIn();
fake.assertLoginCount(1);
expect(Auth.check(), isTrue);
expect(Auth.user(), same(myUser));
});
test('logout clears auth state', () async {
final fake = Auth.fake(user: myUser);
await Auth.logout();
fake.assertLoggedOut();
});
}
FakeAuthManager Assertions
| Method | Description |
|---|---|
fake.assertLoggedIn() |
Fails if no user is currently set. |
fake.assertLoggedOut() |
Fails if a user is currently set. |
fake.assertLoginAttempted() |
Fails if Auth.login() was never called. |
fake.assertLoginCount(n) |
Fails if Auth.login() was not called exactly n times. |
fake.reset() |
Clears user, token, and login-attempt history. |
Call Auth.unfake() in tearDown() to restore normal auth resolution after each test.