Skip to content

fix(auth): move browser sessions to HttpOnly cookies with CSRF origin guard - #286

Open
Ilyas-ek wants to merge 2 commits into
Open-TutorAi:mainfrom
Ilyas-ek:fix/cookie-auth
Open

fix(auth): move browser sessions to HttpOnly cookies with CSRF origin guard#286
Ilyas-ek wants to merge 2 commits into
Open-TutorAi:mainfrom
Ilyas-ek:fix/cookie-auth

Conversation

@Ilyas-ek

@Ilyas-ek Ilyas-ek commented Jul 4, 2026

Copy link
Copy Markdown

This PR closes the known platform-wide limitation where the session JWT was kept in
localStorage — readable by any script running on the page, so a single XSS bug anywhere
in the app could exfiltrate sessions. Browser sessions now live in an HttpOnly,
SameSite=Lax cookie
that page scripts cannot read, with a CSRF origin guard on
cookie-authenticated mutations (the protection that becomes relevant once cookies are the
auth channel).

How it works

  • Signin / signup set the session cookie server-side; signout clears it. The SPA
    never persists the token anywhere script-readable.
  • get_current_user: an explicit Authorization: Bearer token wins (tests, curl,
    SDKs state exactly who they are); otherwise the cookie authenticates. Legacy
    Bearer null/undefined headers from pre-cookie UI code fall through to the cookie,
    so nothing breaks during the transition.
  • CSRF: on any non-safe method carrying the session cookie, the Origin header must
    match this host or a configured CORS origin — second line of defence behind
    SameSite=Lax. Bearer-only clients are unaffected.
  • Socket.IO handshake authenticates via the cookie (auth payload / query token still
    accepted for tools).
  • Same-origin by construction: production already serves the SPA from FastAPI; dev
    now routes /api, /auths and /realtime through the Vite proxy (BACKEND_URL env
    overrides the target for Docker). Because every call is same-origin, the cookie rides
    along automatically — no per-fetch credentials churn across the ~290 existing fetch
    calls, and any future fetch is covered by default.
  • POST /auths/cookie exchanges a bearer token (e.g. OAuth URL fragment) for a cookie
    session.
  • AUTH_COOKIE_SECURE=true env flag adds the Secure attribute for TLS deployments
    (off by default so plain-HTTP local/LAN dev keeps working).

Compatibility

  • API clients, tests, and tools using Authorization: Bearer work unchanged.
  • Users signed in before this change simply sign in once more (the old localStorage
    token is ignored and cleaned up).
  • The legacy /ws proxy entry was removed from Vite config (the backend rejects that
    path by design).

How to test

  1. uvicorn main:app --port 8080 + cd ui && npm run dev, open http://localhost:5173.
  2. Sign in → DevTools → Application → Cookies: token cookie marked HttpOnly
    and localStorage contains no token.
  3. In the console: document.cookie → the session token is not there (that's the fix).
  4. Reload the page → still signed in (cookie session). Sign out → cookie gone,
    redirected to /auth.
  5. Realtime still connects (check the Network tab: /realtime/socket.io handshake, no
    token in the request payload).
  6. Backend suite: pytest -q → all green, including the new tests/test_cookie_auth.py
    (cookie issuing/auth/signout, bearer compatibility and precedence, bearer→cookie
    exchange, CSRF allow/block/bypass, handshake cookie parsing).

UI changes

None visually — auth flow behaves identically; only the storage mechanism changed.

Breaking changes

None for the UI or API consumers. Deployments that terminate TLS should set
AUTH_COOKIE_SECURE=true.

… guard

Session tokens no longer live in localStorage, closing the XSS-exfiltration
path: signin/signup now set an HttpOnly, SameSite=Lax session cookie, and the
SPA never persists the JWT anywhere page scripts can read.

Backend:
- signin/signup/login set the session cookie; signout clears it
- get_current_user: explicit Authorization: Bearer wins (tests, tools, API
  clients); otherwise the cookie authenticates. Legacy "Bearer null/undefined"
  headers from pre-cookie UI code fall through to the cookie
- POST /auths/cookie exchanges a bearer token (e.g. OAuth URL fragment) for a
  cookie session
- CSRF middleware: non-safe methods carrying the session cookie must present
  an Origin matching this host or a configured CORS origin (second line of
  defence behind SameSite=Lax); bearer-only clients unaffected
- Socket.IO handshake accepts the session cookie (auth payload and query token
  still work for tools)
- AUTH_COOKIE_SECURE env opt-in for TLS deployments

Frontend:
- all API calls are same-origin: production already is (FastAPI serves the
  SPA); dev now routes /api, /auths and /realtime through the Vite proxy
  (BACKEND_URL overrides the target for Docker) — so the cookie rides along
  on every fetch with zero per-call credentials handling
- auth page/layout: no localStorage.token writes; session is probed via
  cookie on load; stale legacy tokens are cleaned up
- socket connects same-origin with withCredentials; no token in the handshake

Tests: new tests/test_cookie_auth.py (cookie issuing, cookie auth, signout,
bearer compatibility and precedence, bearer-to-cookie exchange, CSRF allow/
block/bypass cases, handshake cookie parsing); full backend suite green.
@Ilyas-ek
Ilyas-ek requested a review from pr-elhajji as a code owner July 4, 2026 00:45
@Eziane

Eziane commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Salam @Ilyas-ek,
Reviewed the cookie-auth change. Good fix, nice test coverage. Two things I'd fix before merging:

1. CSRF check will silently break once this runs behind real HTTPS
gateway/http/app.py, line ~149 (csrf_origin_check)

It allows the request if Origin == request.url.scheme + host. Problem: in production this app sits behind a reverse proxy that terminates HTTPS, and Uvicorn only ever sees plain HTTP internally (no --proxy-headers set anywhere). So request.url.scheme will always be "http", never "https" — meaning this same-origin check can never match once deployed on a real domain. Every POST that carries the session cookie (password update, etc.) would get blocked with 403 Origin not allowed, unless someone manually sets CORS_ALLOW_ORIGIN to the exact prod URL.

Fix: make the check aware of X-Forwarded-Proto from the proxy, or at least add a big note telling whoever deploys this that CORS_ALLOW_ORIGIN must be set to the real domain.

2. New AUTH_COOKIE_SECURE env var isn't documented anywhere
config/settings.py, line 54

It's not in .env.example or CLAUDE.md's env var table. Whoever deploys this to a real server won't know it exists, so cookies could go out without the Secure flag over HTTPS. Just add one line to both.

Everything else looks solid — bearer-vs-cookie priority logic is correct, test_cookie_auth.py covers the important cases well, and moving to same-origin URLs on the frontend is a clean way to make the cookie work.

@pr-elhajji

Copy link
Copy Markdown
Contributor

Thank you for this contribution and for your continued involvement in the project 🙌
Your work is appreciated and contributes positively to the evolution of OpenTutorAI.
Keep up the great effort!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the browser authentication channel from a script-readable JWT (previously stored in localStorage) to an HttpOnly, SameSite=Lax cookie, and introduces a CSRF origin guard for cookie-authenticated mutations. It also updates the SvelteKit dev setup to keep API/realtime calls same-origin via Vite proxying, and adds backend tests to validate cookie auth + CSRF behavior.

Changes:

  • Set/clear an HttpOnly auth cookie on signin/signup/signout and authenticate requests via cookie fallback when no explicit Bearer token is provided.
  • Add CSRF origin checking middleware for non-safe, cookie-authenticated requests.
  • Update UI + Vite dev proxy to rely on same-origin requests (relative API URLs) and Socket.IO cookie handshake; add cookie-auth test coverage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
ui/vite.config.ts Proxies /api, /auths, /realtime to backend to keep dev traffic same-origin for cookie auth.
ui/src/routes/auth/+page.svelte Stops persisting tokens to localStorage; adds bearer→cookie exchange for OAuth fragment flow.
ui/src/routes/+layout.svelte Socket.IO handshake switches to cookie-based auth; session probe now uses cookie path.
ui/src/lib/constants.ts Makes backend URLs relative to enforce same-origin calls in dev/prod.
ui/src/lib/apis/auths/index.ts Adds establishCookieSession() helper to exchange Bearer token for cookie session.
tests/test_files.py Updates anonymous-access assertion now that auth failures return 401 and signup sets cookies.
tests/test_cookie_auth.py Adds coverage for cookie issuance, bearer precedence, CSRF origin check, and realtime cookie parsing.
gateway/realtime/socket.py Accepts session token from HttpOnly cookie during Socket.IO handshake.
gateway/http/routers/auth.py Sets auth cookie on signin/signup; adds /auths/cookie; clears cookie on signout; returns tokenless session user payload.
gateway/http/dependencies.py Prefers explicit Bearer tokens, otherwise authenticates via auth cookie; treats Bearer null/undefined as absent.
gateway/http/app.py Adds CSRF origin guard middleware for cookie-authenticated non-safe requests.
config/settings.py Adds cookie settings (name, secure flag, samesite).
Comments suppressed due to low confidence (1)

ui/src/routes/auth/+page.svelte:65

  • setSessionUser() still emits user-join with sessionUser.token. After this PR, GET /api/v1/auths/ (used by the OAuth fragment flow) no longer returns a token, so this becomes undefined and the event is ignored server-side. It also unnecessarily re-exposes JWTs to page scripts even though Socket.IO connect can authenticate via the HttpOnly cookie.
			if ($socket) {
				$socket.emit('user-join', { auth: { token: sessionUser.token } });
			}

Comment on lines +166 to 169
// Exchange the fragment token for an HttpOnly cookie session instead of
// persisting it anywhere page scripts could read it.
await establishCookieSession(token).catch(() => {});
await setSessionUser(sessionUser);
Comment thread ui/src/routes/+layout.svelte Outdated
}
// Probe the session — the HttpOnly cookie (sent automatically) decides
// whether we're signed in; nothing is read from localStorage.
const sessionUser = await getSessionUser('').catch(() => null);
Comment thread ui/src/routes/+layout.svelte Outdated
Comment on lines +493 to +495
// Save Session User to Store
$socket.emit('user-join', { auth: { token: sessionUser.token } });

Comment thread gateway/http/app.py Outdated
Comment on lines +145 to +158
if request.method not in ("GET", "HEAD", "OPTIONS") and request.cookies.get(
settings.AUTH_COOKIE_NAME
):
origin = request.headers.get("origin")
if origin:
allowed = origin in settings.cors_origins_list or origin == (
f"{request.url.scheme}://{request.url.netloc}"
)
if not allowed:
return JSONResponse(
status_code=403,
content={"detail": "Origin not allowed"},
)
return await call_next(request)
Comment thread tests/test_cookie_auth.py Outdated
Comment on lines +4 to +6
Signin/signup set an HttpOnly, SameSite=Lax session cookie; `get_current_user`
reads the cookie first and falls back to `Authorization: Bearer` (tests, tools).
A CSRF origin check guards cookie-authenticated mutations, and the Socket.IO
Comment on lines +371 to +375
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
Addresses review findings (Eziane + Copilot) on the cookie-auth PR:

- CSRF check behind a TLS-terminating proxy: the same-origin comparison now
  honours X-Forwarded-Proto/Host, so it no longer sees "http" internally and
  wrongly 403s legitimate https POSTs. CORS_ALLOW_ORIGIN stays the explicit
  allowlist for the deployed domain.
- Closed the "missing Origin" bypass: a cookie-only unsafe request must present
  an Origin/Referer that matches — absent is rejected too. The guard now only
  runs for cookie-*only* requests (a Bearer token isn't a CSRF vector), which
  also keeps bearer clients/tests unaffected. Auth-establishment endpoints
  (signin/signup/login) are exempt — they don't act on an existing session.
- Frontend nits: getSessionUser() omits the Authorization header entirely when
  there's no token (no empty "Bearer "); removed the dead user-join emit that
  sent an undefined token and instead reconnect the socket after login so the
  handshake carries the fresh cookie; establishCookieSession failures now
  surface (toast + stop) instead of being swallowed, and report non-JSON errors.
- Docs: document AUTH_COOKIE_SECURE (.env.example + AGENTS.md) and note that
  CORS_ALLOW_ORIGIN must be the real origin in production for the CSRF check.
- Fixed the test_cookie_auth docstring precedence to match the impl (bearer
  first, cookie fallback); added tests for missing-Origin rejection and the
  X-Forwarded-Proto same-origin path.
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.

4 participants