Skip to content

Authentication

All authentication is handled by the gameserver. Frontends carry tokens but never mint them. This document describes the token lifecycle, OAuth providers, TOTP multi-factor authentication, and the FastAPI middleware that enforces auth on every request.

Who issues what

  • Tokens are issued by the gameserver only. All issuance happens in services/gameserver/src/auth/jwt.py.
  • Two token types:
  • Access token — JWT signed with HS256, payload { "sub": <user_uuid>, "exp": <utc> }. Default lifetime is 60 minutes (ACCESS_TOKEN_EXPIRE_MINUTES, configurable via env).
  • Refresh tokenopaque UUID4 string, persisted as a row in the refresh_token table. Default lifetime is 7 days (REFRESH_TOKEN_EXPIRE_DAYS).
  • Signing key: JWT_SECRET env var, must be ≥32 characters; the gameserver refuses to start otherwise (services/gameserver/src/core/config.py).

Refresh tokens are plain UUIDs stored in refresh_token.token (see auth/jwt.py's create_refresh_token) — they are not JWTs. There is no sliding window; the row is rotated on use (auth.py /refresh revokes the old row and issues a new one).

Authentication routes

All under /api/v1/auth (services/gameserver/src/api/routes/auth.py).

Method Path Purpose
POST /auth/login Admin/player login via OAuth2 form data. Tries admin first, falls back to player.
POST /auth/login/json Same but JSON body.
POST /auth/login/direct Used as the OAuth2 tokenUrl in dependencies.py.
POST /auth/player/login, /auth/player/login/json Player-only login paths (used by player-client).
POST /auth/refresh Validate refresh token, revoke it, issue a new access + refresh pair (rotation).
POST /auth/logout Revoke a refresh token.
GET /auth/me Return the current user (id, username, email, is_admin, is_active, last_login).
POST /auth/me/token Resolve a user from a token in the body — used by some internal flows.
GET /auth/github, /auth/google, /auth/steam OAuth login redirect entry points.
GET /auth/github/callback, /auth/google/callback, /auth/steam/callback Provider redirect handlers.
POST /auth/exchange Consume a single-use OAuth code from the callback redirect and return { access_token, refresh_token, user_id, is_new_user } in the response body (see "Frontend auth flow").

The route file also exposes OPTIONS handlers on the auth endpoints for CORS preflight in Codespaces.

Password/OAuth2 login is not MFA-blind: _mfa_gate_and_mint_tokens in auth.py (wired on /auth/login, /auth/login/json, /auth/login/direct, and the player-only login twins) accepts optional mfa_code. If the user has MFA enabled and no/invalid code is supplied, the response is requires_mfa: true with no tokens. That gate is independent of the /auth/mfa/* management router below.

Multi-factor authentication (TOTP)

Shipped gameserver surface — a reader must not conclude auth is password/OAuth-only. Router services/gameserver/src/api/routes/mfa.py (APIRouter(prefix="/auth/mfa")) is included from services/gameserver/src/api/api.py with no extra prefix, so paths sit next to /auth/login under settings.API_V1_STR (typically /api/v1). Implementation: services/gameserver/src/services/mfa_service.py. Persistence: models/mfa.py (MFASecret / MFAAttempt — see ../DATA_MODELS/player.md).

All /auth/mfa/* routes depend on get_current_user (authenticated bearer), not get_current_admin_user. This section is the live route contract only; it does not add, relax, or rewrite MFA enforcement.

Method Path Purpose
POST /auth/mfa/generate Create a TOTP secret; returns secret, setup_url, qr_code_data_url.
POST /auth/mfa/verify Confirm enrollment with body code (TOTP, 6–8 chars). On success may return backup_codes.
POST /auth/mfa/check Login-time check: TOTP or backup code. Logs client IP / User-Agent when the request provides them.
GET /auth/mfa/status enabled, verified, backup_codes_remaining, last_used.
POST /auth/mfa/disable Disable MFA for the current user.
GET /auth/mfa/backup-codes Remaining backup codes (404 if MFA is not enabled).
POST /auth/mfa/regenerate-backup-codes Issue a new backup-code set; the route docstring states prior codes are invalidated.
GET /auth/mfa/attempts Recent attempts; query hours (default 24).

UI (tip-verified): not gameserver-only.

  • Admin-ui: services/admin-ui/src/components/auth/MFASetup.tsx and MFAVerification.tsx — see ../OPERATIONS/admin-ui.md (Security & audit) and ../OPERATIONS/ui-flows.md § Login + MFA.
  • Player-client: services/player-client/src/contexts/AuthContext.tsx treats login requires_mfa: true as MFARequiredError and retries with mfa_code. No dedicated player MFA-setup component was found beside that login path; enrollment/management for an authenticated user is the /auth/mfa/* API.

Operator runbook (schema notes, backup-code counts in MFAService): ../OPERATIONS/mfa.md. This architecture page is the route + login-gate contract; do not treat the two pages as duplicates of the endpoint table.

How requests are authenticated

  1. Frontends send Authorization: Bearer <access_token> on every API request.
  2. Each protected route depends on get_current_user from services/gameserver/src/auth/dependencies.py. That dependency:
  3. Pulls the bearer token via OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login/direct").
  4. Calls decode_token (from auth/jwt.py) which jwt.decodes with the shared JWT_SECRET and HS256.
  5. Looks up User by the sub claim, filtering out soft-deleted users (User.deleted == False) and inactive users.
  6. On any failure raises 401 Unauthorized with WWW-Authenticate: Bearer.
  7. Admin-only routes wrap that in get_current_admin_user, which adds an is_admin check (raises 403 otherwise).

When an access token expires, the frontend hits /auth/refresh with the refresh token; the server revokes the old refresh row and issues a fresh access + refresh pair. The frontend stores both tokens (the React AuthContext in each UI handles this; see services/player-client/src/auth/ and services/admin-ui/src/auth/).

Admin bootstrap

On startup, services/gameserver/src/main.py calls create_default_admin (services/gameserver/src/auth/admin.py). It:

  • Reads ADMIN_USERNAME / ADMIN_PASSWORD from settings.
  • Hashes the password with Argon2 via passlib's CryptContext (configured in core/security.py).
  • Inserts a User row with is_admin=True and a corresponding admin_credentials row, if no admin exists yet.

Admin credentials are stored in the dedicated admin_credentials table — separate from user so OAuth users can never accidentally end up with a password column.

OAuth providers

Configured in services/gameserver/src/auth/oauth.py. All three providers follow the same pattern: redirect with a state parameter, validate state on callback (CSRF protection — see _validate_oauth_state / _generate_oauth_state), exchange code for an access token, then either look up the linked oauth_account row or create a new User + oauth_account.

Provider Env vars Notes
GitHub CLIENT_ID_GITHUB, CLIENT_SECRET_GITHUB Variable names deliberately avoid the GITHUB_ prefix because GitHub Codespaces reserves it. There are GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET properties on Settings for backwards compatibility.
Google GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET OpenID-style flow.
Steam STEAM_API_KEY Steam OpenID, not OAuth2 — implemented via SteamAuth in oauth.py; no code param on callback.

State storage is in-memory with a 10-minute TTL (_oauth_states). The code comments note that for multi-instance production this should be moved to Redis.

OAuth login URL pattern (verified from the route file):

  • Start: GET /api/v1/auth/{provider} (optionally ?register=true).
  • Callback: GET /api/v1/auth/{provider}/callback?code=...&state=....

When wiring up provider apps, use the route file ordering: provider segment first, then callback.

Frontend auth flow

  • Both UIs maintain an AuthContext (React) that stores tokens and user state (services/player-client/src/auth/, services/admin-ui/src/auth/).
  • The player client kicks off OAuth by redirecting window.location.href = '/api/v1/auth/{provider}'.
  • The callback handler in the gameserver mints the access + refresh JWTs as usual, stores them server-side under a short-lived (60s), single-use code, and redirects the browser to the frontend with only ?code=<uuid>&user_id=<id>&is_new_user=<bool> on the URL — no tokens ever appear in the URL, browser history, referrer headers, or URL-capturing logs (ADR-0085).
  • The SPA POSTs that 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 in 60 seconds, so a leaked callback URL is worthless.
  • Both tokens are then stored in localStorage; every subsequent request carries Authorization: Bearer <access_token> (see "How requests are authenticated"). Placing tokens in an httpOnly, Secure, SameSite=Lax cookie instead of localStorage is recorded as a deferred future hardening option per ADR-0085 — it would require adding CSRF protection to every state-changing route and re-authenticating the WebSocket from a cookie, which is out of scope until the app migrates off the Bearer/localStorage model.

Security middleware

services/gameserver/src/api/middleware/security.py registers (via setup_security_middleware):

  • OWASP-aligned response headers (HSTS, X-Frame-Options, X-Content-Type-Options, CSP with nonces).
  • Rate limiting on auth endpoints.
  • Input validation hooks.
  • Audit logging.

It runs in services/gameserver/src/main.py after the standard CORS / TrustedHost middleware. Failures during registration are logged but non-fatal — production deploys should treat that warning as a hard error.

Token / user data model (verified columns)

  • user(id, username, email, is_active, is_admin, deleted, last_login, created_at, updated_at, ...) — see services/gameserver/src/models/user.py. The deleted column is checked in get_current_user, confirming the soft-delete pattern.
  • admin_credentials(user_id, password_hash, last_password_change) — Argon2-hashed.
  • oauth_account(id, user_id, provider, provider_user_id, provider_account_email, provider_account_username, access_token, refresh_token, expires_at, ...) — links a User to one or more providers.
  • refresh_token(id, user_id, token, expires_at, revoked, created_at) — the gameserver consults revoked == False and an is_expired property when validating a refresh.

Constraints

  • All gameplay endpoints require a valid bearer token (enforced via FastAPI dependencies).
  • Admin endpoints additionally require is_admin == True.
  • JWT_SECRET is required and validated at boot.
  • Refresh-on-rotation: every /auth/refresh revokes the supplied token before issuing the new pair.
  • Soft-deleted users cannot authenticate (filter in get_current_user).
  • OAuth state is single-use, 10-minute TTL.
  • CORS is wide open in DEVELOPMENT_MODE; restricted to FRONTEND_URL + *.app.github.dev + *.repl.co otherwise.