//! SAMLResponse consumption: signature verification + SP-side validation. //! //! [`consume`] is intentionally **stateless** — it verifies the XML signature //! against the IdP's pinned certificate(s) and enforces every check that can //! be made from the response alone (Status, Destination, Issuer, Audience, //! time bounds, signature scope). It then returns the `assertion_id` and //! `in_response_to` so the HTTP layer can perform the *stateful* checks it //! owns: replay rejection, correlating the request we issued, and gating //! IdP-initiated login. use roxmltree::{Document, Node}; use time::format_description::well_known::Rfc3339; use time::{Duration, OffsetDateTime}; use super::metadata::IdpMetadata; use super::{SamlError, SpParams}; const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success"; /// The verified subject of a SAML assertion. OpenPXE is single-tier, so this /// is all an operator session needs. #[derive(Debug, Clone)] pub struct VerifiedPrincipal { /// The `` value (an email, per our requested NameID format). pub name_id: String, /// Email used as the session identity. Equals `name_id` for the /// emailAddress NameID format. pub email: String, /// Human-readable display name, if the IdP sent one as an attribute. pub display_name: Option, } /// Result of a successful [`consume`]. The IDs/expiry feed the HTTP layer's /// stateful checks. #[derive(Debug, Clone)] pub struct VerifiedResponse { pub principal: VerifiedPrincipal, /// `InResponseTo` from the response, if present. `None` = unsolicited /// (IdP-initiated) — the HTTP layer only accepts that when the operator /// enabled it. pub in_response_to: Option, /// The assertion's `ID` — used by the caller as the replay-guard key. pub assertion_id: String, /// The assertion's expiry (`Conditions/@NotOnOrAfter`) — the replay /// guard can drop the consumed ID after this instant. pub assertion_expiry: OffsetDateTime, /// `AuthnStatement/@SessionIndex`, if present (useful for future SLO). pub session_index: Option, } /// Verify and validate a decoded `SAMLResponse` XML document. pub fn consume( xml: &str, sp: &SpParams, idp: &IdpMetadata, now: OffsetDateTime, clock_skew: Duration, ) -> Result { // 1. Cryptographically verify the signature against the pinned IdP cert(s). // `trusted_keys_only` ignores any cert embedded in the document's // KeyInfo, so an attacker can't substitute their own key. let verified_uris = verify_signature(xml, &idp.signing_certs_der)?; // 2. Parse for semantic validation. let doc = Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?; let root = doc.root_element(); if root.tag_name().name() != "Response" { return Err(SamlError::MissingElement("Response".into())); } let response_id = root.attribute("ID").map(str::to_owned); let in_response_to = root.attribute("InResponseTo").map(str::to_owned); // 3. Status must be Success. let status_value = root .descendants() .find(|n| n.is_element() && n.tag_name().name() == "StatusCode") .and_then(|sc| sc.attribute("Value")) .unwrap_or(""); if status_value != STATUS_SUCCESS && !status_value.ends_with(":Success") { return Err(SamlError::Status(status_value.to_owned())); } // 4. Destination (if the IdP set one) must be our ACS. if let Some(dest) = root.attribute("Destination") { if !urls_equal(dest, &sp.acs_url) { return Err(SamlError::DestinationMismatch); } } // 5. Exactly one (unencrypted) Assertion. if root .descendants() .any(|n| n.is_element() && n.tag_name().name() == "EncryptedAssertion") { return Err(SamlError::EncryptedAssertionUnsupported); } let assertions: Vec> = root .children() .filter(|c| c.is_element() && c.tag_name().name() == "Assertion") .collect(); if assertions.len() != 1 { return Err(SamlError::AssertionCount(assertions.len())); } let assertion = assertions[0]; let assertion_id = assertion .attribute("ID") .map(str::to_owned) .ok_or_else(|| SamlError::MissingElement("Assertion/@ID".into()))?; // 6. The signature must actually cover the assertion we're about to trust: // either the assertion itself, the enclosing response, or the whole // document. (bergshamra's strict_verification already constrains where // the signed element may sit; this ties it to *our* assertion.) let covers_assertion = verified_uris.iter().any(|u| { u.is_empty() || u == &format!("#{assertion_id}") || response_id .as_ref() .is_some_and(|rid| u == &format!("#{rid}")) }); if !covers_assertion { return Err(SamlError::SignatureScope); } // 7. Issuer must be the configured IdP. let issuer = first_child(assertion, "Issuer") .map(text_of) .unwrap_or_default(); if !idp.entity_id.is_empty() && issuer != idp.entity_id { return Err(SamlError::IssuerMismatch); } // 8. Subject → NameID + SubjectConfirmationData time/recipient checks. let subject = first_child(assertion, "Subject") .ok_or_else(|| SamlError::MissingElement("Subject".into()))?; let name_id = first_child(subject, "NameID") .map(text_of) .filter(|s| !s.is_empty()) .ok_or_else(|| SamlError::MissingElement("NameID".into()))?; if let Some(scd) = subject .descendants() .find(|n| n.is_element() && n.tag_name().name() == "SubjectConfirmationData") { if let Some(recipient) = scd.attribute("Recipient") { if !urls_equal(recipient, &sp.acs_url) { return Err(SamlError::DestinationMismatch); } } if let Some(noa) = scd.attribute("NotOnOrAfter") { let noa = parse_instant(noa)?; if now >= noa + clock_skew { return Err(SamlError::TimeBounds); } } } // 9. Conditions: time window + audience. let conditions = first_child(assertion, "Conditions"); if let Some(cond) = conditions { if let Some(nb) = cond.attribute("NotBefore") { let nb = parse_instant(nb)?; if now < nb - clock_skew { return Err(SamlError::TimeBounds); } } } let assertion_expiry = conditions .and_then(|c| c.attribute("NotOnOrAfter")) .map(parse_instant) .transpose()? .ok_or_else(|| SamlError::MissingElement("Conditions/@NotOnOrAfter".into()))?; if now >= assertion_expiry + clock_skew { return Err(SamlError::TimeBounds); } let audience_ok = conditions.is_some_and(|c| { c.descendants() .filter(|n| n.is_element() && n.tag_name().name() == "Audience") .any(|a| text_of(a) == sp.entity_id) }); if !audience_ok { return Err(SamlError::AudienceMismatch); } // 10. Optional: SessionIndex + display-name attribute. let session_index = assertion .descendants() .find(|n| n.is_element() && n.tag_name().name() == "AuthnStatement") .and_then(|a| a.attribute("SessionIndex")) .map(str::to_owned); let display_name = extract_display_name(assertion); Ok(VerifiedResponse { principal: VerifiedPrincipal { email: name_id.clone(), name_id, display_name, }, in_response_to, assertion_id, assertion_expiry, session_index, }) } /// Verify the document's XML-DSig against each pinned IdP cert in turn /// (handles key rotation), returning the verified `` URIs. fn verify_signature(xml: &str, certs_der: &[Vec]) -> Result, SamlError> { let mut last_err = String::from("no signing certificate matched"); for der in certs_der { let key = match bergshamra::keys::loader::load_x509_cert_der(der) { Ok(k) => k, Err(e) => { last_err = e.to_string(); continue; } }; let mut km = bergshamra::keys::KeysManager::new(); km.add_key(key); // trusted_keys_only: only ever trust the pinned IdP key, never an // inline KeyInfo cert. strict_verification: XSW positional defense. let ctx = bergshamra::DsigContext::new(km) .with_trusted_keys_only(true) .with_strict_verification(true); match bergshamra::verify(&ctx, xml) { Ok(bergshamra::VerifyResult::Valid { references, .. }) => { return Ok(references.into_iter().map(|r| r.uri).collect()); } Ok(bergshamra::VerifyResult::Invalid { reason }) => last_err = reason, Err(e) => last_err = e.to_string(), } } Err(SamlError::Signature(last_err)) } /// Pull a display name from the assertion's attribute statement, trying the /// common attribute names IdPs use (FleetDM checks the same set). fn extract_display_name(assertion: Node<'_, '_>) -> Option { const WANTED: &[&str] = &[ "name", "displayname", "cn", "urn:oid:2.5.4.3", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", ]; for attr in assertion .descendants() .filter(|n| n.is_element() && n.tag_name().name() == "Attribute") { let key = attr .attribute("Name") .or_else(|| attr.attribute("FriendlyName")) .unwrap_or("") .to_ascii_lowercase(); if WANTED.contains(&key.as_str()) { if let Some(val) = attr .descendants() .find(|n| n.is_element() && n.tag_name().name() == "AttributeValue") { let v = text_of(val); if !v.is_empty() { return Some(v); } } } } None } fn first_child<'a, 'i>(n: Node<'a, 'i>, local: &str) -> Option> { n.children() .find(|c| c.is_element() && c.tag_name().name() == local) } fn text_of(n: Node<'_, '_>) -> String { n.children() .filter(Node::is_text) .filter_map(|c| c.text()) .collect::() .trim() .to_owned() } /// Parse an `xs:dateTime` (e.g. `2026-05-31T12:00:00.000Z`). fn parse_instant(s: &str) -> Result { OffsetDateTime::parse(s.trim(), &Rfc3339).map_err(|e| SamlError::Timestamp(format!("{s}: {e}"))) } /// Compare two URLs for SAML endpoint-matching purposes: exact, or differing /// only by a single trailing slash. fn urls_equal(a: &str, b: &str) -> bool { a == b || a.trim_end_matches('/') == b.trim_end_matches('/') }