# Authentication

- [Introduction](#introduction)
- [Login Flow](#login-flow)
- [Registration](#registration)
- [Forgot Password](#forgot-password)
- [Reset Password](#reset-password)
- [Two-Factor Authentication](#two-factor-authentication)
    - [Two-Factor Challenge](#two-factor-challenge)
    - [Enabling Two-Factor](#enabling-two-factor)
    - [Recovery Codes](#recovery-codes)
- [Social Login](#social-login)
    - [The MagicStarterSocialAuth Contract](#the-magicstartersocialauth-contract)
- [OTP Flow](#otp-flow)
    - [Sending an OTP](#sending-an-otp)
    - [Verifying an OTP](#verifying-an-otp)
- [Guest Sessions](#guest-sessions)
- [Guest Claim](#guest-claim)
- [Logout](#logout)
    - [Before-Logout Hooks](#before-logout-hooks)
- [Onboarding Steps](#onboarding-steps)
- [Identity Modes](#identity-modes)
- [Feature Gates](#feature-gates)
- [Controllers](#controllers)
- [Views](#views)

## Introduction

The Magic Starter authentication module provides a complete, feature-gated authentication system built on top of the Magic Framework. It covers login, registration, password reset, two-factor authentication, social login, phone OTP, and guest authentication — all driven by opt-in configuration flags.

Three controllers split the responsibilities:

| Controller | Scope |
|-----------|-------|
| `MagicStarterAuthController` | Login, register, forgot/reset password, two-factor challenge, logout |
| `MagicStarterGuestAuthController` | Device-ID-based guest login |
| `MagicStarterOtpController` | Phone OTP send and verify |

Every controller follows the Magic singleton pattern (`static T get instance => Magic.findOrPut(T.new)`) and mixes in `MagicStateMixin`, `ValidatesRequests`, and `NavigatesRoutes`.

> [!NOTE]
> All auth routes are registered under the configurable prefix `MagicStarterConfig.authPrefix()` (default `/auth`). Guest-facing routes use `GuestLayout`; post-login routes use `AppLayout`.

## Login Flow

Call `MagicStarterAuthController.instance.doLogin()` with the user's credentials. The controller builds the identity payload based on the active [identity mode](#identity-modes), sends `POST /auth/login`, and handles the response:

```dart
await MagicStarterAuthController.instance.doLogin(
  email: form['email'],
  phone: form['phone'],
  password: form['password'],
  rememberMe: form.value<bool>('remember_me'),
);
```

The login flow has three possible outcomes:

1. **Success**: the controller hands the body to `CompletesSignIn.completeSignIn()`, the one path every token takes (password, social, two-factor challenge, OTP and guest): it extracts `token` and `user` from the nested `data` key, calls `Auth.login()`, shows the `social.deletion_cancelled` toast when `data.deletion_cancelled` is `true` (a sign-in during the grace period of a scheduled account deletion cancels it), 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.

> [!IMPORTANT]
> Two-factor detection checks **both** `response['two_factor']` and `response['data']['two_factor']`. Some backends nest the flag differently, and the controller handles both variants.

A `_isSubmitting` guard prevents double-submit — concurrent calls to `doLogin()` are silently discarded.

The login, register, forgot-password and reset-password views hint their fields (`username`/`email`, `password`, `newPassword`, `telephoneNumber`) so a password manager or the OS keychain can fill them. The login, register and reset-password forms sit in an `AutofillGroup` that cancels on dispose, and the login and register forms stay mounted while the request is in flight; the context is committed (`TextInput.finishAutofillContext(shouldSave: true)`) only after an email and password sign-in or a registration has logged a session in and is about to navigate (`completeSignIn(saveCredentials: true)`), so a failed attempt, a provider or one-time-code sign-in, and a background guest session are never offered for saving.

`Auth.login()` dispatches magic's `AuthLogin` event, which `MagicStarterServiceProvider` listens for to call the host's `MagicStarter.useLogin()` hook (see [Login Callback](https://anilcancakir.com/magic/docs/starter/v0.0/architecture/manager.md#login-callback)), unawaited and logged on failure. It fires for every sign-in path this package ships: password (above), [two-factor challenge](#two-factor-challenge), [social login](#social-login), guest login (`MagicStarterGuestAuthController.doGuestLogin()`) and [OTP verification](#verifying-an-otp), and never for a cold-boot restore of a stored session.

## Registration

`doRegister()` collects `name`, `email`/`phone` (per identity mode), `password`, and `password_confirmation`:

```dart
await MagicStarterAuthController.instance.doRegister(
  name: form['name'],
  email: form['email'],
  phone: form['phone'],
  password: form['password'],
  passwordConfirmation: form['password_confirmation'],
  subscribeNewsletter: form.value<bool>('subscribe_newsletter'),
);
```

When the `newsletter` feature is active and `subscribeNewsletter` is `true`, the payload includes `subscribe_newsletter: true`.

The response determines the post-registration flow:

- **Auto-login** — when the server returns `token` + `user` in `data`, the controller calls `Auth.login()` and navigates home.
- **Email verification required** — when no credentials are returned, the controller navigates to the login page so the user can verify their email first.

> [!TIP]
> Registration is gated by `MagicStarterConfig.hasRegistrationFeatures()`. When disabled, the registration route is not registered and the view is inaccessible.

## Forgot Password

Sends a password reset link via `POST /auth/forgot-password`:

```dart
await MagicStarterAuthController.instance.doForgotPassword(
  email: form['email'],
);
```

On success, the controller sets success state. The view should display a confirmation message. No navigation occurs — the user stays on the same page.

## Verify Email

Registered with `features.email_verification`. The verification mail of `magic-starter-laravel` 0.0.41 and later opens `/auth/verify-email?id=...&hash=...&expires=...&signature=...`. `MagicStarterVerifyEmailView` reads the four query parameters once in `didChangeDependencies` and calls `doVerifyEmail`, which replays them as `GET /email/verify/{id}/{hash}?expires=...&signature=...`; the API's `signed` middleware checks them against its own host. On success a signed-in user is restored so `email_verified_at` lands, and the page offers Continue (home) or Back to Sign In. Any refusal (a bad or expired signature, a changed address), and any link whose `id` is not key-shaped or whose `hash` is not a sha1, shows `magic_starter.email_verification.verify_failed`; the malformed link is never sent. A reader signed in as a different user sees `verify_success_other` instead of the second-person copy.

The route sits outside the `guest` group: the link is opened signed in as often as signed out, and `guest` would send a signed-in reader home before the link was replayed.

## Landing After Sign-In (`?next=`)

`MagicStarterLoginView` and `MagicStarterRegisterView` read a `next` query once when they open. A valid in-app path becomes the router's intended url (`MagicRouter.setIntendedUrl`), so the sign-in or sign-up that follows from that screen (password, social, two-factor, OTP, registration) lands there through `navigateHome()`. Without `next`, or with one that is not valid, the screens record nothing and leave an intent `EnsureAuthenticated` recorded alone.

A valid `next` has exactly one leading `/` (`//host` is another origin), at most 512 characters, no backslash, no whitespace, separator or other `\p{C}` character (controls, format characters such as U+202E, private use, unassigned), the same rule as the backend's `SameAppPath`, no `..` path segment (percent-encoded included). Anything else is ignored silently.

## Sign-In Handoff

Registered with `features.handoff`. Another surface that has already authenticated the visitor (a landing page, a mail) asks `magic-starter-laravel` for a one-time code (`POST auth/handoff`, authenticated) and opens the app at `/auth/handoff?code=<code>&next=<path>`, on the web or through a Universal Link or App Link that resolves to the same route. No bearer token travels in a url.

`MagicStarterHandoffView` reads `code` and `next` once and calls `MagicStarterHandoffController.handOff`:

1. **With a code**, the controller posts it to `POST auth/handoff/exchange`. The answer is a login answer plus `data.next`, held in memory only.
   - The page asks "Sign in as <email or name>?" with Continue and Cancel before anything changes, with or without an open session. Without one the question is what stops a login CSRF: a link carrying an attacker's own code would otherwise sign the visitor in to the attacker's account. `handoff.confirm_without_session` (default `true`) is the one knob: an app may set it to `false` to skip that tap when no session is open; over an open session the page always asks.
   - Continue signs any open session out through `MagicStarterAuthController.logout()` (before-logout hooks included), records `data.next` (validated like `?next=`; the query `next` is ignored once a code is exchanged, since it is not bound to the code) as the intended url and concludes through `completeSignIn`, so a two-factor answer opens the challenge and its success lands on `next` too.
   - Cancel goes home with an open session untouched, or to the sign-in screen without one. The exchange already minted a token on the server (and cancelled a scheduled account deletion), so a declined answer, one dropped by leaving the page, and one that lands after the page closed are revoked with `POST auth/logout` presenting that token on the same bare driver: best effort, never logged, never touching the open session. A two-factor answer carries no token and revokes nothing.
   - A refused code (the backend's one 422 shape, `code: handoff_expired`), a transport failure or an answer that is neither a session nor a challenge changes nothing: home over a session, the sign-in screen without one, with one toast (`magic_starter.handoff.expired`) that never tells which of those it was.
2. **Without a code** but with a valid `next`: signed in, the visitor goes to `next`; signed out, to `handoff.signed_out_route` (default `routes.login`) carrying `?next=`, which the login and register screens honour.
3. **With neither**: home when signed in, the sign-in screen otherwise.

The route sits outside the `guest` group and carries no `auth` middleware: a visitor with an open session has to reach the confirmation. The exchange goes out on a bare `DioNetworkDriver` with the `Http` facade driver's origin, timeout and default headers but none of its interceptors, so magic's `AuthInterceptor` neither attaches the open session's bearer nor answers a 401 with its refresh-or-logout path, and no devtools recorder keeps the answer, which carries the new token. The code, the token and the handed email are never logged; the email (or name) appears only on the confirmation. An app overrides the page through the registry key `auth.handoff`.

The code stays in the browser history entry of `/auth/handoff?code=...`. It is single use, but removing it from history is the app's job; the starter does not rewrite the address bar. A web host should also send `<meta name="referrer" content="strict-origin">` so the code never rides a same-origin `Referer` into a server log.

The exchange mints its token before the visitor confirms. A visitor who closes the tab on "Sign in as ...?" leaves that token alive until it is revoked by hand, because the page can only revoke on Cancel or when it is left through the app.

## Reset Password

Completes the password reset using the token from the email link, `/auth/reset-password?token=...&email=...`. Both values are query parameters, not path segments. The view reads them once in `didChangeDependencies` through `MagicRouter.instance.queryParameterOf(context, ...)`, which answers for the page the view sits in; `initState` cannot read route state, and the static `queryParameter()` answers for whichever page was built last:

```dart
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  if (_token != null) return;

  _token = MagicRouter.instance.queryParameterOf(context, 'token') ?? '';
  _email = MagicRouter.instance.queryParameterOf(context, 'email') ?? '';
}

await MagicStarterAuthController.instance.doResetPassword(
  token: _token!,
  email: _email!,
  password: form['password'],
  passwordConfirmation: form['password_confirmation'],
);
```

On success, the controller sets success state. The view should display a confirmation and provide a link back to login.

## Two-Factor Authentication

Two-factor authentication spans two contexts: **challenging** (during login) and **managing** (in profile settings).

### Two-Factor Challenge

When `doLogin()` detects a two-factor requirement, it navigates to the challenge view with the encrypted `two_factor_token`. The user enters either a TOTP code from their authenticator app or a recovery code:

```dart
await MagicStarterAuthController.instance.doTwoFactorChallenge(
  twoFactorToken: token,
  code: form['code'],         // TOTP from authenticator
  // OR
  recoveryCode: form['recovery_code'],  // One-time recovery code
);
```

Exactly one of `code` or `recoveryCode` must be provided — the controller asserts this invariant. On success, the flow completes the same as a normal login: `Auth.login()` followed by navigation to home.

### Enabling Two-Factor

Enabling 2FA is a multi-step process managed by `MagicStarterProfileController`:

1. **Enable**: `doEnableTwoFactor(proof:)` calls `POST /two-factor-authentication`. Returns a map containing `secret`, `qr_url`, `qr_svg`, and `recovery_codes`.
2. **Confirm** — `doConfirmTwoFactor(code:)` calls `POST /two-factor-authentication/confirm` with the TOTP code from the authenticator app.
3. **Disable**: `doDisableTwoFactor(proof:)` uses Laravel method spoofing (`{'_method': 'DELETE'}`) via `Http.post()`.

> [!NOTE]
> The `isTwoFactorEnabled` getter reads `two_factor_enabled` from the authenticated user model. Use it to conditionally render the enable/disable UI.

### Recovery Codes

Recovery codes can be viewed and regenerated via `MagicStarterProfileController`:

```dart
// View existing codes (requires a step-up proof)
final codes = await controller.getRecoveryCodes(proof: proof);

// Regenerate codes (requires a step-up proof)
final newCodes = await controller.doRegenerateRecoveryCodes(proof: proof);
```

Both methods call endpoints under `/two-factor-recovery-codes`. Like enabling and disabling 2FA, they take a `proof` map: `{'password': ...}` for an account with a password, `{'code': ...}` or `{'confirmation_token': ...}` for one without, `{}` for a guest. See [Confirming Identity](https://anilcancakir.com/magic/docs/starter/v0.0/basics/identity-confirmation.md) for how the starter produces one.

## Social Login

Social login is feature-gated via `magic_starter.features.social_login`. The starter owns the screens (the "Continue with" buttons on login and register, the [Connected accounts](https://anilcancakir.com/magic/docs/starter/v0.0/basics/connected-accounts.md) page, the identity-confirmation dialog) and what a sign-in concludes (a session, a two-factor challenge, a cancelled deletion). The provider SDKs and the backend calls belong to a **bridge** the app registers, so this package depends on no social login package.

```dart
MagicStarter.useSocialAuth(AppSocialAuth());
```

While a bridge is registered and the feature is on, login and register render one button per provider the bridge offers, and the Connected accounts page is reachable from the settings hub. Without a bridge the buttons and the hub row are absent: the feature flag alone renders nothing. The page's route is registered on the feature alone, since the bridge is set after the routes are.

`magic_social_auth` installs the bridge for you: `social:install` publishes `lib/app/providers/social_auth_starter_service_provider.dart`, which implements the contract below over `SocialAuth` and calls `MagicStarter.useSocialAuth`. See its installation guide.

### The MagicStarterSocialAuth Contract

```dart
abstract class MagicStarterSocialAuth {
  List<String> providers();                       // in display order: google, apple, ...
  String label(String provider);                  // 'Google'
  Widget icon(String provider);

  Future<Map<String, dynamic>> signIn(String provider);

  Future<Future<Map<String, dynamic>> Function()> beginConnect(
    String provider,
    Map<String, String> proof,
  );

  Future<String> confirm(String provider);        // the confirmation token
  Future<void> signOut();
}
```

| Method | Contract |
|---|---|
| `signIn(provider)` | Answers the backend's body untouched: a session (`{data: {user, token}}`, plus `data.deletion_cancelled` when the sign-in cancelled a scheduled deletion) or a challenge (`{two_factor: true, two_factor_token}`). Called synchronously from the user's tap, with no `await` before it, so a web implementation can still open its popup. A newer call supersedes a pending one. |
| `beginConnect(provider, proof)` | Starts linking a provider to the signed-in account and answers the call that finishes it. `proof` is minted fresh for every call and empty for a guest. Whatever needs the network before the provider opens (a link ticket) happens here, so the returned call opens the provider before its first `await`: on the web the starter runs it from a fresh user tap, elsewhere at once. It answers `{data: {provider, email}}`. |
| `confirm(provider)` | Re-authenticates the signed-in user with a linked provider and answers the confirmation token the backend issued. |
| `signOut()` | Signs out of every provider SDK that keeps a session of its own. The starter does not call it; the bridge `social:install` publishes signs Google out itself whenever `Auth.stateNotifier` goes to signed-out, and a bridge of your own has to do the same. |

Every failure surfaces as a `MagicStarterSocialException(code:, message:, cancelled:)`. `cancelled` marks the user backing out, which the starter shows nothing for. Otherwise the starter shows the `social.<code>` sentence (`social_email_taken`, `flow_expired`, `step_up_required`, ...) and falls back to `message`.

### What a Social Sign-In Does

`MagicStarterAuthController.doSocialSignIn(provider)` calls the bridge before anything is awaited, then concludes through the same `completeSignIn` as every other sign-in: a two-factor answer opens the challenge, a session logs in and goes home, and `deletion_cancelled` shows the "Account deletion cancelled" toast. A cancelled flow shows nothing; a pending flow does not block a new tap.

> [!IMPORTANT]
> Provider sign-out is the bridge's job. The Google SDK keeps its own session, so a bridge of your own has to sign it out on every transition to signed-out (listen to `Auth.stateNotifier`), or the next sign-in skips the account picker. The published bridge does this.

### Language Keys

The screens read a `social.*` group (`continue_with`, `connect`, `disconnect`, `connected_accounts`, `confirm_identity`, `confirm_with`, `step_up_required`, `deletion_cancelled`, `deletion_scheduled`, and one sentence per backend refusal code), `profile.set_password`, `profile.set_password_description`, `profile.password_set`, `profile.password_set_failed` and `magic_starter.titles.connected_accounts`. A fresh `starter:install` writes them to `assets/lang/en.json`; an existing app adds them from `assets/stubs/install/en.stub`.

## OTP Flow

The phone OTP flow is a two-step process managed by `MagicStarterOtpController`. It requires both `guest_auth` and `phone_otp` feature flags to be enabled.

### Sending an OTP

Step 1 collects the phone number and sends the OTP via `POST /auth/otp/send`:

```dart
await MagicStarterOtpController.instance.sendOtp(
  phone: '+905301234567', // E.164 format
);
```

On success, the controller transitions from `OtpStep.phoneInput` to `OtpStep.codeInput` and stores the phone number internally for step 2.

### Verifying an OTP

Step 2 verifies the 6-digit code via `POST /auth/otp/verify`:

```dart
await MagicStarterOtpController.instance.verifyOtp(
  phone: controller.phoneNumber ?? '',
  code: form['code'],
);
```

On success, the controller calls `Auth.login()` with the returned token and navigates home. Call `controller.resetToPhoneInput()` to restart the flow.

> [!NOTE]
> The `OtpStep` enum drives the view state. Check `controller.step` to determine which input (phone or code) to display.

## Guest Sessions

`MagicStarterGuestAuthController` signs a visitor in as a guest with `POST /auth/guest`, sending a device id it keeps in `Vault` under `guest_device_id`, so the backend recognises a returning guest. Two entry points share the request:

- `doGuestLogin()` is the login screen's "Continue as guest": it opens the session, then goes home.
- `openGuestSession()` opens one in the background and leaves the viewer where they are, for a host that needs a session before anyone asks for one. It resolves `true` when a session exists afterwards and `false` when none could be opened (the reason is in the controller's error state); it never throws.

```dart
final bool hasSession = await MagicStarterGuestAuthController.instance.openGuestSession();
```

A session already held is kept and no request goes out: a guest's, because a second `POST /auth/guest` revokes every token that guest holds, and an account's, signed in or only stored, because a guest never replaces an account. Concurrent callers share the request in flight and its result; once it settles, the next call tries again.

## Guest Claim

With `guest_auth` on, what a guest accumulated moves to the account they sign in to next. `MagicStarterGuestClaim` runs the flow, and `MagicStarterServiceProvider` wires it to magic's auth events:

1. **Guest sign-in** (`AuthLogin` for a user whose `is_guest` is `true`): the guest's bearer token and user id are written to `Vault` under `MagicStarterGuestClaim.tokenKey` (`guest_claim_token`) and `MagicStarterGuestClaim.userKey` (`guest_claim_user`). The write is local and awaited.
2. **Sign-in or restore of a real account** (`AuthLogin` for a non-guest, or `AuthRestored`): `claimIfPending()` runs unawaited, so it never holds up `Auth.login` or a cold-boot `Magic.init`. When the recorded id equals the signed-in id (registration promoted the guest row in place), the record is dropped with no request. Otherwise it posts `POST /auth/guest/claim` with `{'guest_token': <guest token>}` and `Authorization: Bearer <signed-in token>`. A 2xx settles as `claimed`, a 404 (the backend has no claim route) or a 422 settles as `refused`, and either drops the record; anything else keeps it for the next sign-in or restore.
3. **Sign-out** (`AuthLogout`): `MagicStarterGuestClaim.forget()` deletes the record before `Auth.logout()` returns, so the next person to sign in on the device cannot claim the previous viewer's rows. It also ends the session a claim in flight belongs to: that claim's answer is dropped, it settles as `none`, it never touches a guest recorded after it, and the next sign-in starts its own claim.

Every outcome but `GuestClaimOutcome.none` reaches `MagicStarter.useGuestClaimed()` (see [Guest Claim Callback](https://anilcancakir.com/magic/docs/starter/v0.0/architecture/manager.md#guest-claim-callback)).

An app that needs its own ordering after the claim (a refetch that must see the moved rows, say) calls it directly:

```dart
final GuestClaimOutcome outcome = await MagicStarterGuestClaim.instance.claimIfPending();
```

A claim already in flight hands every caller in the same session the same future, so this never posts the token twice.

The claim does not ride the `Http` facade. Its body is the guest's live bearer token, and the facade's driver is the one `magic_devtools` records request bodies from in debug and profile builds. It goes out on a bare `DioNetworkDriver` built from `network.drivers.api` (`base_url`, `timeout`, `headers`) with no interceptors. That driver sends no magic `User-Agent`: `NetworkServiceProvider` adds that header only to the facade driver, so set one in `network.drivers.api.headers` if the backend needs it on this request.

## Logout

There are two sign-out paths a user can take, and both end in `Auth.logout()`:

- `MagicStarterAuthController.instance.logout()`: runs the before-logout hooks, stops notification polling and the push identity (when the `notifications` feature is on), calls `Auth.logout()`, then navigates to `MagicStarterConfig.loginRoute()`.
- The profile dropdown's sign-out item: calls your `MagicStarter.useLogout()` callback when one is registered, after running the before-logout hooks, and the controller path above otherwise. The hooks run once either way.

### Before-Logout Hooks

Some work needs the session token and has to happen before it is dropped: releasing this device's push registration on your backend, revoking a server-side session, signing out of a social SDK. Register it once, from a service provider `boot()`:

```dart
MagicStarter.beforeLogout(() => SocialAuth.signOut());
```

The contract:

- Hooks run in registration order, **before** a custom `useLogout()` callback and **before** `Auth.logout()`, on both paths above.
- Each hook is isolated: a throw is logged with `Log.error` and the next hook runs.
- Each hook is bounded to five seconds (`MagicStarterManager.beforeLogoutTimeout`); a dead network cannot trap somebody who asked to leave.
- Registering the same hook twice keeps one copy.
- A sign-out button of your own calls `await MagicStarter.manager.runBeforeLogoutHooks()` before its own `Auth.logout()`.

When the `magic_notifications` package is bound and its push-state reporting is configured (`notifications.push_state.report_path`), `MagicStarterServiceProvider` registers `Notify.pushState.release` as a default hook, so the backend stops treating this device as reachable for the person who just left it. Nothing to wire.

It also registers a hook that revokes the token on the server, `POST /auth/logout` with the still-valid bearer, so a sign-out ends the session on the backend rather than only forgetting the token on the device. It runs after the push release, sends nothing when no token is held, and logs a refused or failed request without holding up the sign-out. A hook you register after `MagicStarterServiceProvider` boots runs after the revoke, on a dead token.

> [!WARNING]
> Hooks do not run for a sign-out the app performs on its own. Magic's `AuthInterceptor` calls `Auth.logout()` when a token refresh fails, and by then the token is already dead: an authenticated hook could only answer `401`. Server-side state that must not outlive a session (a push-device row, for one) needs its own expiry on the backend.

## Onboarding Steps

`MagicStarterOnboarding` is a one-time gate for first-run steps (a locale confirmation, a feature tour, a welcome banner): each step is shown once per device and never again. Each step persists under its own `Vault` key (`onboarding_<step>_done`), and every loaded step is mirrored in memory so a synchronous `build` can read it.

Load the steps your app cares about once, after `Magic.init()`, then read and flip them from the screens that own them:

```dart
await MagicStarterOnboarding.load(['locale', 'tour']);

if (!MagicStarterOnboarding.isCompleted('tour')) {
  // Show the tour, then:
  await MagicStarterOnboarding.markCompleted('tour');
}
```

| Member | Purpose |
|--------|---------|
| `load(steps)` | Reads every step's flag from `Vault`. A missing key, or a `Vault` read that fails (logged as a warning), reads as not completed. |
| `isCompleted(step)` | Whether the step was resolved on this device. Synchronous. |
| `markCompleted(step)` | Flips the flag in memory at once and persists it. |
| `vaultKeyFor(step)` | The `Vault` key a step persists under. |

The flags are per device, not per account: they survive sign-out and a sign-in as someone else.

## Identity Modes

The starter supports three identity modes configured via `magic_starter.auth.email` and `magic_starter.auth.phone`:

| `auth.email` | `auth.phone` | Mode | Behavior |
|:---:|:---:|---|---|
| `true` | `false` | Email-only (default) | Only `email` field is sent in the payload |
| `false` | `true` | Phone-only | Only `phone` field is sent in the payload |
| `true` | `true` | Both | Both fields sent when non-empty; phone takes precedence when resolving a single identity |

The `_applyIdentityToPayload()` helper centralizes this logic across login and registration. In "both" mode, every non-empty identity field is included so the backend receives the full picture.

```dart
// Config for phone-only mode
Config.set('magic_starter.auth.email', false);
Config.set('magic_starter.auth.phone', true);
```

> [!IMPORTANT]
> When both modes are enabled, phone takes precedence for single-identity resolution. If neither `email` nor `phone` is provided, the login controller sets an error and returns early.

## Feature Gates

Authentication features are controlled by these configuration toggles. All default to `false`.

| Config Key | Gate Method | Affects |
|-----------|------------|---------|
| `magic_starter.features.registration` | `hasRegistrationFeatures()` | Register view and route |
| `magic_starter.features.social_login` | `hasSocialLoginFeatures()` | Social login buttons on login/register, the Connected accounts page and its settings row (all need a bridge from `MagicStarter.useSocialAuth`), and the "Confirm with a provider" option of the identity-confirmation dialog |
| `magic_starter.features.two_factor` | `hasTwoFactorFeatures()` | 2FA enable/disable in profile, challenge route |
| `magic_starter.features.guest_auth` | `hasGuestAuthFeatures()` | Guest login flow |
| `magic_starter.features.phone_otp` | `hasPhoneOtpFeatures()` | Phone OTP send/verify |
| `magic_starter.features.newsletter` | `hasNewsletterFeatures()` | Newsletter checkbox on registration |
| `magic_starter.features.email_verification` | `hasEmailVerificationFeatures()` | Email verification flow |
| `magic_starter.features.handoff` | `hasHandoffFeatures()` | Sign-in handoff route and view |
| `magic_starter.auth.email` | `emailIdentity()` | Email field in login/register (default `true`) |
| `magic_starter.auth.phone` | `phoneIdentity()` | Phone field in login/register (default `false`) |

> [!TIP]
> Feature-gated routes throw `StateError` if accessed when the corresponding feature is disabled. Always check the config before navigating programmatically.

## Controllers

| Controller | Singleton | Key Methods |
|-----------|----------|-------------|
| `MagicStarterAuthController` | `.instance` | `doLogin()`, `doRegister()`, `doForgotPassword()`, `doResetPassword()`, `doTwoFactorChallenge()`, `logout()` |
| `MagicStarterGuestAuthController` | `.instance` | `doGuestLogin()`, `openGuestSession()`, `isGuestUser`, `getStoredDeviceId()` |
| `MagicStarterOtpController` | `.instance` | `sendOtp()`, `verifyOtp()`, `resetToPhoneInput()`, `step`, `phoneNumber` |
| `MagicStarterHandoffController` | `.instance` | `handOff()`, `confirm()`, `cancel()`, `forget()`, `isConfirming`, `confirmingAs` |

All controllers use the `NavigatesRoutes` mixin for safe navigation. Never call `context.go()` directly in a controller — the navigator context may not be mounted. The mixin checks `MagicRouter.instance.navigatorKey.currentContext` and no-ops when unavailable.

After successful login or registration, `Auth.login()` stores the token and user model. Call `Auth.restore()` when you need to refresh the user model from the backend (e.g., after profile updates).

## Views

All views extend `MagicStatefulView<ControllerType>` and are registered in the view registry with string keys:

| Registry Key | View | Layout |
|-------------|------|--------|
| `auth.login` | `LoginView` | `GuestLayout` |
| `auth.register` | `RegisterView` | `GuestLayout` |
| `auth.forgot_password` | `ForgotPasswordView` | `GuestLayout` |
| `auth.reset_password` | `ResetPasswordView` | `GuestLayout` |
| `auth.verify_email` | `VerifyEmailView` | `GuestLayout` |
| `auth.two_factor_challenge` | `TwoFactorChallengeView` | `GuestLayout` |
| `auth.otp_verify` | `OtpVerifyView` | `GuestLayout` |
| `auth.handoff` | `HandoffView` | `GuestLayout` |

Views are instantiated through the registry (`MagicStarter.view.make('auth.login')`) and wrapped in the appropriate layout (`MagicStarter.view.makeLayout('layout.guest', child: view)`). The host app can override any view by registering a custom builder under the same key.

Views contain zero business logic — all async operations, state management, and navigation decisions live in the controllers.

---

**Related links:**

- [Configuration](https://magic.fluttersdk.com/packages/starter/configuration)
- [Profile Management](https://magic.fluttersdk.com/packages/starter/profile)
- [Teams](https://magic.fluttersdk.com/packages/starter/teams)
