LDAP
This commit is contained in:
Executable
+13
@@ -0,0 +1,13 @@
|
||||
# Copy to a private deployment environment/.env file. Never commit secrets.
|
||||
BUTTON_OIDC_ISSUER=https://dex.eom.dev
|
||||
BUTTON_OIDC_CLIENT_ID=the-button
|
||||
BUTTON_OIDC_CLIENT_SECRET=replace-with-dex-client-secret
|
||||
BUTTON_OIDC_REDIRECT_URI=https://button.eom.dev/auth/oidc/callback
|
||||
BUTTON_OIDC_REQUIRED_GROUP=the-button-users
|
||||
BUTTON_OIDC_ADMIN_GROUP=the-button-admins
|
||||
BUTTON_OIDC_SUBJECT_LINKS_JSON={}
|
||||
BUTTON_OIDC_ADMIN_SUBJECTS_JSON=[]
|
||||
BUTTON_OIDC_REGISTER_URL=https://lists.eom.dev/accounts/register/
|
||||
BUTTON_OIDC_COOKIE_SECURE=1
|
||||
# HTTP is allowed only in isolated local OIDC tests:
|
||||
# BUTTON_OIDC_ALLOW_HTTP=1
|
||||
@@ -1,4 +1,5 @@
|
||||
data.json
|
||||
.env
|
||||
data.tmp
|
||||
mailing_list.csv
|
||||
test-data.json
|
||||
|
||||
@@ -11,6 +11,8 @@ RUN useradd --system --uid 10001 --no-create-home button \
|
||||
&& mkdir /data && chown button /data
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY server.py ./
|
||||
COPY static ./static
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Inspired by Reddit's [The Button](https://en.wikipedia.org/wiki/The_Button_(Redd
|
||||
|
||||
## How to play
|
||||
|
||||
1. Sign up with a name and password (or log back in).
|
||||
1. Sign in with your eom.dev account through Dex. New accounts are registered in OpenLDAP from the eom.dev account portal.
|
||||
2. Press the button. The countdown resets to 24 hours.
|
||||
3. You score points based on the zone the countdown was in when you pressed. The longer you wait, the more you score, but if everyone waits too long, the game ends.
|
||||
|
||||
@@ -25,7 +25,7 @@ Higher-value zones are shorter, so scoring big is risky.
|
||||
|
||||
Every press records the zone, the points, and the exact remaining time in milliseconds. Players can view their own score, presses per zone, and press history at any time. A public live leaderboard shows all players ranked by total points, with press counts; ties are sorted alphabetically. Scores refresh every five seconds and immediately after you press or join. Detailed global stats (presses per zone, closest call, full press log) stay hidden until the game ends.
|
||||
|
||||
Logged-in players can also join the mailing list.
|
||||
Logged-in players can also join the separate email-only mailing list; that subscription does not require an LDAP/Dex account.
|
||||
|
||||
The Chat section is a simple public chatroom. Logged-in players can post plain-text messages (up to 200 characters, one message every two seconds). The latest 100 messages are displayed oldest first and refresh every five seconds while Chat is open. Messages persist in SQLite across restarts, including after the game ends. Administrators can remove messages through the Admin dashboard; players cannot edit messages.
|
||||
|
||||
@@ -63,7 +63,7 @@ The database fields `players.is_admin`, `players.is_banned`, and `players.is_del
|
||||
|
||||
Account deletion is **not** a comprehensive personal-data erasure tool: the separate mailing-list CSV, backups, exported data, and third-party copies are not edited automatically. Handle those separately when necessary. Audit identifiers and anonymized game history are deliberately retained. Dashboard controls refresh manually to avoid moving focused actions unexpectedly; access is still rechecked by the server each time. No game reset/end controls are included.
|
||||
|
||||
Admin access uses the existing bearer-token authentication, with cross-site fetch rejection on admin mutations and no permissive CORS. Use HTTPS behind a reverse proxy before exposing the service; administrators should log out on shared devices. The existing login failure limits apply, but there is no separate MFA or password reconfirmation flow yet.
|
||||
Admin access uses the existing per-game session authorization, with cross-site request rejection when Dex mode is enabled and no permissive CORS. In Dex mode the OIDC flow establishes a game session only after server-side token validation; its opaque token is held in an HttpOnly, SameSite cookie and is not exposed to JavaScript, a URL fragment, or localStorage. POST requests require an exact same-origin `Origin`. Use HTTPS behind a reverse proxy before exposing the service; administrators should log out on shared devices. Local-login failure limits apply only when OIDC is disabled; require MFA at Dex for privileged identities.
|
||||
|
||||
Admin API: authenticated admin-only `GET /api/admin`; `POST /api/admin/action` accepts `{action, target_id, value?}` for `set_admin`, `set_banned`, and `delete_player`, or `{action: "delete_message", target_id, at}`. Boolean flags must be actual JSON booleans; IDs/timestamps must match the selected record. Anonymous requests receive 401, non-admin accounts 403, invalid inputs 400, missing targets 404, and protected actions 409. All JSON responses use `Cache-Control: no-store`.
|
||||
|
||||
@@ -189,7 +189,7 @@ docker compose down -v # stop and delete all
|
||||
|
||||
### Without Docker
|
||||
|
||||
Requires Python 3.9+. No third-party packages.
|
||||
Requires Python 3.9+. Install `requirements.txt` before running. OIDC mode uses Authlib, requests, and JOSE verification; install the dependencies even when local development temporarily runs without Dex.
|
||||
|
||||
```sh
|
||||
python server.py
|
||||
@@ -219,6 +219,22 @@ Set via environment variables (in Docker, add them under `environment:` in `comp
|
||||
| `BUTTON_DATA_FILE` | `data.json` | Legacy JSON file to import if no saved SQLite game exists. |
|
||||
| `BUTTON_DATABASE_FILE` | `data.sqlite3` | SQLite game database; defaults to `BUTTON_DATA_FILE` with a `.sqlite3` suffix. |
|
||||
| `BUTTON_MAILING_LIST_FILE` | `mailing_list.csv` | Mailing list file. |
|
||||
| `BUTTON_OIDC_ISSUER` | unset | Dex issuer URL (HTTPS). Enables OIDC mode when configured. |
|
||||
| `BUTTON_OIDC_CLIENT_ID` | unset | Dex OIDC client ID registered for The Button. |
|
||||
| `BUTTON_OIDC_CLIENT_SECRET` | unset | Client secret; provide through a private deployment secret, not Git. |
|
||||
| `BUTTON_OIDC_REDIRECT_URI` | unset | Exact HTTPS callback URI registered in Dex, e.g. `https://button.eom.dev/auth/oidc/callback`. |
|
||||
| `BUTTON_OIDC_REQUIRED_GROUP` | unset | Required Dex `groups` claim value for The Button access. Required in OIDC mode. |
|
||||
| `BUTTON_OIDC_ADMIN_GROUP` | unset | Optional Dex group granting The Button administrator role. |
|
||||
| `BUTTON_OIDC_SUBJECT_LINKS_JSON` | `{}` | Temporary map from OIDC `sub` values to existing player IDs, preserving game history. |
|
||||
| `BUTTON_OIDC_ADMIN_SUBJECTS_JSON` | `[]` | Optional explicit Dex subject allowlist for bootstrap administrators. |
|
||||
| `BUTTON_OIDC_REGISTER_URL` | account portal URL | Link to account registration. |
|
||||
| `BUTTON_OIDC_COOKIE_SECURE` | `1` | Mark the OIDC state cookie Secure; disable only for isolated local tests. |
|
||||
|
||||
Copy `.env.example` to a private deployment environment and register the exact callback URI with Dex. Request `openid`, `profile`, `email`, and `groups` scopes; configure Dex to emit a stable `sub`, verified email, a usable `preferred_username`, and the required service group. OIDC mode disables `/api/register` and `/api/login`; when OIDC is not configured, the legacy local login remains available for development.
|
||||
|
||||
Use Dex `sub` as the immutable identity key. Do not automatically link a local player just because an email or display name matches. For score/history continuity, obtain the correct Dex subject through a trusted admin process, map it to that existing player ID with `BUTTON_OIDC_SUBJECT_LINKS_JSON`, have the player sign in once, then remove the temporary mapping after `oidc_sub` is saved. Never replace player IDs or rewrite press/season history. A name collision without an explicit subject link is rejected rather than silently merging accounts.
|
||||
|
||||
The Button validates the issuer, audience, signature (RS256), expiry, nonce, verified-email claim, and required group. Dex groups gate access; The Button retains its own scores, bans, and administrator roles. Only map the Button's admin group/subjects to administrators. Protect Dex/LDAP and callback traffic with TLS, keep the client secret private, and test sign-in, role changes, and revocation before rollout. Existing local accounts are not automatically migrated or deleted.
|
||||
|
||||
Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing.
|
||||
|
||||
@@ -241,19 +257,24 @@ Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing.
|
||||
| GET | `/api/stats` | | Global stats (403 until the game ends) |
|
||||
| POST | `/api/subscribe` | ✓ | `{first_name, email, last_name?, phone?}` |
|
||||
|
||||
Authenticated requests use `Authorization: Bearer <token>`.
|
||||
In local-login mode, authenticated requests use `Authorization: Bearer <token>`. In Dex mode, the browser uses the server-issued HttpOnly session cookie instead; JavaScript does not receive the bearer token.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Passwords are hashed with PBKDF2-SHA256 (600k iterations); session tokens are stored only as SHA-256 hashes.
|
||||
- Legacy local passwords are hashed with PBKDF2-SHA256 (600k iterations); session tokens are stored only as SHA-256 hashes.
|
||||
- Logins lock for 15 minutes after 5 failed attempts per name.
|
||||
- With Dex configured, legacy local login/register endpoints are disabled. New Button players are linked to stable Dex `sub` values; passwords remain in OpenLDAP and are never copied to The Button. Existing local hashes remain for migration/rollback until explicitly retired.
|
||||
- Configure `BUTTON_OIDC_REQUIRED_GROUP` to a Dex group backed by the appropriate OpenLDAP `groupOfUniqueNames`. The account portal updates that group only after confirming the registration email.
|
||||
- The account portal accepts only allowlisted service targets. After email confirmation, the password is sent via LDAP Password Modify over verified TLS; slapd generates the hash. Production provisioning is gated on `LDAP_EXPECTED_PASSWORD_SCHEME={ARGON2}`. This is an operator acknowledgement, not a live read of `cn=config`; independently verify the server's Argon2 module and `olcPasswordHash` setting.
|
||||
- Do not use MD5-crypt, `{CRYPT}`, MD5, or cleartext for new accounts. Current osixia OpenLDAP v2 documentation includes Argon2 support/defaults; v1 may require a deliberate data/config migration before enabling account provisioning.
|
||||
- Use `BUTTON_OIDC_SUBJECT_LINKS_JSON` only for explicit one-time legacy-account linking. Do not link by matching email or display name alone; a name collision without an administrator-approved subject mapping is rejected to protect player scores.
|
||||
- The server speaks plain HTTP. **Put it behind a TLS-terminating reverse proxy** (e.g. Caddy or nginx) before exposing it to the internet.
|
||||
- Registration and mailing-list signup are not rate limited.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
server.py Game server and API (Python standard library only)
|
||||
server.py Game server and API
|
||||
static/ Frontend (HTML, CSS, vanilla JS)
|
||||
Dockerfile Container image
|
||||
compose.yaml Docker Compose service and data volume
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
services:
|
||||
thebutton:
|
||||
build: .
|
||||
environment:
|
||||
BUTTON_OIDC_ISSUER: ${BUTTON_OIDC_ISSUER:-}
|
||||
BUTTON_OIDC_CLIENT_ID: ${BUTTON_OIDC_CLIENT_ID:-}
|
||||
BUTTON_OIDC_CLIENT_SECRET: ${BUTTON_OIDC_CLIENT_SECRET:-}
|
||||
BUTTON_OIDC_REDIRECT_URI: ${BUTTON_OIDC_REDIRECT_URI:-}
|
||||
BUTTON_OIDC_REQUIRED_GROUP: ${BUTTON_OIDC_REQUIRED_GROUP:-}
|
||||
BUTTON_OIDC_ADMIN_GROUP: ${BUTTON_OIDC_ADMIN_GROUP:-}
|
||||
BUTTON_OIDC_SUBJECT_LINKS_JSON: ${BUTTON_OIDC_SUBJECT_LINKS_JSON:-}
|
||||
BUTTON_OIDC_ADMIN_SUBJECTS_JSON: ${BUTTON_OIDC_ADMIN_SUBJECTS_JSON:-}
|
||||
BUTTON_OIDC_REGISTER_URL: ${BUTTON_OIDC_REGISTER_URL:-https://lists.eom.dev/accounts/register/}
|
||||
BUTTON_OIDC_COOKIE_SECURE: ${BUTTON_OIDC_COOKIE_SECURE:-1}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
Authlib>=1.6,<2
|
||||
joserfc>=1.0,<2
|
||||
requests>=2.32,<3
|
||||
@@ -1,9 +1,10 @@
|
||||
"""The Button - a single-file, dependency-free game server (Python 3.9+)."""
|
||||
"""The Button game server (Python 3.9+); OIDC dependencies are in requirements.txt."""
|
||||
|
||||
import csv
|
||||
import copy
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
@@ -12,14 +13,21 @@ import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from http.cookies import SimpleCookie
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
import requests
|
||||
from authlib.integrations.requests_client import OAuth2Session
|
||||
from joserfc import jwt
|
||||
from joserfc.jwk import KeySet
|
||||
|
||||
ROOT = Path(__file__).absolute().parent
|
||||
STATIC_DIR = ROOT / "static"
|
||||
DATA_FILE = Path(os.environ.get("BUTTON_DATA_FILE", ROOT / "data.json"))
|
||||
DATABASE_FILE = Path(os.environ.get("BUTTON_DATABASE_FILE", DATA_FILE.with_suffix(".sqlite3")))
|
||||
@@ -43,6 +51,21 @@ CHAT_HISTORY_LIMIT = 100
|
||||
CHAT_COOLDOWN_MS = 2000
|
||||
PRESENCE_TTL_SECONDS = 45
|
||||
MAX_PRESENCE_TABS_PER_SESSION = 10
|
||||
OIDC_ISSUER = os.environ.get("BUTTON_OIDC_ISSUER", "").rstrip("/")
|
||||
OIDC_CLIENT_ID = os.environ.get("BUTTON_OIDC_CLIENT_ID", "")
|
||||
OIDC_CLIENT_SECRET = os.environ.get("BUTTON_OIDC_CLIENT_SECRET", "")
|
||||
OIDC_REDIRECT_URI = os.environ.get("BUTTON_OIDC_REDIRECT_URI", "")
|
||||
OIDC_REQUIRED_GROUP = os.environ.get("BUTTON_OIDC_REQUIRED_GROUP", "")
|
||||
OIDC_ADMIN_GROUP = os.environ.get("BUTTON_OIDC_ADMIN_GROUP", "")
|
||||
OIDC_SUBJECT_LINKS = json.loads(os.environ.get("BUTTON_OIDC_SUBJECT_LINKS_JSON", "") or "{}")
|
||||
OIDC_REGISTER_URL = os.environ.get("BUTTON_OIDC_REGISTER_URL", "")
|
||||
OIDC_ADMIN_SUBJECTS = set(json.loads(os.environ.get("BUTTON_OIDC_ADMIN_SUBJECTS_JSON", "") or "[]"))
|
||||
OIDC_COOKIE_SECURE = os.environ.get("BUTTON_OIDC_COOKIE_SECURE", "1").lower() in {"1", "true", "yes"}
|
||||
OIDC_ENABLED = bool(OIDC_ISSUER or OIDC_CLIENT_ID or OIDC_CLIENT_SECRET or OIDC_REDIRECT_URI)
|
||||
if OIDC_ENABLED and not all((OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_REQUIRED_GROUP)):
|
||||
raise RuntimeError("OIDC requires BUTTON_OIDC_ISSUER, BUTTON_OIDC_CLIENT_ID, BUTTON_OIDC_CLIENT_SECRET, BUTTON_OIDC_REDIRECT_URI, and BUTTON_OIDC_REQUIRED_GROUP")
|
||||
if OIDC_ENABLED and urlparse(OIDC_ISSUER).scheme != "https" and os.environ.get("BUTTON_OIDC_ALLOW_HTTP", "0") != "1":
|
||||
raise RuntimeError("OIDC issuer must use HTTPS (HTTP is test-only via BUTTON_OIDC_ALLOW_HTTP=1)")
|
||||
|
||||
# Ordered from a freshly reset countdown to an almost-expired one.
|
||||
# "share" is each zone's relative slice of the timer, so high-value zones are brief.
|
||||
@@ -214,7 +237,7 @@ def new_game():
|
||||
"deadline": t + DURATION_MS,
|
||||
"ended": False,
|
||||
"ended_at": None,
|
||||
"players": {}, # id -> {name, password_hash, token_hashes, created_at}
|
||||
"players": {}, # id -> {name, password_hash, oidc_sub, token_hashes, created_at}
|
||||
"presses": [], # {player_id, at, remaining_ms, zone, points}
|
||||
}
|
||||
|
||||
@@ -238,9 +261,9 @@ def write_state(connection, state):
|
||||
connection.execute("DELETE FROM sessions")
|
||||
connection.execute("DELETE FROM players")
|
||||
connection.executemany(
|
||||
"""INSERT INTO players (id, name, password_hash, created_at, subscribed, is_admin, is_banned, is_deleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[(pid, p["name"], p.get("password_hash"), p["created_at"], p.get("subscribed", False),
|
||||
"""INSERT INTO players (id, name, password_hash, oidc_sub, created_at, subscribed, is_admin, is_banned, is_deleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[(pid, p["name"], p.get("password_hash"), p.get("oidc_sub"), p["created_at"], p.get("subscribed", False),
|
||||
p.get("is_admin", False), p.get("is_banned", False), p.get("is_deleted", False))
|
||||
for pid, p in state["players"].items()],
|
||||
)
|
||||
@@ -308,7 +331,7 @@ def sync_milestones(connection, game_state, at=None):
|
||||
|
||||
|
||||
def load_state():
|
||||
if DATABASE_FILE.resolve() == DATA_FILE.resolve():
|
||||
if DATABASE_FILE.absolute() == DATA_FILE.absolute():
|
||||
raise ValueError("BUTTON_DATABASE_FILE must differ from the legacy BUTTON_DATA_FILE")
|
||||
DATABASE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with database() as connection:
|
||||
@@ -334,6 +357,12 @@ def load_state():
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
PRIMARY KEY (player_id, position)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS oidc_transactions (
|
||||
state_hash TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS presses (
|
||||
position INTEGER PRIMARY KEY,
|
||||
player_id TEXT NOT NULL REFERENCES players(id),
|
||||
@@ -384,6 +413,9 @@ def load_state():
|
||||
);
|
||||
""")
|
||||
player_columns = {row["name"] for row in connection.execute("PRAGMA table_info(players)")}
|
||||
if "oidc_sub" not in player_columns:
|
||||
connection.execute("ALTER TABLE players ADD COLUMN oidc_sub TEXT")
|
||||
connection.execute("CREATE UNIQUE INDEX IF NOT EXISTS players_oidc_sub ON players(oidc_sub) WHERE oidc_sub IS NOT NULL")
|
||||
for field in ("is_admin", "is_banned", "is_deleted"):
|
||||
if field not in player_columns:
|
||||
# Fixed internal identifiers only; never interpolate request input.
|
||||
@@ -402,6 +434,7 @@ def load_state():
|
||||
for player in loaded["players"].values():
|
||||
# Legacy JSON is not an administrator provisioning mechanism.
|
||||
player.update(is_admin=False, is_banned=False, is_deleted=False)
|
||||
player.setdefault("oidc_sub", None)
|
||||
if "token_hash" in player: # migrate pre-login data as well
|
||||
player["token_hashes"] = [player.pop("token_hash")]
|
||||
else:
|
||||
@@ -421,6 +454,7 @@ def load_state():
|
||||
player = dict(row)
|
||||
pid = player.pop("id")
|
||||
player["subscribed"] = bool(player["subscribed"])
|
||||
player.setdefault("oidc_sub", None)
|
||||
for field in ("is_admin", "is_banned", "is_deleted"):
|
||||
player[field] = bool(player[field])
|
||||
player["token_hashes"] = []
|
||||
@@ -521,6 +555,153 @@ def validate_credentials(data):
|
||||
return name, password, None
|
||||
|
||||
|
||||
oidc_metadata = None
|
||||
oidc_metadata_loaded_at = 0.0
|
||||
|
||||
|
||||
def get_oidc_metadata(force=False):
|
||||
"""Load discovery/JWKS only from the configured HTTPS Dex issuer."""
|
||||
global oidc_metadata, oidc_metadata_loaded_at
|
||||
if not OIDC_ENABLED:
|
||||
raise RuntimeError("OIDC is not configured")
|
||||
if oidc_metadata is not None and not force and time.monotonic() - oidc_metadata_loaded_at < 300:
|
||||
return oidc_metadata
|
||||
issuer_url = OIDC_ISSUER + "/.well-known/openid-configuration"
|
||||
response = requests.get(issuer_url, timeout=(3, 5), allow_redirects=False)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
if metadata.get("issuer", "").rstrip("/") != OIDC_ISSUER:
|
||||
raise ValueError("OIDC issuer does not match discovery metadata")
|
||||
issuer_host = urlparse(OIDC_ISSUER).hostname
|
||||
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||
endpoint = metadata.get(key, "")
|
||||
parsed = urlparse(endpoint)
|
||||
if parsed.scheme != "https" or parsed.hostname != issuer_host:
|
||||
raise ValueError("OIDC discovery endpoint is not on the configured HTTPS issuer")
|
||||
response = requests.get(metadata["jwks_uri"], timeout=(3, 5), allow_redirects=False)
|
||||
response.raise_for_status()
|
||||
metadata["_jwks"] = response.json()
|
||||
oidc_metadata = metadata
|
||||
oidc_metadata_loaded_at = time.monotonic()
|
||||
return metadata
|
||||
|
||||
|
||||
def consume_oidc_transaction(state_value):
|
||||
"""Single-use state/nonce/PKCE transaction persisted in SQLite."""
|
||||
state_hash = hash_token(state_value)
|
||||
with database() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT nonce, code_verifier, expires_at FROM oidc_transactions WHERE state_hash = ?",
|
||||
(state_hash,),
|
||||
).fetchone()
|
||||
connection.execute("DELETE FROM oidc_transactions WHERE expires_at <= ?", (now_ms(),))
|
||||
if row is None or row["expires_at"] <= now_ms():
|
||||
connection.execute("DELETE FROM oidc_transactions WHERE state_hash = ?", (state_hash,))
|
||||
return None
|
||||
connection.execute("DELETE FROM oidc_transactions WHERE state_hash = ?", (state_hash,))
|
||||
return dict(row)
|
||||
|
||||
|
||||
def verify_oidc_id_token(raw_token, nonce):
|
||||
metadata = get_oidc_metadata()
|
||||
key_set = KeySet.import_key_set(metadata["_jwks"])
|
||||
try:
|
||||
decoded = jwt.decode(raw_token, key_set, algorithms=["RS256"])
|
||||
except Exception:
|
||||
metadata = get_oidc_metadata(force=True)
|
||||
decoded = jwt.decode(raw_token, KeySet.import_key_set(metadata["_jwks"]), algorithms=["RS256"])
|
||||
claims = dict(decoded.claims)
|
||||
now = time.time()
|
||||
audiences = claims.get("aud")
|
||||
if isinstance(audiences, str):
|
||||
audiences = [audiences]
|
||||
if claims.get("iss", "").rstrip("/") != OIDC_ISSUER:
|
||||
raise ValueError("Invalid OIDC issuer")
|
||||
if not isinstance(audiences, list) or OIDC_CLIENT_ID not in audiences:
|
||||
raise ValueError("Invalid OIDC audience")
|
||||
if len(audiences) > 1 and claims.get("azp") != OIDC_CLIENT_ID:
|
||||
raise ValueError("Invalid OIDC authorized party")
|
||||
if not isinstance(claims.get("exp"), (int, float)) or claims["exp"] <= now:
|
||||
raise ValueError("Expired OIDC token")
|
||||
if not isinstance(claims.get("iat"), (int, float)) or claims["iat"] > now + 60:
|
||||
raise ValueError("Invalid OIDC issued-at time")
|
||||
not_before = claims.get("nbf", 0)
|
||||
if not isinstance(not_before, (int, float)) or not_before > now + 60:
|
||||
raise ValueError("OIDC token not yet valid")
|
||||
if not secrets.compare_digest(str(claims.get("nonce", "")), nonce):
|
||||
raise ValueError("Invalid OIDC nonce")
|
||||
if not isinstance(claims.get("sub"), str) or not claims["sub"] or len(claims["sub"]) > 255:
|
||||
raise ValueError("Invalid OIDC subject")
|
||||
if claims.get("email_verified") is not True:
|
||||
raise ValueError("A verified email address is required")
|
||||
if not isinstance(claims.get("email"), str) or len(claims["email"]) > 254 or not EMAIL_RE.fullmatch(claims["email"]):
|
||||
raise ValueError("A valid email claim is required")
|
||||
groups = claims.get("groups", [])
|
||||
if not isinstance(groups, list) or not all(isinstance(group, str) for group in groups):
|
||||
raise ValueError("Invalid OIDC group claim")
|
||||
if OIDC_REQUIRED_GROUP not in groups:
|
||||
raise PermissionError("This account is not enabled for The Button")
|
||||
return claims, groups
|
||||
|
||||
|
||||
def issue_oidc_player(claims, groups):
|
||||
if OIDC_REQUIRED_GROUP and OIDC_REQUIRED_GROUP not in groups:
|
||||
raise PermissionError("This account is not enabled for The Button")
|
||||
subject = claims["sub"]
|
||||
username = claims.get("preferred_username") or claims.get("username")
|
||||
if not isinstance(username, str):
|
||||
username = claims["email"].split("@", 1)[0]
|
||||
username = " ".join(username.split())
|
||||
if not username or len(username) > MAX_NAME_LEN or not username.isprintable():
|
||||
raise ValueError("The identity provider username is not a valid player name")
|
||||
|
||||
with lock:
|
||||
check_ended()
|
||||
if state["ended"]:
|
||||
raise RuntimeError("The game is over")
|
||||
pid = next((player_id for player_id, player in state["players"].items() if player.get("oidc_sub") == subject), None)
|
||||
if pid is None:
|
||||
mapped_player = OIDC_SUBJECT_LINKS.get(subject)
|
||||
if mapped_player is not None:
|
||||
pid = mapped_player if mapped_player in state["players"] else None
|
||||
if pid is None:
|
||||
raise ValueError("Configured OIDC account link points to a missing player")
|
||||
existing_subject = state["players"][pid].get("oidc_sub")
|
||||
if existing_subject and existing_subject != subject:
|
||||
raise ValueError("Player is already linked to another OIDC subject")
|
||||
state["players"][pid]["oidc_sub"] = subject
|
||||
else:
|
||||
if find_player_by_name(username) is not None:
|
||||
raise ValueError("This player name already exists and needs an administrator-managed account link")
|
||||
pid = secrets.token_hex(8)
|
||||
state["players"][pid] = {
|
||||
"name": username,
|
||||
"password_hash": None,
|
||||
"oidc_sub": subject,
|
||||
"token_hashes": [],
|
||||
"created_at": now_ms(),
|
||||
"subscribed": False,
|
||||
"is_admin": False,
|
||||
"is_banned": False,
|
||||
"is_deleted": False,
|
||||
}
|
||||
player = state["players"].get(pid)
|
||||
if not player or player.get("is_banned") or player.get("is_deleted"):
|
||||
raise PermissionError("This account cannot access The Button")
|
||||
if OIDC_ADMIN_GROUP or OIDC_ADMIN_SUBJECTS:
|
||||
is_admin = subject in OIDC_ADMIN_SUBJECTS or bool(OIDC_ADMIN_GROUP and OIDC_ADMIN_GROUP in groups)
|
||||
else:
|
||||
is_admin = bool(player.get("is_admin", False))
|
||||
changed = player.get("is_admin", False) != is_admin
|
||||
if changed:
|
||||
revoke_player_sessions(pid)
|
||||
player["token_hashes"] = []
|
||||
player["is_admin"] = is_admin
|
||||
token = issue_token(pid)
|
||||
return player["name"], token
|
||||
|
||||
|
||||
def archive_season(connection, season, game_state, finished_at, reason):
|
||||
"""Immutable score/history snapshots have no FK to mutable player rows."""
|
||||
if connection.execute("SELECT 1 FROM season_results WHERE id = ?", (season["id"],)).fetchone():
|
||||
@@ -928,12 +1109,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def send_json(self, status, payload):
|
||||
def send_json(self, status, payload, extra_headers=None):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_common_headers()
|
||||
for name, value in (extra_headers or {}).items():
|
||||
self.send_header(name, value)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
@@ -965,10 +1148,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return None
|
||||
return hash_token(auth[7:].strip())
|
||||
|
||||
def session_hash(self):
|
||||
if not OIDC_ENABLED:
|
||||
return self.bearer_hash()
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(self.headers.get("Cookie", ""))
|
||||
session_cookie = cookie.get("button_session")
|
||||
return hash_token(session_cookie.value) if session_cookie and session_cookie.value else None
|
||||
|
||||
def oidc_post_is_same_origin(self):
|
||||
origin = urlparse(self.headers.get("Origin", ""))
|
||||
expected_scheme = "https" if OIDC_COOKIE_SECURE else "http"
|
||||
return (
|
||||
origin.scheme == expected_scheme
|
||||
and origin.netloc.casefold() == self.headers.get("Host", "").casefold()
|
||||
and self.headers.get("Sec-Fetch-Site", "") != "cross-site"
|
||||
)
|
||||
|
||||
def authed_player(self):
|
||||
th = self.bearer_hash()
|
||||
th = self.session_hash()
|
||||
pid = token_index.get(th) if th else None
|
||||
player = state["players"].get(pid)
|
||||
if OIDC_ENABLED and player and not player.get("oidc_sub"):
|
||||
return None
|
||||
return pid if player and not player.get("is_banned") and not player.get("is_deleted") else None
|
||||
|
||||
def require_admin(self):
|
||||
@@ -981,8 +1183,94 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return None
|
||||
return pid
|
||||
|
||||
def oidc_redirect_error(self, code):
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", "/#oidc_error=" + code)
|
||||
self.send_common_headers()
|
||||
secure = "; Secure" if OIDC_COOKIE_SECURE else ""
|
||||
self.send_header("Set-Cookie", f"button_oidc_state=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax{secure}")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
parsed_request = urlparse(self.path)
|
||||
path = parsed_request.path
|
||||
if path == "/api/config":
|
||||
return self.send_json(HTTPStatus.OK, {
|
||||
"auth_mode": "oidc" if OIDC_ENABLED else "local",
|
||||
"login_url": "/auth/oidc/login" if OIDC_ENABLED else "",
|
||||
"register_url": OIDC_REGISTER_URL if OIDC_ENABLED else "",
|
||||
})
|
||||
if path == "/auth/oidc/login":
|
||||
if not OIDC_ENABLED:
|
||||
return self.send_json(HTTPStatus.NOT_FOUND, {"error": "OIDC is not configured"})
|
||||
try:
|
||||
metadata = get_oidc_metadata()
|
||||
state_value = secrets.token_urlsafe(32)
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
client = OAuth2Session(OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, scope="openid profile email groups", redirect_uri=OIDC_REDIRECT_URI, token_endpoint_auth_method="client_secret_basic", default_timeout=5)
|
||||
client.code_challenge_method = "S256"
|
||||
authorization_url, _ = client.create_authorization_url(metadata["authorization_endpoint"], state=state_value, code_verifier=verifier, nonce=nonce)
|
||||
with database() as connection:
|
||||
connection.execute("DELETE FROM oidc_transactions WHERE expires_at <= ?", (now_ms(),))
|
||||
connection.execute("INSERT INTO oidc_transactions (state_hash, nonce, code_verifier, expires_at) VALUES (?, ?, ?, ?)", (hash_token(state_value), nonce, verifier, now_ms() + 5 * 60 * 1000))
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", authorization_url)
|
||||
self.send_common_headers()
|
||||
secure = "; Secure" if OIDC_COOKIE_SECURE else ""
|
||||
self.send_header("Set-Cookie", f"button_oidc_state={state_value}; Max-Age=300; Path=/; HttpOnly; SameSite=Lax{secure}")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
return
|
||||
except Exception:
|
||||
logging.exception("OIDC login initialization failed")
|
||||
return self.send_json(HTTPStatus.SERVICE_UNAVAILABLE, {"error": "Identity service is temporarily unavailable"})
|
||||
if path == "/auth/oidc/callback":
|
||||
if not OIDC_ENABLED:
|
||||
return self.send_json(HTTPStatus.NOT_FOUND, {"error": "OIDC is not configured"})
|
||||
params = parse_qs(parsed_request.query, keep_blank_values=True)
|
||||
state_value = params.get("state", [""])[0]
|
||||
code = params.get("code", [""])[0]
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(self.headers.get("Cookie", ""))
|
||||
cookie_state = cookie.get("button_oidc_state").value if cookie.get("button_oidc_state") else ""
|
||||
if not state_value or not cookie_state or not secrets.compare_digest(state_value, cookie_state):
|
||||
return self.oidc_redirect_error("invalid_state")
|
||||
transaction_data = consume_oidc_transaction(state_value)
|
||||
if not transaction_data or not code or params.get("error"):
|
||||
return self.oidc_redirect_error("authorization_failed")
|
||||
try:
|
||||
metadata = get_oidc_metadata()
|
||||
client = OAuth2Session(OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, scope="openid profile email groups", redirect_uri=OIDC_REDIRECT_URI, token_endpoint_auth_method="client_secret_basic", default_timeout=5, state=state_value)
|
||||
token_response = client.fetch_token(
|
||||
metadata["token_endpoint"],
|
||||
authorization_response=OIDC_REDIRECT_URI + "?" + urlencode({"code": code, "state": state_value}),
|
||||
state=state_value,
|
||||
code_verifier=transaction_data["code_verifier"],
|
||||
)
|
||||
raw_id_token = token_response.get("id_token")
|
||||
if not isinstance(raw_id_token, str):
|
||||
raise ValueError("OIDC token response did not contain an ID token")
|
||||
claims, groups = verify_oidc_id_token(raw_id_token, transaction_data["nonce"])
|
||||
try:
|
||||
_, bearer = issue_oidc_player(claims, groups)
|
||||
except ValueError:
|
||||
return self.oidc_redirect_error("account_link_required")
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", "/")
|
||||
self.send_common_headers()
|
||||
secure = "; Secure" if OIDC_COOKIE_SECURE else ""
|
||||
self.send_header("Set-Cookie", f"button_oidc_state=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax{secure}")
|
||||
self.send_header("Set-Cookie", f"button_session={bearer}; Path=/; HttpOnly; SameSite=Lax{secure}")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
return
|
||||
except PermissionError:
|
||||
return self.oidc_redirect_error("access_denied")
|
||||
except Exception:
|
||||
logging.exception("OIDC callback failed")
|
||||
return self.oidc_redirect_error("login_failed")
|
||||
if path == "/api/admin":
|
||||
with lock:
|
||||
actor = self.require_admin()
|
||||
@@ -1044,6 +1332,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
if OIDC_ENABLED and not self.oidc_post_is_same_origin():
|
||||
return self.send_json(HTTPStatus.FORBIDDEN, {"error": "Cross-origin requests are not allowed"})
|
||||
if path.startswith("/api/admin"):
|
||||
# Authorization precedes parsing/lookup; unknown admin paths also deny by default.
|
||||
with lock:
|
||||
@@ -1071,7 +1361,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
if path == "/api/presence":
|
||||
with lock:
|
||||
th = self.bearer_hash()
|
||||
th = self.session_hash()
|
||||
if self.authed_player() is None:
|
||||
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to share your presence"})
|
||||
tab = data.get("tab_id")
|
||||
@@ -1117,6 +1407,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.send_json(HTTPStatus.CREATED, posted)
|
||||
|
||||
if path == "/api/register":
|
||||
if OIDC_ENABLED:
|
||||
return self.send_json(HTTPStatus.FORBIDDEN, {"error": "Use the eom.dev identity provider to create an account"})
|
||||
name, password, error = validate_credentials(data)
|
||||
if error:
|
||||
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": error})
|
||||
@@ -1138,6 +1430,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.send_json(HTTPStatus.CREATED, {"name": name, "token": token})
|
||||
|
||||
if path == "/api/login":
|
||||
if OIDC_ENABLED:
|
||||
return self.send_json(HTTPStatus.FORBIDDEN, {"error": "Use the eom.dev identity provider to sign in"})
|
||||
name, password = data.get("name"), data.get("password")
|
||||
if not isinstance(name, str) or not isinstance(password, str) or len(password) > MAX_PASSWORD_LEN:
|
||||
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Name and password required"})
|
||||
@@ -1166,13 +1460,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
if path == "/api/logout":
|
||||
with lock:
|
||||
th = self.bearer_hash()
|
||||
th = self.session_hash()
|
||||
pid = token_index.pop(th, None) if th else None
|
||||
prune_presence()
|
||||
if pid is not None:
|
||||
state["players"][pid]["token_hashes"].remove(th)
|
||||
save_state(state)
|
||||
return self.send_json(HTTPStatus.OK, {"ok": True})
|
||||
headers = {}
|
||||
if OIDC_ENABLED:
|
||||
secure = "; Secure" if OIDC_COOKIE_SECURE else ""
|
||||
headers["Set-Cookie"] = f"button_session=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax{secure}"
|
||||
return self.send_json(HTTPStatus.OK, {"ok": True}, headers)
|
||||
|
||||
if path == "/api/subscribe":
|
||||
with lock:
|
||||
|
||||
+44
-2
@@ -3,9 +3,18 @@
|
||||
const TOKEN_KEY = "thebutton-token";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const callbackParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const callbackError = callbackParams.get("oidc_error");
|
||||
if (callbackError) {
|
||||
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
let game = null; // latest /api/state payload
|
||||
let clockOffset = 0; // serverTime - clientTime
|
||||
let token = localStorage.getItem(TOKEN_KEY);
|
||||
let oidcMode = false;
|
||||
let oidcLoginUrl = "";
|
||||
let oidcRegisterUrl = "";
|
||||
let statsLoaded = false;
|
||||
let subscribed = false;
|
||||
let currentShareText = "";
|
||||
@@ -238,12 +247,19 @@ function renderSections() {
|
||||
const id = button.dataset.section;
|
||||
button.hidden = !available[id];
|
||||
button.setAttribute("aria-expanded", String(selectedSection === id));
|
||||
if (id === "account-section") button.textContent = token ? "Your score" : "Log in / Join";
|
||||
if (id === "account-section") button.textContent = token ? "Your score" : oidcMode ? "Sign in" : "Log in / Join";
|
||||
});
|
||||
document.querySelectorAll("[data-panel]").forEach((panel) => {
|
||||
panel.hidden = panel.id !== selectedSection;
|
||||
});
|
||||
$("mailing-list").hidden = !token || subscribed;
|
||||
$("register-section").hidden = !!token || oidcMode;
|
||||
$("oidc-login-section").hidden = !!token || !oidcMode;
|
||||
if (oidcMode) {
|
||||
$("oidc-login-link").href = oidcLoginUrl;
|
||||
$("oidc-register-link").href = oidcRegisterUrl || "#";
|
||||
$("oidc-register-link").hidden = !oidcRegisterUrl;
|
||||
}
|
||||
$("chat-form").hidden = !token;
|
||||
$("chat-login").hidden = !!token;
|
||||
$("section-hint").hidden = selectedSection !== null;
|
||||
@@ -434,7 +450,6 @@ async function refreshState() {
|
||||
|
||||
function updateButton() {
|
||||
$("the-button").disabled = !token || !game || game.ended;
|
||||
$("register-section").hidden = !!token;
|
||||
$("me-section").hidden = !token;
|
||||
$("my-subscribed").hidden = !token || !subscribed;
|
||||
renderSections();
|
||||
@@ -936,6 +951,32 @@ $("the-button").addEventListener("click", async () => {
|
||||
|
||||
$("mastodon-instance").addEventListener("input", updateShareLinks);
|
||||
|
||||
async function loadAuthConfig() {
|
||||
try {
|
||||
const response = await fetch("/api/config", { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const config = await response.json();
|
||||
oidcMode = config.auth_mode === "oidc";
|
||||
if (oidcMode) {
|
||||
token = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
oidcLoginUrl = config.login_url || "";
|
||||
oidcRegisterUrl = config.register_url || "";
|
||||
renderSections();
|
||||
if (callbackError) {
|
||||
const errorMessage = callbackError === "access_denied"
|
||||
? "This account is not authorized to play The Button."
|
||||
: callbackError === "account_link_required"
|
||||
? "A game account with this name already exists. Ask a moderator to link your eom.dev identity so your scores are preserved."
|
||||
: "Sign-in didn't complete. Please try again.";
|
||||
$("oidc-error").textContent = errorMessage;
|
||||
}
|
||||
} catch {
|
||||
// Keep the local login UI available if the server has no OIDC config endpoint.
|
||||
}
|
||||
}
|
||||
|
||||
$("register-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
$("register-error").textContent = "";
|
||||
@@ -991,6 +1032,7 @@ $("subscribe-form").addEventListener("submit", async (ev) => {
|
||||
});
|
||||
|
||||
(async function init() {
|
||||
await loadAuthConfig();
|
||||
await refreshState();
|
||||
await loadMe();
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
@@ -164,6 +164,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="oidc-login-section" class="panel-body" hidden>
|
||||
<p class="fine-print">Your account is managed by eom.dev. Sign in with the same identity used for your other services.</p>
|
||||
<div class="form-actions"><a id="oidc-login-link" class="primary-button" href="/auth/oidc/login">Sign in with eom.dev <span aria-hidden="true">→</span></a></div>
|
||||
<p id="oidc-error" class="error" role="status"></p>
|
||||
<p class="fine-print">New here? <a id="oidc-register-link" href="https://lists.eom.dev/accounts/register/">Create an eom.dev account</a>.</p>
|
||||
</div>
|
||||
|
||||
<div id="me-section" hidden>
|
||||
<header class="panel-header score-header">
|
||||
<div><p class="eyebrow">YOUR PART IN THE EXPERIMENT</p>
|
||||
|
||||
+205
@@ -45,6 +45,7 @@ class PersistenceTests(unittest.TestCase):
|
||||
"alice": {
|
||||
"name": "Alice '); DROP TABLE players; --",
|
||||
"password_hash": "existing-password-hash",
|
||||
"oidc_sub": None,
|
||||
"token_hashes": ["first-session", "second-session"],
|
||||
"created_at": 123,
|
||||
"subscribed": True,
|
||||
@@ -53,6 +54,7 @@ class PersistenceTests(unittest.TestCase):
|
||||
"bob": {
|
||||
"name": "Bøb",
|
||||
"password_hash": None,
|
||||
"oidc_sub": None,
|
||||
"token_hashes": [],
|
||||
"created_at": 456,
|
||||
"subscribed": False,
|
||||
@@ -977,6 +979,15 @@ class PersistenceTests(unittest.TestCase):
|
||||
self.assertEqual(connection.execute("PRAGMA integrity_check").fetchone()[0], "ok")
|
||||
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||
|
||||
def test_legacy_players_receive_empty_oidc_subject(self):
|
||||
legacy = self.sample_state()
|
||||
for player in legacy["players"].values():
|
||||
player.pop("oidc_sub")
|
||||
self.write_legacy(legacy)
|
||||
loaded = self.game.load_state()
|
||||
self.assertEqual({player_id: player["oidc_sub"] for player_id, player in loaded["players"].items()}, {"alice": None, "bob": None})
|
||||
self.assertNotIn("oidc_sub", json.loads(self.game.DATA_FILE.read_text(encoding="utf-8"))["players"]["alice"])
|
||||
|
||||
def test_existing_database_ignores_legacy_even_if_invalid(self):
|
||||
self.write_legacy(self.sample_state())
|
||||
current = self.game.load_state()
|
||||
@@ -1232,6 +1243,200 @@ class PersistenceTests(unittest.TestCase):
|
||||
self.assertEqual(player["token_hashes"], [self.game.hash_token(logged_in["token"])])
|
||||
self.assertEqual(len(loaded["presses"]), 2)
|
||||
|
||||
def test_oidc_requires_verified_email_and_required_service_group(self):
|
||||
from joserfc import jwt
|
||||
from joserfc.jwk import RSAKey
|
||||
|
||||
signing_key = RSAKey.generate_key(2048, auto_kid=True)
|
||||
public_jwks = {"keys": [signing_key.as_dict(private=False)]}
|
||||
now = int(self.game.time.time())
|
||||
claims = {
|
||||
"iss": "https://dex.example.test",
|
||||
"sub": "stable-subject-123",
|
||||
"aud": "button-client",
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
"nonce": "nonce-value",
|
||||
"email": "alice@example.test",
|
||||
"email_verified": True,
|
||||
"preferred_username": "Alice",
|
||||
"groups": ["the-button-users"],
|
||||
}
|
||||
token = jwt.encode({"alg": "RS256", "kid": signing_key.kid}, claims, signing_key)
|
||||
metadata = {"_jwks": public_jwks}
|
||||
with patch.object(self.game, "OIDC_ISSUER", "https://dex.example.test"), \
|
||||
patch.object(self.game, "OIDC_CLIENT_ID", "button-client"), \
|
||||
patch.object(self.game, "OIDC_REQUIRED_GROUP", "the-button-users"), \
|
||||
patch.object(self.game, "get_oidc_metadata", return_value=metadata):
|
||||
verified, groups = self.game.verify_oidc_id_token(token, "nonce-value")
|
||||
self.assertEqual(verified["sub"], claims["sub"])
|
||||
self.assertIn("the-button-users", groups)
|
||||
with self.assertRaises(ValueError):
|
||||
self.game.verify_oidc_id_token(token, "wrong-nonce")
|
||||
denied = dict(claims, email_verified=False)
|
||||
denied_token = jwt.encode({"alg": "RS256", "kid": signing_key.kid}, denied, signing_key)
|
||||
with self.assertRaises(ValueError):
|
||||
self.game.verify_oidc_id_token(denied_token, "nonce-value")
|
||||
|
||||
def test_oidc_subject_mapping_preserves_existing_player_and_scores(self):
|
||||
state = self.sample_state()
|
||||
state["presses"] = [dict(state["presses"][0])]
|
||||
state["players"]["bob"]["oidc_sub"] = None
|
||||
self.game.state = state
|
||||
self.game.token_index = {}
|
||||
claims = {"sub": "dex-sub-bob", "preferred_username": "Bøb", "email": "bob@example.test"}
|
||||
with patch.object(self.game, "OIDC_SUBJECT_LINKS", {"dex-sub-bob": "bob"}), \
|
||||
patch.object(self.game, "OIDC_ADMIN_GROUP", ""), \
|
||||
patch.object(self.game, "OIDC_ADMIN_SUBJECTS", set()), \
|
||||
patch.object(self.game, "OIDC_REQUIRED_GROUP", "the-button-users"), \
|
||||
patch.object(self.game, "check_ended"), \
|
||||
patch.object(self.game, "save_state"):
|
||||
name, token = self.game.issue_oidc_player(claims, ["the-button-users"])
|
||||
self.assertEqual(name, "Bøb")
|
||||
self.assertEqual(self.game.state["players"]["bob"]["oidc_sub"], "dex-sub-bob")
|
||||
self.assertEqual(len(self.game.state["presses"]), 1)
|
||||
self.assertIn(self.game.hash_token(token), self.game.token_index)
|
||||
|
||||
def test_oidc_auto_registers_only_unique_player_names_and_authorized_groups(self):
|
||||
self.game.state = self.game.new_game()
|
||||
self.game.token_index = {}
|
||||
claims = {"sub": "dex-sub-new", "preferred_username": "Newcomer", "email": "new@example.test"}
|
||||
with patch.object(self.game, "OIDC_SUBJECT_LINKS", {}), \
|
||||
patch.object(self.game, "OIDC_ADMIN_GROUP", ""), \
|
||||
patch.object(self.game, "OIDC_REQUIRED_GROUP", "the-button-users"), \
|
||||
patch.object(self.game, "OIDC_ADMIN_SUBJECTS", set()), \
|
||||
patch.object(self.game, "check_ended"), \
|
||||
patch.object(self.game, "save_state"):
|
||||
name, token = self.game.issue_oidc_player(claims, ["the-button-users"])
|
||||
self.assertEqual(name, "Newcomer")
|
||||
with self.assertRaises(ValueError):
|
||||
self.game.issue_oidc_player({**claims, "sub": "different-sub"}, ["the-button-users"])
|
||||
with self.assertRaises(PermissionError):
|
||||
self.game.issue_oidc_player({**claims, "sub": "outside-user", "preferred_username": "Outside"}, ["other-service"])
|
||||
|
||||
def test_oidc_id_token_signature_issuer_audience_nonce_and_email_claims(self):
|
||||
from joserfc import jwt
|
||||
from joserfc.jwk import RSAKey
|
||||
|
||||
signing_key = RSAKey.generate_key(2048, auto_kid=True)
|
||||
metadata = {"_jwks": {"keys": [signing_key.as_dict(private=False)]}}
|
||||
now = int(self.game.time.time())
|
||||
claims = {
|
||||
"iss": "https://dex.example.test",
|
||||
"sub": "stable-oidc-id",
|
||||
"aud": "button-client",
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
"nonce": "expected-nonce",
|
||||
"email": "player@example.test",
|
||||
"email_verified": True,
|
||||
"preferred_username": "Player",
|
||||
"groups": ["the-button-users"],
|
||||
}
|
||||
signed = jwt.encode({"alg": "RS256", "kid": signing_key.kid}, claims, signing_key)
|
||||
config = (
|
||||
patch.object(self.game, "OIDC_ISSUER", "https://dex.example.test"),
|
||||
patch.object(self.game, "OIDC_CLIENT_ID", "button-client"),
|
||||
patch.object(self.game, "OIDC_REQUIRED_GROUP", "the-button-users"),
|
||||
patch.object(self.game, "get_oidc_metadata", return_value=metadata),
|
||||
)
|
||||
with config[0], config[1], config[2], config[3]:
|
||||
parsed, groups = self.game.verify_oidc_id_token(signed, "expected-nonce")
|
||||
self.assertEqual(parsed["sub"], claims["sub"])
|
||||
self.assertEqual(groups, ["the-button-users"])
|
||||
with self.assertRaises(ValueError):
|
||||
self.game.verify_oidc_id_token(signed, "wrong-nonce")
|
||||
invalid_email = dict(claims, email_verified=False)
|
||||
bad = jwt.encode({"alg": "RS256", "kid": signing_key.kid}, invalid_email, signing_key)
|
||||
with self.assertRaises(ValueError):
|
||||
self.game.verify_oidc_id_token(bad, "expected-nonce")
|
||||
unauthorized = dict(claims, groups=["other-service"])
|
||||
denied = jwt.encode({"alg": "RS256", "kid": signing_key.kid}, unauthorized, signing_key)
|
||||
with self.assertRaises(PermissionError):
|
||||
self.game.verify_oidc_id_token(denied, "expected-nonce")
|
||||
|
||||
def test_oidc_mode_rejects_local_password_registration_and_login(self):
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(thread.join)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
|
||||
def request(path, payload):
|
||||
connection = HTTPConnection(*httpd.server_address, timeout=5)
|
||||
try:
|
||||
headers = {"Content-Type": "application/json", "Origin": f"http://{httpd.server_address[0]}:{httpd.server_address[1]}"}
|
||||
connection.request("POST", path, json.dumps(payload), headers)
|
||||
response = connection.getresponse()
|
||||
return response.status
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with patch.object(self.game, "OIDC_ENABLED", True):
|
||||
self.assertEqual(request("/api/register", {"name": "local", "password": "test-password"}), 403)
|
||||
self.assertEqual(request("/api/login", {"name": "local", "password": "test-password"}), 403)
|
||||
|
||||
def test_oidc_cookie_session_is_http_only_and_same_origin_protected(self):
|
||||
state = self.sample_state()
|
||||
state["players"]["bob"]["oidc_sub"] = "dex-sub-bob"
|
||||
self.game.state = state
|
||||
self.game.token_index = {}
|
||||
with self.game.lock, patch.object(self.game, "save_state"):
|
||||
bearer = self.game.issue_token("bob")
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(thread.join)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
host, port = httpd.server_address
|
||||
origin = f"http://{host}:{port}"
|
||||
|
||||
def request(method, path, headers=None):
|
||||
connection = HTTPConnection(host, port, timeout=5)
|
||||
try:
|
||||
connection.request(method, path, "{}" if method == "POST" else None, headers or {})
|
||||
response = connection.getresponse()
|
||||
result = response.status, response.getheaders(), response.read()
|
||||
return result
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with (
|
||||
patch.object(self.game, "OIDC_ENABLED", True),
|
||||
patch.object(self.game, "OIDC_COOKIE_SECURE", False),
|
||||
patch.object(self.game, "check_ended"),
|
||||
patch.object(self.game, "save_state"),
|
||||
):
|
||||
cookie = {"Cookie": f"button_session={bearer}"}
|
||||
self.assertEqual(request("GET", "/api/me", cookie)[0], 200)
|
||||
self.assertEqual(request("POST", "/api/logout", {**cookie, "Origin": "https://attacker.example"})[0], 403)
|
||||
status, headers, _ = request("POST", "/api/logout", {**cookie, "Origin": origin})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("Max-Age=0", dict(headers)["Set-Cookie"])
|
||||
self.assertEqual(request("GET", "/api/me", cookie)[0], 401)
|
||||
|
||||
def test_oidc_stable_subject_link_preserves_existing_game_history(self):
|
||||
state = self.sample_state()
|
||||
state["presses"] = [dict(state["presses"][0])]
|
||||
state["players"]["bob"]["oidc_sub"] = None
|
||||
self.game.state = state
|
||||
self.game.token_index = {}
|
||||
claims = {"sub": "dex-sub-bob", "preferred_username": "Bøb", "email": "bob@example.test"}
|
||||
with patch.object(self.game, "OIDC_SUBJECT_LINKS", {"dex-sub-bob": "bob"}), \
|
||||
patch.object(self.game, "OIDC_ADMIN_GROUP", ""), \
|
||||
patch.object(self.game, "OIDC_REQUIRED_GROUP", "the-button-users"), \
|
||||
patch.object(self.game, "OIDC_ADMIN_SUBJECTS", set()), \
|
||||
patch.object(self.game, "check_ended"), \
|
||||
patch.object(self.game, "save_state"):
|
||||
name, token = self.game.issue_oidc_player(claims, ["the-button-users"])
|
||||
self.assertEqual(name, "Bøb")
|
||||
self.assertEqual(self.game.state["players"]["bob"]["oidc_sub"], "dex-sub-bob")
|
||||
self.assertEqual(len(self.game.state["presses"]), 1)
|
||||
self.assertIn(self.game.hash_token(token), self.game.token_index)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user