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

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

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 completes the handshake by setting an httpOnly, Secure, SameSite=Lax cookie containing the refresh token, then redirecting the browser to the frontend with only the user id (and is_new_user) on the URL. The access token is fetched separately by the SPA via /auth/refresh once the cookie is present, so tokens never appear in browser history, referrer headers, or URL-capturing logs.

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.