use crate::util::log::{debug, trace};
use crate::xml::XmlElement;
use super::SAML_NS;
use super::response::SamlResponseError;
const CM_BEARER: &str = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
#[doc(alias = "saml_assertion")]
#[derive(Debug, Clone)]
pub struct SamlAssertion {
issuer: String,
subject: SamlSubject,
conditions: SamlConditions,
attributes: Vec<SamlAttribute>,
}
impl SamlAssertion {
#[must_use]
#[inline]
pub fn issuer(&self) -> &str {
&self.issuer
}
#[must_use]
#[inline]
pub fn subject(&self) -> &SamlSubject {
&self.subject
}
#[must_use]
#[inline]
pub fn conditions(&self) -> &SamlConditions {
&self.conditions
}
#[must_use]
#[inline]
pub fn attributes(&self) -> &[SamlAttribute] {
&self.attributes
}
}
#[doc(alias = "saml_subject")]
#[derive(Debug, Clone)]
pub struct SamlSubject {
name_id: String,
name_id_format: Option<String>,
recipient: Option<String>,
confirmation_not_on_or_after: Option<String>,
in_response_to: Option<String>,
}
impl SamlSubject {
#[must_use]
#[inline]
pub fn name_id(&self) -> &str {
&self.name_id
}
#[must_use]
#[inline]
pub fn name_id_format(&self) -> Option<&str> {
self.name_id_format.as_deref()
}
#[must_use]
#[inline]
pub fn recipient(&self) -> Option<&str> {
self.recipient.as_deref()
}
#[must_use]
#[inline]
pub fn confirmation_not_on_or_after(&self) -> Option<&str> {
self.confirmation_not_on_or_after.as_deref()
}
#[must_use]
#[inline]
pub fn in_response_to(&self) -> Option<&str> {
self.in_response_to.as_deref()
}
}
#[doc(alias = "saml_conditions")]
#[derive(Debug, Clone)]
pub struct SamlConditions {
not_before: Option<String>,
not_on_or_after: Option<String>,
audience_restrictions: Vec<Vec<String>>,
}
impl SamlConditions {
#[must_use]
#[inline]
pub fn not_before(&self) -> Option<&str> {
self.not_before.as_deref()
}
#[must_use]
#[inline]
pub fn not_on_or_after(&self) -> Option<&str> {
self.not_on_or_after.as_deref()
}
#[must_use]
#[inline]
pub fn audience_restrictions(&self) -> &[Vec<String>] {
&self.audience_restrictions
}
}
#[doc(alias = "saml_attribute")]
#[derive(Debug, Clone)]
pub struct SamlAttribute {
name: String,
name_format: Option<String>,
values: Vec<String>,
}
impl SamlAttribute {
#[must_use]
#[inline]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
#[inline]
pub fn name_format(&self) -> Option<&str> {
self.name_format.as_deref()
}
#[must_use]
#[inline]
pub fn values(&self) -> &[String] {
&self.values
}
}
pub(crate) fn parse_assertion(el: &XmlElement) -> Result<SamlAssertion, SamlResponseError> {
if el.attribute("Version") != Some("2.0") {
return Err(SamlResponseError::invalid_version("Assertion"));
}
let ensure_single = |parent: &XmlElement, name: &str| -> Result<(), SamlResponseError> {
if find_saml_children(parent, name).len() > 1 {
return Err(SamlResponseError::duplicate_element(name));
}
Ok(())
};
ensure_single(el, "Issuer")?;
ensure_single(el, "Subject")?;
ensure_single(el, "Conditions")?;
trace!("saml: extracting assertion issuer");
let issuer_el = find_saml_child(el, "Issuer")
.ok_or_else(|| SamlResponseError::missing_element("Assertion/Issuer"))?;
let issuer = issuer_el
.text_content()
.ok_or_else(|| SamlResponseError::missing_element("Assertion/Issuer text"))?
.to_string();
trace!("saml: extracting assertion subject");
let subject_el = find_saml_child(el, "Subject")
.ok_or_else(|| SamlResponseError::missing_element("Assertion/Subject"))?;
ensure_single(subject_el, "NameID")?;
let name_id_el = find_saml_child(subject_el, "NameID")
.ok_or_else(|| SamlResponseError::missing_element("Assertion/Subject/NameID"))?;
let name_id_text = name_id_el
.text_content()
.ok_or_else(|| SamlResponseError::missing_element("Assertion/Subject/NameID text"))?;
let bearer: Vec<&XmlElement> = find_saml_children(subject_el, "SubjectConfirmation")
.into_iter()
.filter(|sc| sc.attribute("Method").is_none_or(|m| m == CM_BEARER))
.collect();
if bearer.len() > 1 {
return Err(SamlResponseError::duplicate_element("SubjectConfirmation"));
}
let scd = bearer
.first()
.and_then(|sc| find_saml_child(sc, "SubjectConfirmationData"));
let subject = SamlSubject {
name_id: name_id_text.to_string(),
name_id_format: name_id_el.attribute("Format").map(String::from),
recipient: scd.and_then(|d| d.attribute("Recipient")).map(String::from),
confirmation_not_on_or_after: scd
.and_then(|d| d.attribute("NotOnOrAfter"))
.map(String::from),
in_response_to: scd
.and_then(|d| d.attribute("InResponseTo"))
.map(String::from),
};
trace!("saml: extracting assertion conditions");
let conditions = parse_conditions(el);
trace!("saml: extracting assertion attributes");
let mut attributes = Vec::new();
for attr_stmt in find_saml_children(el, "AttributeStatement") {
for attr_el in find_saml_children(attr_stmt, "Attribute") {
if let Some(name_val) = attr_el.attribute("Name") {
let name = name_val.to_string();
trace!(name = %name, "saml: extracting attribute");
let name_format = attr_el.attribute("NameFormat").map(String::from);
let values = find_saml_children(attr_el, "AttributeValue")
.into_iter()
.filter_map(|v| v.text_content().map(String::from))
.collect();
attributes.push(SamlAttribute {
name,
name_format,
values,
});
}
}
}
debug!(issuer = %issuer, "saml: assertion parsed");
Ok(SamlAssertion {
issuer,
subject,
conditions,
attributes,
})
}
fn parse_conditions(el: &XmlElement) -> SamlConditions {
let Some(cond_el) = find_saml_child(el, "Conditions") else {
return SamlConditions {
not_before: None,
not_on_or_after: None,
audience_restrictions: Vec::new(),
};
};
let audience_restrictions: Vec<Vec<String>> =
find_saml_children(cond_el, "AudienceRestriction")
.into_iter()
.map(|ar| {
find_saml_children(ar, "Audience")
.into_iter()
.filter_map(|aud| aud.text_content().map(String::from))
.collect()
})
.collect();
SamlConditions {
not_before: cond_el.attribute("NotBefore").map(String::from),
not_on_or_after: cond_el.attribute("NotOnOrAfter").map(String::from),
audience_restrictions,
}
}
fn find_saml_child<'a>(parent: &'a XmlElement, local_name: &str) -> Option<&'a XmlElement> {
parent
.children()
.iter()
.find(|c| is_saml_named(c, local_name))
}
fn find_saml_children<'a>(parent: &'a XmlElement, local_name: &str) -> Vec<&'a XmlElement> {
parent
.children()
.iter()
.filter(|c| is_saml_named(c, local_name))
.collect()
}
fn is_saml_named(el: &XmlElement, local_name: &str) -> bool {
el.name() == local_name && el.namespace().is_none_or(|ns| ns == SAML_NS)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::xml::parse_xml;
fn sample_assertion_xml() -> String {
r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_abc123" IssueInstant="2025-01-15T12:00:00Z">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">user@example.com</saml:NameID>
</saml:Subject>
<saml:Conditions NotBefore="2025-01-15T11:59:00Z" NotOnOrAfter="2025-01-15T12:05:00Z">
<saml:AudienceRestriction>
<saml:Audience>https://sp.example.com</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AttributeStatement>
<saml:Attribute Name="email">
<saml:AttributeValue>user@example.com</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="groups" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
<saml:AttributeValue>admins</saml:AttributeValue>
<saml:AttributeValue>users</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>"#.to_string()
}
#[test]
fn parse_assertion_issuer() {
let el = parse_xml(&sample_assertion_xml()).unwrap();
let assertion = parse_assertion(&el).unwrap();
assert_eq!(assertion.issuer(), "https://idp.example.com");
}
#[test]
fn parse_assertion_subject() {
let el = parse_xml(&sample_assertion_xml()).unwrap();
let assertion = parse_assertion(&el).unwrap();
assert_eq!(assertion.subject().name_id(), "user@example.com");
assert_eq!(
assertion.subject().name_id_format(),
Some("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress")
);
}
#[test]
fn parse_assertion_conditions() {
let el = parse_xml(&sample_assertion_xml()).unwrap();
let assertion = parse_assertion(&el).unwrap();
assert_eq!(
assertion.conditions().not_before(),
Some("2025-01-15T11:59:00Z")
);
assert_eq!(
assertion.conditions().not_on_or_after(),
Some("2025-01-15T12:05:00Z")
);
assert_eq!(
assertion.conditions().audience_restrictions(),
[vec!["https://sp.example.com".to_string()]]
);
}
#[test]
fn parse_assertion_missing_issuer() {
let xml = r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
<saml:Subject>
<saml:NameID>user@example.com</saml:NameID>
</saml:Subject>
</saml:Assertion>"#;
let el = parse_xml(xml).unwrap();
let err = parse_assertion(&el).unwrap_err();
assert!(
err.is_missing_element(),
"expected MissingElement, got: {err}"
);
}
#[test]
fn parse_assertion_missing_subject() {
let xml = r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
<saml:Issuer>https://idp.example.com</saml:Issuer>
</saml:Assertion>"#;
let el = parse_xml(xml).unwrap();
let err = parse_assertion(&el).unwrap_err();
assert!(
err.is_missing_element(),
"expected MissingElement, got: {err}"
);
}
#[test]
fn parse_assertion_missing_name_id() {
let xml = r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<saml:Subject/>
</saml:Assertion>"#;
let el = parse_xml(xml).unwrap();
let err = parse_assertion(&el).unwrap_err();
assert!(
err.is_missing_element(),
"expected MissingElement, got: {err}"
);
}
#[test]
fn parse_assertion_rejects_missing_or_wrong_version() {
for version in ["", r#" Version="1.1""#, r#" Version="2.0.0""#] {
let xml = format!(
r#"<saml:Assertion{version} xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<saml:Subject><saml:NameID>u@example.com</saml:NameID></saml:Subject>
</saml:Assertion>"#
);
let el = parse_xml(&xml).unwrap();
let err = parse_assertion(&el).unwrap_err();
assert!(err.is_invalid_version(), "version={version:?}: {err}");
}
}
#[test]
fn parse_assertion_rejects_duplicate_single_occurrence_elements() {
let xml = r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
<saml:Issuer>https://evil.example.com</saml:Issuer>
<saml:Issuer>https://idp.example.com</saml:Issuer>
<saml:Subject><saml:NameID>u@example.com</saml:NameID></saml:Subject>
</saml:Assertion>"#;
let el = parse_xml(xml).unwrap();
let err = parse_assertion(&el).unwrap_err();
assert!(
err.is_duplicate_element(),
"expected DuplicateElement: {err}"
);
}
#[test]
fn parse_assertion_attributes() {
let el = parse_xml(&sample_assertion_xml()).unwrap();
let assertion = parse_assertion(&el).unwrap();
assert_eq!(assertion.attributes().len(), 2);
let email = &assertion.attributes()[0];
assert_eq!(email.name(), "email");
assert_eq!(email.values(), ["user@example.com"]);
let groups = &assertion.attributes()[1];
assert_eq!(groups.name(), "groups");
assert_eq!(groups.values(), ["admins", "users"]);
assert_eq!(
groups.name_format(),
Some("urn:oasis:names:tc:SAML:2.0:attrname-format:basic")
);
}
}