Files
baya-monorepo/archive/docs/rules/client/auth.md
T
2026-08-02 20:01:31 +03:30

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/ (LoginFlowPhoneStepOtpStep) 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 §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}/welcomenever 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:

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.