# Authentication

The starter kit combines Laravel Fortify for web authentication and Passport for API authentication.

## Web Authentication

Built-in flows include:

- login
- register
- forgot password
- reset password
- email verification
- confirm password
- two-factor challenge

These screens live under `resources/js/pages/Auth/`.

When Turnstile is enabled from the settings panel, the login, register, and forgot-password forms render the shared `TurnstileWidget` and validate `cf_turnstile_response` server-side.

## Profile Security

Authenticated users also get profile security tools:

- profile info update
- password update
- two-factor settings
- recovery code display and regeneration behind password confirmation
- browser session management
- avatar upload and removal

These flows are mounted from the profile screen and related routes in `routes/web/profile-route.php`.

## Password Policy

The password policy is driven by the **Settings → Security → Password Policy** admin tab. Rules are stored as `auth.*` setting keys and applied at runtime by `PasswordValidationRules`.

| Setting key | What it enforces |
|---|---|
| `auth.password_min_length` | Minimum character count (default: `8`) |
| `auth.password_require_mixed_case` | Upper and lower case required |
| `auth.password_require_numbers` | At least one digit required |
| `auth.password_require_symbols` | At least one symbol required |

Every Fortify flow picks up the active rules automatically — registration, password reset, password confirmation, and profile password update. Admin user create/update flows also apply the same rules.

Existing users' passwords are not invalidated when the policy changes — only newly submitted passwords are measured against the current rules.

### Password expiry

Setting `auth.password_expiry_days` to a value greater than `0` enables the `EnsurePasswordNotExpired` middleware. Authenticated users whose `password_changed_at` timestamp is older than the configured number of days are redirected to a dedicated, guest-style password-expired screen (route `password.expired`) until they set a new password. Setting `0` (the default) disables expiry entirely.

`password_changed_at` is stamped automatically on every password write: registration, password reset, profile update, and admin user create/update. Existing users received a `now()` back-fill when the migration ran, so they start the expiry clock from the deployment date rather than being immediately expired.

## Runtime Rules

- inactive users cannot start a web session; the Fortify login pipeline blocks accounts whose status is not `active`
- login is rate-limited by IP and by email/IP combinations when `auth.login_throttle = '1'` (the default, strict limiter); setting it to `'0'` in Settings → Security swaps in a more generous `login-relaxed` floor instead of removing the limiter entirely — no admin setting can leave web login unthrottled. The API auth routes carry their own hardcoded `throttle:5,1` middleware, unaffected by this setting.
- the two-factor challenge flow has its own limiter
- the two-factor challenge is **single-use** — any wrong code, empty submit, or invalid recovery code immediately invalidates the challenge id; the client must re-login to obtain a fresh one
- the forgot-password POST route receives Turnstile middleware dynamically when the route is matched
- **self-delete is blocked on the API.** `UserPolicy::delete` returns `false` when actor === target, so `DELETE /api/v1/users/{self}` returns 403 even for users holding `users.delete`. The only supported self-removal flow is the password-confirmed Fortify path in the Profile UI.

### Cutting off an account that is already signed in

The login-time check above cannot reach a session that is *already open* — an admin who deactivates a user would otherwise wait for that user's cookie to expire. Two pieces close that window.

**`EnsureUserIsActive` middleware.** Registered by `StarterKitServiceProvider::boot()` as the `sk.active` alias and appended to the `web` and `api` groups, so an existing install picks it up on `composer update` without touching `bootstrap/app.php`. On every request it reads `status` on the authenticated model and terminates when the value is on the operator's deny-list:

- **API / JSON request** → `403` in the kit's documented `ApiResponse` envelope (built in the middleware, so the shape does not depend on `ApiExceptionHandler` being registered).
- **Web request** → the stateful guard is logged out, the session invalidated, the CSRF token regenerated, then a redirect to the named `login` route carrying the same `sk-auth.inactive` copy the login-time block uses.
- **Web request whose credential cannot be cut** (a token guard reached through the `web` group), or an app with no `login` route → a plain `403`. Redirecting would loop, because the next request arrives with the same credential attached.

**It is deliberately fail-open.** It ships into apps whose `users.status` column the kit does not control, and a mass lockout is far worse than one extra request served to an account disabled a second ago. The request passes through whenever: no listed guard has a user; a listed guard is not declared under `auth.guards` or throws while resolving; the model carries no `status` attribute; `status` is null, a bool, or anything that does not normalise to a string; or the normalised value is simply **not** on the deny-list — unknown strings included. The middleware never infers "not active, therefore blocked"; it can only block a status that was explicitly listed.

**`RevokeUserAccessAction`.** The middleware only acts on requests that pass through the `web`/`api` groups. When a user's normalised status *transitions into* a denied value, this action additionally revokes Passport access **and** refresh tokens, unredeemed authorization/device codes (which could otherwise still be exchanged for a fresh access token), and the user's database session rows. It fires on a real transition only — editing the name of a user who has been inactive for a year revokes nothing — and never throws into the caller, because the status write has already committed.

**Configuration** — `config/starter-kit.php`, `security` block:

| Key | Default | What it does |
|---|---|---|
| `security.enforce_active_status` | `true` | Kill switch. `false` short-circuits the middleware **and** the token revocation. |
| `security.active_status_denied` | `['inactive', 'banned']` | The only statuses that terminate a session. Limited on purpose to the two non-active values the shipped `userStatus` definition produces. |
| `security.active_status_guards` | `['web', 'api']` | Guards inspected on each request. |
| `security.csp_extra_origins` | `[]` | Extra origins appended to the kit's Content-Security-Policy header. |

> `mergeConfigFrom` merges **top-level** keys only. A `config/starter-kit.php` published before this release has no `security` key at all and inherits the vendor block whole; a file carrying a *partial* `security` array replaces the vendor one for every nested key it omits. The middleware therefore falls back to class constants (`EnsureUserIsActive::ENFORCE_DEFAULT` / `::DENIED_DEFAULT` / `::GUARDS_DEFAULT`) that reproduce the shipped literals exactly, so both populations resolve the same values.

### Signing out other sessions

**`AuthenticateSession` in the `web` group.** `StarterKitServiceProvider` also appends Laravel's `AuthenticateSession` middleware to the `web` group, so an existing install picks it up on `composer update` without touching `bootstrap/app.php`. It stamps every signed-in browser session with a hash of the user's password and logs the session out on its next request once the two no longer match. A session with no stamp yet (one opened before the update) is stamped, not logged out. A `web` group that already lists the class or its `auth.session` alias is left as it is.

- **A password change or reset signs the user out everywhere else.** The profile password update, an admin changing the user's password and the forgot-password reset all change the hash, so every other browser and device is signed out, remember-me cookies included. When users change their own password, the browser they are using stays signed in.
- **"Log out other browser sessions" works on every session driver.** The profile action checks the password, re-hashes it through the session guard's `logoutOtherDevices()` and stores a new remember token, which also signs out sessions kept in `file`, `redis`, `memcached` and `cookie` stores. On the `database` driver the other session rows are deleted as well, so they leave the sessions list at once. The re-hash runs without model events: `password_changed_at` (the expiry clock) does not move and the activity log records no password change.
- `PurgeOtherSessionsAction` needs the default guard to be a session guard signed in as the same user; otherwise it throws a `LogicException` instead of reporting success.

## API Authentication

Passport powers the API side:

- personal access tokens
- `POST /api/v1/auth/register` and `POST /api/v1/auth/login` are public and throttled
- `POST /api/v1/auth/two-factor-challenge` is public and throttled
- `POST /api/v1/auth/logout` and `GET /api/v1/auth/me` require `auth:api`

### API Auth Flow

- `register` returns `201` with `{ user, token }` only when email verification is disabled
- when email verification is enabled, `register` returns `201` with `{ user, requires_verification: true }` and no token
- `login` can return `{ user, token }`, `{ requires_verification: true }`, or `{ requires_two_factor: true, challenge }`
- `two-factor-challenge` completes the API 2FA flow with either `code` or `recovery_code` and returns `{ user, token }` on success
- clients should branch on `requires_verification` and `requires_two_factor` instead of assuming every successful auth response contains a token

## Passport Configuration

Token lifetimes and the scope catalog are set under the `passport` key in `config/starter-kit.php` and applied by `StarterKitServiceProvider` at boot (a no-op when `laravel/passport` isn't installed).

| Config key (`passport.…`) | Env var | Default | Effect |
|---|---|---|---|
| `provider` | `STARTER_KIT_PASSPORT_PROVIDER` | `users` | Auth provider backing the auto-registered `api` guard. The guard is only synthesized when the app hasn't already defined `auth.guards.api` — a custom guard you defined yourself is never overridden. |
| `access_token_minutes` | `PASSPORT_TOKEN_MINUTES` | `60` | Access token TTL, applied via `Passport::tokensExpireIn()`. |
| `refresh_token_days` | `PASSPORT_REFRESH_TOKEN_DAYS` | `14` | Refresh token TTL, applied via `Passport::refreshTokensExpireIn()`. |
| `personal_token_days` | `PASSPORT_PERSONAL_TOKEN_DAYS` | `30` | Personal Access Token TTL, applied via `Passport::personalAccessTokensExpireIn()`. |
| `scopes` | — | 5 example scopes (`users.read`, `users.write`, `files.read`, `files.write`, `admin`) | Scope catalog registered with `Passport::tokensCan()`. It is also the allow-list `StoreApiTokenRequest` validates against when a PAT is created from `/admin/api-tokens` — requesting a scope outside this catalog is rejected with a validation error before it ever reaches Passport. The literal `*` is stripped from the allow-list even if present in the catalog, so a PAT can never be created with the wildcard scope from the UI. |
| `default_scopes` | — | `[]` (empty) | Scope(s) applied via `Passport::setDefaultScope()`. Only takes effect when `scopes` is non-empty. |

Two legacy env vars still take precedence when set (non-null, non-empty string):

- `PASSPORT_TOKEN_DAYS` (days) overrides `access_token_minutes` — the value is multiplied by `24 * 60` to derive minutes.
- `PASSPORT_PERSONAL_TOKEN_MONTHS` (months) overrides `personal_token_days` — the value is multiplied by `30` to derive days.

Both map to the `passport.access_token_days` / `passport.personal_token_months` config keys, which are `null` by default; leave them unset to use the minutes/days keys above.

**Scope enforcement is opt-in.** Populating `passport.scopes` alone restricts nothing — it only registers the catalog and (optionally) the default. To actually gate a route, attach Passport's own `scope`/`scopes` middleware (e.g. `->middleware('scope:users.read')`). Leaving `scopes` empty preserves Passport's implicit `*` scope, so existing clients and tokens keep working unchanged. Note that OAuth2 clients created via `/admin/api-clients` don't carry scopes at all (removed — see [API Clients & Tokens](https://starter-kit.lvntr.dev/docs/api-clients)); the scope catalog above applies to Personal Access Tokens only.

## API Clients & Tokens

The admin panel provides a UI for managing Passport OAuth2 clients and Personal Access Tokens (PATs):

- `/admin/api-clients` — list, create, update, and delete OAuth2 clients
- `/admin/api-tokens` — manage Personal Access Tokens

Client secrets and PAT values are shown exactly once in a dismissal-blocked modal at creation time and are never stored in plaintext. See [API Clients & Tokens](https://starter-kit.lvntr.dev/docs/api-clients) for the full reference.

## Notes

- use Fortify for the browser-facing auth experience
- use Passport for external or token-based API consumers
- keep web and API auth concerns separate even when they share the same user model
