Skip to content

F145: move refresh token to an HttpOnly cookie; access token in memory only - #167

Merged
mforce merged 2 commits into
mainfrom
fix/145-httponly-refresh
Jul 23, 2026
Merged

mforce merged 2 commits into
mainfrom
fix/145-httponly-refresh

Conversation

@mforce

@mforce mforce commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Closes #145.

What

Both the access and 30-day refresh tokens were kept in localStorage, so any XSS (e.g. via a rendered flock name or customer note) could read the refresh token and take over the account — the audit's top finding. This moves the storage to match the already-strong server-side token hygiene (single-use, rotation, replay/theft revocation).

Server (AuthEndpoints + new AuthCookies)

  • Login sets the refresh token as an HttpOnly; Secure; SameSite=Strict cookie path-scoped to /api/v1/auth; the body returns only the access token + expiry (AccessTokenResponse). The refresh token never appears in a response body.
  • Refresh reads the token from the cookie (no body), rotates the cookie, returns a fresh access token; a failed refresh expires the cookie.
  • Logout revokes server-side and expires the cookie.
  • CSRF: SameSite=Strict keeps the cookie off cross-site requests; refresh/logout additionally require a custom header (X-Cluckwork-Auth) a cross-site simple request can't set (adding it forces a preflight the API doesn't grant). Secure = Request.IsHttps — on in prod (the Security: forwarded headers, HSTS, CSP + security headers, pinned AllowedHosts #144 forwarded proto), off for plain-HTTP local dev so the cookie still stores.

SPA

  • Token store shrinks to an in-memory access-token holder and purges any pre-Security: move refresh token to HttpOnly cookie, access token in memory only (CSRF posture included) #145 localStorage token on first load (forced re-login for existing sessions — the accepted migration path).
  • Client sends no refresh body; refresh/logout carry the CSRF header and the cookie rides automatically. Same single-flight refresh + one transparent retry-on-401, different transport.
  • AuthProvider bootstraps the session on load via a silent refresh; an isLoading gate holds the router so it never flashes /login before the cookie is exchanged; ProtectedRoute holds during bootstrap.

Acceptance criteria

  • After login, no token in localStorage/sessionStorage; cookie is HttpOnly; Secure; SameSite=Strict + path-scoped.
  • Reload restores the session via silent refresh; expired/absent cookie lands cleanly on login.
  • Rotation + theft-detection integration tests pass under the cookie flow.
  • Logout expires the cookie + revokes; a subsequent silent refresh fails.
  • Vitest covers the new token store + bootstrap.
  • Pre-existing localStorage tokens purged on first load.

Out of scope (per the issue)

Access token stays a Bearer header (no CSRF on every write); no ASP.NET antiforgery service (the SameSite + custom-header posture is deliberate); CSP is the complementary #144 defence.

Verification

  • API integration 233/233 (RefreshTokenFlow moved to cookies; new AuthCookieContract tests: HttpOnly/SameSite/path attributes, no-refresh-in-body, missing-CSRF → 403, missing-cookie → 401, Secure over forwarded-https). Unit 134/134.
  • Web 390/390, tsc clean (rewrote token-store / client / AuthContext / bootstrap tests for the in-memory + cookie model).
  • Browser (chromium), end to end: after login localStorage/sessionStorage empty and the refresh cookie is not readable from JS; reload restores the session with no login flash; logout clears it; the pre-Security: move refresh token to HttpOnly cookie, access token in memory only (CSRF posture included) #145 localStorage token is purged on load.
  • curl: Set-Cookie: cluckwork_rt=…; path=/api/v1/auth; samesite=strict; httponly; refresh returns 200 (cookie+CSRF) / 403 (no CSRF) / 401 (no cookie).

…y only

Both tokens were persisted in localStorage, so any XSS (e.g. via a rendered
flock name or customer note) could read the 30-day refresh token and take over
the account (audit's top finding). This moves the storage to match the already
strong server-side token hygiene.

Server (AuthEndpoints + AuthCookies):
- Login sets the refresh token as an HttpOnly; Secure; SameSite=Strict cookie
  path-scoped to /api/v1/auth; the body returns only the access token + expiry
  (AccessTokenResponse) — the refresh token never appears in a response body.
- Refresh reads the token from the cookie (no body), rotates the cookie, and
  returns a fresh access token; a failed refresh expires the cookie.
- Logout revokes server-side and expires the cookie.
- CSRF: SameSite=Strict keeps the cookie off cross-site requests; refresh/logout
  additionally require a custom header (X-Cluckwork-Auth) a cross-site simple
  request cannot set. Secure = Request.IsHttps (on behind the #144 forwarded
  proto; off for plain-HTTP local dev).

SPA:
- tokenStore shrinks to an in-memory access-token holder and purges any pre-#145
  localStorage token on first load (forced re-login for existing sessions).
- The client sends no refresh body; refresh/logout carry the CSRF header and the
  cookie rides automatically. Same single-flight refresh + one transparent
  retry-on-401 semantics, different transport.
- AuthProvider bootstraps the session on load via a silent refresh (isLoading
  gates the router so it never flashes /login before the cookie is exchanged);
  ProtectedRoute holds during bootstrap.

Tests:
- API: RefreshTokenFlowTests moved to the cookie flow; new AuthCookieContract
  tests (HttpOnly/SameSite/path attributes, no refresh in body, missing-CSRF 403,
  missing-cookie 401) + Secure-over-forwarded-https. Full suite 233/233.
- Web: rewrote the token-store / client / AuthContext / bootstrap tests for the
  in-memory + cookie model; 390/390, tsc clean.

Browser-verified end to end: after login no token in localStorage/sessionStorage
and the refresh cookie is not readable from JS; reload restores the session via a
silent refresh with no login flash; logout clears it. Set-Cookie attributes and
the refresh CSRF/cookie status codes (200 / 403 no-CSRF / 401 no-cookie)
confirmed via curl.

Docs: GLOSSARY 'Session tokens' entry.

Closes #145.
@mforce mforce added this to the Phase 1.5 — Hardening milestone Jul 23, 2026
@mforce mforce added enhancement New feature or request area:api API/endpoint layer area:frontend React/Vite web client labels Jul 23, 2026
From the 4-way review (codex + pi + 2 agents), verified against the running app:

- Logout didn't actually log out (codex High, pi Med, confirmed by curl):
  the SPA logout sent no Idempotency-Key, and once the access token resolved a
  tenant the idempotency middleware returned 400 BEFORE the handler ran, so the
  refresh cookie was never revoked — a reload silently restored the session. Also
  (codex Med) an expired access token made logout 401 for the same net effect.
  Fix: logout is now AllowAnonymous, authenticated by the refresh cookie + CSRF
  header (like refresh), so it needs neither a valid access token nor an
  idempotency key and always destroys the session. The SPA logout drops the
  bearer, sends only the CSRF header, and always fires (JS can't read the
  HttpOnly cookie to know a session exists). New integration test proves the
  exact SPA path (cookie + CSRF, no bearer/key) → 204 and revokes; curl confirms
  refresh-after-logout → 401.

- Cookie Secure hardening (server agent Low): Secure is now !IsDevelopment()
  rather than Request.IsHttps, so a misconfigured forwarded-proto can never emit
  a non-Secure auth cookie in production (the browser always reaches prod over
  HTTPS; the internal proxy hop scheme is irrelevant).

- Login redirect (codex Med): an already-authenticated visit to /login (e.g. the
  load-time silent refresh restoring a session) now navigates to the destination
  instead of stranding the user on the form.

- AuthContext bootstrap (SPA agent Low): clear isLoading in .finally so a future
  throw in restoreSession can't wedge the router on a blank screen.

- Docs (codex Low): web/README auth flow and the in-app Help 'Signing in' note
  updated for the memory-only access token + HttpOnly refresh cookie + one-time
  re-login after an update.

Not changed (reviewed): cross-tab concurrent bootstrap can trip refresh
reuse-detection and log both tabs out — pre-existing to the rotating-refresh
design, made marginally more likely by eager load-time refresh; cross-tab
coordination is a separate follow-up, not a #145 regression. A 429 during the
load-time bootstrap lands on /login rather than a retry state — minor UX; the
cookie/session is untouched so it is not a #143 session-teardown regression.

Full API integration 233/233; web 391/391; tsc clean.
@mforce

mforce commented Jul 23, 2026

Copy link
Copy Markdown
Owner Author

Review round (codex + pi + 2 Claude agents)

Reviewer Verdict
codex 1 High + 4 Med + 2 Low
pi 1 Med (logout Idempotency-Key)
Claude server agent (cookie/CSRF) Sound; Low/Info (Secure hardening, logout-auth edge)
Claude SPA agent (session lifecycle) Sound; Low/Info

The headline risk (cookie Set/Delete attribute symmetry, CSRF on both endpoints, no token leakage, rotation/theft over the cookie) was verified correct by all four. The real find was logout.

Fixed (commit 40e9d55)

  1. Logout didn't actually log out — High (codex), confirmed by curl. The SPA logout sent no Idempotency-Key; once the access token resolved a tenant, the idempotency middleware returned 400 before the handler ran, so the refresh cookie was never revoked → a reload silently restored the session. (Also codex Med: an expired access token made logout 401 for the same net effect.) Fix: logout is now AllowAnonymous, authenticated by the refresh cookie + CSRF header (like refresh), so it needs neither a valid access token nor an idempotency key and always destroys the session. The SPA logout drops the bearer, sends only the CSRF header, and always fires. New integration test proves the exact SPA path (cookie + CSRF, no bearer/key) → 204 + revokes; curl confirms: keyless logout 204, refresh-after 401, no-CSRF 403.
  2. Cookie Secure — Low. Now !IsDevelopment() instead of Request.IsHttps, so a misconfigured forwarded-proto can never emit a non-Secure auth cookie in prod.
  3. Login redirect — codex Med. An already-authenticated visit to /login (e.g. the load-time silent refresh restoring a session) now navigates to the destination instead of stranding the user on the form.
  4. AuthContext bootstrap — SPA agent Low. isLoading cleared in .finally so a future throw in restoreSession can't wedge the router on a blank screen.
  5. Docs — codex Low. web/README auth flow + in-app Help "Signing in" note updated (memory-only access token, HttpOnly refresh cookie, one-time re-login after an update).

Reviewed, not changed (with rationale)

Verification

Full API integration 233/233 (incl. the new cookie-authenticated logout test); web 391/391; unit 134/134; tsc clean; logout fix confirmed end-to-end via curl.

@mforce
mforce merged commit c799172 into main Jul 23, 2026
2 of 3 checks passed
@mforce
mforce deleted the fix/145-httponly-refresh branch July 23, 2026 06:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:api API/endpoint layer area:frontend React/Vite web client enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: move refresh token to HttpOnly cookie, access token in memory only (CSRF posture included)

1 participant