# Authentication

Magic provides a frontend-focused authentication system with secure token storage, user caching, automatic token refresh, and support for multiple configurable guards.

- [Introduction](#introduction)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [The Auth Facade](#the-auth-facade)
- [Guards](#guards)
    - [Built-in Guards](#built-in-guards)
    - [Custom Guards](#custom-guards)
- [Reactive Auth State](#reactive-auth-state)
- [Auto Token Refresh](#auto-token-refresh)
- [Protecting Routes](#protecting-routes)
- [Login & Logout](#login--logout)
- [Auth Events](#auth-events)
- [Testing](#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

```dart
// 1. Register user factory (tells Magic how to create User from API data)
Auth.registerModel<User>(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<User>();
  print('Welcome, ${user?.name}');
}

// 4. Logout
await Auth.logout();
MagicRoute.to('/login');
```

> [!WARNING]
> `logout()` can throw. Secure storage is a platform service and a delete can
> fail (a locked keychain, a lost entitlement). When it does, every step is
> still attempted and the in-memory session is still cleared and announced, so
> your UI is correct either way, but the throw reaches you because a credential
> may have survived on the device. Navigate away and surface it; do not swallow
> it:
>
> ```dart
> try {
>   await Auth.logout();
> } catch (e) {
>   Log.warning('Logout could not clear stored credentials: $e');
> } finally {
>   MagicRoute.to('/login');
> }
> ```

## Configuration

Create `lib/config/auth.dart`:

```dart
Map<String, dynamic> 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`:

```dart
'providers': [
  (app) => AuthServiceProvider(app),
  // ...
],
```

## The Auth Facade

The `Auth` facade provides convenient access to authentication functionality:

```dart
// 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>()         // 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<int>
```

## Guards

Guards define how users are authenticated. Each guard implements the `Guard` contract:

```dart
abstract class Guard {
  Future<void> login(Map<String, dynamic> data, Authenticatable user);
  Future<void> logout();
  bool check();
  bool get guest;
  T? user<T>();
  dynamic id();
  void setUser(Authenticatable user);
  Future<bool> hasToken();
  Future<String?> getToken();
  Future<bool> refreshToken();
  Future<void> restore();

  /// Bumped on every auth state change (login, logout, restore).
  ValueNotifier<int> 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`:

```dart
class MyGuard extends BaseGuard {
  MyGuard() : super(
    userEndpoint: '/api/me',
    refreshEndpoint: '/api/refresh',
    userFactory: (data) => User.fromMap(data),
  );

  @override
  Future<void> login(Map<String, dynamic> data, Authenticatable user) async {
    await startSession(
      user,
      token: data['token'] as String?,
      refreshToken: data['refresh_token'] as String?,
    );
  }
}

// Register in your auth config
Auth.manager.extend('myguard', (config) => MyGuard());
```

`startSession` marks the session as opened and moves the in-memory token before its first await, then persists the tokens, sets the user and caches it. Prefer it to `storeToken` followed by `setUser`: a boot-time user sync still in the air sees a sign-in made through `startSession` from its first line and ignores its own answer, whereas between `storeToken` and `setUser` it cannot tell the sign-in from a token refresh and applies the previous account.

### Firebase Guard Example

```dart
class FirebaseGuard extends BaseGuard {
  final _auth = firebase.FirebaseAuth.instance;

  FirebaseGuard() : super(userFactory: (data) => User.fromMap(data));

  @override
  Future<void> login(Map<String, dynamic> data, Authenticatable user) async {
    final idToken = await _auth.currentUser?.getIdToken();
    await startSession(user, token: idToken);
  }

  @override
  Future<void> 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 user = userFactory!({
      'id': fbUser.uid,
      'email': fbUser.email,
      'name': fbUser.displayName,
    });
    await startSession(user, token: await fbUser.getIdToken());
  }

  @override
  Future<void> logout() async {
    await _auth.signOut();
    await super.logout();
  }
}
```

## Reactive Auth State

Every guard exposes a `ValueNotifier<int> stateNotifier` that increments on every auth state change (`setUser()`, `logout()`, and session restore). Use it to reactively rebuild UI without manual state management.

```dart
// Using ListenableBuilder (Flutter built-in)
ListenableBuilder(
  listenable: Auth.guard.stateNotifier,
  builder: (context, _) {
    if (Auth.check()) {
      return Text('Hello, ${Auth.user<User>()?.name}');
    }
    return const Text('Not logged in');
  },
)
```

With `MagicBuilder` the pattern is identical; pass `stateNotifier` as the listenable:

```dart
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:

1. Original request fails with 401, **having presented the token**
2. Interceptor calls `Auth.refreshToken()`
3. If refresh succeeds, original request is retried with new token
4. If refresh fails, user is logged out

The auth interceptor is built into `AuthServiceProvider` and works automatically when configured.

**A 401 on a request that carried no auth header, or an older token, is ignored.** The ladder above runs only when the refused request actually presented the header `auth.token.header` names, matched without regard to case, carrying the token the guard holds now. A request dispatched before a sign-in completed goes out anonymous, and its refusal arrives once the session exists; treating that as a rejection would end a session the server was never shown. Only a credential the server actually saw and refused ends a session.

**A request that raced a token change is handed back refused, not replayed.** When the refused request carried an older token and the guard already holds a newer one, the interceptor cannot tell a refresh from a different account signing in, and a replay with the newer token could answer one account's screen with another account's data. The caller receives the 401 and decides whether its request still stands.

**A guard that does not extend `BaseGuard`** keeps no token the interceptor can compare against, so it is judged on presence alone: a 401 ends its session only when the request carried the header `auth.token.header` names. A cookie-based guard, or one that sends its credential under another header, stays signed in while calls return 401; such a guard has to end its own session.

```dart
// 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:

```dart
// 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:

```dart
class RedirectIfAuthenticated extends MagicMiddleware {
  @override
  String? redirectTarget(String location) {
    if (Auth.check() && location != '/dashboard') return '/dashboard';
    return null;
  }
}
```

## Login & Logout

### Login Flow

```dart
class AuthController extends MagicController with ValidatesRequests {
  Future<void> login(Map<String, dynamic> 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

```dart
Future<void> 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`:

```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.

A sign-in or a sign-out that completes while the sync is in the air wins: a late 401 about the restored token does not log out the new session, and a late 200 does not put the previous account back. A token refresh is not a new session, so a 200 that arrives under a refreshed token is still applied, and a 401 or 403 about a token that has since been refreshed is re-checked once under the current token, whose answer decides.

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.

## Auth Events

`BaseGuard` (and `FakeAuthManager`'s fake guard) dispatch `MagicEvent`s through the `Event` facade; see [Events](https://anilcancakir.com/magic/docs/v0.0/digging-deeper/events.md#framework-events) for `Event.listen<T>`'s registration shape.

- **`AuthLogin`** fires at the end of `startSession`, once the token (when given) is persisted and the user is set and cached. Not fired on a restore; a restored user announces itself through `AuthRestored` instead. Skipped when the session ended or was replaced while the user was being cached, so a sign-out racing a sign-in never gets a stray `AuthLogin` for the session it just ended.
- **`AuthLogout`** fires from `logout()`, after the `stateNotifier` bump and before a rethrown Vault failure. It carries the user held when the logout began (`null` for a guest), and a guest logout still dispatches. It means "the in-memory session ended", not "the credentials are gone": it fires even when a Vault delete failed, so a listener releasing server-side state must still gate on `Auth.hasToken()`.
- **`AuthRestored`** fires only after an API-confirmed sync (`BaseGuard`'s background `/user` fetch inside `restore()`), never for the cache-only step that runs first. A sign-in or sign-out racing that sync suppresses it the same way `AuthLogin` is suppressed. Its `changed` field is `false` when the synced user serializes (`toMap()`, compared deeply) the same as the user the guard held before the sync, the usual warm cold boot, and `true` when an attribute moved or no user was held; a listener that remounts or refetches on it should skip `changed: false`. It defaults to `true` for an event built by hand.

```dart
class LogAuthEvents extends MagicListener<AuthLogout> {
  @override
  Future<void> handle(AuthLogout event) async {
    Log.info('Session ended for ${event.user?.authIdentifier ?? 'guest'}');
  }
}

Event.listen<AuthLogout>(() => LogAuthEvents());
```

## 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. Its `login()` and `logout()` dispatch `AuthLogin`/`AuthLogout` through the real `Event` facade too, same as the real guard, so a listener under test observes a faked session exactly as it would a real one.

```dart
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<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.
