Skip to content

0085 — OAuth tokens out of the redirect URL via single-use code exchange

Status

Accepted (Max 2026-06-19). Resolves the pending decision oauth-token-redirect-exposure and the audit finding that the OAuth callback exposed access_token + refresh_token in the redirect URL query string, where tokens land in browser history, Referer headers, and URL-capturing logs — defeating the security property ARCHITECTURE/auth.md claims.

Built + proven live. All three OAuth callbacks emit a single-use ?code with no tokens in the URL (auth.py, commit 5209213); the player-client consumes the code via POST /api/v1/auth/exchange (OAuthCallback.tsx, commit 3eef06e, with a single-use guard against a StrictMode double-exchange). Proven on stage: a fresh GitHub login succeeds and the redirect carries only ?code=<single-use>, and /auth/exchange rejects a bogus code with a clean 400.

Context

ARCHITECTURE/auth.md prescribes the fix as "tokens placed in an httpOnly Secure SameSite=Lax cookie; only user_id in the redirect URL." That is the strongest posture against XSS token theft in the abstract. But this codebase is a thoroughly Bearer / localStorage SPA architecture:

  • player-client/src/services/apiClient.ts reads accessToken from localStorage and sets Authorization: Bearer on every request; the 401 refresh flow reads/writes localStorage.
  • websocket.ts authenticates from the localStorage token (query param on the WS handshake); aiTradingService.ts and other services do the same.
  • admin-ui has its own equivalent Bearer/localStorage auth context.

Retrofitting httpOnly cookies would require: ripping Bearer/localStorage out of both clients, switching every call to withCredentials, re-authenticating the WebSocket from a cookie, and — critically — adding CSRF protection to every state-changing route (a cookie is sent automatically, which is exactly the CSRF vector that Authorization headers are immune to). Done partially or hastily on the highest-risk surface, that migration is net less secure than the status quo, not more.

Decision

Adopt the OAuth authorization-code exchange pattern instead of putting tokens in the URL:

  1. The OAuth callback mints the JWTs as today, stores them server-side under a short-lived (60 s), single-use code, and redirects with only ?code=<uuid>&user_id=<id>&is_new_user=<bool>no tokens in the URL.
  2. The SPA POSTs the code to POST /api/v1/auth/exchange { code } exactly once and receives { access_token, refresh_token, user_id, is_new_user } in the response body. The code is consumed on first use and expires fast, so a leaked callback URL is worthless.
  3. Everything downstream is unchanged: tokens still live in localStorage, the Bearer header model, the refresh flow, and WS auth are all retained.

This eliminates the documented vulnerability (tokens in URL → history / Referer / logs) with near-zero new attack surface — no CSRF/CORS/WS rework.

httpOnly cookies are not abandoned — they are recorded here as the preferred future hardening if/when the app migrates off localStorage (at which point the CSRF/WS work is justified and scoped on its own). For the current architecture, code-exchange is the most secure change that can be delivered correctly.

Code store

In-memory single-use map with a 60 s TTL (src/auth/oauth.py store_auth_code / consume_auth_code), mirroring the existing _oauth_states CSRF-state store. Multi-instance production must move this to Redis (same caveat the OAuth state store already carries).

Consequences

  • The /auth/exchange endpoint is additive and safe to deploy immediately; the player-client callback consumer is backward-compatible (prefers code, falls back to legacy URL tokens) so it deploys with no coordination.
  • The leak is closed: the server callbacks emit code-only and the player-client consumes the code. admin-ui has no /oauth-callback consumer (admins authenticate by password), so no cross-lane coordination was required for the flip.
  • ARCHITECTURE/auth.md should be updated to describe code-exchange as the implemented mechanism, with httpOnly cookies noted as future hardening (Max-gated prose change).