Fix OIDC subject collisions by introducing canonical provider-plus-subject principals

- Add canonical_principal(provider, sub) => 'oidc:v1:<provider>:<sub>'
- Preserve provider identity across HTTP sessions and WebSockets
- Apply principals throughout: game creation/joining/seating, WebSocket views/actions,
  Redis engine state, match history, leaderboard focus, ratings, /api/me, OpenAPI,
  and telemetry
- Widen identity columns to 1024 chars with schema-only aerich migration (5_20261024221522_canonical_principals.py)
- Document provider names as stable security identifiers
- Add regression tests: same subject at different providers cannot share games, private hands,
  join codes, moves, history, or ratings; distinct seat and rating ownership verified
- All platform, integration, game, and frontend tests pass; mypy clean
This commit is contained in:
2026-10-24 22:25:44 +00:00
parent 4d940add1c
commit 71493f720b
30 changed files with 591 additions and 228 deletions
@@ -43,7 +43,11 @@ from tavolo.platform import (
PlayerResult,
Seat,
)
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
from tavolo.platform.auth import (
AuthenticatedUser,
canonical_principal,
get_ws_user, # noqa: F401 (re-exported for patching)
)
from tavolo.platform.deadlines import DeadlineScheduler
from tavolo.platform.openapi import SCHEMAS
from tavolo.platform.registry import GameRegistry
@@ -326,13 +330,32 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
return OIDCUser({"sub": sub, "preferred_username": name or sub})
def make_principal(sub: str, provider: str = "default") -> str:
return canonical_principal(provider, sub)
def make_identity(
sub: str, name: Optional[str] = None, provider: str = "default"
) -> AuthenticatedUser:
claims = make_user(sub, name)
return AuthenticatedUser(
provider=provider,
claims=claims,
principal=canonical_principal(provider, sub),
)
@contextlib.contextmanager
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."""
def oidc_user(
oidc: OIDCProviders,
sub: str,
name: Optional[str] = None,
provider: Optional[str] = None,
) -> Iterator[OIDCUser]:
"""Context manager: fake a login, using the first provider by default."""
user = make_user(sub, name)
provider = next(iter(oidc.providers))
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
selected = provider or next(iter(oidc.providers))
patcher = _mock.patch.object(oidc, "get_users", return_value={selected: user})
patcher.start()
try:
yield user
@@ -341,13 +364,20 @@ def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iter
@contextlib.contextmanager
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
one per websocket connection, in order. Once exhausted it keeps
returning the last user."""
def ws_users(
users: Sequence[OIDCUser | AuthenticatedUser], provider: str = "default"
) -> Iterator[None]:
"""Patch ``auth.get_ws_user`` to hand out identities in sequence."""
from tavolo.platform import auth
remaining = list(users)
remaining = [
user if isinstance(user, AuthenticatedUser) else AuthenticatedUser(
provider=provider,
claims=user,
principal=canonical_principal(provider, user.sub),
)
for user in users
]
last = remaining[-1] if remaining else None
def _next(_ws):