SSO and turn gate

This commit is contained in:
2026-10-30 16:36:36 -04:00
parent c4d9d1b86c
commit b96be7076e
8 changed files with 273 additions and 132 deletions
+80 -25
View File
@@ -51,6 +51,7 @@ CHAT_HISTORY_LIMIT = 100
CHAT_COOLDOWN_MS = 2000
PRESENCE_TTL_SECONDS = 45
MAX_PRESENCE_TABS_PER_SESSION = 10
OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES = 2
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", "")
@@ -61,10 +62,10 @@ OIDC_SUBJECT_LINKS = json.loads(os.environ.get("BUTTON_OIDC_SUBJECT_LINKS_JSON",
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":
if not all((OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_REQUIRED_GROUP)):
raise RuntimeError("Dex OIDC is required; configure BUTTON_OIDC_ISSUER, BUTTON_OIDC_CLIENT_ID, BUTTON_OIDC_CLIENT_SECRET, BUTTON_OIDC_REDIRECT_URI, and BUTTON_OIDC_REQUIRED_GROUP")
OIDC_ENABLED = True
if 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.
@@ -229,6 +230,36 @@ def zone_for_remaining(remaining_ms):
return ZONES[-1]
def press_eligibility(pid):
"""Require two distinct other accounts to press between a player's turns."""
last_press_index = next(
(index for index in range(len(state["presses"]) - 1, -1, -1)
if state["presses"][index]["player_id"] == pid),
None,
)
if last_press_index is None:
return {
"eligible": True,
"has_pressed": False,
"other_players_required": OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES,
"other_players_pressed": 0,
"other_players_remaining": 0,
}
other_players = {
press["player_id"] for press in state["presses"][last_press_index + 1:]
if press["player_id"] != pid
}
pressed = min(len(other_players), OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES)
remaining = OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES - pressed
return {
"eligible": remaining == 0,
"has_pressed": True,
"other_players_required": OTHER_PLAYERS_REQUIRED_BETWEEN_PRESSES,
"other_players_pressed": pressed,
"other_players_remaining": remaining,
}
def new_game():
t = now_ms()
return {
@@ -649,9 +680,12 @@ 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")
display_name = claims.get("displayName") or claims.get("name")
username = display_name if isinstance(display_name, str) and display_name.strip() else (
claims.get("uid") or claims.get("preferred_username") or claims.get("username")
)
if not isinstance(username, str):
username = claims["email"].split("@", 1)[0]
raise ValueError("The identity provider did not supply a display name or uid")
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")
@@ -672,23 +706,30 @@ def issue_oidc_player(claims, groups):
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"):
pid = None
player = state["players"].get(pid) if pid is not None else None
if player and (player.get("is_banned") or player.get("is_deleted")):
raise PermissionError("This account cannot access The Button")
name_owner = find_player_by_name(username)
if name_owner is not None and name_owner != pid:
raise ValueError("This player name already exists and needs an administrator-managed account link")
if pid is None:
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"][pid]
if not player:
raise PermissionError("This account cannot access The Button")
player["name"] = username
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:
@@ -1197,9 +1238,9 @@ class Handler(BaseHTTPRequestHandler):
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 "",
"auth_mode": "oidc",
"login_url": "/auth/oidc/login",
"register_url": OIDC_REGISTER_URL,
})
if path == "/auth/oidc/login":
if not OIDC_ENABLED:
@@ -1281,6 +1322,7 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/state":
with lock:
check_ended()
pid = self.authed_player()
return self.send_json(HTTPStatus.OK, {
"now": now_ms(),
"started_at": state["started_at"],
@@ -1291,6 +1333,7 @@ class Handler(BaseHTTPRequestHandler):
"total_presses": len(state["presses"]),
"zones": ZONES,
"season": season_summary(),
"press_eligibility": press_eligibility(pid) if pid is not None else None,
})
if path == "/api/seasons":
with lock:
@@ -1505,6 +1548,17 @@ class Handler(BaseHTTPRequestHandler):
check_ended(t)
if state["ended"]:
return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"})
eligibility = press_eligibility(pid)
if not eligibility["eligible"]:
remaining_players = eligibility["other_players_remaining"]
message = (
f"Wait for {remaining_players} more distinct "
f"{'player' if remaining_players == 1 else 'players'} to press before your next turn."
)
return self.send_json(HTTPStatus.CONFLICT, {
"error": message,
"press_eligibility": eligibility,
})
remaining = state["deadline"] - t
zone = zone_for_remaining(remaining)
press = {
@@ -1542,6 +1596,7 @@ class Handler(BaseHTTPRequestHandler):
"press": {k: press[k] for k in ("at", "remaining_ms", "zone", "points")},
"deadline": state["deadline"],
"now": t,
"press_eligibility": press_eligibility(pid),
})
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})