Skip to content

feat(auth): move session auth to HttpOnly cookies#5

Merged
danjdewhurst merged 1 commit into
mainfrom
cookie-auth
Jun 13, 2026
Merged

feat(auth): move session auth to HttpOnly cookies#5
danjdewhurst merged 1 commit into
mainfrom
cookie-auth

Conversation

@danjdewhurst

@danjdewhurst danjdewhurst commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements review finding 1.8 — the auth token no longer lives in page JavaScript. The server delivers the session as an HttpOnly, SameSite=Lax cookie (Secure in production) and the SPA holds no token at all.

Server

  • Set the session cookie on register/login; clear it on logout.
  • readSessionToken() reads the cookie or an Authorization: Bearer header — including on the /ws upgrade, so the WebSocket authenticates from the cookie and the token no longer rides in the ws query string.
  • New GET /auth/session lets the client restore a session from the cookie on load instead of reading a token from localStorage.
  • Origin-aware CORS: credentialed (cookie) requests from configured origins (CORS_ALLOWED_ORIGINS, default the dev client) get that origin echoed + Access-Control-Allow-Credentials; everyone else keeps the non-credentialed wildcard. New config: corsAllowedOrigins, cookieSecure.

Client

  • Every authenticated request uses credentials: "include" and drops the Authorization header — the token is never in JS.
  • Session restored via GET /auth/session on load; all localStorage session storage removed. logout() clears the server cookie.
  • NetClient connects without a token in the URL (the cookie carries it).
  • Bonus (finding 8.6): deduped the signed-in top-bar visibility into revealSignedInChrome(), fixing the divergent edit-character reveal.

Test plan

  • bun run lint ✅ · bun run typecheck ✅ · bun test255 pass · bun run coverage:check95.2%
  • New router tests cover the Set-Cookie, /auth/session, logout-clear, and origin-aware CORS behavior; client tests updated for credentials: "include".
  • Verified end-to-end in a real browser (Playwright/Chromium) against the running server + Postgres:
    • Register → Set-Cookie … HttpOnly; SameSite=Lax; document.cookie empty, localStorage empty (token unreachable by JS).
    • ReloadGET /auth/session → 200 restores the session from the cookie alone and connects the WebSocket (connected as user_…).
    • Logoutlogout → 200, cookie cleared, back to the login form.

Notes

  • Independent of the integration-tests PR (fix(server): Postgres integration tests + unique-violation detection bug #4) — no overlapping files; either can merge first.
  • An earlier draft of this PR flagged a possible WebGL shader issue with full-body avatars. Resolved / false alarm: it only reproduces in the avatar preview harness, which creates one Pixi Application (and WebGL context) per cell — 28 contexts, past the browser's ~16 limit, so the excess get context-lost. The real app uses a single context per room and renders full-body avatars correctly (verified live). Pre-existing preview-tooling limitation, not in scope here.

🤖 Generated with Claude Code

Stop exposing the auth token to page JavaScript (review finding 1.8). The server
now delivers the session token as an HttpOnly, SameSite=Lax cookie (Secure in
production) and the SPA keeps no token at all.

Server:
- Set the session cookie on register/login; clear it on logout.
- Read the token from the cookie or the Authorization header (readSessionToken),
  including on the /ws upgrade — so the WebSocket authenticates from the cookie
  and the token no longer rides in the ws query string.
- Add GET /auth/session so the client can restore a session from the cookie on
  load instead of reading a token out of localStorage.
- Origin-aware CORS: credentialed (cookie) requests from configured origins
  (CORS_ALLOWED_ORIGINS, default the dev client) get that origin echoed plus
  Access-Control-Allow-Credentials; everyone else keeps the non-credentialed
  wildcard. New config: corsAllowedOrigins, cookieSecure.

Client:
- All authenticated requests use `credentials: "include"` and drop the
  Authorization header; the token is never held in JS.
- Restore the session via GET /auth/session on load; remove all localStorage
  session storage. logout() clears the server cookie.
- NetClient connects without a token in the URL (cookie carries it).
- Dedupe the signed-in top-bar visibility into revealSignedInChrome() (finding
  8.6), fixing the divergent edit-character button reveal.

Verified end-to-end in a real browser (Playwright/Chromium): register sets an
HttpOnly cookie with no token in document.cookie or localStorage; reload restores
the session and connects the WebSocket from the cookie alone; logout clears it
and returns to login. Server cookie/CORS behavior also covered by router tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@danjdewhurst danjdewhurst merged commit b81462d into main Jun 13, 2026
2 checks passed
@danjdewhurst danjdewhurst deleted the cookie-auth branch June 13, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant