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
+2 -2
View File
@@ -1,5 +1,5 @@
"""Test helpers package for the tavolo-app integration suite."""
from .asynctest import async_test
from .oidc import make_user, oidc_user, ws_users
from .oidc import make_principal, make_user, oidc_user, ws_users
__all__ = ["async_test", "make_user", "oidc_user", "ws_users"]
__all__ = ["async_test", "make_principal", "make_user", "oidc_user", "ws_users"]
+16 -2
View File
@@ -17,6 +17,7 @@ from kaya.oidc import OIDCUser
# ``tavolo.platform.auth`` themselves.
from tavolo.app import oidc
from tavolo.platform import auth
from tavolo.platform.auth import AuthenticatedUser, canonical_principal
# The app's first configured provider (the test environment configures one
# named "default").
@@ -37,12 +38,17 @@ def make_user(
return OIDCUser(claims)
def make_principal(sub: str, provider: str = _PROVIDER) -> str:
return canonical_principal(provider, sub)
@contextlib.contextmanager
def oidc_user(
sub: str,
name: Optional[str] = None,
email: Optional[str] = None,
picture: Optional[str] = None,
provider: Optional[str] = None,
) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user.
@@ -52,7 +58,8 @@ def oidc_user(
scope's avatar claim.
"""
user = make_user(sub, name, email, picture)
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
selected = provider or _PROVIDER
patcher = _mock.patch.object(oidc, "get_users", return_value={selected: user})
patcher.start()
try:
yield user
@@ -65,7 +72,14 @@ 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."""
remaining = list(users)
remaining = [
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):
+9 -9
View File
@@ -23,7 +23,7 @@ from tavolo.briscola.state import (
PlayerState,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
class BriscolaRoutesTest(unittest.TestCase):
@@ -235,14 +235,14 @@ class BriscolaTurnTimeoutTest(unittest.TestCase):
id="briscola-turn-timeout-1",
game_type=engine.id,
join_code="BT0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
],
)
engine.create(session, {})
session.state.turn_timeout = 1
engine.join(session, "bob", "Bob")
engine.join(session, make_principal("bob"), "Bob")
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
@@ -283,10 +283,10 @@ async def _seed_hand_end_state() -> str:
id="briscola-hand-end-1",
game_type=engine.id,
join_code="BH0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
],
)
session.state = BriscolaState(
@@ -298,11 +298,11 @@ async def _seed_hand_end_state() -> str:
briscola_suit="S",
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
sub=make_principal("alice"), name="Alice", seat=0,
hand=[Card.parse("02D")], captured=[Card.parse("01C")],
),
PlayerState(
sub="bob", name="Bob", seat=1,
sub=make_principal("bob"), name="Bob", seat=1,
hand=[Card.parse("05D")], captured=[Card.parse("10C")],
),
],
+8 -6
View File
@@ -23,7 +23,7 @@ from tavolo.briscola.chiamata import (
Bid,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
@@ -163,7 +163,7 @@ class ChiamataWebSocketTest(unittest.TestCase):
session = await game_store.load(game_id)
assert session is not None
for sub in ("carol", "dave", "erin", "alice"):
engine.handle_action(session, sub, "pass", {})
engine.handle_action(session, make_principal(sub), "pass", {})
await game_store.save(session)
self.assertEqual(PHASE_CALLING, session.state.phase)
self.assertEqual(1, session.state.caller)
@@ -227,15 +227,17 @@ class ChiamataTimeoutTest(unittest.TestCase):
id=game_id,
game_type="briscola_chiamata",
join_code=join_code,
creator_sub=PLAYERS[0],
creator_sub=make_principal(PLAYERS[0]),
players=[
Seat(user_sub=name, display_name=name.capitalize())
Seat(user_sub=make_principal(name), display_name=name.capitalize())
for name in PLAYERS
],
)
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
session.state = rules.create_game(
make_principal(PLAYERS[0]), "Alice", turn_timeout=1
)
for name in PLAYERS[1:]:
rules.join_game(session.state, name, name.capitalize())
rules.join_game(session.state, make_principal(name), name.capitalize())
state_mutator(session.state)
await game_store.save(session)
return session.id
+29 -3
View File
@@ -6,7 +6,7 @@ import unittest
from httpx import ASGITransport, AsyncClient
from tavolo.app import app
from tests.helpers import async_test, oidc_user
from tests.helpers import async_test, make_principal, oidc_user
class GamesRouteTest(unittest.TestCase):
@@ -37,7 +37,7 @@ class GamesRouteTest(unittest.TestCase):
with oidc_user("alice"):
snapshot = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, snapshot.status_code)
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
self.assertEqual(make_principal("alice"), snapshot.json()["players"][0]["sub"])
# A logged-in non-player is a read-only spectator: no join
# code, and the view is marked as a spectator view.
@@ -98,6 +98,32 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(0, participant["captured_count"])
self.assertEqual(0, participant["scope"])
@async_test
async def test_same_sub_other_provider_cannot_read_private_hand(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
game_id = created.json()["id"]
for player in ("bob", "carol", "dave"):
with oidc_user(player):
await client.post("/api/games/join", json={"code": code})
with oidc_user("alice", provider="google"):
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
spectator = watched.json()
self.assertTrue(spectator["spectator"])
self.assertNotIn("join_code", spectator)
for participant in spectator["players"]:
self.assertNotIn("hand", participant)
with oidc_user("alice"):
owned = await client.get(f"/api/games/{game_id}")
self.assertFalse(owned.json()["spectator"])
self.assertIn("hand", owned.json()["players"][0])
@async_test
async def test_create_napola_option(self) -> None:
transport = ASGITransport(app=app)
@@ -180,7 +206,7 @@ class GamesRouteTest(unittest.TestCase):
self.assertFalse(body["napola"])
self.assertTrue(body["asso_pigliatutto"])
self.assertEqual(3, body["seats_open"])
self.assertEqual(["alice"], [p["sub"] for p in body["players"]])
self.assertEqual([make_principal("alice")], [p["sub"] for p in body["players"]])
for player in ("bob", "carol", "dave"):
with oidc_user(player):
+2 -2
View File
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient
from tavolo.app import app
from tavolo.config import settings
from tests.helpers import async_test, oidc_user
from tests.helpers import async_test, make_principal, oidc_user
class MeRouteTest(unittest.TestCase):
@@ -25,7 +25,7 @@ class MeRouteTest(unittest.TestCase):
# The email claim the IdP returns is neither stored nor exposed;
# no picture claim in this request.
self.assertEqual(
{"sub": "alice", "name": "alice", "picture": None},
{"sub": make_principal("alice"), "name": "alice", "picture": None},
response.json(),
)
+14 -14
View File
@@ -23,7 +23,7 @@ from tavolo.scopa.state import (
ScopaState,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
class ScopaRoutesTest(unittest.TestCase):
@@ -249,11 +249,11 @@ class ScopaWebSocketTest(unittest.TestCase):
turn=0,
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
sub=make_principal("alice"), name="Alice", seat=0,
hand=[Card.parse("01D"), Card.parse("09C")],
),
PlayerState(
sub="bob", name="Bob", seat=1,
sub=make_principal("bob"), name="Bob", seat=1,
hand=[Card.parse("04D")],
),
],
@@ -264,10 +264,10 @@ class ScopaWebSocketTest(unittest.TestCase):
id="scopa-asso-pigliatutto-1",
game_type="scopa",
join_code="SA0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
],
)
session.state = state
@@ -304,10 +304,10 @@ class ScopaWebSocketTest(unittest.TestCase):
turn=0,
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
sub=make_principal("alice"), name="Alice", seat=0,
hand=[Card.parse("02D")],
),
PlayerState(sub="bob", name="Bob", seat=1),
PlayerState(sub=make_principal("bob"), name="Bob", seat=1),
],
table=[Card.parse("02C")],
)
@@ -315,10 +315,10 @@ class ScopaWebSocketTest(unittest.TestCase):
id="scopa-hand-end-1",
game_type="scopa",
join_code="SH0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
],
)
session.state = state
@@ -374,14 +374,14 @@ class ScopaTurnTimeoutTest(unittest.TestCase):
id="scopa-turn-timeout-1",
game_type=engine.id,
join_code="ST0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
],
)
engine.create(session, {})
session.state.turn_timeout = 1
engine.join(session, "bob", "Bob")
engine.join(session, make_principal("bob"), "Bob")
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
+13 -13
View File
@@ -21,7 +21,7 @@ from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.platform.stats import save_match_result
from tavolo.scopa import engine as scopa_rules
from tavolo.scopone import engine as rules
from tests.helpers import async_test, oidc_user
from tests.helpers import async_test, make_principal, oidc_user
PLAYERS = ("alice", "bob", "carol", "dave")
@@ -32,12 +32,12 @@ async def _finished_session(target_score: int = 1) -> GameSession:
id="stats-scope-1",
game_type=engine.id,
join_code="SS0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
creator_sub=make_principal("alice"),
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
)
engine.create(session, {"target_score": target_score})
for name in PLAYERS[1:]:
engine.join(session, name, name.capitalize())
engine.join(session, make_principal(name), name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
@@ -160,11 +160,11 @@ async def _finished_briscola_session() -> GameSession:
id="stats-briscola-1",
game_type=engine.id,
join_code="BS0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
creator_sub=make_principal("alice"),
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
)
engine.create(session, {})
engine.join(session, "bob", "Bob")
engine.join(session, make_principal("bob"), "Bob")
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
@@ -246,15 +246,15 @@ async def _finished_chiamata_session() -> GameSession:
id="stats-chiamata-1",
game_type=engine.id,
join_code="BC0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub=name, display_name=name.capitalize())
Seat(user_sub=make_principal(name), display_name=name.capitalize())
for name in ("alice", "bob", "carol", "dave", "erin")
],
)
engine.create(session, {})
for name in ("bob", "carol", "dave", "erin"):
engine.join(session, name, name.capitalize())
engine.join(session, make_principal(name), name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
@@ -344,15 +344,15 @@ class ScopaStatsTest(unittest.TestCase):
id=f"stats-scopa-{player_count}",
game_type=engine.id,
join_code="SC0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
creator_sub=make_principal("alice"),
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
)
engine.create(
session,
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
)
for name in PLAYERS[1:player_count]:
engine.join(session, name, name.capitalize())
engine.join(session, make_principal(name), name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
+5 -5
View File
@@ -25,7 +25,7 @@ from tavolo.telemetry import (
)
from tavolo.config import Settings
from tests.helpers import async_test
from tests.helpers import async_test, make_principal
try:
from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
@@ -158,7 +158,7 @@ class SessionTagHookTest(unittest.TestCase):
hashlib.sha256(b"sid").hexdigest()[:16],
span.attributes["session.id"],
)
self.assertEqual("alice", span.attributes["user.id"])
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
def test_tags_user_of_any_provider(self) -> None:
span = _RecordedSpan()
@@ -166,7 +166,7 @@ class SessionTagHookTest(unittest.TestCase):
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertEqual("alice", span.attributes["user.id"])
self.assertEqual(make_principal("alice", "google"), span.attributes["user.id"])
def test_ignores_legacy_unprefixed_user_key(self) -> None:
span = _RecordedSpan()
@@ -190,7 +190,7 @@ class SessionTagHookTest(unittest.TestCase):
)
session_tag_hook(span, carrier)
self.assertNotIn("session.id", span.attributes)
self.assertEqual("alice", span.attributes["user.id"])
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
def test_missing_session_is_a_noop(self) -> None:
span = _RecordedSpan()
@@ -266,7 +266,7 @@ class SessionTagIntegrationTest(unittest.TestCase):
attributes = span.attributes
assert attributes is not None
self.assertEqual(digest, attributes["session.id"], name)
self.assertEqual("alice", attributes["user.id"], name)
self.assertEqual(make_principal("alice"), attributes["user.id"], name)
def _metric_points(reader) -> dict:
+14 -14
View File
@@ -11,7 +11,7 @@ from httpx_ws.transport import ASGIWebSocketTransport
from tavolo.app import app, game_store, platform
from tavolo.platform import GameSession, Seat
from tavolo.scopone.state import Card, PlayerState, ScoponeState
from tests.helpers import async_test, make_user, oidc_user, ws_users
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave")
@@ -27,8 +27,8 @@ def _started_session(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
creator_sub=make_principal("alice"),
players=[Seat(user_sub=make_principal("alice"), display_name="Alice")],
)
engine.create(
session,
@@ -42,7 +42,7 @@ def _started_session(
session.state.hand_ack_timeout = hand_ack_timeout
session.state.turn_timeout = turn_timeout
for name in PLAYERS[1:]:
engine.join(session, name, name.capitalize())
engine.join(session, make_principal(name), name.capitalize())
return session
@@ -83,7 +83,7 @@ class WebSocketTest(unittest.TestCase):
alice_first = await alice_ws.receive_json()
self.assertEqual("state", alice_first["type"])
self.assertEqual(
"alice", alice_first["game"]["players"][0]["sub"]
make_principal("alice"), alice_first["game"]["players"][0]["sub"]
)
self.assertNotIn("hand", alice_first["game"]["players"][1])
@@ -189,12 +189,12 @@ async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
id="hand-end-1",
game_type=engine.id,
join_code="HEND01",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub="carol", display_name="Carol", team="A"),
Seat(user_sub="dave", display_name="Dave", team="B"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
Seat(user_sub=make_principal("carol"), display_name="Carol", team="A"),
Seat(user_sub=make_principal("dave"), display_name="Dave", team="B"),
],
)
session.state = ScoponeState(
@@ -203,10 +203,10 @@ async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
turn=0,
table=[Card.parse("02C")],
players=[
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub="bob", name="Bob", seat=1),
PlayerState(sub="carol", name="Carol", seat=2),
PlayerState(sub="dave", name="Dave", seat=3),
PlayerState(sub=make_principal("alice"), name="Alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub=make_principal("bob"), name="Bob", seat=1),
PlayerState(sub=make_principal("carol"), name="Carol", seat=2),
PlayerState(sub=make_principal("dave"), name="Dave", seat=3),
],
hand_ack_timeout=hand_ack_timeout,
)