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.
166 lines
5.5 KiB
Rust
166 lines
5.5 KiB
Rust
//! REST client for the tavolo backend. Same-origin requests carry the
|
|
//! session cookie automatically.
|
|
use crate::model::*;
|
|
use gloo_net::http::Request;
|
|
|
|
fn server_error(status: u16) -> String {
|
|
format!("server returned {status}")
|
|
}
|
|
|
|
/// Fetch the current user; `None` when unauthenticated (401).
|
|
pub async fn me() -> Result<Option<User>, String> {
|
|
let resp = Request::get("/api/me")
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if resp.status() == 401 {
|
|
return Ok(None);
|
|
}
|
|
if !resp.ok() {
|
|
return Err(server_error(resp.status()));
|
|
}
|
|
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")
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if !resp.ok() {
|
|
return Err(server_error(resp.status()));
|
|
}
|
|
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
|
Ok(page.results)
|
|
}
|
|
|
|
/// Create a lobby game. `options` is the game-specific creation object
|
|
/// described by the engine's `options_schema` (see
|
|
/// [`crate::model::GameTypeInfo::option_fields`]).
|
|
pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<GameView, String> {
|
|
let resp = Request::post("/api/games")
|
|
.json(&serde_json::json!({ "game_type": game_type, "options": options }))
|
|
.map_err(|e| e.to_string())?
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if !resp.ok() {
|
|
return Err(server_error(resp.status()));
|
|
}
|
|
resp.json().await.map_err(|e| e.to_string())
|
|
}
|
|
|
|
pub async fn join_game(code: &str) -> Result<GameView, String> {
|
|
let resp = Request::post("/api/games/join")
|
|
.json(&serde_json::json!({ "code": code }))
|
|
.map_err(|e| e.to_string())?
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if !resp.ok() {
|
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
|
let message = body
|
|
.get("error")
|
|
.and_then(|e| e.as_str())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| server_error(resp.status()));
|
|
return Err(message);
|
|
}
|
|
resp.json().await.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Look up a game by join code without joining, for the pre-join summary.
|
|
/// `Ok(None)` means the code names no live game (404); other failures are
|
|
/// `Err`.
|
|
pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
|
let resp = Request::get(&format!("/api/games/preview?code={code}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if resp.status() == 404 {
|
|
return Ok(None);
|
|
}
|
|
if !resp.ok() {
|
|
return Err(server_error(resp.status()));
|
|
}
|
|
resp.json().await.map(Some).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Why a game connection failed before the first snapshot arrived.
|
|
///
|
|
/// A refused WebSocket handshake is invisible to the browser: the close
|
|
/// event reports an abnormal closure (1006) with no HTTP status, so the
|
|
/// game page asks the REST API to tell a deliberate rejection from a
|
|
/// transient network problem.
|
|
pub enum GameEntry {
|
|
/// Not logged in (`401` from `GET /api/games/{id}`).
|
|
Unauthenticated,
|
|
/// Unknown game (`404`) or the user is not seated (`403`).
|
|
Unavailable,
|
|
/// The API could not be asked (or answered unexpectedly): treat the
|
|
/// connection loss as transient and retry.
|
|
Transient,
|
|
}
|
|
|
|
/// Classify a failed game connection via `GET /api/games/{game_id}`,
|
|
/// mirroring the statuses used to refuse the websocket upgrade.
|
|
pub async fn probe_game(game_id: &str) -> GameEntry {
|
|
match Request::get(&format!("/api/games/{game_id}")).send().await {
|
|
Ok(resp) => match resp.status() {
|
|
401 => GameEntry::Unauthenticated,
|
|
403 | 404 => GameEntry::Unavailable,
|
|
_ => GameEntry::Transient,
|
|
},
|
|
Err(_) => GameEntry::Transient,
|
|
}
|
|
}
|
|
|
|
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
|
let url = match cursor {
|
|
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
|
None => "/api/me/matches?limit=10".to_string(),
|
|
};
|
|
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
|
if !resp.ok() {
|
|
return Err(server_error(resp.status()));
|
|
}
|
|
resp.json().await.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Fetch the caller's Elo ratings (one row per game type played).
|
|
pub async fn my_ratings() -> Result<RatingsPage, String> {
|
|
let resp = Request::get("/api/me/ratings")
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if !resp.ok() {
|
|
return Err(server_error(resp.status()));
|
|
}
|
|
resp.json().await.map_err(|e| e.to_string())
|
|
}
|
|
|
|
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
|
let resp = Request::get("/api/leaderboard")
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if !resp.ok() {
|
|
return Err(server_error(resp.status()));
|
|
}
|
|
resp.json().await.map_err(|e| e.to_string())
|
|
}
|