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
+4 -3
View File
@@ -1,5 +1,6 @@
//! User avatar: the provider's `picture` claim when available, a
//! deterministic identicon generated from the OIDC `sub` otherwise.
//! deterministic identicon generated from the canonical user principal
//! otherwise.
//!
//! The identicon is a 5x5 mirrored pixel grid derived from an inline
//! FNV-1a hash. The hash is implemented here (instead of
@@ -62,8 +63,8 @@ fn safe_picture_url(url: &str) -> Option<String> {
}
/// The user's avatar: their profile picture, or an identicon generated
/// from the `sub`. A picture that fails to load falls back to the
/// identicon as well.
/// from the canonical principal. A picture that fails to load falls back to
/// the identicon as well.
pub fn avatar(sub: &str, picture: Option<&str>) -> View {
let Some(url) = picture.and_then(safe_picture_url) else {
return identicon(sub);
+4
View File
@@ -9,6 +9,7 @@ fn default_true() -> bool {
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct User {
/// Canonical provider-qualified principal (`oidc:v1:<provider>:<sub>`).
pub sub: String,
pub name: String,
#[serde(default)]
@@ -18,6 +19,7 @@ pub struct User {
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct PlayerView {
/// Canonical provider-qualified principal.
pub sub: String,
pub name: String,
pub seat: usize,
@@ -386,6 +388,7 @@ pub enum ServerMessage {
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct MatchPlayer {
/// Canonical provider-qualified principal (legacy API field name).
pub user_sub: String,
pub display_name: String,
pub seat: usize,
@@ -475,6 +478,7 @@ fn default_page() -> u32 {
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardEntry {
/// Canonical provider-qualified principal (legacy API field name).
pub user_sub: String,
pub display_name: String,
/// Chess-style Elo rating for the requested game type.