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:
@@ -5,7 +5,7 @@ import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from helpers import async_test, make_platform, oidc_user
|
||||
from helpers import async_test, make_platform, make_principal, oidc_user
|
||||
|
||||
|
||||
class GamesRouteTest(unittest.TestCase):
|
||||
@@ -118,7 +118,7 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(5, body["target"])
|
||||
self.assertEqual(1, body["seats_open"])
|
||||
self.assertEqual("alice", body["players"][0]["sub"])
|
||||
self.assertEqual(make_principal("alice"), body["players"][0]["sub"])
|
||||
|
||||
# Join codes are case-insensitive.
|
||||
with oidc_user(platform.oidc, "mallory"):
|
||||
@@ -219,7 +219,7 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(1, entry["seats_open"])
|
||||
self.assertEqual(0, entry["your_seat"])
|
||||
self.assertNotIn("join_code", entry)
|
||||
self.assertEqual("alice", entry["players"][0]["sub"])
|
||||
self.assertEqual(make_principal("alice"), entry["players"][0]["sub"])
|
||||
|
||||
# Bob's own game is the only one he sees; Alice's is not his.
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
@@ -246,6 +246,57 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
|
||||
|
||||
|
||||
class CrossProviderGamesTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_same_sub_other_provider_does_not_inherit_player_access(self) -> None:
|
||||
app, platform, _ = make_platform(oidc_providers=("default", "google"))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
game_id = created.json()["id"]
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
|
||||
with oidc_user(platform.oidc, "alice", provider="google"):
|
||||
listed = await client.get("/api/me/games")
|
||||
watched = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual([], listed.json()["results"])
|
||||
self.assertTrue(watched.json()["spectator"])
|
||||
self.assertFalse(watched.json()["viewer_seated"])
|
||||
self.assertNotIn("join_code", watched.json())
|
||||
|
||||
# The original provider's identical subject remains seated.
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
owned = await client.get(f"/api/games/{game_id}")
|
||||
self.assertFalse(owned.json()["spectator"])
|
||||
self.assertTrue(owned.json()["viewer_seated"])
|
||||
self.assertIn("join_code", owned.json())
|
||||
|
||||
@async_test
|
||||
async def test_same_sub_at_two_providers_occupies_distinct_seats(self) -> None:
|
||||
app, platform, _ = make_platform(oidc_providers=("default", "google"))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
with oidc_user(platform.oidc, "alice", provider="google"):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(200, joined.status_code)
|
||||
self.assertTrue(joined.json()["viewer_seated"])
|
||||
session = await platform.game_store.load(created.json()["id"])
|
||||
assert session is not None
|
||||
self.assertEqual(
|
||||
{
|
||||
make_principal("alice"),
|
||||
make_principal("alice", "google"),
|
||||
},
|
||||
{seat.user_sub for seat in session.players},
|
||||
)
|
||||
|
||||
|
||||
class GameTypesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_lists_available_game_types(self) -> None:
|
||||
@@ -311,7 +362,7 @@ class MeRouteTest(unittest.TestCase):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
{"sub": "alice", "name": "alice", "picture": None},
|
||||
{"sub": make_principal("alice"), "name": "alice", "picture": None},
|
||||
response.json(),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user