# Client auth

Cookies, the session lifecycle, silent refresh, `RoleGuard`, and the middleware gate — plus an explicit
statement of what is *not* a security boundary.

> Last verified: 2026-07-30 against commit `d3ec723`.

---

## 1. The credential is phone-OTP

There is **no username/password anywhere**, and email is never a login key. The flow lives in
`src/components/auth/` (`LoginFlow` → `PhoneStep` → `OtpStep`) at `/login`, over the `services/auth` domain
(`requestOtp` / `verifyOtp` / `refresh` / `logout` / `getMe` / `selectRole`).

`useWebOtp` is the WebOTP autofill seam — on a supporting browser the code fills itself from the SMS.

---

## 2. The two cookies

| Cookie | Constant | TTL | Written by |
| --- | --- | --- | --- |
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `persistAuthTokens` (`lib/auth/session.ts`) — via `useVerifyOtp`, `useRefresh`, `useSelectRole`, and the fetch-layer silent refresh |
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | same |

Lifecycle:

- **Written** by `persistAuthTokens` after verify / refresh / select-role, which also dispatches `LOG_IN` so
  `AuthContext` stays in sync without a reload.
- **Deleted** by `useLogout()` — the single logout path: revoke the server session, clear both cookies,
  `LOG_OUT`, drop the `/me` cache, redirect. Also cleared by `clientFetch` when a 401 can't be recovered.
- **Read** server-side by `serverFetch` / `getServerAuthState` via `getServerCookie`; client-side by
  `clientFetch` via `getClientCookie`, to attach `Authorization: Bearer`.

The `refresh_token` cookie's 7-day TTL is **shorter** than the server session default (30 days). Aligning the
cookie `maxAge` to the server's `refreshExpiresAt` is a known follow-up, not a bug to fix blind.

All cookie access goes through the manager — see [services.md](services.md) §6.

---

## 3. Session state

`AuthContext` (`src/context/auth/`) carries `SessionUser { id?, phone, roles: AppRole[] }`.

The root layout resolves the session **on the server** with `getServerAuthState()` (`lib/auth/server.ts`),
which reads the `access_token` cookie and checks the JWT `exp` via the shared `isTokenAlive`
(`lib/auth/token.ts`), and passes it to `<AuthProvider initialState={…}>`. So the very first render already
knows whether the user is authenticated.

**Roles are not derivable from the opaque JWE token server-side.** The server therefore seeds
`isAuthenticated` only; `useSessionRoleSync()` — mounted in the `(private-routes)` layout — hydrates
`currentUser.roles` from `/me`. That is the single source the shells read via `useActorRole()`.

`invalidateQueries(authKeys.me())` runs on login; `removeQueries(authKeys.all)` on logout.

---

## 4. Where the user lands: the role router

After a successful verify, `RoleRouter` reads `/me` and navigates — customer → the family app, nurse → the
nurse app, admin → the console, empty roles → `/select-role`. It shows the branded splash while `/me` loads,
**so the wrong shell never flashes.**

The decision itself is the **pure, unit-tested** `resolveRoleDestination(me, intendedRole)` in
`services/auth/routing.ts`. That function is the single "which app" source — every other place that needs to
send a user to their home calls it rather than re-deriving.

The middleware owns the auth *gate*; the router only decides which app.

---

## 5. `RoleGuard`: resolved vs. pending

Every private shell — `(customer)`, `nurse`, `admin`, `partner` — wraps its layout in **`RoleGuard`**.

It exists because the *core* role bug is conflating **"`/me` hasn't resolved yet"** with **"the user has no
nurse/admin role"**. A `/me` in flight used to fall through the `DEFAULT_ROLE = customer` fallback and flash
a nurse the customer app — or strand them there if `/me` failed.

`RoleGuard` reads **`useRoleHydration()`** (`services/auth`), a discriminated `loading | error | ready` over
`useMe`:

| State | Behaviour |
| --- | --- |
| **loading** | A neutral brand splash. **Never the customer shell as a stand-in** |
| **error** (`/me` failed — API down) | `AuthAccountError` with retry. **Never a silent customer fallback** — a transient error must not downgrade a nurse or an admin |
| **role mismatch** | Redirect to the caller's real app via `resolveRoleDestination`, with a `guard_denied` toast — rather than rendering a shell they lack the role for |

A shell passes `expected={APP_ROLES.*}`. **The partner portal passes no `expected`** — a partner-centre admin
is not an `AppRole`. It self-gates on `useMyPartnerCenter` (a 403/404 renders a non-leaking access-denied
state, never a raw id), so `RoleGuard` there only hardens hydration.

`useActorRole()`'s `DEFAULT_ROLE` fallback is now a last resort only — the guard ensures roles are hydrated
before a shell renders — never the loading state.

**`RoleGuard` is UX and chrome, not security.** The server authorizes every endpoint. A dual customer+nurse
session holds both roles and moves freely between the two apps (`ActorSwitcher`).

---

## 6. Silent refresh

`clientFetch` attempts one **single-flight** `attemptTokenRefresh` (`lib/api/refresh.ts`) on a 401 and
retries the request once. A failed refresh — unknown, expired, or reused token, at which point the server
revokes the session — clears tokens and redirects to `/login`.

The refresh and OTP endpoints are **excluded** from this retry, or a failing refresh would recurse.

This mirrors the server's rotation + reuse-detection: a replayed refresh token revokes **all** the user's
sessions.

---

## 7. Middleware

`middleware.ts` runs in this order, and the order is load-bearing:

1. **next-intl locale normalization.** If it is issuing a 307/308, return immediately.
2. **The guest front door.** An **unauthenticated** exact-match on `/` is `NextResponse.rewrite()`d to
   `/{locale}/welcome` — **never a redirect**, so the URL and the SEO canonical stay `/`.
3. An **authenticated** hit on `/welcome` redirects to `/`.
4. **The auth gate.** A non-public path without a live token redirects to `/login`, appending the attempted
   locale-stripped path + query as **`?next=`** (`RETURN_URL_PARAM`) so a deep link — an SMS booking link, a
   shared nurse profile — survives the round trip.
5. Otherwise: pass through, stamping the resolved locale on the request headers and preserving next-intl's
   response headers (the `Link: alternate` hreflang set).

`LoginFlow` reads `?next=` and `RoleRouter` resolves it via **`resolvePostLoginDestination`**
(`services/auth/routing.ts`), which accepts **same-origin-relative and role-permitting destinations only**
and otherwise falls back to `resolveRoleDestination`. **Never an open redirect.**

### Two traps in this file

**The matcher must list bare `'/'` explicitly** alongside the catch-all regex:

```ts
matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)']
```

This Next 16 / Turbopack build does **not** reliably invoke middleware for the literal root through the
negative-lookahead pattern alone — `/` skipped middleware entirely and 404'd, while every other path matched.
It is load-bearing for the guest front door, which only fires on an exact `/` match. (Verified in dev; a
production `next build && next start` confirmed the intended behaviour end to end, so the underlying quirk is
dev-server-only — but the explicit entry stays.)

**Never append `ROUTES.HOME` (`'/'`) to `PUBLIC_PATHS`.** `PUBLIC_PATHS` is matched with `startsWith`, so
`'/'` would silently make **every route public**. The guest-facing root is handled by the exact-match rewrite
above instead. To add a genuinely public route, append it to `PUBLIC_PATHS` and the middleware picks it up
automatically.

---

## 8. Security posture — what is and isn't a boundary

The design above is deliberate, and some of its hardening needs *server* coordination. **Don't silently "fix"
these client-only.**

- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header. That
  trades XSS hardening for the bearer pattern. Real hardening — httpOnly cookies set by the server plus a
  same-origin proxy — spans both projects.
- **The middleware check is UX-only, not a security boundary.** It decodes the JWT and checks `exp`; it does
  **not** verify the signature. The API is the only authority. **Never gate real authorization on the
  middleware or on `isTokenAlive`.**
- **Role gating is coarse for chrome, fine for the backoffice.** Shells pick chrome from the collapsed
  `currentUser.roles` (`useActorRole`). `useAdminCapabilities()` (`@/hooks`) is a memoized selector over the
  session's **fine-grained** `roleCodes` (`super_admin` / `admin` / `support` / `finance` / `moderation`)
  returning per-console booleans — `canVerify`, `canRefund`, `canPayout`, `canModerate`, `canConfig`,
  `canManageAlerts`, `canManageTickets`, `canManagePartners`, `canViewAudit`, `canManageRoles`. `AdminLayout`'s
  nav and every admin action hide or disable on it **so a role never sees a control that will 403** — but it
  is a **display convenience only; the server authorizes every command.** Never gate real authz on it.
- **Cross-actor route access is not hard-guarded client-side.** Add route guards when a feature needs them.
- **The partner portal is a separate scope** — its pages resolve the caller's own centre via
  `useMyPartnerCenter()`, never a raw id.
- **Signed URLs are fetched on demand, never cached long-lived.** Verification documents load via a
  short-lived signed URL from `useVerificationDocumentUrl(documentId)` (short `staleTime`, `retry: false`);
  `DocumentViewer` re-requests on expiry or error rather than reading an embedded URL out of the long-lived
  case query. **Reuse this pattern for any short-lived signed asset** — invoice PDFs included.
- **Refresh-token rotation is wired** client-side (the fetch-layer silent refresh plus `useRefresh`), matching
  the server's rotation and reuse-detection.

Admin sub-roles are **server-granted and never self-selectable**; `POST me/select_role` accepts only
`customer` / `nurse` and returns 403 for anything else. Don't build a UI that implies otherwise.
