From eb195568025bcbbe0e446533c40122b535167362 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 22 Jul 2026 16:50:33 -0500 Subject: [PATCH] docs(website): add API v2 plan (auth merge, CSP hardening, domain split) Plan for website API v2, sequenced in two phases behind a parallel /api/v2: - Phase 1: retire httpOnly session cookies; unify web + mobile on the existing bearer access + rotating/revocable refresh model. Separates removable session cookies from the SSO/email transaction cookies that must stay. SSE moves to fetch-based streaming with Authorization: Bearer. - Phase 1b: tighten the shipped CSP for the now-JS-held token (add form-action 'self', frame-ancestors 'none'); self-host fonts + Trusted Types as follow-ups. - Phase 2: break the monolithic route wiring (admin.routes.js, ~100 routes) into one router per business capability so the URL predicts the file. Co-Authored-By: Claude --- website/API_V2_PLAN.md | 193 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 website/API_V2_PLAN.md diff --git a/website/API_V2_PLAN.md b/website/API_V2_PLAN.md new file mode 100644 index 0000000..ed9b11a --- /dev/null +++ b/website/API_V2_PLAN.md @@ -0,0 +1,193 @@ +# Website API v2 — Plan + +Status: **planning** · Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md` + +v2 is two sequenced pieces of work, landed in order: + +1. **The auth merge** — httpOnly session cookies go away; a bearer JWT (access) + rotating + refresh token becomes the single session model for *every* client (web and mobile). +2. **The domain split** — the monolithic route wiring (esp. `admin.routes.js`, 1552 lines) is + broken into one router file per business capability, so a developer can predict where an + endpoint lives from its URL. + +## Locked decisions + +| Decision | Choice | Consequence | +|---|---|---| +| Versioning | **New `/api/v2` mounted in parallel** with a frozen `/api/v1` | Migrate the client route-by-route; delete v1 once nothing calls it. No big-bang break. | +| Web session model | **Web adopts mobile's access + refresh** | One session model everywhere. Reuses `session.service` machinery that already exists — nothing new invented. | +| Live-feed (SSE) auth | **fetch-based streaming with `Authorization: Bearer`** | Token stays out of URLs/logs. Client re-implements reconnect/backoff that `EventSource` gave for free. | + +## What already exists (so we don't rebuild it) + +- `auth/token.js` already extracts a token from cookie **or** `Authorization: Bearer`, and + `auth/session.service.js` already unifies both into one session. +- Mobile already has the full target model: `mintMobileTokens` / `createMobileSession` / + `refreshMobileSession`, with **hashed, rotated, revocable** refresh tokens stored server-side. + Endpoints live at `/api/v1/auth/mobile/{login,refresh,logout}`. +- **The merge is therefore mostly deletion + rename:** web joins the mobile session model, the + `/mobile` namespace disappears (it's just "auth" now), and the cookie code path is removed. + +--- + +## Phase 1 — The auth merge (cookie removal) + +### Server + +1. **Promote the mobile flow to the mainline v2 auth routes.** Under `/api/v2/auth`: + - `POST /login` → password (+ TOTP) → returns `{ accessToken, refreshToken, user }` (no `Set-Cookie`). + - `POST /refresh` → rotate: validate + revoke presented refresh, mint a new pair. + - `POST /logout` → revoke the current refresh/session server-side. + - `POST /login/totp` → second-factor step, same token shape. + The `/auth/mobile/*` namespace is **not** carried into v2 — it collapses into these. +2. **Delete the session-cookie code path in v2 controllers.** Stop calling `setAuthCookie` / + `clearAuthCookie`. `extractToken` keeps its Bearer branch; the cookie branch is dead for v2 + routes (v1 keeps it until v1 is removed). +3. **Separate *session* cookies from *transaction* cookies — the latter stay.** The SSO / email- + connect redirect flow (`sso.controller.js`, `emailConfig.controller.js`) *must* keep its + short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies: the browser leaves for the IdP + and returns with no JS context to carry a bearer across the hop. Only the **final session** + handoff changes — the callback ends by issuing bearer tokens (redirect with a one-time code the + SPA exchanges, so tokens never land in the URL) instead of setting `rg_token`. +4. **Trusted-device token** already supports the `X-Trust-Token` header for native clients + (`extractTrustToken`). Web switches to the same header + client storage; `rg_trust` cookie is + dropped for v2. +5. **SSE endpoints authenticate via `requireAuth` on the Bearer header.** Both the public and + admin shard streams move under v2 and read the token from the header, since the client is now + fetch-based (below). Keep the public/admin allowlist split — that security boundary is unchanged. + +### Client + +6. **`api/client.js`:** drop `credentials: 'include'`; attach `Authorization: Bearer `; + on `401`, silent-refresh once via `/auth/refresh`, retry, else bounce to login. +7. **Token storage:** access token in memory (JS var/context); refresh token in `localStorage`. + Short access TTL keeps the XSS window small — the accepted tradeoff for losing httpOnly. +8. **`lib/useShardFeed.js`:** replace `EventSource(..., { withCredentials: true })` with a + `fetch()` + `ReadableStream` reader that sends the Bearer header and parses SSE frames; add + reconnect/backoff + access-token refresh-on-401. + +### Docs / spec (required, same PR) + +9. Update `BACKEND_DESIGN.md` auth section (cookie → bearer-everywhere; tx-cookie exception). +10. Regenerate Swagger (`npm run swagger`) — v2 auth routes, `Authorization` security scheme, + remove `Set-Cookie` from documented responses. + +--- + +## Phase 1b — CSP hardening (ships with the auth merge) + +Once httpOnly is gone the access token lives in JS, so CSP's job becomes: **injected script can't +run, and if it somehow runs it can't phone home.** The app already ships a tuned policy +(`server/src/app.js`); v2 tightens it rather than rewriting it. + +Two directives are load-bearing for the token-theft threat and must stay tight: + +- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense; guard it. +- `connect-src 'self'` — the exfiltration channel. Do **not** widen it (e.g. to a separate API host) + unless the API genuinely becomes cross-origin; the SPA + REST + fetch-SSE are all same-origin here. + +`style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's +pervasive `style={{…}}` attributes can't be nonce'd. It is not a meaningful hole. + +Target enforced policy: + +``` +default-src 'self'; +script-src 'self'; +connect-src 'self'; +img-src 'self' data: https:; +style-src 'self' 'unsafe-inline'; /* + fonts.googleapis.com only until fonts are self-hosted */ +font-src 'self'; /* + fonts.gstatic.com only until fonts are self-hosted */ +object-src 'none'; +base-uri 'self'; +form-action 'self'; +frame-ancestors 'none'; +``` + +Changes vs. the current policy: + +- **Add `form-action 'self'`** — blocks an injected `
` from POSTing the + token/credentials off-origin (an exfil path `connect-src` doesn't cover). +- **Tighten `frame-ancestors` `'self'` → `'none'`** — nothing legitimately frames the site. +- Keep `base-uri 'self'`, `object-src 'none'`, and `img-src … https:` (external `BRAND_*` + logo/hero and `` in sanitized wiki/news bodies rely on `https:`). + +Tracked follow-ups (own PRs, not blocking the merge): + +- **Self-host the Cinzel font** → drop `fonts.googleapis.com` from `style-src` and + `fonts.gstatic.com` from `font-src`, removing two third-party origins from the trust surface. +- **Trusted Types** — roll out `require-trusted-types-for 'script'` + a `trusted-types` policy in + **report-only** first; neutralizes most DOM-based XSS at the sink (the exact bug class that would + steal a JS-held token). Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first. +- **Report-only rollout** — ship the tightened policy via `Content-Security-Policy-Report-Only` with + `report-to` for one release, watch for violations, then flip to enforce. + +Verify before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill +`