This commit is contained in:
2026-10-23 10:23:03 -04:00
parent 84b363b863
commit c4d9d1b86c
10 changed files with 625 additions and 22 deletions
+311 -13
View File
@@ -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: