185 lines
9.9 KiB
Markdown
185 lines
9.9 KiB
Markdown
# 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.
|