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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user