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
@@ -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(),
)