//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5. //! //! The operator pastes their IdP's metadata XML (or its URL) and a //! human-readable label; v0.4.5 just persists it. The actual SAML //! response-validation / JIT-provisioning flow lands in a later release //! — for now we cover the "configurable" half so an operator can teach //! OpenPXE about their IdP today and flip the switch on next upgrade. //! //! Shape borrowed from 's app-config //! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you //! have access or you don't). Entity ID is omitted from the operator //! UI per the v0.4.5 brief — it defaults to the advertised public base //! URL when SAML wiring lands, 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, } 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()) } } /// 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() } /// 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(); 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(), }) .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(), }) .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(), }); 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(), }); 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(), }); 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(), }) .unwrap(); assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png"); } #[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(), }); assert!(matches!(r, Err(Error::Invalid(_)))); } }