9.9 KiB
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
persistAuthTokensafter verify / refresh / select-role, which also dispatchesLOG_INsoAuthContextstays in sync without a reload. - Deleted by
useLogout()— the single logout path: revoke the server session, clear both cookies,LOG_OUT, drop the/mecache, redirect. Also cleared byclientFetchwhen a 401 can't be recovered. - Read server-side by
serverFetch/getServerAuthStateviagetServerCookie; client-side byclientFetchviagetClientCookie, to attachAuthorization: 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 §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:
- next-intl locale normalization. If it is issuing a 307/308, return immediately.
- The guest front door. An unauthenticated exact-match on
/isNextResponse.rewrite()d to/{locale}/welcome— never a redirect, so the URL and the SEO canonical stay/. - An authenticated hit on
/welcomeredirects to/. - 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. - Otherwise: pass through, stamping the resolved locale on the request headers and preserving next-intl's
response headers (the
Link: alternatehreflang 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:
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
clientFetchcan 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 onisTokenAlive. - 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-grainedroleCodes(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)(shortstaleTime,retry: false);DocumentViewerre-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.