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 token — opaque UUID4 string, persisted as a row in the
refresh_tokentable. Default lifetime is 7 days (REFRESH_TOKEN_EXPIRE_DAYS). - Signing key:
JWT_SECRETenv 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.tsxandMFAVerification.tsx— see../OPERATIONS/admin-ui.md(Security & audit) and../OPERATIONS/ui-flows.md§ Login + MFA. - Player-client:
services/player-client/src/contexts/AuthContext.tsxtreats loginrequires_mfa: trueasMFARequiredErrorand retries withmfa_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¶
- Frontends send
Authorization: Bearer <access_token>on every API request. - Each protected route depends on
get_current_userfromservices/gameserver/src/auth/dependencies.py. That dependency: - Pulls the bearer token via
OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login/direct"). - Calls
decode_token(fromauth/jwt.py) whichjwt.decodes with the sharedJWT_SECRETandHS256. - Looks up
Userby thesubclaim, filtering out soft-deleted users (User.deleted == False) and inactive users. - On any failure raises
401 UnauthorizedwithWWW-Authenticate: Bearer. - Admin-only routes wrap that in
get_current_admin_user, which adds anis_admincheck (raises403otherwise).
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_PASSWORDfrom settings. - Hashes the password with Argon2 via
passlib'sCryptContext(configured incore/security.py). - Inserts a
Userrow withis_admin=Trueand a correspondingadmin_credentialsrow, 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_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 carriesAuthorization: Bearer <access_token>(see "How requests are authenticated"). Placing tokens in an httpOnly,Secure,SameSite=Laxcookie instead oflocalStorageis 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/localStoragemodel.
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, ...)— seeservices/gameserver/src/models/user.py. Thedeletedcolumn is checked inget_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 aUserto one or more providers.refresh_token(id, user_id, token, expires_at, revoked, created_at)— the gameserver consultsrevoked == Falseand anis_expiredproperty 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_SECRETis required and validated at boot.- Refresh-on-rotation: every
/auth/refreshrevokes 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 toFRONTEND_URL+*.app.github.dev+*.repl.cootherwise.