Skip to content

Authentication

The API authenticates two kinds of caller: browser sessions (dashboard and Training Center agents) and the Discord bot (BotGhost). They are distinct identities with distinct capabilities, and the system never conflates them.

Browser OAuth flow

text
Dashboard /login
   │  window.location → /auth/discord/login

Flask: generate state ("<env>.<random>"), store in session, redirect to Discord

Discord authorize → /auth/discord/callback?code=…&state=…

Flask: verify state (environment prefix + constant-time token compare)

Exchange code for token (OAuth proxy Worker preferred, direct fallback,
bounded retry, capped total sleep)

GET /users/@me → discord_id

Resolve against public.agents (demo: allowlist → fictional agent mapping)

Session: {discord_id} only → redirect to dashboard/training origin

Key properties:

  • State is bound to the environment. state = "<env>.<token_urlsafe(32)>", and the callback verifies the prefix against the request's environment and the stored session value. A production OAuth state can never complete a demo callback.
  • Only known agents sign in. The Discord ID must exist in agents and the row must be active or onboarding; otherwise the flow redirects back with an error (not_a_dps_agent, agent_not_active). This is the admission boundary — there is no self-registration.
  • The session stores only the Discord ID. No tokens, no profile data. Every request re-resolves the live agents row.
  • return_to is allowlist-validated (scheme+host must be a configured dashboard/training origin, path must start with /), preventing open redirects.
  • OAuth errors are surfaced as redirect/postMessage errors (discord_authorization_denied, oauth_state_mismatch, discord_redirect_uri_mismatch, …), never as raw stack traces.
  • Retry budget caps bound the time a login request can sleep during a Discord outage (30 s total), so a flood of logins cannot pin workers and starve bot queue pickups.

Session/token handling

  • Sessions are signed cookies (SecureCookieSessionInterface), not server state: SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE default true, SESSION_COOKIE_SAMESITE=Lax (keeps the OAuth state cookie available on the top-level redirect without third-party cookie support).
  • Cookies are environment-bound (EnvironmentSessionInterface): distinct cookie names (eccdps_production_session / eccdps_demo_session), distinct secrets, and an environment claim in the payload. Cross-environment cookies fail signature verification and the claim check.
  • The session is cleared on sign-out, on failed agent lookups, and at the start of a new login (preventing session fixation).
  • Session lifetime/rotation is not prominently configured beyond the signed cookie settings; treat session expiry as operationally confirmed behavior (see Troubleshooting).

API key (bot) authentication

  • The bot sends Authorization: <API_KEY> (no Bearer prefix).
  • verify_api_key() compares against the active environment's key with secrets.compare_digest (constant time) and caches per request.
  • What the key grants is route-specific and narrow:
    • may: report creation, generators, queue pickup/completion, contact respond, discord-profile ingestion, ER:LC lookups, onboarding agent field updates;
    • may not: training routes (403 — the key has no training role), punishment/discipline mutation routes (403), admin routes (403 — browser session required).
  • Migration 034 removed the bot's ability to set agent_rank — rank changes require a Director via the dashboard, closing the static-key privilege-escalation path.

Authentication boundaries

  1. The API is the only authenticator. The frontend never validates tokens; it calls /auth/me and renders what the API reports.
  2. The database does not authenticate browsers. Browser JWTs (anon key + Realtime token) carry role=authenticated with no data-table grants and RLS deny-all; they exist only for Realtime channel authorization.
  3. Environment selection is server-side (ingress hostname + optional HMAC assertion). A client cannot choose which environment it authenticates against by sending headers or parameters.
  4. Realtime tokens are short-lived and scoped. /training/realtime-token mints role=authenticated, sub=<discord_id>, TTL 600 s (configurable), signed with the environment's SUPABASE_JWT_SECRET; the frontend refreshes before expiry via setAuth.

Environment secrets

Authentication depends on secrets that exist only as environment variables on the hosting platforms. Names are listed in the Environment Variables reference (OAUTH_SESSION_SECRET / PRODUCTION_OAUTH_SESSION_SECRET, DISCORD_CLIENT_ID/SECRET, DISCORD_OAUTH_PROXY_URL/SECRET, API_KEY, SUPABASE_JWT_SECRET, *_INGRESS_SECRET). Rules:

  • Never log secret values; error paths log messages, never credentials.
  • Never expose server secrets to the browser (the anon key and the Realtime token are the only Supabase credentials a browser ever receives).
  • Rotate secrets via the platform consoles; the API fails fast at boot when the production session secret is missing.