# Authentication & sessions


## Login

`src/routes/login/+page.svelte` renders `<LoginForm>` from
`src/lib/components/auth/LoginForm.svelte`. The form uses
`sveltekit-superforms` with a Zod schema (`username`, `password` both
required) and POSTs to `/api/v1/auth/login`.

On success it stores the user in `userProfileStore` and invokes the
`onsuccess` callback, which the login page resolves through
`resolvePostLoginTarget()` — sanitized `?redirect_path=` query param
first, then the sessionStorage-saved redirect, then `/`. On failure it
maps RFC7807 error responses to i18n-localized messages, with special
handling for rate-limited attempts (showing minutes remaining).

### Session boot (auto-login)

Before showing the form, the login page runs a client-side boot check —
no `/auth/me` round-trip:

1. **Valid local session** — `sessionStorage['user']` exists and its
   `expires_at` is not within the 30 s expiry buffer → navigate straight
   to the post-login target while a `LoadingWatermark` shows "Auto
   Logging" (cookie icon, flip animation).
2. **Expired or missing local session** → a single `triggerRefresh()`
   attempt ("Refreshing Token" state, rotate-ccw-key icon). The HttpOnly
   refresh cookie is the authoritative channel, so this also recovers
   sessions in fresh tabs where `sessionStorage` is empty.
3. **Refresh fails** → stale local state is cleared via
   `userProfileStore.clear()` and the normal login form renders.

The shared helpers live in `src/lib/auth/session-check.ts`
(`hasLocalSession`, `isTokenExpired`, `refreshAccessToken`,
`triggerRefresh`) and are also used by `apiFetch`'s 401 retry path —
`triggerRefresh` is single-flight, so concurrent callers share one
refresh request.

### Identity vs session — one certified source

`sessionStorage['user']` / `userProfileStore` mixes two domains that are
written by different paths:

| Writer | Allowed fields |
|--------|----------------|
| `GET /api/v1/auth/me` → `userProfileStore.set(profile)` | **Identity** — `uuid`, `display_name`, `email`, `avatar_*`, `is_admin`, `is_verified`, `idp_*`, `permissions`, audit fields. THE certified source (DB `user_profiles`). |
| login / refresh responses (IDP JWT claims) → `userProfileStore.setSession(data.user)` | **Session only** — `username`, `idp_code`, `expires_at`, `roles`, `organization`. `setSession` whitelists these keys: claim-level `display_name`/`email` are dropped — never deduced identity. |

`setSession` also carries an account-switch guard: a payload `idp_code`
different from the stored one drops the current identity fields, which
`/auth/me` then repopulates reactively — no stale identity of another
account can linger. Never write `sessionStorage['user']` directly or feed
claims to `set()`.

### Post-login redirect

`src/lib/auth/redirect-cache.ts` exposes the redirect helpers:

| Helper | Purpose |
|--------|---------|
| `saveRedirectUrl(url)` | Persists the current app path in sessionStorage (API routes skipped) |
| `getAndClearRedirectUrl()` | Reads and clears the saved redirect |
| `sanitizeRedirectPath(raw)` | Accepts only same-origin `/...` paths — rejects `//host`, `/api/…`, backslashes, control chars |
| `resolvePostLoginTarget(qsParam, lastRoute)` | `redirect_path` QS → saved redirect → last meaningful route → `/` |
| `goToLoginPage()` | Hard-navigates to `/login` preserving the current path both in sessionStorage and as a sanitized `?redirect_path=` (survives reloads and new tabs) |

`SessionExpiredDialog`'s "Go to login" button uses `goToLoginPage()`, so
the original destination is restored after re-authentication even from
the full login page.

### Last-route restore

`pb:shell:lastRoute` (localStorage) tracks the last meaningful page via
`afterNavigate` — `/`, `/login*` and `/api/*` are never saved, so the
post-login resolution's third fallback is deterministic rather than
degenerating to `/`.

## Module switching & route-aware translations

Selecting a module in the sidebar switcher calls
`shellNav.selectModule(id)` **and navigates** to the module home — the
first `nav` entry of the current root slice (`home[root]` from the
`/api/v1/modules` payload; `nav[0].href` is the single source of truth). `/system/settings` itself redirects to `/system/settings/profile`
via a `+page.ts` universal `load()` — `redirect()` inside a component
`<script>` only works on SSR; on client-side navigation it surfaces as
an unhandled "Redirect" error and a blank page.

The route change is what drives lazy translation loading:
`useModuleTranslations()` (mounted in the `(app)` layout) resolves the
module from `page.url.pathname` and fetches its dictionary — so module
dictionaries load on navigation, not on selection.

## Logout

`POST /api/v1/auth/logout` (PUBLIC) clears both HttpOnly auth cookies —
`access_token` (path `/`) and `refresh_token`
(path `/api/v1/auth/refresh`). JavaScript cannot clear HttpOnly
cookies, so the server round-trip is required.

`AppSidebar`'s `handleLogout` calls the endpoint (best-effort), then
clears local state — `userProfileStore.clear()`,
`getAndClearRedirectUrl()`, `shellNav.clearLastRoute()` — and navigates
to `/login` **without** `redirect_path`. After a real logout the login
boot's refresh attempt fails and the form renders: the session is
officially over.

## Session expiration

When an API call returns **401**, the request is enqueued in the
session-expired store and the `SessionExpiredDialog` opens so the user can
re-authenticate without losing context.

`src/lib/auth/session-expired-store.svelte.ts` exposes:

| Member | Purpose |
|--------|---------|
| `enqueue(input, init)` | Enqueues a failed request and opens the dialog; returns a `Promise<Response>` that resolves after re-login |
| `drainPending()` | Returns and clears the queued requests (called after successful re-login) |
| `setFailed()` | Marks the last login attempt as failed (shows the error alert + "Go to login" button) |
| `close()` | Closes the dialog and resets state |

State is exposed read-only via `state` (`isOpen`, `pendingRequests`,
`hasFailedAttempt`). After a successful re-login in the dialog,
`handleLoginSuccess()` calls `close()` and `drainPending()`, and the
enqueued requests are retried with a `_sessionRetry` flag to avoid
re-triggering the dialog.

## Password policy

The backend exposes the active password policy at
`GET /api/v1/system/password-policy`. The `usePasswordPolicy()` composable
fetches it and exposes both the rules and a derived validation regex.

### Policies

| Policy | Rules enforced |
|--------|----------------|
| `alpha_numeric` | Length 8–64, alphanumeric only |
| `letter_and_number` | Length, ≥1 letter, ≥1 number |
| `letter_number_special` | Length, letter, number, special char |
| `mixed_case_special` | Length, lowercase, uppercase, number, special char |

Allowed special characters: `*-_.#@!|?^:`

The regex patterns are kept in sync with the backend exactly:

```ts
ALPHA_NUMERIC:        /^[A-Za-z0-9]{8,64}$/
LETTER_AND_NUMBER:    /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9]{8,64}$/
LETTER_NUMBER_SPECIAL:/^(?=.*[A-Za-z])(?=.*\d)(?=.*[*\-_.#@!|?^:])[A-Za-z0-9*\-_.#@!|?^:]{8,64}$/
MIXED_CASE_SPECIAL:   /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[*\-_.#@!|?^:])[A-Za-z0-9*\-_.#@!|?^:]{8,64}$/
```

### Composable API

```ts
const policy = usePasswordPolicy();
policy.state.policy          // PasswordPolicy enum
policy.state.checklistRules  // PasswordChecklistRule[]
policy.state.specialChars    // string
policy.state.loading         // boolean
policy.state.loaded          // boolean
policy.regex                 // RegExp matching the active policy
policy.load()                // fetch from backend
```

### Password checklist

`PasswordChecklist.svelte` renders a live checklist of the active rules
(`length`, `letter`, `lowercase`, `uppercase`, `number`, `special`) with
check/circle icons, updating as the user types. It takes `password`,
`rules: PasswordChecklistRule[]`, and optional `specialChars`.

### Change password dialog

`ChangePasswordDialog.svelte` (in the entity-list-table dialogs) loads the
policy on open, validates the new password against `policy.regex`, requires
confirmation match, and POSTs to
`/api/v1/entities/user_profile/{uuid}/change-password`. It embeds
`<PasswordChecklist>` for inline feedback.

## Password UI components

The `src/lib/components/ui/password/` family wraps password input with
strength checking via `@zxcvbn-ts/core`:

- `Password.Root` — context provider (`usePassword({ hidden, minScore })`)
- `Password.Input` — the input itself (`usePasswordInput({ value, ref })`)
- `Password.ToggleVisibility` — show/hide toggle
- `Password.Copy` — copy-to-clipboard
- `Password.Strength` — zxcvbn strength meter
- `PasswordInputField` — convenience wrapper combining Root + Input + Toggle

zxcvbn is lazy-loaded and strength checking is debounced with request
cancellation. When the score is below `minScore`, the input's custom
validity is set.

## Active roles

`useActiveRoles()` fetches roles from `/api/v1/system/roles/active` and
exposes `state.roles` (`ActiveRole[]` with `idp_role`, `label_key`,
`permissions`, `is_admin`) plus a derived `roleNames` getter for use in
forms.

## MCP OAuth consent

`src/lib/mcp-oauth.ts` provides OAuth client helpers for the MCP consent
screen at `/mcp/consent`:

| Function | Purpose |
|----------|---------|
| `parseConsentParams(searchParams)` | Parses OAuth params from the URL query string |
| `parseScopes(scopeString)` | Splits the scope string into `{ name, description }` entries |
| `buildApproveUrl(params, beBaseUrl)` | Builds the backend authorize URL with `consent_approved=true` |
| `buildDenyUrl(params)` | Builds the deny redirect URL per RFC 6749 §4.1.2.1 |

The consent page checks `/api/v1/auth/me`; if the user is not logged in it
shows `<LoginForm>` and reloads on success. Otherwise it shows the client
name and requested scopes, then redirects to the approve or deny URL.

## Form guards

Two composables consolidate logic shared across the settings form pages:

### `useFormGuard`

Derives `hasChanges` and `canSave` from a SuperForm instance. Because
Superform's auto-subscription does not work inside `.svelte.ts` files, it
takes **getter functions** for `$tainted` and `$errors` plus the form's
`isTainted` function:

```ts
const guard = useFormGuard(() => $tainted, () => $errors, (p) => isTainted(p));
$derived(guard.hasChanges); // true when form is tainted
$derived(guard.canSave);    // hasChanges && no errors
```

### `useUnsavedChangesGuard`

Registers navigation guards for unsaved changes:

```ts
const guard = useUnsavedChangesGuard(() => formGuard.hasChanges, 'settings.unsavedChangesConfirm');
```

- `handleBeforeUnload` — for `<svelte:window onbeforeunload>`, prevents
  browser close/refresh
- `handleCancel` — for the footer Cancel button; confirms then closes the
  window or navigates back
- Internally registers a SvelteKit™ `beforeNavigate` guard that prompts on
  in-app navigation

## Next steps

- [System settings](/frontend/dev/settings)
- [Entity list table](/frontend/dev/components/entity-list-table)
