create mvp path

This commit is contained in:
hamid
2026-08-02 20:01:31 +03:30
parent 72ab290da1
commit fb58ca54e1
203 changed files with 863 additions and 156 deletions
+184
View File
@@ -0,0 +1,184 @@
# 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.