//! SAML SSO configuration — FleetDM-shaped. //! //! The operator pastes their IdP's metadata XML (or its URL) and a //! human-readable label. As of v0.5.1 the SAML login flow is wired //! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS //! endpoint, pure-Rust signature verification, and operator-session //! minting. This module owns only the persisted *configuration*. //! //! Shape borrowed from 's app-config //! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any //! IdP-authenticated user the SP cryptographically verifies gets an //! operator session; there is no per-user role table). Entity ID is //! exposed (FleetDM-style) but defaults to the advertised public base //! URL when blank, which is what most IdPs expect anyway. use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Arc; use crate::{Error, Result}; /// The configurable surface. `metadata` and `metadata_url` are mutually /// exclusive at apply time (one or the other identifies the IdP); the /// store keeps both fields so an operator can switch between them /// without losing the inactive one. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SsoConfig { /// Master switch — when false, all SSO machinery (planned for a /// later release) is skipped regardless of the rest of the fields. #[serde(default)] pub enabled: bool, /// Display name shown on the WebUI's login screen as the "Sign in /// with X" button label. Empty/whitespace falls back to "SSO". #[serde(default)] pub idp_name: String, /// Optional HTTPS URL pointing at the IdP's brand logo. Rendered /// next to `idp_name` on the WebUI's login screen (FleetDM-style). /// Length-capped at [`MAX_URL_LEN`]; empty is fine. #[serde(default)] pub idp_logo_url: String, /// Raw SAML metadata XML pasted by the operator. Mutually exclusive /// with `metadata_url`; if both are set, the URL wins at apply time /// (operators typically forget about a stale XML paste). #[serde(default)] pub metadata: String, /// HTTPS URL where the IdP serves its metadata. Loaded lazily by the /// future SAML flow; not validated here beyond a basic length cap. #[serde(default)] pub metadata_url: String, /// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID". /// Must exactly match the SP/Relying-Party entry configured on the IdP. /// Empty falls back to the advertised public base URL at runtime, which /// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`]. #[serde(default)] pub entity_id: String, /// Allow IdP-initiated login — an unsolicited `` POSTed to the /// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login /// initiated by identity provider". Default off; SP-initiated (the /// "Sign in with X" button) is always allowed regardless. #[serde(default)] pub allow_idp_initiated: bool, } impl SsoConfig { /// Returns `true` only when the config is *usable* — enabled, and /// at least one of metadata/metadata_url is present. The future /// login flow will key off this; for v0.4.5 the WebUI uses it to /// surface a yellow "configured but not live yet" hint. #[must_use] pub fn is_usable(&self) -> bool { self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty()) } } /// The minimal, non-sensitive slice of the SSO config that the **pre-auth** /// login screen needs to render the "Sign in with …" button. Carries only /// the display affordances — never the metadata XML/URL or entity ID, which /// stay behind the auth-gated `/api/sso`. Served as part of the public /// `/api/me` so the button renders reliably whether or not anyone is signed /// in (v0.5.9: fixes the button vanishing because `/api/sso` 401s pre-auth). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SsoLoginInfo { /// True only when SSO is *usable* (enabled AND a metadata source is /// present) — i.e. clicking the button will actually reach an IdP. pub enabled: bool, /// Button label, e.g. "STC AD". Empty falls back to "SSO" in the UI. pub idp_name: String, /// Optional IdP logo rendered on the button. Empty = no image. pub idp_logo_url: String, } /// In-memory + on-disk SSO settings registry. #[derive(Debug, Clone)] pub struct SsoStore { path: Arc, inner: Arc>, } impl SsoStore { /// Load from `/sso.json`, or start with the default empty /// (`enabled = false`) config. A corrupt file falls back to default /// rather than blocking startup. #[must_use] pub fn load_or_default(work_dir: &std::path::Path) -> Self { let path = work_dir.join("sso.json"); let cfg = match std::fs::read_to_string(&path) { Ok(text) => match serde_json::from_str::(&text) { Ok(parsed) => parsed, Err(e) => { tracing::warn!( target: "openpxe::sso", "sso.json present but unreadable ({e}); starting with default config" ); SsoConfig::default() } }, Err(_) => SsoConfig::default(), }; Self { path: Arc::new(path), inner: Arc::new(RwLock::new(cfg)), } } #[must_use] pub fn snapshot(&self) -> SsoConfig { self.inner.read().clone() } /// Public, non-sensitive descriptor for the login screen. Safe to /// expose pre-auth — it's exactly what the "Sign in with …" button /// keys off, with no metadata/entity-ID leakage. v0.5.9. #[must_use] pub fn login_info(&self) -> SsoLoginInfo { let cfg = self.inner.read(); SsoLoginInfo { enabled: cfg.is_usable(), idp_name: cfg.idp_name.clone(), idp_logo_url: cfg.idp_logo_url.clone(), } } /// Replace the whole config in one shot. Light validation: metadata /// XML and URL are length-capped so an operator can't OOM us by /// pasting a 10 GiB blob; the IdP UI tab clamps the input visually, /// but the server enforces a hard ceiling regardless. pub fn replace(&self, mut cfg: SsoConfig) -> Result { cfg.idp_name = cfg.idp_name.trim().to_string(); cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string(); cfg.metadata = cfg.metadata.trim().to_string(); cfg.metadata_url = cfg.metadata_url.trim().to_string(); cfg.entity_id = cfg.entity_id.trim().to_string(); if cfg.entity_id.len() > MAX_URL_LEN { return Err(Error::Invalid(format!( "entity_id exceeds {MAX_URL_LEN}-char cap" ))); } if cfg.metadata.len() > MAX_METADATA_BYTES { return Err(Error::Invalid(format!( "metadata XML exceeds {MAX_METADATA_BYTES}-byte cap" ))); } if cfg.metadata_url.len() > MAX_URL_LEN { return Err(Error::Invalid(format!( "metadata_url exceeds {MAX_URL_LEN}-char cap" ))); } if cfg.idp_logo_url.len() > MAX_URL_LEN { return Err(Error::Invalid(format!( "idp_logo_url exceeds {MAX_URL_LEN}-char cap" ))); } if !cfg.metadata_url.is_empty() && !cfg.metadata_url.starts_with("http://") && !cfg.metadata_url.starts_with("https://") { return Err(Error::Invalid( "metadata_url must start with http:// or https://".into(), )); } if !cfg.idp_logo_url.is_empty() && !cfg.idp_logo_url.starts_with("http://") && !cfg.idp_logo_url.starts_with("https://") { return Err(Error::Invalid( "idp_logo_url must start with http:// or https://".into(), )); } // If they're trying to *enable* the integration but haven't // supplied either source, reject — saves a "configured but // unusable" surprise later. if cfg.enabled && cfg.metadata.is_empty() && cfg.metadata_url.is_empty() { return Err(Error::Invalid( "enable SSO requires either metadata XML or a metadata URL".into(), )); } { let mut g = self.inner.write(); *g = cfg.clone(); } self.persist(); tracing::info!( target: "openpxe::sso", enabled = cfg.enabled, idp = %cfg.idp_name, has_xml = !cfg.metadata.is_empty(), has_url = !cfg.metadata_url.is_empty(), "sso configuration updated" ); Ok(cfg) } fn persist(&self) { let snap = self.inner.read().clone(); let body = match serde_json::to_vec_pretty(&snap) { Ok(b) => b, Err(e) => { tracing::warn!(target: "openpxe::sso", "serialize sso.json: {e}"); return; } }; if let Some(parent) = self.path.parent() { let _ = std::fs::create_dir_all(parent); } let tmp = self.path.with_extension("json.tmp"); if let Err(e) = std::fs::write(&tmp, body) { tracing::warn!(target: "openpxe::sso", "write sso.json tmp: {e}"); return; } if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) { tracing::warn!(target: "openpxe::sso", "rename sso.json: {e}"); } } } /// Saturation caps. The numbers are generous for any real IdP metadata /// document — Okta's largest is ~50 KB, Azure AD's ~30 KB. const MAX_METADATA_BYTES: usize = 1024 * 1024; const MAX_URL_LEN: usize = 2048; #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn default_is_disabled_and_empty() { let dir = tempdir().unwrap(); let s = SsoStore::load_or_default(dir.path()); let cfg = s.snapshot(); assert!(!cfg.enabled); assert!(cfg.metadata.is_empty()); assert!(cfg.metadata_url.is_empty()); assert!(!cfg.is_usable()); } #[test] fn replace_metadata_url_round_trip_via_disk() { let dir = tempdir().unwrap(); let s = SsoStore::load_or_default(dir.path()); s.replace(SsoConfig { enabled: true, idp_name: "Okta".into(), metadata: String::new(), metadata_url: "https://idp.example.com/metadata".into(), idp_logo_url: String::new(), entity_id: String::new(), allow_idp_initiated: false, }) .unwrap(); drop(s); let s2 = SsoStore::load_or_default(dir.path()); let cfg = s2.snapshot(); assert!(cfg.enabled); assert!(cfg.is_usable()); assert_eq!(cfg.idp_name, "Okta"); assert_eq!(cfg.metadata_url, "https://idp.example.com/metadata"); } #[test] fn replace_xml_paste_is_accepted() { let dir = tempdir().unwrap(); let s = SsoStore::load_or_default(dir.path()); let xml = r#"test"#; s.replace(SsoConfig { enabled: true, idp_name: "Test IdP".into(), metadata: xml.into(), metadata_url: String::new(), idp_logo_url: String::new(), entity_id: String::new(), allow_idp_initiated: false, }) .unwrap(); assert!(s.snapshot().is_usable()); } #[test] fn enable_without_source_is_rejected() { let dir = tempdir().unwrap(); let s = SsoStore::load_or_default(dir.path()); let r = s.replace(SsoConfig { enabled: true, idp_name: "Okta".into(), metadata: String::new(), metadata_url: String::new(), idp_logo_url: String::new(), entity_id: String::new(), allow_idp_initiated: false, }); assert!(matches!(r, Err(Error::Invalid(_)))); // …and a disabled blank config is fine. s.replace(SsoConfig::default()).unwrap(); } #[test] fn metadata_url_must_be_http_scheme() { let dir = tempdir().unwrap(); let s = SsoStore::load_or_default(dir.path()); let r = s.replace(SsoConfig { enabled: false, idp_name: String::new(), metadata: String::new(), metadata_url: "ftp://idp.example.com/metadata".into(), idp_logo_url: String::new(), entity_id: String::new(), allow_idp_initiated: false, }); assert!(matches!(r, Err(Error::Invalid(_)))); } #[test] fn idp_logo_url_must_be_http_scheme() { // v0.4.6: SSO settings learned an idp_logo_url so the login // screen can render the FleetDM-style "Sign in with " // affordance. Same scheme rule as metadata_url. let dir = tempdir().unwrap(); let s = SsoStore::load_or_default(dir.path()); let r = s.replace(SsoConfig { enabled: false, idp_name: "Okta".into(), metadata: String::new(), metadata_url: String::new(), idp_logo_url: "data:image/png;base64,...".into(), entity_id: String::new(), allow_idp_initiated: false, }); assert!(matches!(r, Err(Error::Invalid(_)))); // Real HTTPS URL is fine. s.replace(SsoConfig { enabled: false, idp_name: "Okta".into(), metadata: String::new(), metadata_url: String::new(), idp_logo_url: "https://idp.example.com/logo.png".into(), entity_id: String::new(), allow_idp_initiated: false, }) .unwrap(); assert_eq!( s.snapshot().idp_logo_url, "https://idp.example.com/logo.png" ); } #[test] fn entity_id_and_idp_initiated_round_trip() { // v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload. let dir = tempdir().unwrap(); let s = SsoStore::load_or_default(dir.path()); s.replace(SsoConfig { enabled: true, idp_name: "Keycloak".into(), metadata: String::new(), metadata_url: "https://idp.example.com/metadata".into(), idp_logo_url: String::new(), entity_id: "https://pxe.example.com".into(), allow_idp_initiated: true, }) .unwrap(); drop(s); let cfg = SsoStore::load_or_default(dir.path()).snapshot(); assert_eq!(cfg.entity_id, "https://pxe.example.com"); assert!(cfg.allow_idp_initiated); } #[test] fn entity_id_cap_enforced() { let dir = tempdir().unwrap(); let s = SsoStore::load_or_default(dir.path()); let r = s.replace(SsoConfig { enabled: false, idp_name: String::new(), metadata: String::new(), metadata_url: String::new(), idp_logo_url: String::new(), entity_id: "x".repeat(MAX_URL_LEN + 1), allow_idp_initiated: false, }); assert!(matches!(r, Err(Error::Invalid(_)))); } #[test] fn metadata_size_cap_enforced() { let dir = tempdir().unwrap(); let s = SsoStore::load_or_default(dir.path()); let oversize = "a".repeat(MAX_METADATA_BYTES + 1); let r = s.replace(SsoConfig { enabled: false, idp_name: String::new(), metadata: oversize, metadata_url: String::new(), idp_logo_url: String::new(), entity_id: String::new(), allow_idp_initiated: false, }); assert!(matches!(r, Err(Error::Invalid(_)))); } }