LDAP
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user