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