Skip to content

fix(security): move Hasura Auth tokens from response body to httpOnly cookies - #26

Merged
acamarata merged 2 commits into
mainfrom
fix/security-httponly-cookie-tokens
Jul 6, 2026
Merged

acamarata merged 2 commits into
mainfrom
fix/security-httponly-cookie-tokens

Conversation

@acamarata

Copy link
Copy Markdown
Contributor

Summary

  • signin.ts/signup.ts previously proxied Hasura Auth and returned accessToken/refreshToken directly in the JSON response body — an XSS token-theft risk for whatever client code eventually stored them. No signin/signup UI was wired up yet, but the insecure pattern was live in the API layer and needed fixing before a UI got built on top of it.
  • Ports the httpOnly-cookie session pattern already shipped in praycalc/web (ADR-010, no-localstorage-token): tokens are now set as httpOnly/secure/sameSite=lax cookies (ci_access_token/ci_refresh_token) by same-origin /api/auth/* routes. Only a non-sensitive profile (email/displayName/initials + a plain expiry timestamp) is kept in localStorage (lib/session.ts).
  • Adds the previously-missing refresh.ts and signout.ts routes so a session set up this way can actually be renewed and ended.
  • Migrates TutorIsland, SettingsIsland, and the tutor API routes (progress/message/session) off the chatislam_token localStorage/Bearer pattern onto the httpOnly cookie.
  • Fixes signin/signup to read import.meta.env.PUBLIC_AUTH_URL (the actually-wired env var per astro.config.ts + .env.example) instead of the dead process.env.NEXT_PUBLIC_AUTH_URL leftover from this app's pre-Astro Next.js code — that env var was never wired to a deployed value.

Out of scope (filed separately)

  • JWT signature verification for the tutor routes' cookie-sourced token — currently decodes claims without verifying the signature (pre-existing behavior, unchanged here). Tracked as its own follow-up since it's a distinct, more severe finding.
  • Two more chatislam_token localStorage reads in hooks/useFeynman.ts and components/chat/ChatSidebar.tsx, outside this PR's explicit scope.
  • /api/settings/byo-key remains unimplemented (pre-existing gap, unrelated to this fix) — only the client fetch call was updated for consistency.

Test plan

  • pnpm vitest run — 392 passed, 1 pre-existing skip
  • pnpm run typecheck (astro check) — 0 errors
  • New unit tests: __tests__/session.test.ts, __tests__/auth-client.test.ts — cover profile round-trip, no-raw-tokens-returned, same-origin credentials usage, error handling

… cookies

signin.ts and signup.ts previously proxied Hasura Auth and returned
accessToken/refreshToken directly in the JSON response body, which any
client-side code would have to store somewhere readable by JS
(localStorage/sessionStorage/a variable) — an XSS token-theft risk.
No signin/signup UI was wired up to these routes yet, but the
insecure pattern was live in the API layer.

Ports the httpOnly-cookie session pattern already shipped in
praycalc/web (ADR-010, no-localstorage-token): tokens are now set as
httpOnly/secure/sameSite=lax cookies (ci_access_token/ci_refresh_token)
by same-origin /api/auth/* routes, and only a non-sensitive profile
(email/displayName/initials + a plain expiry timestamp) is kept in
localStorage. Adds the missing refresh.ts and signout.ts routes so a
session can actually be renewed/ended.

Also migrates TutorIsland, SettingsIsland, and the tutor API routes
(progress/message/session) off the chatislam_token localStorage/Bearer
pattern onto the httpOnly cookie, and fixes signin/signup to read
import.meta.env.PUBLIC_AUTH_URL (the actually-wired env var per
astro.config.ts + .env.example) instead of the dead
process.env.NEXT_PUBLIC_AUTH_URL leftover from this app's pre-Astro
Next.js code.

JWT signature verification for the tutor routes' cookie-sourced token
is tracked as a separate follow-up, not bundled into this change.
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ummat-chatislam Ignored Ignored Preview Jul 6, 2026 8:09pm

Request Review

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🟢 Rampart Security Gate — CLEAN

Tool CRITICAL HIGH MEDIUM LOW
(no findings)

Totals: 0 critical · 0 high · 0 medium · 0 low

Mode: RAMPART_ENFORCE=warn

…idebar

useFeynman.ts and ChatSidebar.tsx still read a raw bearer token from
localStorage (chatislam_token) and sent it as an Authorization header,
the same pattern already fixed in TutorIsland/SettingsIsland/tutor
routes by the prior commit on this branch.

useFeynman.ts now sends credentials: 'same-origin' to /api/research so
the httpOnly ci_access_token cookie is used instead; /api/research's
parseUserId is updated to read the cookie (via readAccessToken) rather
than an Authorization header, matching tutor/progress.ts and
tutor/session.ts.

ChatSidebar.tsx called Hasura directly from the client with the raw
token — architecturally incompatible with an httpOnly cookie, since JS
can never read it. Added GET /api/chat/sessions as a same-origin proxy
(same admin-secret + user_id-filtered pattern as tutor/progress.ts) so
the component can drop the client-held token entirely. Sign-in gating
in both components now uses getSession() from @/lib/session.
@acamarata
acamarata merged commit 948dcc8 into main Jul 6, 2026
19 checks passed
@acamarata
acamarata deleted the fix/security-httponly-cookie-tokens branch July 6, 2026 20:12
acamarata added a commit that referenced this pull request Sep 2, 2026
…laims

Six API routes each hand-rolled a base64 decode of the JWT payload and read
`x-hasura-user-id` straight out of it without ever calling jwtVerify(). No
signature check means a forged token grants any identity: full auth bypass on
consent, research, chat/sessions, tutor/message, tutor/progress and
tutor/session. chat/sessions.ts even carried the comment "no signature
verification here — tracked as a separate follow-up".

All six now delegate to a shared, signature-verified helper
(src/lib/auth/verify-jwt.ts — jose.jwtVerify, HS256 only), mirroring
ummat/app/web's getSession() reference pattern (S12-03/H1). The helper fails
CLOSED: a missing HASURA_GRAPHQL_JWT_SECRET re-throws rather than returning
null, so a misconfigured deployment cannot silently fall back to trusting
unverified claims. Documented in web/.env.example.

PR #26 moved these tokens from the response body into httpOnly cookies, which
stops client-side theft but does nothing about forgery — the cookie value was
still decoded unverified. This closes that half.

Note chat/sessions.ts is NOT in the original local version of this fix; it
arrived with #26 afterwards. Re-applying against current main instead of
force-resolving the rebase conflict is what caught it, so this covers six
routes rather than five.

Verified: tsc --noEmit clean (0 errors); 21 test files / 402 tests pass,
including 10 new verify-jwt cases covering forged, expired and malformed
tokens. No raw `split('.')[1]` decode remains anywhere under web/src.
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