//! OIDC login entry points for the admin dashboard. //! //! Two unauthenticated GET endpoints used by `admin_ui/login.html`: //! //! - `GET /admin/oidc/providers` returns the enabled providers as JSON so //! the login page can render a button per provider. //! - `GET /admin/login/oidc/:provider` creates an OIDC session marked as //! admin-flow (via the sentinel below) and 302-redirects the browser to //! the IdP authorization URL. After the IdP redirects to //! `/oidc/callback`, the existing callback handler detects the sentinel //! and finishes by setting `rd_admin_session` + redirecting to `/admin/` //! (see api/oidc/callback.rs). //! //! We keep this module separate from the desktop-client OIDC flow so the //! "device polls /api/oidc/auth-query" semantics stay untouched. use crate::api::error::ApiError; use crate::api::oidc::{discovery, random_token, require_provider, OIDC_SESSION_TTL_SECS}; use crate::api::state::AppState; use crate::database::OidcSessionInsert; use axum::extract::{Extension, Path}; use axum::response::Redirect; use axum::Json; use serde_json::{json, Value}; use std::sync::Arc; /// Sentinel stuffed into `client_id_str` / `client_uuid` of an OidcSession /// so the callback can tell admin-UI flows apart from desktop-client flows. /// Real device UUIDs from the desktop client are hex-formatted GUIDs and /// won't collide. pub const ADMIN_SENTINEL: &str = "__admin_ui__"; pub async fn list_providers( Extension(state): Extension>, ) -> Json { let mut out: Vec = Vec::new(); if !state.cfg.public_base_url.is_empty() { if let Ok(providers) = state.db.oidc_provider_list_enabled().await { for p in providers { out.push(json!({ "name": p.name, "display_name": p.display_name.unwrap_or_else(|| p.name.clone()), "icon_url": p.icon_url, })); } } } Json(json!(out)) } pub async fn start_login( Extension(state): Extension>, Path(provider_name): Path, ) -> Result { if state.cfg.public_base_url.is_empty() { return Err(ApiError::Internal( "OIDC requires --public-base-url to be set".into(), )); } let provider = require_provider(&state, &provider_name).await?; let disc = discovery::discover(&provider.issuer_url) .await .map_err(ApiError::Internal)?; let code = random_token(); let csrf_state = random_token(); let expires_at = chrono::Utc::now().timestamp() + OIDC_SESSION_TTL_SECS; state .db .oidc_session_create(&OidcSessionInsert { code: &code, provider: &provider.name, state: &csrf_state, client_id_str: ADMIN_SENTINEL, client_uuid: ADMIN_SENTINEL, device_info_json: r#"{"source":"admin-ui"}"#, expires_at, }) .await .map_err(|e| ApiError::Internal(e.to_string()))?; let url = format!( "{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={st}", auth = disc.authorization_endpoint, cid = url_encode(&provider.client_id), ru = url_encode(&provider.redirect_url), scope = url_encode(&provider.scopes), st = url_encode(&csrf_state), ); Ok(Redirect::temporary(&url)) } fn url_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.as_bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(*b as char); } _ => { use std::fmt::Write; let _ = write!(out, "%{:02X}", b); } } } out }