Skip to content

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

Status

Folded into ARCHITECTURE/auth.md — single-use OAuth ?code exchange is the live Frontend auth flow (callbacks emit code-only; SPA consumes via POST /api/v1/auth/exchange). This ADR retains the Bearer/localStorage vs httpOnly rationale and decision history. (re-verified 2026-08-04)

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).