Support multiple OIDC providers

kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.

The platform now authenticates through OIDCProviders: auth.current_user
returns the session's user (sessions stay single-identity, so at most one
provider is logged in) and require_auth/route handlers use it, while the
websocket path keeps scanning for any provider's user. New routes expose
the configuration to the SPA and keep old links working: GET
/api/auth/providers lists the login URLs, GET /auth/login redirects to
the first provider and GET /auth/logout to whichever provider the
session is logged in with. The lobby renders one login button per
provider from that endpoint.

Update the env examples, compose and k8s manifests to the indexed scheme,
document the multiple-provider setup and the migration, and cover it with
config, auth and route tests.
This commit is contained in:
2026-10-01 12:38:29 +00:00
parent 3abbbc6b68
commit 915a294dc8
25 changed files with 798 additions and 119 deletions
+13
View File
@@ -22,6 +22,19 @@ pub async fn me() -> Result<Option<User>, String> {
resp.json().await.map(Some).map_err(|e| e.to_string())
}
/// Fetch the configured OIDC login providers (public endpoint).
pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
let resp = Request::get("/api/auth/providers")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.providers)
}
/// Fetch the card games the platform can host (for the creation dropdown).
pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
let resp = Request::get("/api/game-types")
+13
View File
@@ -344,6 +344,19 @@ pub struct GameTypesPage {
pub results: Vec<GameTypeInfo>,
}
/// One configured OIDC login provider (GET /api/auth/providers).
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AuthProvider {
pub name: String,
pub login_url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthProvidersPage {
#[serde(default)]
pub providers: Vec<AuthProvider>,
}
/// A join-code lookup result (GET /api/games/preview): the game's lobby
/// payload plus its display name, for the pre-join summary. The options
/// echoed by the engine land in `details` under their `options_schema`
+53 -10
View File
@@ -14,7 +14,9 @@ use sycamore_router::navigate;
use crate::api;
use crate::components::card::card_back;
use crate::components::toast::toast;
use crate::model::{GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User};
use crate::model::{
AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User,
};
/// Used when the game-types fetch fails: match creation must still work.
fn fallback_game_types() -> Vec<GameTypeInfo> {
@@ -28,6 +30,15 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
}]
}
/// Human-readable label for a provider name ("google" -> "Google").
fn provider_label(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn json_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
@@ -283,6 +294,8 @@ fn join_preview_panel(
pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out.
let user = create_signal(Option::<Option<User>>::None);
// Configured login providers, for the logged-out card's buttons.
let auth_providers = create_signal(Vec::<AuthProvider>::new());
// The player's Elo ratings, one row per game type played.
let ratings = create_signal(Vec::<PlayerRating>::new());
let error = create_signal(Option::<String>::None);
@@ -400,6 +413,12 @@ pub fn LobbyPage() -> View {
}
});
spawn_local(async move {
if let Ok(list) = api::auth_providers().await {
auth_providers.set(list);
}
});
let on_create = move |_| {
let game_type = selected_game.get_clone();
let opts: serde_json::Map<String, serde_json::Value> =
@@ -462,15 +481,39 @@ pub fn LobbyPage() -> View {
}
p(class="text-secondary") { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: this must be a full page navigation
// to the backend's OIDC login endpoint.
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
// interception: these must be full page navigations
// to the backend's OIDC login endpoints.
(move || {
let providers = auth_providers.get_clone();
if providers.is_empty() {
// Providers could not be fetched: the
// generic route redirects to the first
// configured provider.
view! {
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
}
} else {
view! {
div(class="d-grid gap-2") {
(providers.iter().cloned().map(|p| view! {
a(
class="btn btn-warning btn-lg w-100",
href=p.login_url,
rel="external",
) {
"Log in with " (provider_label(&p.name))
}
}).collect::<Vec<_>>())
}
}
}
})
}
}
}