Compare commits

..
Author SHA1 Message Date
woggioni f59064e580 Upgrade kaya to 0.0.7
CI / Build and push docker image (push) Successful in 1m56s
2026-10-01 23:43:55 +08:00
woggioni a89bd06010 Support multiple OIDC providers
CI / Build and push docker image (push) Successful in 2m27s
kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.

The platform now authenticates through OIDCProviders: auth.current_user
returns the session's user (sessions stay single-identity, so at most one
provider is logged in) and require_auth/route handlers use it, while the
websocket path keeps scanning for any provider's user. New routes expose
the configuration to the SPA and keep old links working: GET
/api/auth/providers lists the login URLs, GET /auth/login redirects to
the first provider and GET /auth/logout to whichever provider the
session is logged in with. The lobby renders one login button per
provider from that endpoint.

Update the env examples, compose and k8s manifests to the indexed scheme,
document the multiple-provider setup and the migration, and cover it with
config, auth and route tests.
2026-10-01 20:38:51 +08:00
woggioni 5eccaafadc Upgrade kaya to 0.0.6
kaya-oidc 0.0.6 namespaces its session state per provider as
oidc:{provider}:user (the default provider is "default", so the key is
oidc:default:user). The unprefixed oidc_user key is no longer written:
left unadapted, every websocket upgrade would be rejected with 401 and
traced requests would silently lose their user.id attribute. Adopt it
with a session_user helper on tavolo.platform.auth that finds any
provider's user, used by both get_ws_user and telemetry's
session_tag_hook. Websocket and route handlers keep their signatures.

kaya-otel 0.0.6 only enriches websocket spans with standard URL, network
and peer attributes (plus error.type), which needs no application change.

Regenerate the lock: the eight kaya pins move to 0.0.6 and, the wheels'
declared metadata being unchanged, nothing else moves. tavolo-platform
now floors kaya-core>=0.0.6 (it reads the new session layout) and
tavolo-app floors kaya-otel>=0.0.6.

As kaya documents, the session-key move signs existing logged-in users
out once; they simply log in again.
2026-10-01 20:38:21 +08:00
woggioni 6e9d1a4652 Wake deadline consumers on enqueue instead of polling every second
CI / Build and push docker image (push) Successful in 1m15s
2026-09-30 07:40:04 +08:00
woggioni 723b3b11cf Upgrade kaya to 0.0.5
CI / Build and push docker image (push) Successful in 1m20s
kaya 0.0.5 surfaces a vanished websocket peer as
kaya.core.WebSocketDisconnect instead of leaking granian's
RSGIProtocolClosed: receive() reports a close message so iteration ends
normally, sends raise the new exception, and KayaApp.handle_websocket
swallows it as normal termination. Adopt it: drop the granian-specific
workaround from the platform websocket handler, catch
WebSocketDisconnect in the send helper, and model the new semantics in
the abrupt-disconnect test (with coverage that a send on a vanished
peer is ignored).

Regenerate the lock: the eight kaya pins move to 0.0.5 (declared
metadata is unchanged) and pip-compile also bumps PyJWT to 2.15.1, a
security release with no breaking changes for kaya-oidc's jwt.decode
usage. tavolo-platform now floors kaya-core>=0.0.5 (it imports
WebSocketDisconnect) and tavolo-app floors kaya-otel>=0.0.5.
2026-09-30 06:38:02 +08:00
woggioni ed850aa73c Sort the player's hand by rank then suit 2026-09-30 00:58:31 +08:00
woggioni 2910fc4600 Refuse websocket upgrades with HTTP statuses, not close codes
A close() before accept() carries the HTTP status of the refused
upgrade, not a WebSocket close code: RSGI (granian) uses the value
verbatim as the response status, so 4401 panicked on the invalid status
(InvalidStatusCode) and — granian being built with panic = "abort" —
aborted the worker and took the whole app down. ASGI servers answer 403
regardless, and browsers report a refused handshake as an abnormal close
(1006), so the 4401/4403/4404 codes never reached clients anyway.

Reject 401/404/403 to mirror GET /api/games/{id}, and classify a failed
first connection in the game page through the REST API so players still
see "session expired" / "game no longer available" instead of a retry
loop. Add a regression test asserting every pre-accept rejection uses a
status in the HTTP range.
2026-09-29 22:31:41 +08:00
woggioni 21911caf9e Poll deadlines with ZRANGE BYSCORE instead of the deprecated ZRANGEBYSCORE 2026-09-28 05:50:54 +08:00
38 changed files with 1436 additions and 199 deletions
+6 -2
View File
@@ -78,8 +78,12 @@ Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see
run the backend with:
```sh
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
OIDC_0_PROVIDER=default \
OIDC_0_ISSUER=http://localhost:8180/tavolo \
OIDC_0_CLIENT_ID=tavolo \
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
```
+17 -10
View File
@@ -21,7 +21,7 @@
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
# reach the app with:
# kubectl port-forward -n tavolo svc/tavolo 8080:80
# and set OIDC_REDIRECT_URI to match whatever URL the browser uses.
# and set OIDC_0_REDIRECT_URI to match whatever URL the browser uses.
---
apiVersion: v1
@@ -81,15 +81,22 @@ data:
# OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing
# OTEL_METRIC_EXPORT_INTERVAL_MILLIS: "60000"
# OTEL_METRICS_POLL_INTERVAL_SECONDS: "15"
# OIDC (provider lives in another namespace).
OIDC_CLIENT_ID: tavolo
OIDC_POST_LOGIN_REDIRECT: /
OIDC_POST_LOGOUT_REDIRECT: /
# OIDC (the provider lives in another namespace). Each provider is an
# indexed block: OIDC_<n>_PROVIDER names it in the login URL
# (/auth/<provider>/login) and indices must be contiguous from 0. Add
# OIDC_1_*, OIDC_2_*, ... blocks for more providers; add their secrets
# to tavolo-secrets as well.
OIDC_0_PROVIDER: default
OIDC_0_CLIENT_ID: tavolo
OIDC_0_POST_LOGIN_REDIRECT: /
OIDC_0_POST_LOGOUT_REDIRECT: /
# Issuer URL as seen by the browser AND the pods (see notes above).
OIDC_ISSUER: REPLACE_ME
OIDC_0_ISSUER: REPLACE_ME
# Public callback URL of this deployment, e.g.
# http://<host>:<port>/auth/callback (must be allowed at the provider).
OIDC_REDIRECT_URI: REPLACE_ME
# http://<host>:<port>/auth/default/callback (must be allowed at the
# provider). When unset it defaults to
# http://localhost:8080/auth/<provider>/callback.
OIDC_0_REDIRECT_URI: REPLACE_ME
---
apiVersion: v1
@@ -106,8 +113,8 @@ stringData:
# The only Postgres secret: the password for DATABASE_USER at
# DATABASE_HOST (both configured in the tavolo-config ConfigMap).
DATABASE_PASSWORD: REPLACE_ME
# Client secret for OIDC_CLIENT_ID at the provider.
OIDC_CLIENT_SECRET: REPLACE_ME
# Client secret for OIDC_0_CLIENT_ID at the provider.
OIDC_0_CLIENT_SECRET: REPLACE_ME
---
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
+8 -6
View File
@@ -103,13 +103,15 @@ services:
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
# By default the app and browsers reach the mock IdP under the same
# name (see README /etc/hosts note); override OIDC_ISSUER and
# OIDC_REDIRECT_URI to use a real provider or a different host port.
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo}
# name (see README /etc/hosts note); override the OIDC_0_* values to
# use a real provider or a different host port. Additional providers
# take OIDC_1_*, OIDC_2_*, ... blocks (indices are contiguous from 0).
OIDC_0_PROVIDER: ${OIDC_0_PROVIDER:-default}
OIDC_0_ISSUER: ${OIDC_0_ISSUER:-http://mockoauth:8180/tavolo}
# The mock OIDC server does not validate clients: any id/secret works.
OIDC_CLIENT_ID: tavolo
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
OIDC_0_CLIENT_ID: tavolo
OIDC_0_CLIENT_SECRET: ${OIDC_0_CLIENT_SECRET:-dev-secret}
OIDC_0_REDIRECT_URI: ${OIDC_0_REDIRECT_URI:-http://localhost:8080/auth/default/callback}
REDIS_URL: redis://redis:6379/0
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
+23 -6
View File
@@ -13,12 +13,29 @@ DATABASE_OPTIONS=
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
#DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any
# client id/secret works. For a real IdP like Keycloak, use its values here.)
OIDC_ISSUER=http://localhost:8180/tavolo
OIDC_CLIENT_ID=tavolo
OIDC_CLIENT_SECRET=dev-secret
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
# OIDC providers. Each provider is an indexed block: OIDC_<n>_PROVIDER
# (required; names the provider in the login URL /auth/<provider>/login and
# in its session state), OIDC_<n>_ISSUER, OIDC_<n>_CLIENT_ID,
# OIDC_<n>_CLIENT_SECRET (optional: leave unset for public clients),
# OIDC_<n>_REDIRECT_URI (defaults to
# http://localhost:8080/auth/<provider>/callback), OIDC_<n>_POST_LOGIN_REDIRECT
# and OIDC_<n>_POST_LOGOUT_REDIRECT (default /). Indices must be contiguous
# from 0; add a second provider with an OIDC_1_* block (and so on).
#
# The values below target the dev mock-oauth2-server (it does not validate
# clients, so any client id/secret works). For a real IdP like Keycloak,
# use its values and register the redirect URI there.
OIDC_0_PROVIDER=default
OIDC_0_ISSUER=http://localhost:8180/tavolo
OIDC_0_CLIENT_ID=tavolo
OIDC_0_CLIENT_SECRET=dev-secret
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback
# A second provider, e.g. Google:
#OIDC_1_PROVIDER=google
#OIDC_1_ISSUER=https://accounts.google.com
#OIDC_1_CLIENT_ID=...
#OIDC_1_CLIENT_SECRET=...
#OIDC_1_REDIRECT_URI=http://localhost:8080/auth/google/callback
# Redis (session and live-game storage). Unset to fall back to in-memory
# stores (only sensible for local development with a single worker).
+56 -8
View File
@@ -43,7 +43,8 @@ Because the browser and the app both talk to the OIDC issuer at
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
```
Then log in: `GET http://localhost:8080/auth/login`, type any username
Then log in: `GET http://localhost:8080/auth/login` (a provider-agnostic
alias that redirects to the first configured provider), type any username
(e.g. `alice`) in the interactive login, and you are redirected back with a
session cookie. That cookie authenticates both the REST API and the
WebSocket endpoint.
@@ -65,14 +66,17 @@ All configuration comes from environment variables (see `.env.example`):
| `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) |
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
| `OIDC_<n>_PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth/<provider>/login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` |
| `OIDC_<n>_ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL |
| `OIDC_<n>_CLIENT_ID` | `tavolo` ¹ | OIDC client id |
| `OIDC_<n>_CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients |
| `OIDC_<n>_REDIRECT_URI` | `http://localhost:8080/auth/<provider>/callback` | Login callback URL; must be registered at the provider |
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
| `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker |
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
@@ -90,6 +94,44 @@ All configuration comes from environment variables (see `.env.example`):
| `OTEL_METRICS_POLL_INTERVAL_SECONDS` | `15` | How often the session/game count gauges are refreshed |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
¹ When no `OIDC_<n>_*` variable is set at all, a single provider named
`default` pointing at the dev mock IdP is used. Indices must be contiguous
from 0 (`OIDC_0_*`, `OIDC_1_*`, ...).
### Multiple OIDC providers
Providers are configured in indexed blocks; each becomes its own login
route `/auth/<provider>/login` with its own callback URL and session
namespace:
```sh
OIDC_0_PROVIDER=google
OIDC_0_ISSUER=https://accounts.google.com
OIDC_0_CLIENT_ID=...
OIDC_0_CLIENT_SECRET=...
OIDC_0_REDIRECT_URI=https://tavolo.example.com/auth/google/callback
OIDC_1_PROVIDER=github
OIDC_1_ISSUER=https://github.com/login/oauth
OIDC_1_CLIENT_ID=...
OIDC_1_CLIENT_SECRET=...
OIDC_1_REDIRECT_URI=https://tavolo.example.com/auth/github/callback
```
The login page reads `GET /api/auth/providers` and shows one button per
provider. `GET /auth/login` redirects to the first provider (useful for
bookmarks and scripts), and `GET /auth/logout` signs out whichever
provider the session is logged in with.
Sessions hold a single identity: logging in with another provider
replaces the previous one. Each provider's `sub` is a separate account,
so match history and Elo ratings are per provider identity (someone using
two providers has two accounts). Migrating from the pre-0.0.6 unindexed
variables: rename `OIDC_ISSUER`/`OIDC_CLIENT_ID`/... to `OIDC_0_...`, add
`OIDC_0_PROVIDER`, and register the new callback path
(`/auth/<provider>/callback`) at the provider. Sessions survive only when
the provider is named `default`; otherwise users log in once more.
## Logging
The application logs through the Python stdlib `logging` module, one
@@ -176,6 +218,9 @@ aggregate with `max`, not `sum`.
on any player being connected and survive the death of any worker
(delivery is at-least-once; engines revalidate the entry's token
against the live state before firing).
- `tavolo:deadlines:events` — a pub/sub channel announcing newly enqueued
deadlines; it wakes every worker's consumer immediately, while the
heartbeat poll remains as a backstop for lost signals.
### Postgres (statistics, via Tortoise ORM + aerich migrations)
@@ -233,8 +278,11 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
## WebSocket protocol
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
players are accepted; unauthenticated, unknown-game and non-player
connections are closed with codes `4401`, `4404` and `4403` respectively.
players are accepted; the upgrade is refused before the handshake
completes with an HTTP status mirroring `GET /api/games/{id}`: `401`
unauthenticated, `404` unknown game, `403` not seated. (Browsers report
a refused handshake as an abnormal close — there is no close code to
read.)
Server → client messages are JSON objects with a `type`:
+4 -3
View File
@@ -10,7 +10,8 @@ from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
os.environ.setdefault("OIDC_0_PROVIDER", "default")
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
os.environ.pop("REDIS_URL", None)
+1 -1
View File
@@ -34,7 +34,7 @@ dev = [
"httpx-ws",
]
otel = [
"kaya-otel>=0.0.4",
"kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0",
"opentelemetry-instrumentation-redis>=0.66b0",
"opentelemetry-instrumentation-system-metrics>=0.66b0",
+22 -14
View File
@@ -6,7 +6,7 @@ platform:
- :class:`~kaya.session.SessionMixin` (sessions persisted in Redis via
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
otherwise an in-memory store — e.g. for tests)
- :class:`~kaya.oidc.OIDCMixin` (OIDC login)
- :class:`~kaya.oidc.OIDCProviders` (the configured OIDC providers)
- :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` (Postgres match
statistics; skipped for ``/api/health`` and the OpenAPI documentation
endpoints)
@@ -35,7 +35,7 @@ from typing import Any, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from kaya.cors import CorsMixin
from kaya.oidc import OIDCConfig, OIDCMixin
from kaya.oidc import OIDCConfig, OIDCProviders
from kaya.openapi import OpenAPIMixin
from kaya.session import SessionMixin
from redis.asyncio import Redis
@@ -156,18 +156,26 @@ else:
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
session_mixin = SessionMixin(session_store)
oidc_mixin = OIDCMixin(
OIDCConfig(
issuer=settings.oidc_issuer,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
redirect_uri=settings.oidc_redirect_uri,
post_login_redirect=settings.oidc_post_login_redirect,
post_logout_redirect=settings.oidc_post_logout_redirect,
fetch_userinfo=True,
),
oidc = OIDCProviders(
[
OIDCConfig(
provider=provider.provider,
issuer=provider.issuer,
client_id=provider.client_id,
client_secret=provider.client_secret,
redirect_uri=provider.redirect_uri,
post_login_redirect=provider.post_login_redirect,
post_logout_redirect=provider.post_logout_redirect,
fetch_userinfo=True,
)
for provider in settings.oidc_providers
],
session=session_mixin,
)
log.info(
"OIDC providers: %s",
", ".join(provider.provider for provider in settings.oidc_providers),
)
openapi_mixin = OpenAPIMixin(
title="tavolo",
version=_pkg_version("tavolo-app"),
@@ -192,10 +200,10 @@ platform = Platform(
registry=registry,
game_store=game_store,
scheduler=scheduler,
oidc=oidc_mixin,
oidc=oidc,
)
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
mixins: list[KayaMixin] = [session_mixin, oidc, tortoise_mixin, openapi_mixin,
PlatformMixin(platform), DeadlineSchedulerMixin(scheduler)]
telemetry = telemetry_from_settings(settings, session_store, game_store)
if telemetry is not None:
+140 -20
View File
@@ -6,6 +6,7 @@ dataclass. No pydantic-settings, no settings module.
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from typing import Optional, Tuple
from urllib.parse import quote
@@ -39,6 +40,135 @@ def _env_bool(name: str, default: bool = False) -> bool:
return value.strip().lower() in ("1", "true", "yes", "on")
# Per-provider OIDC settings are indexed with a 0-based suffix:
# OIDC_<n>_PROVIDER, OIDC_<n>_ISSUER, ... Indices must be contiguous from 0.
_OIDC_VARIABLES = (
"PROVIDER",
"ISSUER",
"CLIENT_ID",
"CLIENT_SECRET",
"REDIRECT_URI",
"POST_LOGIN_REDIRECT",
"POST_LOGOUT_REDIRECT",
)
_OIDC_ENV = re.compile(r"^OIDC_(\d+)_([A-Z0-9_]+)$")
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
# The pre-0.0.6 unindexed names, detected only to point the migration out.
_LEGACY_OIDC_VARIABLES = (
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
"OIDC_POST_LOGIN_REDIRECT",
"OIDC_POST_LOGOUT_REDIRECT",
)
_DEFAULT_OIDC_ISSUER = "http://localhost:8180/tavolo"
_DEFAULT_OIDC_CLIENT_ID = "tavolo"
@dataclass(frozen=True)
class OidcProviderSettings:
"""One OIDC provider, parsed from the ``OIDC_<n>_*`` variables.
``provider`` names the provider in URLs (``/auth/<provider>/login``)
and namespaces its session state (``oidc:<provider>:...``); it must
match kaya-oidc's ``[A-Za-z0-9_-]+`` pattern.
"""
provider: str
issuer: str
client_id: str
client_secret: Optional[str]
redirect_uri: str
post_login_redirect: str
post_logout_redirect: str
def _oidc_providers_from_env() -> Tuple[OidcProviderSettings, ...]:
"""Parse the indexed ``OIDC_<n>_*`` provider configuration.
With no indexed variables at all, fall back to a single provider
named ``default`` pointing at the dev mock IdP, mirroring the
pre-0.0.6 defaults. The old unindexed variables are rejected with a
migration hint: they would otherwise be silently ignored.
"""
legacy = [name for name in _LEGACY_OIDC_VARIABLES if os.environ.get(name)]
if legacy:
renamed = ", ".join(
f"{name} -> OIDC_0_{name[len('OIDC_'):]}" for name in legacy
)
raise RuntimeError(
"the OIDC variables are now indexed per provider "
f"({renamed}; add OIDC_0_PROVIDER)"
)
indices: set = set()
for name in os.environ:
match = _OIDC_ENV.match(name)
if match is None:
continue
index, variable = match.groups()
if variable not in _OIDC_VARIABLES:
raise RuntimeError(
f"unknown OIDC environment variable {name!r}; expected one of "
+ ", ".join(f"OIDC_<n>_{v}" for v in _OIDC_VARIABLES)
)
indices.add(int(index))
if not indices:
return (
OidcProviderSettings(
provider="default",
issuer=_DEFAULT_OIDC_ISSUER,
client_id=_DEFAULT_OIDC_CLIENT_ID,
client_secret=None,
redirect_uri="http://localhost:8080/auth/default/callback",
post_login_redirect="/",
post_logout_redirect="/",
),
)
missing = sorted(set(range(max(indices) + 1)) - indices)
if missing:
raise RuntimeError(
"OIDC provider indices must be contiguous from 0; missing "
+ ", ".join(f"OIDC_{index}_*" for index in missing)
)
providers = []
seen = set()
for index in sorted(indices):
prefix = f"OIDC_{index}_"
provider = _env(prefix + "PROVIDER")
if _PROVIDER_NAME.match(provider) is None:
raise RuntimeError(
f"{prefix}PROVIDER must be a non-empty string of letters, "
f"digits, underscores or hyphens (got {provider!r})"
)
if provider in seen:
raise RuntimeError(f"duplicate OIDC provider name {provider!r}")
seen.add(provider)
providers.append(
OidcProviderSettings(
provider=provider,
issuer=_env(prefix + "ISSUER"),
client_id=_env(prefix + "CLIENT_ID"),
client_secret=os.environ.get(prefix + "CLIENT_SECRET") or None,
# kaya-oidc's OIDCProviders derives the callback route from
# the provider name; keep the default in sync with it.
redirect_uri=_env(
prefix + "REDIRECT_URI",
f"http://localhost:8080/auth/{provider}/callback",
),
post_login_redirect=_env(prefix + "POST_LOGIN_REDIRECT", "/"),
post_logout_redirect=_env(prefix + "POST_LOGOUT_REDIRECT", "/"),
)
)
return tuple(providers)
def _database_url_from_parts(engine: str,
user: str,
password: Optional[str],
@@ -70,15 +200,10 @@ def _database_url_from_parts(engine: str,
@dataclass(frozen=True)
class Settings:
database_url: str
oidc_issuer: str
oidc_client_id: str
oidc_client_secret: Optional[str]
oidc_redirect_uri: str
# Where the browser is sent after login/logout. In production the SPA is
# served by this app ("/"); in development point these at the trunk dev
# server (e.g. "http://localhost:8000/").
oidc_post_login_redirect: str
oidc_post_logout_redirect: str
# The configured OIDC providers, in index order. Each provider
# authenticates users independently and namespaces its session state;
# see OidcProviderSettings above.
oidc_providers: Tuple[OidcProviderSettings, ...]
app_host: str
app_port: int
redis_url: Optional[str]
@@ -97,10 +222,10 @@ class Settings:
# card for them, covering disconnects and idle players (wired into the
# ScoponeEngine; see tavolo.app).
turn_timeout_seconds: int
# Upper bound on how long the deadline consumer sleeps between polls.
# Locally enqueued deadlines wake the consumer immediately; the
# heartbeat only bounds the discovery delay for deadlines enqueued by
# other workers.
# Upper bound on how long the deadline consumer sleeps between polls
# when nothing else wakes it. Enqueued deadlines are announced over a
# pub/sub channel, so this is only the backstop recovering lost
# notifications and deadlines orphaned by a dead worker.
deadline_heartbeat_ms: int
# Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset uses the built-in default: DEBUG to the console.
@@ -147,12 +272,7 @@ class Settings:
# Raw DSN query string (e.g. "ssl=require"); empty = none.
options=os.environ.get("DATABASE_OPTIONS", ""),
),
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
oidc_providers=_oidc_providers_from_env(),
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")),
# When unset, sessions and live games fall back to in-memory
@@ -163,7 +283,7 @@ class Settings:
static_dir=_env("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "30000")),
logging_config=os.environ.get("LOGGING_CONFIG"),
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
@@ -28,12 +28,13 @@ import hashlib
import logging
from dataclasses import dataclass
from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Union
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from kaya.core import KayaApp, KayaMixin
from kaya.session import InMemorySessionStore, SessionStore
from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis
from tavolo.platform.auth import session_user
from tavolo.platform.store import GameStore
from .config import Settings
@@ -108,8 +109,8 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
"session.id",
hashlib.sha256(session_id.encode()).hexdigest()[:16],
)
user = session.get("oidc_user")
if isinstance(user, Mapping):
user = session_user(session)
if user is not None:
sub = user.get("sub")
if sub:
span.set_attribute("user.id", str(sub))
@@ -9,9 +9,9 @@ description = "Game-independent multiplayer game platform (lobby, live play, mat
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"kaya-core",
"kaya-core>=0.0.7",
"kaya-session",
"kaya-oidc",
"kaya-oidc>=0.0.7",
"kaya-openapi",
"tortoise-orm",
"redis",
@@ -9,7 +9,29 @@ from __future__ import annotations
from typing import Any, Callable, Mapping, Optional
from kaya.core import HttpContext, WebSocket
from kaya.oidc import OIDCMixin, OIDCUser
from kaya.oidc import OIDCProviders, OIDCUser
from kaya.session import Session
# kaya-oidc namespaces its session state per provider as
# ``oidc:{provider}:user`` (since 0.0.6), so the lookup cannot assume a
# fixed key or provider name.
_OIDC_PREFIX = "oidc:"
_USER_SUFFIX = ":user"
def session_user(session: Session) -> Optional[OIDCUser]:
"""Return the authenticated user stored in a session, if any.
The kaya-oidc mixin writes the userinfo at login under a per-provider
key, so any provider's user is accepted (the configured provider is
``default`` unless the app says otherwise).
"""
for key in session:
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
claims = session.get(key)
if isinstance(claims, Mapping):
return OIDCUser(claims)
return None
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
@@ -21,10 +43,7 @@ def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
session = getattr(ws, "session", None)
if session is None:
return None
claims = session.get("oidc_user")
if not isinstance(claims, Mapping):
return None
return OIDCUser(claims)
return session_user(session)
def display_name(user: OIDCUser) -> str:
@@ -36,7 +55,16 @@ def display_name(user: OIDCUser) -> str:
return user.sub
def require_auth(oidc: OIDCMixin) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
"""Return the authenticated user of an HTTP request, if any.
Sessions are single-identity by default, so at most one provider has
a user; if several do, the first configured provider wins.
"""
return next(iter(oidc.get_users(ctx).values()), None)
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Build a decorator gating a handler on being authenticated.
Responds ``401`` with a JSON error envelope when unauthenticated —
@@ -46,7 +74,7 @@ def require_auth(oidc: OIDCMixin) -> Callable[[Callable[..., Any]], Callable[...
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
if oidc.get_user(ctx) is None:
if current_user(oidc, ctx) is None:
await ctx.send_bytes(
401,
b'{"error":"unauthenticated"}',
@@ -11,8 +11,11 @@ machinery; the *meaning* of each deadline belongs to the game engine:
token), and the scheduler enqueues it in the store's shared deadline
queue (a Redis sorted set in production, see
:mod:`tavolo.platform.store`); enqueueing is idempotent;
* a background consumer running on **every** worker polls the queue for
due entries and hands them to the engine's
* enqueueing a deadline announces it on a shared pub/sub channel, so
every worker's consumer wakes immediately instead of waiting for its
next poll; the consumer also wakes on a slow heartbeat, which is the
backstop recovering notifications lost to a reconnect or a worker
crash. Due entries are handed to the engine's
:meth:`~tavolo.platform.engine.GameEngine.fire_deadline` under the
per-game lock; the engine revalidates the token against the live state
and raises :class:`~tavolo.platform.errors.GameError` when the entry
@@ -65,8 +68,8 @@ class DeadlineScheduler:
"""Enqueue and fire the deadlines game engines ask for.
Holds the store and the registry so a single instance serves every
game type. One consumer task (and its wake-up event) is kept per
event loop — tests run each test on a fresh loop.
game type. One consumer task, its listener task and its wake-up
event are kept per event loop — tests run each test on a fresh loop.
``context_binder`` is an optional async callable the consumer awaits
before persisting a finished match. The consumer is a long-lived task
@@ -80,14 +83,19 @@ class DeadlineScheduler:
store: GameStore,
registry: GameRegistry,
heartbeat_ms: int = 1000,
retry_ms: int = 1000,
context_binder: Optional[Callable[[], Awaitable[None]]] = None,
) -> None:
self._store = store
self._registry = registry
self._heartbeat = heartbeat_ms / 1000
# Backoff for entries that failed to process and for listener
# reconnects; deliberately independent of the (slow) heartbeat.
self._retry_delay = retry_ms / 1000
self._context_binder = context_binder
self._consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
self._wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
self._listeners: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
async def sync_deadline(self, session: GameSession) -> None:
"""Enqueue the deadline the current state implies, if any.
@@ -109,6 +117,16 @@ class DeadlineScheduler:
self.ensure_consumer()
due_at = deadline.due_at.timestamp()
await self._store.add_deadline(encode(entry), due_at)
# Announce the enqueue so consumers on other workers wake up too.
# The entry is already queued, so a lost notification only delays
# their discovery to the next poll backstop.
try:
await self._store.notify_deadline()
except Exception:
log.warning(
"deadline enqueue notification failed; relying on the poll backstop",
exc_info=True,
)
wake = self._wake_events.get(asyncio.get_running_loop())
if wake is not None:
wake.set()
@@ -190,15 +208,22 @@ class DeadlineScheduler:
if old.is_closed():
self._consumers.pop(old, None)
self._wake_events.pop(old, None)
self._listeners.pop(old, None)
task = self._consumers.get(loop)
if task is None or task.done():
self._wake_events[loop] = asyncio.Event()
self._consumers[loop] = loop.create_task(self._run(loop))
listener = self._listeners.get(loop)
if listener is None or listener.done():
self._listeners[loop] = loop.create_task(self._listen(loop))
log.debug("deadline consumer started")
def stop_consumer(self, loop: asyncio.AbstractEventLoop) -> None:
task = self._consumers.pop(loop, None)
self._wake_events.pop(loop, None)
listener = self._listeners.pop(loop, None)
if listener is not None:
listener.cancel()
if task is not None:
task.cancel()
@@ -228,21 +253,48 @@ class DeadlineScheduler:
log.exception("deadline consumer: poll failed; retrying")
failed = True
if failed:
# A failed entry is still due: back off to the heartbeat
# instead of polling it again immediately.
delay = self._heartbeat
# A failed entry is still due: back off briefly instead of
# polling it again immediately. Kept independent of the
# heartbeat, which is only a slow backstop.
delay = self._retry_delay
try:
await asyncio.wait_for(wake.wait(), timeout=delay)
except asyncio.TimeoutError:
pass
async def _listen(self, loop: asyncio.AbstractEventLoop) -> None:
"""Wake the consumer on deadline-enqueue notifications.
The subscription is best effort: a dropped connection or a lost
message is recovered by the consumer's heartbeat backstop, so
this task only has to reconnect and keep forwarding signals.
"""
while True:
try:
async with self._store.subscribe_deadlines() as events:
async for _ in events:
wake = self._wake_events.get(loop)
if wake is None:
# Shut down: the consumer is gone.
return
wake.set()
except asyncio.CancelledError:
raise
except Exception:
log.exception("deadline listener failed; reconnecting")
try:
await asyncio.sleep(self._retry_delay)
except asyncio.CancelledError:
raise
class DeadlineSchedulerMixin(KayaMixin):
"""Run the deadline consumer for the whole app lifetime.
Every worker (and every pod) runs the same consumer; coordination
happens exclusively through the shared deadline queue and the
per-game locks, so any worker may fire any game's deadline.
Every worker (and every pod) runs the same consumer and listener;
coordination happens exclusively through the shared deadline queue,
its notification channel and the per-game locks, so any worker may
fire any game's deadline.
"""
def __init__(self, scheduler: DeadlineScheduler) -> None:
@@ -2,7 +2,7 @@
``Platform`` bundles the collaborators every platform endpoint needs —
the game registry, the live-game store, the deadline scheduler and the
OIDC mixin used for authentication — so route and websocket modules
OIDC providers used for authentication — so route and websocket modules
never reach for module-global state. :class:`PlatformMixin` is a plain
:class:`~kaya.core.KayaMixin`: applying it registers the lobby, stats
and health HTTP routes plus the live-play websocket endpoint, and the
@@ -14,7 +14,7 @@ from __future__ import annotations
from dataclasses import dataclass
from kaya.core import KayaApp, KayaMixin
from kaya.oidc import OIDCMixin
from kaya.oidc import OIDCProviders
from .deadlines import DeadlineScheduler
from .registry import GameRegistry
@@ -28,7 +28,7 @@ class Platform:
registry: GameRegistry
game_store: GameStore
scheduler: DeadlineScheduler
oidc: OIDCMixin
oidc: OIDCProviders
class PlatformMixin(KayaMixin):
@@ -41,9 +41,10 @@ class PlatformMixin(KayaMixin):
# Imported here so module import order stays acyclic: the route
# modules reference Platform only for typing.
from . import ws
from .routes import games, health, me, stats
from .routes import auth, games, health, me, stats
health.register(app, self.platform)
auth.register(app, self.platform)
me.register(app, self.platform)
games.register(app, self.platform)
stats.register(app, self.platform)
@@ -0,0 +1,62 @@
"""Authentication plumbing for the single-page app.
kaya-oidc registers one login/callback/logout route per provider
(``/auth/<provider>/...``); this module adds the provider-agnostic
pieces:
* ``GET /api/auth/providers`` lists the configured providers with their
login URLs, so the SPA can offer a choice without hardcoding names;
* ``GET /auth/login`` redirects to the first configured provider;
* ``GET /auth/logout`` redirects to the provider this session is logged
in with (or the SPA root when not logged in), so the UI can keep a
single logout link.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..http import send_json
if TYPE_CHECKING:
from ..mixin import Platform
def _login_url(platform: "Platform", provider: str) -> str:
return f"{platform.oidc.base_path}/{provider}/login"
def register(app: KayaApp, platform: "Platform") -> None:
providers = list(platform.oidc.providers)
@app.GET("/api/auth/providers")
@operation(summary="Configured login providers",
description="Names and login URLs of the configured OIDC "
"providers, in configuration order.",
tags=["auth"],
responses={200: {"description": "The configured providers"}})
async def auth_providers(ctx: HttpContext) -> None:
await send_json(
ctx,
200,
{
"providers": [
{"name": name, "login_url": _login_url(platform, name)}
for name in providers
]
},
)
@app.GET("/auth/login")
async def login(ctx: HttpContext) -> None:
await ctx.send_empty(302, {"Location": _login_url(platform, providers[0])})
@app.GET("/auth/logout")
async def logout(ctx: HttpContext) -> None:
logged_in = platform.oidc.logged_in_providers(ctx)
location = (
f"{platform.oidc.base_path}/{logged_in[0]}/logout" if logged_in else "/"
)
await ctx.send_empty(302, {"Location": location})
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any, Dict
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import display_name, require_auth
from ..auth import current_user, display_name, require_auth
from ..engine import GameSession, Seat
from ..errors import GameError
from ..http import (
@@ -183,7 +183,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
await send_error(ctx, 400, "options must be an object")
return
user = platform.oidc.get_user(ctx)
user = current_user(platform.oidc, ctx)
assert user is not None # enforced by @require_auth
session = GameSession(
id=str(uuid.uuid4()),
@@ -244,7 +244,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
await send_error(ctx, 400, "code is required")
return
user = platform.oidc.get_user(ctx)
user = current_user(platform.oidc, ctx)
assert user is not None
existing = await platform.game_store.find_by_code(code)
if existing is None:
@@ -325,7 +325,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
if session is None:
await send_error(ctx, 404, "game not found")
return
user = platform.oidc.get_user(ctx)
user = current_user(platform.oidc, ctx)
assert user is not None
if not session.seated(user.sub):
await send_error(ctx, 403, "forbidden")
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import display_name, require_auth
from ..auth import current_user, display_name, require_auth
from ..http import send_json
if TYPE_CHECKING:
@@ -25,6 +25,6 @@ def register(app: KayaApp, platform: "Platform") -> None:
})
@require_auth(platform.oidc)
async def me(ctx: HttpContext) -> None:
user = platform.oidc.get_user(ctx)
user = current_user(platform.oidc, ctx)
assert user is not None # enforced by @require_auth
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
@@ -15,7 +15,7 @@ from uuid import UUID
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import require_auth
from ..auth import current_user, require_auth
from ..elo import INITIAL_RATING
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer, PlayerRating
@@ -104,7 +104,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
if error is not None:
await send_error(ctx, 400, error)
return
user = platform.oidc.get_user(ctx)
user = current_user(platform.oidc, ctx)
assert user is not None
queryset = Match.filter(players__user_sub=user.sub).distinct()
if game_type is not None:
@@ -193,7 +193,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
})
@require_auth(platform.oidc)
async def my_ratings(ctx: HttpContext) -> None:
user = platform.oidc.get_user(ctx)
user = current_user(platform.oidc, ctx)
assert user is not None
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
await send_json(ctx, 200, {
@@ -26,8 +26,9 @@ signal (never the state) means updated state reaches connections on
every worker without leaking hidden information into the channel.
Timeouts are driven by a shared delayed-deadline queue: producers
enqueue an opaque ``member`` string with a due timestamp, and a consumer
on every worker polls for due entries (see
enqueue an opaque ``member`` string with a due timestamp and announce it
on a shared "deadline enqueued" channel, and a consumer on every worker
wakes on that signal to process due entries (see
:mod:`tavolo.platform.deadlines`). Delivery is at-least-once — entries
are removed only after they are processed — so a worker dying
mid-processing cannot lose a deadline; engines revalidate entries
@@ -55,6 +56,9 @@ GAME_KEY_PREFIX = "tavolo:game:"
CODE_KEY_PREFIX = "tavolo:code:"
CHANNEL_PREFIX = "tavolo:game:"
DEADLINES_KEY = "tavolo:deadlines"
# Channel announcing newly enqueued deadline entries, so every worker's
# consumer can wake immediately instead of polling on its heartbeat.
DEADLINES_CHANNEL = "tavolo:deadlines:events"
# Sorted set of running games (score = expiry timestamp), maintained by the
# stores so the telemetry gauge can count ongoing matches without loading
# every session. Finished games are removed on their final save.
@@ -165,6 +169,18 @@ class GameStore(ABC):
async def remove_deadline(self, member: str) -> None:
"""Remove ``member`` from the queue; a no-op when absent."""
@abstractmethod
async def notify_deadline(self) -> None:
"""Wake every worker's deadline consumer (best effort).
Notification is advisory: consumers stay correct when a signal
is lost, because their poll backstop recovers it.
"""
@abstractmethod
def subscribe_deadlines(self) -> AsyncContextManager[AsyncIterator[None]]:
"""Async context manager yielding deadline-enqueue signals."""
def _channel(game_id: str) -> str:
return f"{CHANNEL_PREFIX}{game_id}:events"
@@ -244,10 +260,17 @@ class RedisGameStore(GameStore):
await self._redis.zadd(DEADLINES_KEY, {member: due_at})
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
# ``ZRANGE ... BYSCORE`` is the non-deprecated spelling of the
# old ``ZRANGEBYSCORE``.
members = cast(
list,
await self._redis.zrangebyscore(
DEADLINES_KEY, "-inf", now, start=0, num=limit
await self._redis.zrange(
DEADLINES_KEY,
"-inf",
now,
byscore=True,
offset=0,
num=limit,
),
)
return [m.decode("utf-8") if isinstance(m, bytes) else m for m in members]
@@ -259,6 +282,20 @@ class RedisGameStore(GameStore):
async def remove_deadline(self, member: str) -> None:
await self._redis.zrem(DEADLINES_KEY, member)
async def notify_deadline(self) -> None:
await self._redis.publish(DEADLINES_CHANNEL, "deadline")
@contextlib.asynccontextmanager
async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]:
pubsub = self._redis.pubsub()
await pubsub.subscribe(DEADLINES_CHANNEL)
try:
yield _redis_events(pubsub)
finally:
with contextlib.suppress(Exception):
await pubsub.unsubscribe(DEADLINES_CHANNEL)
await pubsub.aclose()
async def _redis_events(pubsub) -> AsyncIterator[None]:
async for message in pubsub.listen():
@@ -277,6 +314,7 @@ class InMemoryGameStore(GameStore):
self._locks: Dict[str, asyncio.Lock] = {}
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
self._deadlines: Dict[str, float] = {}
self._deadline_subscribers: Set[asyncio.Queue] = set()
def _lock_for(self, game_id: str) -> asyncio.Lock:
lock = self._locks.get(game_id)
@@ -345,6 +383,19 @@ class InMemoryGameStore(GameStore):
async def remove_deadline(self, member: str) -> None:
self._deadlines.pop(member, None)
async def notify_deadline(self) -> None:
for queue in list(self._deadline_subscribers):
queue.put_nowait(_BUMP)
@contextlib.asynccontextmanager
async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]:
queue: asyncio.Queue = asyncio.Queue()
self._deadline_subscribers.add(queue)
try:
yield _queue_events(queue)
finally:
self._deadline_subscribers.discard(queue)
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
while True:
@@ -50,7 +50,7 @@ from contextlib import suppress
from logging import getLogger
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
from kaya.core import KayaApp, WebSocket
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
from . import auth
from .engine import GameSession
@@ -68,6 +68,21 @@ def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
return {"type": "error", "code": code, "message": message}
async def _reject(ws: WebSocket, status: int) -> None:
"""Refuse a websocket upgrade with the HTTP ``status``.
Rejections happen before :meth:`WebSocket.accept`, so ``close()``
carries the HTTP status of the refused upgrade rather than a
WebSocket close code: ASGI servers answer ``403`` per the spec, and
RSGI (granian) uses the value verbatim as the response status.
Passing a 4xxx close code here would panic granian's RSGI layer on
the invalid status — and since granian is built with
``panic = "abort"``, that kills the worker and takes the whole app
down.
"""
await ws.close(status)
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
@@ -89,17 +104,17 @@ def register(app: KayaApp, platform: "Platform") -> None:
user = auth.get_ws_user(ws)
if user is None:
log.debug("websocket %s rejected: no authenticated user", game_id)
await ws.close(4401)
await _reject(ws, 401)
return
session = await platform.game_store.load(game_id)
if session is None:
log.debug("websocket rejected: unknown game %s", game_id)
await ws.close(4404)
await _reject(ws, 404)
return
if not session.seated(user.sub):
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
await ws.close(4403)
await _reject(ws, 403)
return
await ws.accept()
@@ -109,7 +124,13 @@ def register(app: KayaApp, platform: "Platform") -> None:
async def send(payload: Dict[str, Any]) -> None:
async with send_lock:
await ws.send_text(json.dumps(payload))
try:
await ws.send_text(json.dumps(payload))
except WebSocketDisconnect:
# The peer went away without a close handshake: kaya
# raises this from the transport, and the receive loop
# ends momentarily.
pass
await send(_state_message(platform, session, user.sub))
# Backstop: make sure the current deadline is queued even if its
@@ -8,7 +8,7 @@ without importing any real game. Its rules: the first player to reach
``target`` plays wins the match.
:func:`make_platform` builds a throwaway :class:`~kaya.core.KayaApp`
wired with in-memory stores, a dummy OIDC mixin (patched per test) and
wired with in-memory stores, dummy OIDC providers (patched per test) and
a sqlite :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin`, so
tests construct their own app instead of importing a global one.
"""
@@ -22,7 +22,7 @@ from functools import wraps
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
from kaya.core import KayaApp
from kaya.oidc import OIDCConfig, OIDCMixin, OIDCUser
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin
@@ -220,20 +220,26 @@ class DummyEngine(GameEngine):
def make_platform(
engines: Sequence[GameEngine] = (DummyEngine(),),
heartbeat_ms: int = 50,
oidc_providers: Sequence[str] = ("default",),
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
"""Build a throwaway app + platform wired with in-memory stores."""
registry = GameRegistry(engines)
game_store = InMemoryGameStore(registry)
session_mixin = SessionMixin(InMemorySessionStore())
oidc_mixin = OIDCMixin(
OIDCConfig(
issuer="http://localhost:8180/tavolo",
client_id="tavolo",
client_secret=None,
redirect_uri="http://localhost:8080/auth/callback",
post_login_redirect="/",
post_logout_redirect="/",
),
oidc = OIDCProviders(
[
OIDCConfig(
provider=name,
issuer=f"http://localhost:8180/{name}",
client_id="tavolo",
client_secret=None,
redirect_uri=f"http://localhost:8080/auth/{name}/callback",
post_login_redirect="/",
post_logout_redirect="/",
)
for name in oidc_providers
],
session=session_mixin,
)
tortoise_mixin = TortoiseMixin(
@@ -251,19 +257,19 @@ def make_platform(
scheduler = DeadlineScheduler(
game_store,
registry,
heartbeat_ms=50,
heartbeat_ms=heartbeat_ms,
context_binder=tortoise_mixin.ensure_context,
)
platform = Platform(
registry=registry,
game_store=game_store,
scheduler=scheduler,
oidc=oidc_mixin,
oidc=oidc,
)
app = KayaApp(
mixins=[
session_mixin,
oidc_mixin,
oidc,
tortoise_mixin,
openapi_mixin,
PlatformMixin(platform),
@@ -319,10 +325,12 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
@contextlib.contextmanager
def oidc_user(oidc: OIDCMixin, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: patch ``oidc.get_user`` to return this user."""
def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user for the
first configured provider."""
user = make_user(sub, name)
patcher = _mock.patch.object(oidc, "get_user", return_value=user)
provider = next(iter(oidc.providers))
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
patcher.start()
try:
yield user
@@ -0,0 +1,152 @@
"""Tests for the auth helpers' OIDC user lookup and gating.
kaya-oidc (0.0.6) namespaces session state per provider as
``oidc:{provider}:user`` and tavolo wires an :class:`OIDCProviders`; these
tests pin both contracts so a kaya upgrade or a provider change cannot
silently break HTTP or websocket authentication.
"""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from typing import Any
from kaya.oidc import OIDCConfig, OIDCProviders
from kaya.session import InMemorySessionStore, Session, SessionMixin
from helpers import async_test
from tavolo.platform.auth import current_user, get_ws_user, require_auth, session_user
def _providers(*names: str) -> OIDCProviders:
session = SessionMixin(InMemorySessionStore())
return OIDCProviders(
[
OIDCConfig(
provider=name,
issuer=f"http://localhost:8180/{name}",
client_id="tavolo",
redirect_uri=f"http://localhost:8080/auth/{name}/callback",
)
for name in names
],
session=session,
)
def _ctx(data: Any) -> Any:
return SimpleNamespace(session=Session("sid", data))
def _ws(session: Any) -> Any:
return SimpleNamespace(session=session)
class SessionUserTest(unittest.TestCase):
def test_finds_default_provider_user(self) -> None:
user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}}))
assert user is not None
self.assertEqual("alice", user.sub)
def test_finds_user_of_any_provider(self) -> None:
user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}}))
assert user is not None
self.assertEqual("alice", user.sub)
def test_ignores_flow_state_and_tokens(self) -> None:
session = Session(
"sid",
{
"oidc:default:state": "state",
"oidc:default:id_token": "token",
"oidc:default:access_token": "token",
},
)
self.assertIsNone(session_user(session))
def test_ignores_legacy_unprefixed_user_key(self) -> None:
session = Session("sid", {"oidc_user": {"sub": "alice"}})
self.assertIsNone(session_user(session))
def test_ignores_malformed_user_claims(self) -> None:
session = Session("sid", {"oidc:default:user": "alice"})
self.assertIsNone(session_user(session))
class GetWsUserTest(unittest.TestCase):
def test_returns_user_from_provider_namespaced_session(self) -> None:
session = Session(
"sid",
{"oidc:default:user": {"sub": "alice", "preferred_username": "Alice"}},
)
user = get_ws_user(_ws(session))
assert user is not None
self.assertEqual("alice", user.sub)
def test_missing_session_returns_none(self) -> None:
self.assertIsNone(get_ws_user(SimpleNamespace()))
class CurrentUserTest(unittest.TestCase):
def test_anonymous_session_has_no_user(self) -> None:
self.assertIsNone(current_user(_providers("default"), _ctx({})))
def test_returns_the_authenticated_provider_user(self) -> None:
user = current_user(
_providers("default", "google"),
_ctx({"oidc:google:user": {"sub": "alice"}}),
)
assert user is not None
self.assertEqual("alice", user.sub)
def test_first_configured_provider_wins(self) -> None:
# Single-identity sessions never hold two users; this only pins the
# tie-break should they ever coexist.
user = current_user(
_providers("default", "google"),
_ctx(
{
"oidc:default:user": {"sub": "alice"},
"oidc:google:user": {"sub": "bob"},
}
),
)
assert user is not None
self.assertEqual("alice", user.sub)
class RequireAuthTest(unittest.TestCase):
@async_test
async def test_rejects_anonymous_requests(self) -> None:
oidc = _providers("default")
ctx = _ctx({})
sent = {}
async def send_bytes(*args: Any) -> None:
sent["status"] = args[0]
ctx.send_bytes = send_bytes
handled = []
@require_auth(oidc)
async def handler(_ctx: Any) -> None:
handled.append(True)
await handler(ctx)
self.assertEqual(401, sent["status"])
self.assertEqual([], handled)
@async_test
async def test_allows_authenticated_requests(self) -> None:
oidc = _providers("default")
ctx = _ctx({"oidc:default:user": {"sub": "alice"}})
handled = []
@require_auth(oidc)
async def handler(request_ctx: Any) -> None:
user = current_user(oidc, request_ctx)
assert user is not None
handled.append(user.sub)
await handler(ctx)
self.assertEqual(["alice"], handled)
@@ -112,6 +112,38 @@ async def _finished(store, game_id: str) -> Optional[GameSession]:
return None
class NotificationTest(unittest.TestCase):
"""Deadline-enqueue notifications wake consumers on other workers."""
@async_test
async def test_notification_wakes_a_long_heartbeat_consumer(self) -> None:
# The consumer's heartbeat is a minute away and nothing sets its
# local wake event: only the pub/sub notification can deliver the
# deadline in time. This is the cross-worker path.
_, platform, _ = make_platform(heartbeat_ms=60_000)
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(deadline_in_seconds=0.1)
await store.save(session)
scheduler.ensure_consumer()
# Let the listener task subscribe before announcing.
await asyncio.sleep(0.05)
engine = platform.registry.require(session.game_type)
deadline = engine.next_deadline(session)
assert deadline is not None
member = encode({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
})
await store.add_deadline(member, deadline.due_at.timestamp())
await store.notify_deadline()
result = await _wait_for(lambda: _plays_is(store, session.id, 1))
self.assertIsNotNone(result, "notification did not wake the consumer")
class ProcessDueTest(unittest.TestCase):
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
@@ -0,0 +1,67 @@
"""Tests for the provider-agnostic auth routes.
``GET /api/auth/providers`` feeds the SPA's login buttons; ``/auth/login``
and ``/auth/logout`` are the generic aliases that redirect to the right
per-provider kaya route.
"""
from __future__ import annotations
import unittest
from unittest import mock
from httpx import ASGITransport, AsyncClient
from helpers import async_test, make_platform
class AuthProvidersRouteTest(unittest.TestCase):
@async_test
async def test_lists_providers_in_configuration_order(self) -> None:
app, _platform, _tortoise = make_platform(oidc_providers=("default", "google"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/auth/providers")
self.assertEqual(200, response.status_code)
self.assertEqual(
{
"providers": [
{"name": "default", "login_url": "/auth/default/login"},
{"name": "google", "login_url": "/auth/google/login"},
]
},
response.json(),
)
class GenericAuthRedirectTest(unittest.TestCase):
@async_test
async def test_login_redirects_to_the_first_provider(self) -> None:
app, _platform, _tortoise = make_platform(oidc_providers=("google", "github"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/auth/login", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/auth/google/login", response.headers["location"])
@async_test
async def test_logout_redirects_to_the_logged_in_provider(self) -> None:
app, platform, _tortoise = make_platform()
with mock.patch.object(
platform.oidc, "logged_in_providers", return_value=["default"]
):
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://127.0.0.1"
) as client:
response = await client.get("/auth/logout", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/auth/default/logout", response.headers["location"])
@async_test
async def test_logout_without_a_session_redirects_home(self) -> None:
app, _platform, _tortoise = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/auth/logout", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/", response.headers["location"])
@@ -4,10 +4,12 @@ from __future__ import annotations
import asyncio
import time
import unittest
from typing import Any, Optional, cast
from tavolo.platform import GameSession, Seat
from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import (
DEADLINES_KEY,
ONGOING_GAMES_KEY,
InMemoryGameStore,
RedisGameStore,
@@ -75,10 +77,39 @@ class _FakeRedis:
def __init__(self) -> None:
self.keys: dict = {}
self.zsets: dict = {}
self.zrange_calls: list = []
def pipeline(self, transaction: bool = True) -> _FakePipeline:
return _FakePipeline(self)
async def zadd(self, key: str, mapping: dict) -> int:
return cast(int, self.apply(("zadd", key, mapping)))
async def zrem(self, key: str, member: str) -> int:
return cast(int, self.apply(("zrem", key, member)))
async def zrange(
self,
name: str,
start: Any,
end: Any,
byscore: bool = False,
offset: Optional[int] = None,
num: Optional[int] = None,
**kwargs: object,
) -> list:
self.zrange_calls.append((name, start, end, byscore, offset, num))
members = sorted(
self.zsets.get(name, {}).items(), key=lambda item: item[1]
)
if byscore:
low = float("-inf") if start == "-inf" else float(start)
high = float("inf") if end == "+inf" else float(end)
members = [item for item in members if low <= item[1] <= high]
if offset is not None and num is not None:
members = members[offset : offset + num]
return [member for member, _ in members]
def apply(self, op: tuple) -> object:
name, *args = op
if name == "set":
@@ -239,6 +270,17 @@ class InMemoryGameStoreTest(unittest.TestCase):
self.assertEqual(60.0, await store.next_deadline())
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
@async_test
async def test_deadline_notifications_reach_subscribers(self) -> None:
store = InMemoryGameStore(_registry())
async with store.subscribe_deadlines() as events:
await store.notify_deadline()
signal = await asyncio.wait_for(anext(events), timeout=1.0)
self.assertIsNone(signal)
# The subscription is gone after the context exits: notifying
# again is a no-op rather than an error.
await store.notify_deadline()
class RedisGameStoreTest(unittest.TestCase):
@async_test
@@ -269,6 +311,24 @@ class RedisGameStoreTest(unittest.TestCase):
self.assertEqual(1, await store.count_ongoing())
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
@async_test
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
redis = _FakeRedis()
store = RedisGameStore(redis, _registry()) # type: ignore[arg-type]
await store.add_deadline("b", due_at=50.0)
await store.add_deadline("a", due_at=10.0)
await store.add_deadline("c", due_at=200.0)
# Re-adding an existing member only updates its due time.
await store.add_deadline("b", due_at=60.0)
self.assertEqual(["a"], await store.due_deadlines(now=20.0))
self.assertEqual(
[(DEADLINES_KEY, "-inf", 20.0, True, 0, 32)], redis.zrange_calls
)
# Matches come back in due-time order and honour the limit.
self.assertEqual(["a"], await store.due_deadlines(now=100.0, limit=1))
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
if __name__ == "__main__":
unittest.main()
@@ -1,13 +1,91 @@
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
from __future__ import annotations
import json
import unittest
from typing import Any, Dict, Mapping, Sequence
from httpx import ASGITransport, AsyncClient
from httpx_ws import WebSocketDisconnect, aconnect_ws
from httpx_ws import WebSocketDisconnect as HttpxWebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
from tavolo.platform import GameSession, Seat
class AbruptCloseSocket(WebSocket):
"""A socket whose peer vanished without a close handshake.
kaya surfaces this from the transport: ``receive()`` reports a
``close`` message so iteration ends normally, while sends raise
``WebSocketDisconnect``.
"""
def __init__(self, path: str, gone: bool = False) -> None:
self.path = path
self.query_string = ""
self.scheme = "ws"
self.headers: Mapping[str, Sequence[str]] = {}
self.client = ("127.0.0.1", 12345)
self.server = ("127.0.0.1", 80)
self.sent: list[Dict[str, Any]] = []
self.close_codes: list[int] = []
self.gone = gone
async def accept(self, headers: Any = None) -> None:
pass
async def receive(self) -> WebSocketMessage:
self.gone = True
return WebSocketMessage(kind="close")
async def send_text(self, data: str) -> None:
if self.gone:
raise WebSocketDisconnect(code=1006)
self.sent.append(json.loads(data))
async def send_bytes(self, data: bytes) -> None:
if self.gone:
raise WebSocketDisconnect(code=1006)
async def close(self, code: int = 1000) -> None:
self.close_codes.append(code)
async def __anext__(self) -> WebSocketMessage:
await self.receive()
raise StopAsyncIteration
class RecordingSocket(WebSocket):
"""A socket that only records pre-accept rejections."""
def __init__(self, path: str) -> None:
self.path = path
self.query_string = ""
self.scheme = "ws"
self.headers: Mapping[str, Sequence[str]] = {}
self.client = ("127.0.0.1", 12345)
self.server = ("127.0.0.1", 80)
self.close_codes: list[int] = []
async def accept(self, headers: Any = None) -> None:
raise AssertionError("a rejected socket must not be accepted")
async def receive(self) -> WebSocketMessage:
raise AssertionError("a rejected socket must not receive")
async def send_text(self, data: str) -> None:
raise AssertionError("a rejected socket must not send")
async def send_bytes(self, data: bytes) -> None:
raise AssertionError("a rejected socket must not send")
async def close(self, code: int = 1000) -> None:
self.close_codes.append(code)
async def __anext__(self) -> WebSocketMessage:
raise AssertionError("a rejected socket must not receive")
class WebSocketTest(unittest.TestCase):
@@ -87,6 +165,38 @@ class WebSocketTest(unittest.TestCase):
resent = await bob_ws.receive_json()
self.assertEqual("state", resent["type"])
@async_test
async def test_abrupt_disconnect_is_not_an_error(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws = AbruptCloseSocket(f"/ws/games/{game_id}")
with ws_users([make_user("bob")]):
# kaya reports a peer that vanished without a close handshake as
# a close message; the handler must exit cleanly.
await app.handle_websocket(ws)
self.assertEqual("state", ws.sent[0]["type"])
self.assertEqual([], ws.close_codes)
@async_test
async def test_send_on_vanished_peer_is_ignored(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws = AbruptCloseSocket(f"/ws/games/{game_id}", gone=True)
with ws_users([make_user("bob")]):
# Sending raises WebSocketDisconnect; the handler swallows it
# rather than letting it escape the forward task.
await app.handle_websocket(ws)
self.assertEqual([], ws.sent)
self.assertEqual([], ws.close_codes)
@async_test
async def test_game_over_broadcast(self) -> None:
app, platform, _ = make_platform()
@@ -121,10 +231,10 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
with self.assertRaises(WebSocketDisconnect) as caught:
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/no-such-game", ws_client):
pass
self.assertEqual(4404, caught.exception.code)
self.assertEqual(404, caught.exception.code)
@async_test
async def test_non_player_is_closed(self) -> None:
@@ -137,10 +247,10 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
with self.assertRaises(WebSocketDisconnect) as caught:
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(4403, caught.exception.code)
self.assertEqual(403, caught.exception.code)
@async_test
async def test_unauthenticated_is_closed(self) -> None:
@@ -148,10 +258,48 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
with self.assertRaises(WebSocketDisconnect) as caught:
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(4401, caught.exception.code)
self.assertEqual(401, caught.exception.code)
class RejectionStatusTest(unittest.TestCase):
"""Pre-accept rejections must carry HTTP statuses, not close codes.
Granian's RSGI layer uses the value passed to ``close()`` before
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
close code like 4401 is not a valid status and panics the worker
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
dies). Drive the rejections through the app with a recording socket
so the invariant is checked without a real server.
"""
@async_test
async def test_pre_accept_rejections_are_http_statuses(self) -> None:
app, platform, _ = make_platform()
session = GameSession(
id="seated-game",
game_type="dummy",
join_code="SEAT01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine = platform.registry.require("dummy")
engine.create(session, {})
await platform.game_store.save(session)
cases = [
("unauthenticated", [], "/ws/games/whatever", 401),
("unknown game", [make_user("alice")], "/ws/games/no-such-game", 404),
("not seated", [make_user("mallory")], f"/ws/games/{session.id}", 403),
]
for label, users, path, expected in cases:
ws = RecordingSocket(path)
with ws_users(users):
await app.handle_websocket(ws)
self.assertEqual([expected], ws.close_codes, label)
for code in ws.close_codes:
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
if __name__ == "__main__":
+9 -9
View File
@@ -63,7 +63,7 @@ idna==3.20
# httpx
iso8601==2.1.0
# via tortoise-orm
kaya-core==0.0.4
kaya-core==0.0.7
# via
# kaya-cors
# kaya-oidc
@@ -72,22 +72,22 @@ kaya-core==0.0.4
# kaya-rsgi
# kaya-session
# tavolo-app (pyproject.toml)
kaya-cors==0.0.4
kaya-cors==0.0.7
# via tavolo-app (pyproject.toml)
kaya-oidc==0.0.4
kaya-oidc==0.0.7
# via tavolo-app (pyproject.toml)
kaya-openapi==0.0.4
kaya-openapi==0.0.7
# via tavolo-app (pyproject.toml)
kaya-otel==0.0.4
kaya-otel==0.0.7
# via tavolo-app (pyproject.toml)
kaya-rsgi==0.0.4
kaya-rsgi==0.0.7
# via tavolo-app (pyproject.toml)
kaya-session==0.0.4
kaya-session==0.0.7
# via
# kaya-oidc
# kaya-session-redis
# tavolo-app (pyproject.toml)
kaya-session-redis==0.0.4
kaya-session-redis==0.0.7
# via tavolo-app (pyproject.toml)
opentelemetry-api==1.45.0
# via
@@ -150,7 +150,7 @@ pwo==0.1.2
# tavolo-app (pyproject.toml)
pycparser==3.0
# via cffi
pyjwt[crypto]==2.14.0
pyjwt[crypto]==2.15.1
# via
# kaya-oidc
# tavolo-app (pyproject.toml)
+4 -3
View File
@@ -10,8 +10,9 @@ from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
os.environ.setdefault("OIDC_0_PROVIDER", "default")
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
os.environ.pop("REDIS_URL", None)
+11 -6
View File
@@ -1,8 +1,9 @@
"""Helpers for faking the OIDC authenticated user during tests.
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
through :func:`tavolo.platform.auth.get_ws_user`. Patching those two entry
points lets route and websocket tests run entirely in-process with no IdP.
HTTP handlers funnel through ``OIDCProviders.get_users`` (wrapped by
``tavolo.platform.auth.current_user``); websocket handlers through
:func:`tavolo.platform.auth.get_ws_user`. Patching those two entry points
lets route and websocket tests run entirely in-process with no IdP.
"""
from __future__ import annotations
@@ -14,9 +15,13 @@ from kaya.oidc import OIDCUser
# Import the app first: it pulls in the route modules, which import
# ``tavolo.platform.auth`` themselves.
from tavolo.app import oidc_mixin
from tavolo.app import oidc
from tavolo.platform import auth
# The app's first configured provider (the test environment configures one
# named "default").
_PROVIDER = next(iter(oidc.providers))
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
return OIDCUser({"sub": sub, "preferred_username": name or sub})
@@ -24,9 +29,9 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
@contextlib.contextmanager
def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: patch ``oidc_mixin.get_user`` to return this user."""
"""Context manager: make ``oidc.get_users`` return this user."""
user = make_user(sub, name)
patcher = _mock.patch.object(oidc_mixin, "get_user", return_value=user)
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
patcher.start()
try:
yield user
+135
View File
@@ -178,5 +178,140 @@ class OTelSettingsTests(unittest.TestCase):
self.assertEqual(3, settings.otel_metrics_poll_interval_seconds)
class OidcSettingsTests(unittest.TestCase):
def test_defaults_to_the_dev_mock_provider(self):
settings = _settings({})
self.assertEqual(1, len(settings.oidc_providers))
provider = settings.oidc_providers[0]
self.assertEqual("default", provider.provider)
self.assertEqual("http://localhost:8180/tavolo", provider.issuer)
self.assertEqual("tavolo", provider.client_id)
self.assertIsNone(provider.client_secret)
self.assertEqual(
"http://localhost:8080/auth/default/callback", provider.redirect_uri
)
self.assertEqual("/", provider.post_login_redirect)
self.assertEqual("/", provider.post_logout_redirect)
def test_single_indexed_provider_defaults(self):
settings = _settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://accounts.google.com",
"OIDC_0_CLIENT_ID": "client-id",
"OIDC_0_CLIENT_SECRET": "client-secret",
})
self.assertEqual(1, len(settings.oidc_providers))
provider = settings.oidc_providers[0]
self.assertEqual("google", provider.provider)
self.assertEqual("https://accounts.google.com", provider.issuer)
self.assertEqual("client-id", provider.client_id)
self.assertEqual("client-secret", provider.client_secret)
# The callback default mirrors kaya's /auth/<provider>/callback.
self.assertEqual(
"http://localhost:8080/auth/google/callback", provider.redirect_uri
)
self.assertEqual("/", provider.post_login_redirect)
self.assertEqual("/", provider.post_logout_redirect)
def test_multiple_providers_are_ordered_by_index(self):
settings = _settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://accounts.google.com",
"OIDC_0_CLIENT_ID": "g",
"OIDC_1_PROVIDER": "github",
"OIDC_1_ISSUER": "https://github.com/login/oauth",
"OIDC_1_CLIENT_ID": "h",
"OIDC_1_REDIRECT_URI": "https://tavolo.example/auth/github/callback",
"OIDC_1_POST_LOGIN_REDIRECT": "/lobby",
"OIDC_1_POST_LOGOUT_REDIRECT": "/bye",
})
self.assertEqual(
["google", "github"], [p.provider for p in settings.oidc_providers]
)
github = settings.oidc_providers[1]
self.assertEqual(
"https://tavolo.example/auth/github/callback", github.redirect_uri
)
self.assertEqual("/lobby", github.post_login_redirect)
self.assertEqual("/bye", github.post_logout_redirect)
def test_empty_optional_values_fall_back_to_defaults(self):
settings = _settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://accounts.google.com",
"OIDC_0_CLIENT_ID": "g",
"OIDC_0_CLIENT_SECRET": "",
"OIDC_0_REDIRECT_URI": "",
})
provider = settings.oidc_providers[0]
self.assertIsNone(provider.client_secret)
self.assertEqual(
"http://localhost:8080/auth/google/callback", provider.redirect_uri
)
def test_missing_provider_name(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_PROVIDER"):
_settings({"OIDC_0_ISSUER": "https://idp", "OIDC_0_CLIENT_ID": "c"})
def test_missing_issuer(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_ISSUER"):
_settings({"OIDC_0_PROVIDER": "google", "OIDC_0_CLIENT_ID": "c"})
def test_missing_client_id(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_CLIENT_ID"):
_settings({"OIDC_0_PROVIDER": "google", "OIDC_0_ISSUER": "https://idp"})
def test_indices_must_start_at_zero(self):
with self.assertRaisesRegex(RuntimeError, "contiguous"):
_settings({
"OIDC_3_PROVIDER": "google",
"OIDC_3_ISSUER": "https://idp",
"OIDC_3_CLIENT_ID": "c",
})
def test_indices_must_be_contiguous(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_1_"):
_settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://idp",
"OIDC_0_CLIENT_ID": "c",
"OIDC_2_PROVIDER": "github",
"OIDC_2_ISSUER": "https://idp2",
"OIDC_2_CLIENT_ID": "c2",
})
def test_duplicate_provider_names_are_rejected(self):
with self.assertRaisesRegex(RuntimeError, "duplicate"):
_settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://idp",
"OIDC_0_CLIENT_ID": "c",
"OIDC_1_PROVIDER": "google",
"OIDC_1_ISSUER": "https://idp2",
"OIDC_1_CLIENT_ID": "c2",
})
def test_invalid_provider_name_is_rejected(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_PROVIDER"):
_settings({
"OIDC_0_PROVIDER": "not a name",
"OIDC_0_ISSUER": "https://idp",
"OIDC_0_CLIENT_ID": "c",
})
def test_unknown_indexed_variable_is_rejected(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_0_ISUER"):
_settings({
"OIDC_0_PROVIDER": "google",
"OIDC_0_ISSUER": "https://idp",
"OIDC_0_CLIENT_ID": "c",
"OIDC_0_ISUER": "typo",
})
def test_legacy_unindexed_variables_get_a_migration_hint(self):
with self.assertRaisesRegex(RuntimeError, "OIDC_ISSUER -> OIDC_0_ISSUER"):
_settings({"OIDC_ISSUER": "https://idp", "OIDC_CLIENT_ID": "c"})
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
"""Auth routes on the fully wired application.
The integration suite's app is built from the indexed test environment
(one provider named ``default``); these tests pin the public surface the
SPA and bookmarks rely on.
"""
from __future__ import annotations
import unittest
from httpx import ASGITransport, AsyncClient
from tavolo.app import app
from tests.helpers import async_test
class AuthRoutesTest(unittest.TestCase):
@async_test
async def test_providers_endpoint_reports_the_configured_provider(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/auth/providers")
self.assertEqual(200, response.status_code)
self.assertEqual(
{"providers": [{"name": "default", "login_url": "/auth/default/login"}]},
response.json(),
)
@async_test
async def test_generic_login_redirects_to_the_provider(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/auth/login", follow_redirects=False)
self.assertEqual(302, response.status_code)
self.assertEqual("/auth/default/login", response.headers["location"])
+21 -4
View File
@@ -151,7 +151,7 @@ class SessionTagHookTest(unittest.TestCase):
def test_tags_authenticated_session(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(
session=Session("sid", {"oidc_user": {"sub": "alice"}})
session=Session("sid", {"oidc:default:user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertEqual(
@@ -160,6 +160,23 @@ class SessionTagHookTest(unittest.TestCase):
)
self.assertEqual("alice", span.attributes["user.id"])
def test_tags_user_of_any_provider(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertEqual("alice", span.attributes["user.id"])
def test_ignores_legacy_unprefixed_user_key(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(
session=Session("sid", {"oidc_user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertIn("session.id", span.attributes)
self.assertNotIn("user.id", span.attributes)
def test_tags_session_without_user(self) -> None:
span = _RecordedSpan()
session_tag_hook(span, SimpleNamespace(session=Session("sid", {})))
@@ -169,7 +186,7 @@ class SessionTagHookTest(unittest.TestCase):
def test_tags_user_when_session_id_not_yet_minted(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(
session=Session(None, {"oidc_user": {"sub": "alice"}})
session=Session(None, {"oidc:default:user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertNotIn("session.id", span.attributes)
@@ -182,7 +199,7 @@ class SessionTagHookTest(unittest.TestCase):
def test_malformed_user_is_ignored(self) -> None:
span = _RecordedSpan()
carrier = SimpleNamespace(session=Session("sid", {"oidc_user": "alice"}))
carrier = SimpleNamespace(session=Session("sid", {"oidc:default:user": "alice"}))
session_tag_hook(span, carrier)
self.assertIn("session.id", span.attributes)
self.assertNotIn("user.id", span.attributes)
@@ -224,7 +241,7 @@ class SessionTagIntegrationTest(unittest.TestCase):
async def login(ctx: HttpContext) -> None:
session = ctx.session
assert session is not None
session["oidc_user"] = {"sub": "alice"}
session["oidc:default:user"] = {"sub": "alice"}
await ctx.send_str(200, "ok")
@app.GET("/me")
+3 -3
View File
@@ -131,7 +131,7 @@ class WebSocketTest(unittest.TestCase):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/no-such-game", ws_client):
pass
self.assertEqual(4404, caught.exception.code)
self.assertEqual(404, caught.exception.code)
@async_test
async def test_non_player_is_closed(self) -> None:
@@ -146,7 +146,7 @@ class WebSocketTest(unittest.TestCase):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(4403, caught.exception.code)
self.assertEqual(403, caught.exception.code)
@async_test
async def test_unauthenticated_is_closed(self) -> None:
@@ -156,7 +156,7 @@ class WebSocketTest(unittest.TestCase):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(4401, caught.exception.code)
self.assertEqual(401, caught.exception.code)
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
+39 -9
View File
@@ -22,6 +22,19 @@ pub async fn me() -> Result<Option<User>, String> {
resp.json().await.map(Some).map_err(|e| e.to_string())
}
/// Fetch the configured OIDC login providers (public endpoint).
pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
let resp = Request::get("/api/auth/providers")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.providers)
}
/// Fetch the card games the platform can host (for the creation dropdown).
pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
let resp = Request::get("/api/game-types")
@@ -87,16 +100,33 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
resp.json().await.map(Some).map_err(|e| e.to_string())
}
#[allow(dead_code)]
pub async fn game_state(game_id: &str) -> Result<GameView, String> {
let resp = Request::get(&format!("/api/games/{game_id}"))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
/// Why a game connection failed before the first snapshot arrived.
///
/// A refused WebSocket handshake is invisible to the browser: the close
/// event reports an abnormal closure (1006) with no HTTP status, so the
/// game page asks the REST API to tell a deliberate rejection from a
/// transient network problem.
pub enum GameEntry {
/// Not logged in (`401` from `GET /api/games/{id}`).
Unauthenticated,
/// Unknown game (`404`) or the user is not seated (`403`).
Unavailable,
/// The API could not be asked (or answered unexpectedly): treat the
/// connection loss as transient and retry.
Transient,
}
/// Classify a failed game connection via `GET /api/games/{game_id}`,
/// mirroring the statuses used to refuse the websocket upgrade.
pub async fn probe_game(game_id: &str) -> GameEntry {
match Request::get(&format!("/api/games/{game_id}")).send().await {
Ok(resp) => match resp.status() {
401 => GameEntry::Unauthenticated,
403 | 404 => GameEntry::Unavailable,
_ => GameEntry::Transient,
},
Err(_) => GameEntry::Transient,
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
+60
View File
@@ -344,6 +344,19 @@ pub struct GameTypesPage {
pub results: Vec<GameTypeInfo>,
}
/// One configured OIDC login provider (GET /api/auth/providers).
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AuthProvider {
pub name: String,
pub login_url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthProvidersPage {
#[serde(default)]
pub providers: Vec<AuthProvider>,
}
/// A join-code lookup result (GET /api/games/preview): the game's lobby
/// payload plus its display name, for the pre-join summary. The options
/// echoed by the engine land in `details` under their `options_schema`
@@ -551,6 +564,21 @@ pub fn card_asset(code: &str) -> String {
pub const CARD_BACK: &str = "/assets/cards/back.svg";
/// Display order key for a hand: rank ascending (01..10, i.e. 1..7 then
/// Fante, Cavallo, Re), then suit (denari, coppe, spade, bastoni).
/// Malformed codes sort last.
pub fn hand_sort_key(code: &str) -> (u8, u8) {
let rank = code.get(..2).and_then(|r| r.parse().ok()).unwrap_or(u8::MAX);
let suit = match code.get(2..3) {
Some("D") => 0,
Some("C") => 1,
Some("S") => 2,
Some("B") => 3,
_ => u8::MAX,
};
(rank, suit)
}
/// Human-friendly rank+suit label, e.g. `07D` -> "7 of denari".
pub fn card_label(code: &str) -> String {
if code.len() != 3 {
@@ -600,6 +628,38 @@ mod tests {
assert_eq!("7 di denari", card_label("07D"));
}
#[test]
fn hand_sort_orders_ranks_ascending() {
let mut hand = vec![
"10B".to_string(),
"01S".to_string(),
"07D".to_string(),
"08C".to_string(),
];
hand.sort_by_key(|c| hand_sort_key(c));
assert_eq!(vec!["01S", "07D", "08C", "10B"], hand);
}
#[test]
fn hand_sort_breaks_ties_by_suit() {
let mut hand = vec![
"02B".to_string(),
"02S".to_string(),
"02C".to_string(),
"02D".to_string(),
"01S".to_string(),
];
hand.sort_by_key(|c| hand_sort_key(c));
assert_eq!(vec!["01S", "02D", "02C", "02S", "02B"], hand);
}
#[test]
fn hand_sort_puts_malformed_codes_last() {
let mut hand = vec!["07D".to_string(), "xx".to_string(), "AB1".to_string()];
hand.sort_by_key(|c| hand_sort_key(c));
assert_eq!(vec!["07D", "xx", "AB1"], hand);
}
fn scopone_match() -> MatchSummary {
serde_json::from_value(json!({
"id": "m1",
+33 -15
View File
@@ -4,11 +4,13 @@ use std::cell::RefCell;
use std::rc::Rc;
use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::card::{card_back, card_img};
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::model::{card_label, hand_sort_key, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::ws::{self, GameSocket};
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
@@ -135,23 +137,38 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
let on_close = {
let id = id.clone();
let alive = alive.clone();
move |code: Option<u16>| {
move |_code: Option<u16>| {
if !alive.get() {
return;
}
ctx.closed.set(true);
match code {
Some(4401) => {
ctx.fatal.set(true);
ctx.error
.set(Some("Session expired — please log in again.".to_string()));
}
Some(4403) | Some(4404) => {
ctx.fatal.set(true);
ctx.error
.set(Some("This game is no longer available.".to_string()));
}
_ => schedule_retry(id.clone(), ctx, alive.clone()),
if ctx.game.get_clone().is_none() && ctx.attempts.get() == 0 {
// The connection ended before any snapshot arrived. A
// refused handshake looks exactly like a network drop to
// the browser (close code 1006, no HTTP status), so ask
// the API whether the server deliberately rejected us —
// in which case retrying is pointless — or the loss was
// transient. Only on the first failure: later attempts
// are governed by the retry backoff.
let id = id.clone();
let alive = alive.clone();
spawn_local(async move {
match api::probe_game(&id).await {
api::GameEntry::Unauthenticated => {
ctx.fatal.set(true);
ctx.error
.set(Some("Session expired — please log in again.".to_string()));
}
api::GameEntry::Unavailable => {
ctx.fatal.set(true);
ctx.error
.set(Some("This game is no longer available.".to_string()));
}
api::GameEntry::Transient => schedule_retry(id, ctx, alive),
}
});
} else {
schedule_retry(id.clone(), ctx, alive.clone());
}
}
};
@@ -576,7 +593,8 @@ fn table_view(
let viewer = player_for_seat(&game, viewer_seat);
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
let my_scope = viewer.as_ref().map(|p| p.scope).unwrap_or(0);
let hand = viewer.and_then(|p| p.hand).unwrap_or_default();
let mut hand = viewer.and_then(|p| p.hand).unwrap_or_default();
hand.sort_by_key(|c| hand_sort_key(c));
let current_selection = selected.get_clone();
let hand_cards = hand
.into_iter()
+53 -10
View File
@@ -14,7 +14,9 @@ use sycamore_router::navigate;
use crate::api;
use crate::components::card::card_back;
use crate::components::toast::toast;
use crate::model::{GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User};
use crate::model::{
AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User,
};
/// Used when the game-types fetch fails: match creation must still work.
fn fallback_game_types() -> Vec<GameTypeInfo> {
@@ -28,6 +30,15 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
}]
}
/// Human-readable label for a provider name ("google" -> "Google").
fn provider_label(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn json_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
@@ -283,6 +294,8 @@ fn join_preview_panel(
pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out.
let user = create_signal(Option::<Option<User>>::None);
// Configured login providers, for the logged-out card's buttons.
let auth_providers = create_signal(Vec::<AuthProvider>::new());
// The player's Elo ratings, one row per game type played.
let ratings = create_signal(Vec::<PlayerRating>::new());
let error = create_signal(Option::<String>::None);
@@ -400,6 +413,12 @@ pub fn LobbyPage() -> View {
}
});
spawn_local(async move {
if let Ok(list) = api::auth_providers().await {
auth_providers.set(list);
}
});
let on_create = move |_| {
let game_type = selected_game.get_clone();
let opts: serde_json::Map<String, serde_json::Value> =
@@ -462,15 +481,39 @@ pub fn LobbyPage() -> View {
}
p(class="text-secondary") { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: this must be a full page navigation
// to the backend's OIDC login endpoint.
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
// interception: these must be full page navigations
// to the backend's OIDC login endpoints.
(move || {
let providers = auth_providers.get_clone();
if providers.is_empty() {
// Providers could not be fetched: the
// generic route redirects to the first
// configured provider.
view! {
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
}
} else {
view! {
div(class="d-grid gap-2") {
(providers.iter().cloned().map(|p| view! {
a(
class="btn btn-warning btn-lg w-100",
href=p.login_url,
rel="external",
) {
"Log in with " (provider_label(&p.name))
}
}).collect::<Vec<_>>())
}
}
}
})
}
}
}
+5 -2
View File
@@ -55,8 +55,11 @@ impl GameSocket {
/// could not be created.
///
/// `on_close` fires exactly once when the connection ends; it receives the
/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not
/// seated, 4404 unknown game) or `None` for an abnormal network loss.
/// server close code when one was sent (e.g. 1000 for a normal closure) or
/// `None` for an abnormal loss. A *refused* handshake is not distinguishable
/// here: browsers report it as an abnormal closure (1006) and never expose
/// the HTTP status the server answered with, so callers must classify the
/// failure out of band.
pub fn connect(
game_id: &str,
on_message: impl Fn(ServerMessage) + 'static,