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