Repository navigation
F145: move refresh token to an HttpOnly cookie; access token in memory only - #167
Merged
Merged
Conversation
…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.
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.
Owner
Author
Review round (codex + pi + 2 Claude agents)
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
|
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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+ newAuthCookies)HttpOnly; Secure; SameSite=Strictcookie path-scoped to/api/v1/auth; the body returns only the access token + expiry (AccessTokenResponse). The refresh token never appears in a response body.SameSite=Strictkeeps 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
localStoragetoken on first load (forced re-login for existing sessions — the accepted migration path).AuthProviderbootstraps the session on load via a silent refresh; anisLoadinggate holds the router so it never flashes/loginbefore the cookie is exchanged;ProtectedRouteholds during bootstrap.Acceptance criteria
localStorage/sessionStorage; cookie isHttpOnly; Secure; SameSite=Strict+ path-scoped.localStoragetokens 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
AuthCookieContracttests: HttpOnly/SameSite/path attributes, no-refresh-in-body, missing-CSRF → 403, missing-cookie → 401, Secure over forwarded-https). Unit 134/134.tscclean (rewrote token-store / client / AuthContext / bootstrap tests for the in-memory + cookie model).localStorage/sessionStorageempty 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.Set-Cookie: cluckwork_rt=…; path=/api/v1/auth; samesite=strict; httponly; refresh returns200(cookie+CSRF) /403(no CSRF) /401(no cookie).