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 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. |
The route file also exposes OPTIONS handlers on the auth endpoints for CORS preflight in Codespaces.
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 completes the handshake by setting an
httpOnly,Secure,SameSite=Laxcookie containing the refresh token, then redirecting the browser to the frontend with only the user id (andis_new_user) on the URL. The access token is fetched separately by the SPA via/auth/refreshonce 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, ...)— 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.