SSO and turn gate
This commit is contained in:
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"python-envs.pythonProjects": [
|
||||
{
|
||||
"path": ".",
|
||||
"envManager": "ms-python.python:system",
|
||||
"packageManager": "ms-python.python:pip"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ Inspired by Reddit's [The Button](https://en.wikipedia.org/wiki/The_Button_(Redd
|
||||
## How to play
|
||||
|
||||
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.
|
||||
2. Press the button. The countdown resets to 24 hours. Your first press each season is always available; after that, two different players must press before you can press again.
|
||||
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.
|
||||
|
||||
### Zones
|
||||
@@ -25,6 +25,8 @@ 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.
|
||||
|
||||
Press eligibility is enforced by the server and counts distinct player accounts, not sessions or repeated presses. The first press each season is allowed; after each successful press, two different players must press before that player can take another turn. Eligibility resets when the season resets. With fewer than three participating accounts, a player who has pressed may have to wait until another player joins.
|
||||
|
||||
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.
|
||||
@@ -37,7 +39,7 @@ Administrator controls are available in the **Admin** section after signing in w
|
||||
|
||||
### Assigning the first administrator
|
||||
|
||||
1. Upgrade/start the server once so it adds the new database fields, then create a regular account if needed.
|
||||
1. Upgrade/start the server once so it adds the new database fields, then create a regular account through the eom.dev account portal and sign in through Dex if needed.
|
||||
2. **Stop the server and back up the SQLite database.** The game uses an in-memory snapshot; editing flags while it runs is unsupported and can be overwritten.
|
||||
3. Open the configured SQLite database with a trusted local SQLite editor. In Docker, the database lives in the persistent `/data` volume; stop the service before editing/copying it. Do not modify the legacy JSON backup.
|
||||
4. Find the intended account's exact ID, then promote only that ID:
|
||||
@@ -63,7 +65,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 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 access uses the existing per-game session authorization, with cross-site request rejection and no permissive CORS. 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 and 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`.
|
||||
|
||||
@@ -174,10 +176,14 @@ Presence tests cover authentication, validation, expiry and heartbeat renewal, t
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
Copy `.env.example` to a private `.env`, set the Dex client secret and required access group, then start the service:
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The service refuses to start without complete Dex configuration. There is no local-password fallback.
|
||||
|
||||
Open http://localhost:8000. The SQLite game database and the mailing list are stored in the `button-data` volume. Existing JSON game data in that volume is automatically migrated on the first startup after upgrading.
|
||||
|
||||
```sh
|
||||
@@ -189,7 +195,7 @@ docker compose down -v # stop and delete all
|
||||
|
||||
### Without Docker
|
||||
|
||||
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.
|
||||
Requires Python 3.9+. Install `requirements.txt` and set the required Dex variables from `.env.example` before running. The game has no local-password login mode.
|
||||
|
||||
```sh
|
||||
python server.py
|
||||
@@ -199,7 +205,7 @@ Open http://127.0.0.1:8000. Game state is stored in `data.sqlite3`.
|
||||
|
||||
### Migrating existing data
|
||||
|
||||
On startup, if the database has no saved game and `data.json` exists, the server automatically imports the countdown, players, password hashes, active sessions, subscription flags, and complete press history in one transaction. Older single-token player records are also supported. No manual conversion or additional dependencies are needed.
|
||||
On startup, if the database has no saved game and `data.json` exists, the server automatically imports the countdown, players, legacy password hashes, active sessions, subscription flags, and complete press history in one transaction. Password hashes are retained for data compatibility but are never accepted for login. Older single-token player records are also supported. No manual conversion or additional dependencies are needed.
|
||||
|
||||
The JSON file is left untouched as a backup. Once the database contains a game, it is authoritative: subsequent restarts never re-import the JSON file. Invalid source data causes startup to fail rather than silently starting a new game; fix the source and restart to retry. The mailing-list CSV is unchanged.
|
||||
|
||||
@@ -219,18 +225,18 @@ 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_ISSUER` | required | Dex issuer URL (HTTPS), normally `https://dex.eom.dev`. |
|
||||
| `BUTTON_OIDC_CLIENT_ID` | required | Dex OIDC client ID registered for The Button (`the-button`). |
|
||||
| `BUTTON_OIDC_CLIENT_SECRET` | required | Client secret; provide through a private deployment secret, not Git. |
|
||||
| `BUTTON_OIDC_REDIRECT_URI` | required | Exact HTTPS callback URI registered in Dex, normally `https://button.eom.dev/auth/oidc/callback`. |
|
||||
| `BUTTON_OIDC_REQUIRED_GROUP` | required | Required Dex `groups` claim value for The Button access. |
|
||||
| `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.
|
||||
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, `preferred_username` (the LDAP `uid`), the LDAP `displayName` as the OIDC `name` claim, and the required service group. If `displayName` is empty, the player name falls back to `uid`. Dex is mandatory: the server fails closed if its configuration is missing, and local password login and registration are not available.
|
||||
|
||||
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.
|
||||
|
||||
@@ -243,8 +249,8 @@ Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing.
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------------------|------|-----------------------------------------------|
|
||||
| GET | `/api/state` | | Deadline, server time, zones, whether ended |
|
||||
| POST | `/api/register` | | `{name, password}` → `{name, token}` |
|
||||
| POST | `/api/login` | | `{name, password}` → `{name, token}` |
|
||||
| POST | `/api/register` | | Retired; returns 403. Create accounts through the eom.dev portal. |
|
||||
| POST | `/api/login` | | Retired; returns 403. Sign in through Dex. |
|
||||
| POST | `/api/logout` | ✓ | Revoke the current session token |
|
||||
| POST | `/api/press` | ✓ | Press the button |
|
||||
| GET | `/api/me` | ✓ | Your score, zone counts, and press history |
|
||||
@@ -257,13 +263,13 @@ 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?}` |
|
||||
|
||||
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.
|
||||
Authenticated requests use the server-issued HttpOnly session cookie; JavaScript does not receive a bearer token. Dex is the only login method.
|
||||
|
||||
## Security notes
|
||||
|
||||
- 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.
|
||||
- Legacy local passwords are hashed with PBKDF2-SHA256 (600k iterations) for import compatibility only; session tokens are stored only as SHA-256 hashes.
|
||||
- Legacy local logins previously locked for 15 minutes after 5 failed attempts per name; those endpoints are now retired.
|
||||
- New Button players are linked to stable Dex `sub` values; passwords remain in OpenLDAP and are never copied to The Button. Imported local hashes remain stored for migration compatibility but cannot authenticate.
|
||||
- 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.
|
||||
|
||||
+5
-5
@@ -2,11 +2,11 @@ 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_ISSUER: ${BUTTON_OIDC_ISSUER:?Set the Dex issuer URL}
|
||||
BUTTON_OIDC_CLIENT_ID: ${BUTTON_OIDC_CLIENT_ID:?Set the Dex client ID}
|
||||
BUTTON_OIDC_CLIENT_SECRET: ${BUTTON_OIDC_CLIENT_SECRET:?Set the private Dex client secret}
|
||||
BUTTON_OIDC_REDIRECT_URI: ${BUTTON_OIDC_REDIRECT_URI:?Set the registered Dex callback URI}
|
||||
BUTTON_OIDC_REQUIRED_GROUP: ${BUTTON_OIDC_REQUIRED_GROUP:?Set the required Dex access 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:-}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
+32
-55
@@ -1,6 +1,5 @@
|
||||
"use strict";
|
||||
|
||||
const TOKEN_KEY = "thebutton-token";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const callbackParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
@@ -11,10 +10,10 @@ if (callbackError) {
|
||||
|
||||
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 token = false;
|
||||
let oidcLoginUrl = "/auth/oidc/login";
|
||||
let oidcRegisterUrl = "https://lists.eom.dev/accounts/register/";
|
||||
localStorage.removeItem("thebutton-token");
|
||||
let statsLoaded = false;
|
||||
let subscribed = false;
|
||||
let currentShareText = "";
|
||||
@@ -198,7 +197,6 @@ function syncPresence() {
|
||||
if (token && sharePresence) $("presence-error").textContent = "Couldn't update online status. Retrying automatically.";
|
||||
if (error.status === 401) {
|
||||
token = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
lastPresenceUpdate = null;
|
||||
updateButton();
|
||||
}
|
||||
@@ -218,13 +216,10 @@ window.addEventListener("storage", (event) => {
|
||||
alertObservation = null;
|
||||
renderAlertSettings();
|
||||
}
|
||||
if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) {
|
||||
if (event.key === PRESENCE_KEY) {
|
||||
sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
|
||||
if (event.key === TOKEN_KEY) isAdmin = false;
|
||||
token = localStorage.getItem(TOKEN_KEY);
|
||||
updateButton();
|
||||
syncPresence();
|
||||
if (event.key === TOKEN_KEY && token) loadMe();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -247,19 +242,16 @@ 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" : oidcMode ? "Sign in" : "Log in / Join";
|
||||
if (id === "account-section") button.textContent = token ? "Your score" : "Sign in";
|
||||
});
|
||||
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;
|
||||
}
|
||||
$("oidc-login-section").hidden = !!token;
|
||||
$("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;
|
||||
@@ -282,7 +274,6 @@ $("section-nav").addEventListener("click", (event) => {
|
||||
|
||||
async function api(method, path, body) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status });
|
||||
@@ -449,7 +440,18 @@ async function refreshState() {
|
||||
}
|
||||
|
||||
function updateButton() {
|
||||
$("the-button").disabled = !token || !game || game.ended;
|
||||
const eligibility = game?.press_eligibility;
|
||||
$("the-button").disabled = !token || !game || game.ended || eligibility?.eligible !== true;
|
||||
$("press-eligibility").hidden = !token;
|
||||
if (token) {
|
||||
$("press-eligibility").textContent = !eligibility
|
||||
? "Checking when you can press..."
|
||||
: !eligibility.has_pressed
|
||||
? "Your first press this season is ready."
|
||||
: eligibility.eligible
|
||||
? "Your turn is ready: two different players have pressed since your last turn."
|
||||
: `Waiting for ${eligibility.other_players_remaining} more different ${eligibility.other_players_remaining === 1 ? "player" : "players"} to press.`;
|
||||
}
|
||||
$("me-section").hidden = !token;
|
||||
$("my-subscribed").hidden = !token || !subscribed;
|
||||
renderSections();
|
||||
@@ -512,11 +514,11 @@ function renderAchievements(achievements) {
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
if (!token) return;
|
||||
const requestedToken = token;
|
||||
try {
|
||||
const me = await api("GET", "/api/me");
|
||||
if (token !== requestedToken) return;
|
||||
if (token !== requestedToken && token) return;
|
||||
token = true;
|
||||
isAdmin = me.is_admin === true;
|
||||
$("my-name").textContent = me.name;
|
||||
$("my-points").textContent = me.total_points.toLocaleString();
|
||||
@@ -549,6 +551,7 @@ async function loadMe() {
|
||||
}
|
||||
subscribed = me.subscribed;
|
||||
updateButton();
|
||||
syncPresence();
|
||||
fillTable($("my-zones"), ["Zone", "Points each", "Your presses"], zoneRows(me.zone_counts));
|
||||
fillTable(
|
||||
$("my-history"),
|
||||
@@ -561,7 +564,6 @@ async function loadMe() {
|
||||
if (e.status === 401) {
|
||||
token = null;
|
||||
isAdmin = false;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
updateButton();
|
||||
}
|
||||
}
|
||||
@@ -879,7 +881,6 @@ $("chat-form").addEventListener("submit", async (event) => {
|
||||
$("chat-error").textContent = error.message;
|
||||
if (error.status === 401) {
|
||||
token = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
updateButton();
|
||||
}
|
||||
} finally {
|
||||
@@ -934,6 +935,7 @@ $("the-button").addEventListener("click", async () => {
|
||||
try {
|
||||
const r = await api("POST", "/api/press");
|
||||
game.deadline = r.deadline;
|
||||
game.press_eligibility = r.press_eligibility;
|
||||
currentShareText = `I pushed #TheButton at ${formatPrecise(r.press.remaining_ms)}.`;
|
||||
$("press-result").textContent =
|
||||
`You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`;
|
||||
@@ -954,15 +956,10 @@ $("mastodon-instance").addEventListener("input", updateShareLinks);
|
||||
async function loadAuthConfig() {
|
||||
try {
|
||||
const response = await fetch("/api/config", { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
if (!response.ok) throw new Error("Identity configuration is unavailable");
|
||||
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 || "";
|
||||
oidcLoginUrl = config.login_url || "/auth/oidc/login";
|
||||
oidcRegisterUrl = config.register_url || "https://lists.eom.dev/accounts/register/";
|
||||
renderSections();
|
||||
if (callbackError) {
|
||||
const errorMessage = callbackError === "access_denied"
|
||||
@@ -973,36 +970,16 @@ async function loadAuthConfig() {
|
||||
$("oidc-error").textContent = errorMessage;
|
||||
}
|
||||
} catch {
|
||||
// Keep the local login UI available if the server has no OIDC config endpoint.
|
||||
// Never fall back to local credentials when Dex is unavailable.
|
||||
renderSections();
|
||||
}
|
||||
}
|
||||
|
||||
$("register-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
$("register-error").textContent = "";
|
||||
const action = ev.submitter?.value === "register" ? "register" : "login";
|
||||
try {
|
||||
const r = await api("POST", `/api/${action}`, {
|
||||
name: $("name-input").value,
|
||||
password: $("password-input").value,
|
||||
});
|
||||
token = r.token;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
syncPresence();
|
||||
$("password-input").value = "";
|
||||
updateButton();
|
||||
await loadMe();
|
||||
await loadLeaderboard();
|
||||
} catch (e) {
|
||||
$("register-error").textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
$("logout-button").addEventListener("click", async () => {
|
||||
await api("POST", "/api/logout").catch(() => {});
|
||||
token = null;
|
||||
subscribed = false;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
isAdmin = false;
|
||||
$("me-section").hidden = true;
|
||||
$("press-result").textContent = "";
|
||||
$("subscribe-result").textContent = "";
|
||||
|
||||
+4
-26
@@ -34,6 +34,7 @@
|
||||
<section id="button-section">
|
||||
<button id="the-button" class="zone-green" disabled>PRESS</button>
|
||||
<p class="button-caption">Every press buys everyone more time.</p>
|
||||
<p id="press-eligibility" class="fine-print" role="status" hidden></p>
|
||||
<p id="press-result" aria-live="polite"></p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -41,7 +42,7 @@
|
||||
<div class="section-browser">
|
||||
<div class="browser-heading"><span class="eyebrow">MAKE YOURSELF AT HOME</span><span class="site-badge">The Button / 01</span></div>
|
||||
<nav id="section-nav" aria-label="Game sections">
|
||||
<button type="button" data-section="account-section" aria-controls="account-section" aria-expanded="false">Log in / Join</button>
|
||||
<button type="button" data-section="account-section" aria-controls="account-section" aria-expanded="false">Sign in</button>
|
||||
<button type="button" data-section="leaderboard-section" aria-controls="leaderboard-section" aria-expanded="false">Leaderboard</button>
|
||||
<button type="button" data-section="seasons-section" aria-controls="seasons-section" aria-expanded="false">Seasons</button>
|
||||
<button type="button" data-section="chat-section" aria-controls="chat-section" aria-expanded="false">Chat</button>
|
||||
@@ -141,29 +142,6 @@
|
||||
</section>
|
||||
|
||||
<section id="account-section" data-panel hidden>
|
||||
<div id="register-section" hidden>
|
||||
<header class="panel-header">
|
||||
<p class="eyebrow">YOUR NEXT MOVE</p>
|
||||
<h2>Take your place.</h2>
|
||||
<p class="panel-description">Choose a name. Trust your timing. Every press is part of the story.</p>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<form id="register-form">
|
||||
<label class="field" for="name-input"><span>Player name</span>
|
||||
<input id="name-input" maxlength="24" placeholder="Your name" required autocomplete="username">
|
||||
</label>
|
||||
<label class="field" for="password-input"><span>Password</span>
|
||||
<input id="password-input" type="password" minlength="8" maxlength="128" placeholder="Password (8+ chars)" required autocomplete="current-password">
|
||||
</label>
|
||||
<div class="form-actions"><button class="primary-button" type="submit" value="login">Log in <span aria-hidden="true">→</span></button>
|
||||
<button type="submit" value="register">Sign up</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="register-error" class="error" role="status"></p>
|
||||
<p class="fine-print">Already playing? Log in with the same name and password to pick up where you left off.</p>
|
||||
</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>
|
||||
@@ -246,7 +224,7 @@
|
||||
<p id="chat-status" role="status">Loading messages…</p>
|
||||
</div>
|
||||
<div class="chat-composer">
|
||||
<p id="chat-login">Pull up a seat. Log in or join the game to say hello.</p>
|
||||
<p id="chat-login">Pull up a seat. Sign in with eom.dev to say hello.</p>
|
||||
<form id="chat-form" hidden>
|
||||
<label for="chat-input">Add to the conversation</label>
|
||||
<div class="chat-input-row">
|
||||
@@ -284,7 +262,7 @@
|
||||
<div class="panel-body">
|
||||
<ol class="rules-list">
|
||||
<li><div><h3>Keep the clock alive</h3><p>Every press resets the countdown for everyone.</p></div></li>
|
||||
<li><div><h3>Wait for your moment</h3><p>The lower the countdown, the more you score. Each zone awards a fixed number of points per press.</p></div></li>
|
||||
<li><div><h3>Wait for your moment</h3><p>The lower the countdown, the more you score. Each zone awards a fixed number of points per press. After your first press each season, two different players must press before you can press again.</p></div></li>
|
||||
<li><div><h3>Don't wait too long</h3><p>If the countdown reaches zero, this season's game ends. The next equinox or solstice starts a new countdown and resets scores. Accounts and chat stay.</p></div></li>
|
||||
</ol>
|
||||
<h3 class="subheading">Six zones. Increasing stakes.</h3>
|
||||
|
||||
@@ -273,7 +273,6 @@ button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visibl
|
||||
.field > span { color: var(--subtext); font-size: 0.75rem; font-weight: 600; }
|
||||
.field small { font-weight: 400; }
|
||||
.field input { width: 100%; }
|
||||
#register-form { display: grid; gap: 1rem; max-width: 420px; justify-content: stretch; }
|
||||
.form-actions { display: flex; flex-wrap: wrap; gap: 0.65rem; margin-top: 0.25rem; }
|
||||
.form-actions button { flex: 1; }
|
||||
#logout-button { flex-shrink: 0; font-size: 0.75rem; }
|
||||
|
||||
+120
-3
@@ -5,6 +5,8 @@ import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
@@ -23,6 +25,11 @@ class PersistenceTests(unittest.TestCase):
|
||||
"BUTTON_DATA_FILE": str(Path(directory) / "data.json"),
|
||||
"BUTTON_DATABASE_FILE": str(Path(directory) / "data.sqlite3"),
|
||||
"BUTTON_MAILING_LIST_FILE": str(Path(directory) / "mailing.csv"),
|
||||
"BUTTON_OIDC_ISSUER": "https://dex.example.test",
|
||||
"BUTTON_OIDC_CLIENT_ID": "button-test-client",
|
||||
"BUTTON_OIDC_CLIENT_SECRET": "test-client-secret",
|
||||
"BUTTON_OIDC_REDIRECT_URI": "https://button.example.test/auth/oidc/callback",
|
||||
"BUTTON_OIDC_REQUIRED_GROUP": "the-button-users",
|
||||
}):
|
||||
spec = importlib.util.spec_from_file_location("button_test_server", Path(__file__).with_name("server.py"))
|
||||
cls.game = importlib.util.module_from_spec(spec)
|
||||
@@ -38,6 +45,9 @@ class PersistenceTests(unittest.TestCase):
|
||||
self.game.failed_logins.clear()
|
||||
self.game.subscribed_emails.clear()
|
||||
self.game.presence.clear()
|
||||
# Exercise legacy data/API behavior in isolated unit tests only. A real
|
||||
# server cannot start without complete Dex configuration.
|
||||
self.game.OIDC_ENABLED = False
|
||||
|
||||
def sample_state(self):
|
||||
state = self.game.new_game()
|
||||
@@ -1219,6 +1229,9 @@ class PersistenceTests(unittest.TestCase):
|
||||
self.game.state["deadline"] = t + 1234
|
||||
with patch.object(self.game, "now_ms", return_value=t):
|
||||
self.assertEqual(request("/api/press", {}, token)[0], 200)
|
||||
status, denied = request("/api/press", {}, token)
|
||||
self.assertEqual(status, 409)
|
||||
self.assertIn("2 more distinct players", denied["error"])
|
||||
announcements = self.game.chat_messages()
|
||||
self.assertEqual(len(announcements), 2)
|
||||
self.assertEqual(announcements[1]["kind"], "milestone")
|
||||
@@ -1231,17 +1244,63 @@ class PersistenceTests(unittest.TestCase):
|
||||
self.game.state = self.game.load_state()
|
||||
self.assertEqual(self.game.chat_messages()[0], announcements[0])
|
||||
self.game.token_index = {th: pid for pid, p in self.game.state["players"].items() for th in p["token_hashes"]}
|
||||
_, bob = request("/api/register", {"name": "Bob", "password": "test-password"})
|
||||
self.assertEqual(request("/api/press", {}, bob["token"])[0], 200)
|
||||
status, denied = request("/api/press", {}, token)
|
||||
self.assertEqual(status, 409)
|
||||
self.assertIn("1 more distinct player", denied["error"])
|
||||
_, carol = request("/api/register", {"name": "Carol", "password": "test-password"})
|
||||
self.assertEqual(request("/api/press", {}, carol["token"])[0], 200)
|
||||
self.game.state = self.game.load_state()
|
||||
self.game.token_index = {th: pid for pid, p in self.game.state["players"].items() for th in p["token_hashes"]}
|
||||
self.assertEqual(request("/api/press", {}, token)[0], 200)
|
||||
status, logged_in = request("/api/login", {"name": "Alice", "password": "test-password"})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(request("/api/logout", {}, token)[0], 200)
|
||||
self.assertEqual(request("/api/press", {}, token)[0], 401)
|
||||
self.assertEqual(len([m for m in self.game.chat_messages() if m["kind"] == "press"]), 2)
|
||||
self.assertEqual(len([m for m in self.game.chat_messages() if m["kind"] == "press"]), 4)
|
||||
loaded = self.game.load_state()
|
||||
player = next(iter(loaded["players"].values()))
|
||||
player = loaded["players"][self.game.find_player_by_name("Alice")]
|
||||
self.assertTrue(player["subscribed"])
|
||||
self.assertEqual(player["token_hashes"], [self.game.hash_token(logged_in["token"])])
|
||||
self.assertEqual(len(loaded["presses"]), 2)
|
||||
self.assertEqual(len(loaded["presses"]), 4)
|
||||
|
||||
def test_press_eligibility_counts_two_distinct_other_players(self):
|
||||
self.game.state = self.game.new_game()
|
||||
self.assertTrue(self.game.press_eligibility("alice")["eligible"])
|
||||
self.game.state["presses"] = [
|
||||
{"player_id": "alice"},
|
||||
{"player_id": "bob"},
|
||||
{"player_id": "bob"},
|
||||
]
|
||||
gate = self.game.press_eligibility("alice")
|
||||
self.assertFalse(gate["eligible"])
|
||||
self.assertEqual(gate["other_players_pressed"], 1)
|
||||
self.assertEqual(gate["other_players_remaining"], 1)
|
||||
self.game.state["presses"].append({"player_id": "alice"})
|
||||
self.game.state["presses"].extend([
|
||||
{"player_id": "carol"},
|
||||
{"player_id": "dave"},
|
||||
])
|
||||
gate = self.game.press_eligibility("alice")
|
||||
self.assertTrue(gate["eligible"])
|
||||
self.assertEqual(gate["other_players_pressed"], 2)
|
||||
self.assertEqual(gate["other_players_remaining"], 0)
|
||||
self.game.state["presses"].append({"player_id": "alice"})
|
||||
gate = self.game.press_eligibility("alice")
|
||||
self.assertFalse(gate["eligible"])
|
||||
self.assertEqual(gate["other_players_pressed"], 0)
|
||||
self.assertEqual(gate["other_players_remaining"], 2)
|
||||
|
||||
def test_press_eligibility_in_state_is_private_to_authenticated_player(self):
|
||||
request = self.presence_client()
|
||||
status, public_state = request(path="/api/state")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIsNone(public_state["press_eligibility"])
|
||||
status, own_state = request(path="/api/state", token="alice-token")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(own_state["press_eligibility"]["other_players_pressed"], 0)
|
||||
self.assertEqual(own_state["press_eligibility"]["other_players_remaining"], 2)
|
||||
|
||||
def test_oidc_requires_verified_email_and_required_service_group(self):
|
||||
from joserfc import jwt
|
||||
@@ -1278,6 +1337,26 @@ class PersistenceTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.game.verify_oidc_id_token(denied_token, "nonce-value")
|
||||
|
||||
def test_server_refuses_to_start_without_dex_configuration(self):
|
||||
env = os.environ.copy()
|
||||
for key in (
|
||||
"BUTTON_OIDC_ISSUER", "BUTTON_OIDC_CLIENT_ID", "BUTTON_OIDC_CLIENT_SECRET",
|
||||
"BUTTON_OIDC_REDIRECT_URI", "BUTTON_OIDC_REQUIRED_GROUP",
|
||||
):
|
||||
env.pop(key, None)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", "import runpy; runpy.run_path('server.py')"],
|
||||
cwd=Path(__file__).parent,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=15,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Dex OIDC is required", result.stderr)
|
||||
|
||||
def test_oidc_subject_mapping_preserves_existing_player_and_scores(self):
|
||||
state = self.sample_state()
|
||||
state["presses"] = [dict(state["presses"][0])]
|
||||
@@ -1314,6 +1393,44 @@ class PersistenceTests(unittest.TestCase):
|
||||
with self.assertRaises(PermissionError):
|
||||
self.game.issue_oidc_player({**claims, "sub": "outside-user", "preferred_username": "Outside"}, ["other-service"])
|
||||
|
||||
def test_oidc_player_name_prefers_display_name_and_falls_back_to_uid(self):
|
||||
self.game.state = self.game.new_game()
|
||||
self.game.token_index = {}
|
||||
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"):
|
||||
display_name, _ = self.game.issue_oidc_player({
|
||||
"sub": "display-name-sub", "displayName": "Display Name", "name": "OIDC Name", "uid": "displayuid",
|
||||
}, ["the-button-users"])
|
||||
oidc_name, _ = self.game.issue_oidc_player({
|
||||
"sub": "oidc-name-sub", "name": "Dex Name", "uid": "dexuid",
|
||||
}, ["the-button-users"])
|
||||
uid_name, _ = self.game.issue_oidc_player({
|
||||
"sub": "uid-sub", "uid": "regularuid", "preferred_username": "otheruid",
|
||||
}, ["the-button-users"])
|
||||
self.assertEqual(display_name, "Display Name")
|
||||
self.assertEqual(oidc_name, "Dex Name")
|
||||
self.assertEqual(uid_name, "regularuid")
|
||||
|
||||
def test_oidc_login_updates_linked_player_name_without_losing_history(self):
|
||||
self.game.state = self.sample_state()
|
||||
self.game.state["presses"] = [dict(self.game.state["presses"][0])]
|
||||
self.game.token_index = {}
|
||||
claims = {"sub": "dex-sub-bob", "name": "Robert", "uid": "bobuid", "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, _ = self.game.issue_oidc_player(claims, ["the-button-users"])
|
||||
self.assertEqual(name, "Robert")
|
||||
self.assertEqual(self.game.state["players"]["bob"]["name"], "Robert")
|
||||
self.assertEqual(len(self.game.state["presses"]), 1)
|
||||
|
||||
def test_oidc_id_token_signature_issuer_audience_nonce_and_email_claims(self):
|
||||
from joserfc import jwt
|
||||
from joserfc.jwk import RSAKey
|
||||
|
||||
Reference in New Issue
Block a user