//! IdP metadata parsing + SP metadata generation. //! //! We parse only what the SP flow needs: the IdP Entity ID, its //! `SingleSignOnService` endpoints (HTTP-Redirect / HTTP-POST), and the //! X.509 signing certificate(s). Everything else in the document is ignored. use base64::Engine; use super::{SamlError, SpParams}; use crate::encoding::xml_escape; /// SAML 2.0 binding URIs. pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; pub const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"; /// The subset of an IdP's `EntityDescriptor` the SP flow consumes. #[derive(Debug, Clone)] pub struct IdpMetadata { /// The IdP's Entity ID — we require incoming assertions to be issued by it. pub entity_id: String, /// SSO endpoint for the HTTP-Redirect binding (where we send AuthnRequests). pub sso_redirect_url: Option, /// SSO endpoint for the HTTP-POST binding (fallback target). pub sso_post_url: Option, /// DER-encoded X.509 signing certificate(s). More than one appears during /// key rotation; verification tries each. pub signing_certs_der: Vec>, } impl IdpMetadata { /// Parse an IdP `EntityDescriptor` document. /// /// Robust to namespace-prefix variation (matches on local element names), /// since IdPs disagree on prefixes (`md:`, `ns0:`, default, …). pub fn parse(xml: &str) -> Result { let doc = roxmltree::Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?; let root = doc.root_element(); // The signing IDP descriptor. Some metadata wraps multiple // descriptors (AA, SP) in one document; we want IDPSSODescriptor. let idp_desc = root .descendants() .find(|n| n.is_element() && n.tag_name().name() == "IDPSSODescriptor") .ok_or_else(|| SamlError::Metadata("IDPSSODescriptor".into()))?; // Entity ID lives on the EntityDescriptor (root, or an ancestor of the // IDPSSODescriptor when several are nested). let entity_id = idp_desc .ancestors() .find_map(|n| { if n.tag_name().name() == "EntityDescriptor" { n.attribute("entityID") } else { None } }) .or_else(|| root.attribute("entityID")) .map(str::to_owned) .ok_or_else(|| SamlError::Metadata("entityID".into()))?; let mut sso_redirect_url = None; let mut sso_post_url = None; for sso in idp_desc .children() .filter(|n| n.is_element() && n.tag_name().name() == "SingleSignOnService") { let binding = sso.attribute("Binding").unwrap_or(""); let location = sso.attribute("Location").map(str::to_owned); match binding { BINDING_REDIRECT if sso_redirect_url.is_none() => sso_redirect_url = location, BINDING_POST if sso_post_url.is_none() => sso_post_url = location, _ => {} } } // Signing certs: KeyDescriptor with use="signing" or no use attribute // (a bare KeyDescriptor is valid for both signing and encryption). let mut signing_certs_der = Vec::new(); for kd in idp_desc .children() .filter(|n| n.is_element() && n.tag_name().name() == "KeyDescriptor") { match kd.attribute("use") { Some("signing") | None => {} Some(_) => continue, // encryption-only key — skip } for cert_node in kd .descendants() .filter(|n| n.is_element() && n.tag_name().name() == "X509Certificate") { let b64: String = node_text(&cert_node) .chars() .filter(|c| !c.is_whitespace()) .collect(); if b64.is_empty() { continue; } let der = base64::engine::general_purpose::STANDARD .decode(b64.as_bytes()) .map_err(|e| SamlError::Base64(e.to_string()))?; signing_certs_der.push(der); } } if signing_certs_der.is_empty() { return Err(SamlError::NoSigningCert); } Ok(Self { entity_id, sso_redirect_url, sso_post_url, signing_certs_der, }) } /// Preferred SSO destination for an outbound AuthnRequest: HTTP-Redirect /// if advertised, otherwise HTTP-POST. pub fn sso_destination(&self) -> Option<&str> { self.sso_redirect_url .as_deref() .or(self.sso_post_url.as_deref()) } } /// Build our SP `EntityDescriptor` XML so an IdP admin can import OpenPXE as a /// relying party. Advertises the ACS URL (HTTP-POST binding) and an emailAddress /// NameID format — matching what the response path expects. pub fn build_sp_metadata(sp: &SpParams) -> String { let entity = xml_escape(&sp.entity_id); let acs = xml_escape(&sp.acs_url); format!( r#" urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress "# ) } /// Collect the concatenated text of an element's direct text children. fn node_text(n: &roxmltree::Node<'_, '_>) -> String { n.children() .filter(roxmltree::Node::is_text) .filter_map(|c| c.text()) .collect() } // `xml_escape` now lives in `openpxe_core::encoding` (v0.5.4) — imported above. #[cfg(test)] mod tests { use super::*; // A trimmed-down Keycloak-style IdP descriptor (cert body is a stand-in; // signing tests build real certs in the parent module's tests). const SAMPLE: &str = r#" QUJDREVG WlpaWg== "#; #[test] fn parses_entity_sso_and_signing_cert() { let m = IdpMetadata::parse(SAMPLE).unwrap(); assert_eq!(m.entity_id, "https://idp.example.com/realms/fleet"); assert_eq!( m.sso_redirect_url.as_deref(), Some("https://idp.example.com/realms/fleet/protocol/saml") ); assert!(m.sso_post_url.is_some()); // Only the signing KeyDescriptor's cert is collected (ABCDEF), not the // encryption one (ZZZZ). assert_eq!(m.signing_certs_der.len(), 1); assert_eq!(m.signing_certs_der[0], b"ABCDEF"); } #[test] fn missing_signing_cert_is_rejected() { let xml = r#" "#; assert!(matches!( IdpMetadata::parse(xml), Err(SamlError::NoSigningCert) )); } #[test] fn missing_idp_descriptor_is_rejected() { let xml = r#""#; assert!(matches!( IdpMetadata::parse(xml), Err(SamlError::Metadata(_)) )); } #[test] fn sp_metadata_contains_entity_and_acs() { let sp = SpParams { entity_id: "https://pxe.example.com".into(), acs_url: "https://pxe.example.com/api/sso/acs".into(), }; let xml = build_sp_metadata(&sp); assert!(xml.contains(r#"entityID="https://pxe.example.com""#)); assert!(xml.contains("https://pxe.example.com/api/sso/acs")); assert!(xml.contains(BINDING_POST)); // Must be well-formed. roxmltree::Document::parse(&xml).unwrap(); } }