Repo is now a monorepo:
- server/: the kaya backend, unchanged in behaviour, plus:
- GET /api/me for SPA session detection
- last_move recorded on every play and broadcast in the game state, so
clients can show who played which card the moment they play it
- legal_moves per hand card for the player on turn (rules stay
server-side)
- static catch-all route serving the compiled SPA with index.html
fallback; Tortoise context now bound only for /api/* requests
- configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
lobby (create match / join by code), live game page over websocket with
card images (CC0 woodcut napoletane deck), capture picker, move banner,
game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
app image serves the SPA; compose builds from the repo root with
overridable ports/OIDC env
Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
60 lines
1.9 KiB
Python
60 lines
1.9 KiB
Python
"""Authentication helpers on top of the kaya-oidc mixin.
|
|
|
|
Scopa has no application roles: every authenticated user may create and
|
|
join games. Authorization beyond login is game membership, checked against
|
|
the live game state in Redis.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Callable, Mapping, Optional
|
|
|
|
from kaya.core import HttpContext, WebSocket
|
|
from kaya.oidc import OIDCUser
|
|
|
|
from .app import oidc_mixin
|
|
|
|
|
|
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
|
"""Return the authenticated user of a WebSocket connection, if any.
|
|
|
|
The session mixin injects ``session`` into the websocket wrapper; the
|
|
OIDC mixin stores the userinfo there at login. Patched in tests.
|
|
"""
|
|
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)
|
|
|
|
|
|
def display_name(user: OIDCUser) -> str:
|
|
"""Best-effort human-readable name for a user."""
|
|
for key in ("name", "preferred_username", "email"):
|
|
value = user.get(key)
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
return user.sub
|
|
|
|
|
|
def require_auth(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
"""Decorator: gate a handler on being authenticated (any OIDC user).
|
|
|
|
Responds ``401`` with a JSON error envelope when unauthenticated —
|
|
unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to
|
|
the login page (wrong for a JSON API).
|
|
"""
|
|
|
|
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
|
|
if oidc_mixin.get_user(ctx) is None:
|
|
await ctx.send_bytes(
|
|
401,
|
|
b'{"error":"unauthenticated"}',
|
|
{"content-type": ("application/json",)},
|
|
)
|
|
return
|
|
await handler(ctx, *args, **kwargs)
|
|
|
|
return guarded
|