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.tsreadsaccessTokenfromlocalStorageand setsAuthorization: Beareron every request; the 401 refresh flow reads/writeslocalStorage.websocket.tsauthenticates from thelocalStoragetoken (query param on the WS handshake);aiTradingService.tsand other services do the same.- admin-ui has its own equivalent Bearer/
localStorageauth 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:
- 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. - 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. - 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/exchangeendpoint is additive and safe to deploy immediately; the player-client callback consumer is backward-compatible (preferscode, 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-callbackconsumer (admins authenticate by password), so no cross-lane coordination was required for the flip. ARCHITECTURE/auth.mdshould be updated to describe code-exchange as the implemented mechanism, with httpOnly cookies noted as future hardening (Max-gated prose change).