use std::time::{Duration, SystemTime};
#[cfg(test)]
use crate::authn_context::{AuthnContextClassRef, AuthnContextComparison};
use crate::authn_context::{ComparatorOutcome, RequestedAuthnContext, StandardComparator};
#[cfg(any(test, feature = "xmlenc"))]
use crate::crypto::keypair::KeyPair;
use crate::descriptor::IdpDescriptor;
use crate::dsig::algorithms::PeerCryptoPolicy;
use crate::dsig::reference::DS_NS;
use crate::dsig::verify::{VerifiedSignature, verify_signature};
use crate::error::Error;
use crate::response::identity::Identity;
use crate::response::parse::{
AssertionWrapper, ParsedAssertion, ParsedResponse, STATUS_SUCCESS, SUBJECT_CONFIRMATION_BEARER,
SUBJECT_CONFIRMATION_HOLDER_OF_KEY, SubjectConfirmation, parse_assertion,
};
use crate::xml::parse::{Document, Element, ElementId};
pub(crate) struct ValidateResponse<'a> {
pub document: &'a Document,
pub parsed: ParsedResponse,
pub idp: &'a IdpDescriptor,
pub peer_crypto_policy: &'a PeerCryptoPolicy,
#[cfg(feature = "xmlenc")]
pub decryption_keys: &'a [&'a KeyPair],
pub sp_entity_id: &'a str,
pub expected_destination: &'a str,
pub tracker_request_id: Option<&'a str>,
pub allow_unsolicited: bool,
pub want_response_signed: bool,
pub want_assertions_signed: bool,
pub now: SystemTime,
pub clock_skew: Duration,
pub requested_authn_context: Option<&'a RequestedAuthnContext>,
pub holder_of_key_cert: Option<&'a crate::crypto::cert::X509Certificate>,
}
pub(crate) fn validate_response(input: ValidateResponse<'_>) -> Result<Identity, Error> {
let ValidateResponse {
document,
parsed,
idp,
peer_crypto_policy,
#[cfg(feature = "xmlenc")]
decryption_keys,
sp_entity_id,
expected_destination,
tracker_request_id,
allow_unsolicited,
want_response_signed,
want_assertions_signed,
now,
clock_skew,
requested_authn_context,
holder_of_key_cert,
} = input;
if let Some(dest) = parsed.destination.as_deref()
&& dest != expected_destination
{
return Err(Error::DestinationMismatch);
}
let response_issuer = parsed.issuer.as_deref();
if response_issuer != Some(idp.entity_id.as_str()) {
return Err(Error::IssuerMismatch {
expected: idp.entity_id.clone(),
got: parsed.issuer.clone(),
});
}
if parsed.status_code != STATUS_SUCCESS {
return Err(Error::StatusNotSuccess {
code: parsed.status_code,
message: parsed.status_message,
});
}
match (tracker_request_id, parsed.in_response_to.as_deref()) {
(Some(tracker), Some(in_resp)) if tracker == in_resp => {}
(Some(_), _) => return Err(Error::InResponseToMismatch),
(None, None) => {
if !allow_unsolicited {
return Err(Error::UnsolicitedNotAllowed);
}
}
(None, Some(_)) => return Err(Error::UnsolicitedNotAllowed),
}
let response_root_element_id = document.root().id();
enforce_signature_positions(document)?;
let assertion_wrapper = parsed
.assertion
.as_ref()
.ok_or_else(|| Error::XmlParse("Response contains no Assertion".to_string()))?;
let (verified_cert_fingerprint, assertion_element, _decrypted_doc) = match assertion_wrapper {
AssertionWrapper::Cleartext(assertion_id) => {
let (verified, assertion_id) = verify_response_and_or_assertion(
document,
response_root_element_id,
*assertion_id,
idp,
peer_crypto_policy,
want_response_signed,
want_assertions_signed,
)?;
let assertion_elem = document
.element(assertion_id)
.ok_or(Error::SignatureVerification {
reason: "verified assertion element id not resolvable",
})?
.clone();
(
verified.verifying_cert_fingerprint,
assertion_elem,
None::<Document>,
)
}
#[cfg(feature = "xmlenc")]
AssertionWrapper::Encrypted(enc_id) => handle_encrypted(HandleEncryptedParams {
document,
response_root_id: response_root_element_id,
encrypted_assertion_id: *enc_id,
idp,
policy: peer_crypto_policy,
decryption_keys,
want_response_signed,
want_assertions_signed,
})?,
#[cfg(not(feature = "xmlenc"))]
AssertionWrapper::Encrypted(_enc_id) => {
return Err(Error::InvalidConfiguration {
reason: "EncryptedAssertion requires the `xmlenc` feature",
});
}
};
#[cfg_attr(
not(feature = "xmlenc"),
expect(
unused_mut,
reason = "assertion is only reassigned on the xmlenc EncryptedID decrypt path below"
)
)]
let mut assertion = parse_assertion(&assertion_element)?;
#[cfg(feature = "xmlenc")]
if let Some(name_id) = crate::response::parse::decrypt_subject_encrypted_id(
&assertion_element,
decryption_keys,
peer_crypto_policy,
)? {
assertion.subject_name_id = name_id;
}
if assertion.issuer != idp.entity_id {
return Err(Error::IssuerMismatch {
expected: idp.entity_id.clone(),
got: Some(assertion.issuer.clone()),
});
}
if let Some(nb) = assertion.conditions.not_before {
let now_plus_skew = now
.checked_add(clock_skew)
.ok_or_else(|| Error::XmlParse("now + clock_skew overflows SystemTime".to_string()))?;
if nb > now_plus_skew {
return Err(Error::NotYetValid);
}
}
let conditions_not_on_or_after = assertion.conditions.not_on_or_after.ok_or(
Error::XmlParse("Conditions missing NotOnOrAfter".to_string()),
)?;
let now_minus_skew = now
.checked_sub(clock_skew)
.ok_or_else(|| Error::XmlParse("now - clock_skew underflows SystemTime".to_string()))?;
if conditions_not_on_or_after <= now_minus_skew {
return Err(Error::Expired);
}
if assertion.conditions.audiences.is_empty() {
return Err(Error::AudienceMismatch);
}
if !assertion
.conditions
.audiences
.iter()
.any(|a| a == sp_entity_id)
{
return Err(Error::AudienceMismatch);
}
find_valid_subject_confirmation(
&assertion,
expected_destination,
tracker_request_id,
now,
clock_skew,
holder_of_key_cert,
)?;
if let Some(req) = requested_authn_context {
let first = assertion
.authn_statements
.first()
.ok_or(Error::AuthnContextDowngrade)?;
let actual_uri = first
.authn_context_class_ref
.as_deref()
.ok_or(Error::AuthnContextDowngrade)?;
check_authn_context(req, actual_uri)?;
}
let (session_index, authn_instant, session_not_on_or_after, authn_context_class_ref) =
match assertion.authn_statements.first() {
Some(s) => (
s.session_index.clone(),
s.authn_instant,
s.session_not_on_or_after,
s.authn_context_class_ref.clone(),
),
None => (None, assertion.issue_instant, None, None),
};
Ok(Identity {
name_id: assertion.subject_name_id,
session_index,
authn_instant,
session_not_on_or_after,
authn_context_class_ref,
attributes: assertion.attributes,
assertion_id: assertion.id,
not_on_or_after: conditions_not_on_or_after,
verifying_cert_fingerprint: verified_cert_fingerprint,
is_one_time_use: assertion.conditions.one_time_use,
})
}
const SAMLP_NS: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const SAML_NS: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
fn enforce_signature_positions(document: &Document) -> Result<(), Error> {
fn walk(element: &Element, parent: Option<&Element>) -> Result<(), Error> {
if element.qname().namespace() == Some(DS_NS) && element.qname().local() == "Signature" {
let allowed = parent.is_some_and(|p| {
let ns = p.qname().namespace();
let local = p.qname().local();
matches!(
(ns, local),
(Some(SAMLP_NS), "Response")
| (Some(SAML_NS), "Assertion" | "EncryptedAssertion")
)
});
if !allowed {
return Err(Error::SignatureVerification {
reason: "ds:Signature in disallowed position",
});
}
return Ok(());
}
for child in element.child_elements() {
walk(child, Some(element))?;
}
Ok(())
}
walk(document.root(), None)
}
fn verify_response_and_or_assertion(
document: &Document,
response_root_id: ElementId,
assertion_name_lookup_id: ElementId,
idp: &IdpDescriptor,
policy: &PeerCryptoPolicy,
want_response_signed: bool,
want_assertions_signed: bool,
) -> Result<(VerifiedSignature, ElementId), Error> {
let root = document.root();
let response_signature = root.child_element(Some(DS_NS), "Signature");
let assertion_elem =
document
.element(assertion_name_lookup_id)
.ok_or(Error::SignatureVerification {
reason: "assertion element id not resolvable",
})?;
let assertion_signature = assertion_elem.child_element(Some(DS_NS), "Signature");
let verify_response = |sig: &Element| -> Result<VerifiedSignature, Error> {
let verified = verify_signature(
document,
sig,
&idp.signing_certs,
&policy.allowed_signature_algorithms,
)?;
if verified.signed_element != response_root_id {
return Err(Error::SignatureVerification {
reason: "signature does not cover Response root",
});
}
Ok(verified)
};
let verify_assertion = |sig: &Element| -> Result<VerifiedSignature, Error> {
let verified = verify_signature(
document,
sig,
&idp.signing_certs,
&policy.allowed_signature_algorithms,
)?;
if verified.signed_element != assertion_name_lookup_id {
return Err(Error::SignatureVerification {
reason: "signature does not cover Assertion",
});
}
Ok(verified)
};
if want_response_signed {
let verified = verify_response(response_signature.ok_or(Error::SignatureMissing)?)?;
if want_assertions_signed {
let averified = verify_assertion(assertion_signature.ok_or(Error::SignatureMissing)?)?;
return Ok((averified, assertion_name_lookup_id));
}
return Ok((verified, assertion_name_lookup_id));
}
if want_assertions_signed {
let verified = verify_assertion(assertion_signature.ok_or(Error::SignatureMissing)?)?;
return Ok((verified, assertion_name_lookup_id));
}
if let Some(sig) = response_signature {
let verified = verify_response(sig)?;
return Ok((verified, assertion_name_lookup_id));
}
if let Some(sig) = assertion_signature {
let verified = verify_assertion(sig)?;
return Ok((verified, assertion_name_lookup_id));
}
Err(Error::SignatureMissing)
}
#[cfg(feature = "xmlenc")]
struct HandleEncryptedParams<'a> {
document: &'a Document,
response_root_id: ElementId,
encrypted_assertion_id: ElementId,
idp: &'a IdpDescriptor,
policy: &'a PeerCryptoPolicy,
decryption_keys: &'a [&'a KeyPair],
want_response_signed: bool,
want_assertions_signed: bool,
}
#[cfg(feature = "xmlenc")]
fn handle_encrypted(
params: HandleEncryptedParams<'_>,
) -> Result<([u8; 32], Element, Option<Document>), Error> {
let HandleEncryptedParams {
document,
response_root_id,
encrypted_assertion_id,
idp,
policy,
decryption_keys,
want_response_signed,
want_assertions_signed,
} = params;
use crate::xmlenc::decrypt::decrypt_encrypted_assertion;
let root = document.root();
let response_signature = root.child_element(Some(DS_NS), "Signature");
let mut response_verified_fingerprint: Option<[u8; 32]> = None;
if let Some(sig) = response_signature {
let verified = verify_signature(
document,
sig,
&idp.signing_certs,
&policy.allowed_signature_algorithms,
)?;
if verified.signed_element != response_root_id {
return Err(Error::SignatureVerification {
reason: "signature does not cover Response root",
});
}
response_verified_fingerprint = Some(verified.verifying_cert_fingerprint);
} else if want_response_signed {
return Err(Error::SignatureMissing);
}
let enc_elem = document
.element(encrypted_assertion_id)
.ok_or(Error::DecryptFailed {
reason: "EncryptedAssertion element id not resolvable",
})?;
let cleartext_assertion = decrypt_encrypted_assertion(
enc_elem,
decryption_keys,
&policy.allowed_data_encryption_algorithms,
&policy.allowed_key_transport_algorithms,
)?;
let decrypted_doc = Document::new(cleartext_assertion)?;
let assertion_root = decrypted_doc.root();
let inner_signature = assertion_root.child_element(Some(DS_NS), "Signature");
let verify_inner = |sig: &Element| -> Result<([u8; 32], Element), Error> {
let verified = verify_signature(
&decrypted_doc,
sig,
&idp.signing_certs,
&policy.allowed_signature_algorithms,
)?;
if verified.signed_element != assertion_root.id() {
return Err(Error::SignatureVerification {
reason: "signature does not cover Assertion",
});
}
let resolved = decrypted_doc
.element(verified.signed_element)
.ok_or(Error::SignatureVerification {
reason: "verified assertion element id not resolvable",
})?
.clone();
Ok((verified.verifying_cert_fingerprint, resolved))
};
if want_assertions_signed {
let (fp, resolved) = verify_inner(inner_signature.ok_or(Error::SignatureMissing)?)?;
return Ok((fp, resolved, Some(decrypted_doc)));
}
if let Some(sig) = inner_signature {
let (fp, resolved) = verify_inner(sig)?;
return Ok((fp, resolved, Some(decrypted_doc)));
}
if let Some(fp) = response_verified_fingerprint {
return Ok((fp, assertion_root.clone(), Some(decrypted_doc)));
}
Err(Error::SignatureMissing)
}
fn find_valid_subject_confirmation<'a>(
assertion: &'a ParsedAssertion,
expected_destination: &str,
tracker_request_id: Option<&str>,
now: SystemTime,
clock_skew: Duration,
holder_of_key_cert: Option<&crate::crypto::cert::X509Certificate>,
) -> Result<&'a SubjectConfirmation, Error> {
let mut candidates: Vec<&SubjectConfirmation> = assertion
.subject_confirmations
.iter()
.filter(|sc| {
sc.method == SUBJECT_CONFIRMATION_BEARER
|| (holder_of_key_cert.is_some() && sc.method == SUBJECT_CONFIRMATION_HOLDER_OF_KEY)
})
.collect();
candidates.sort_by_key(|sc| sc.method != SUBJECT_CONFIRMATION_BEARER);
if candidates.is_empty() {
let offers_hok = assertion
.subject_confirmations
.iter()
.any(|sc| sc.method == SUBJECT_CONFIRMATION_HOLDER_OF_KEY);
if offers_hok && holder_of_key_cert.is_none() {
return Err(Error::HolderOfKeyConfirmation {
reason: "assertion offers only Holder-of-Key but no presenter certificate was configured",
});
}
return Err(Error::SignatureVerification {
reason: "no bearer SubjectConfirmation",
});
}
let mut last_err: Option<Error> = None;
let now_minus_skew = now
.checked_sub(clock_skew)
.ok_or_else(|| Error::XmlParse("now - clock_skew underflows SystemTime".to_string()))?;
let now_plus_skew = now
.checked_add(clock_skew)
.ok_or_else(|| Error::XmlParse("now + clock_skew overflows SystemTime".to_string()))?;
for sc in &candidates {
match sc.recipient.as_deref() {
Some(r) if r == expected_destination => {}
_ => {
last_err = Some(Error::RecipientMismatch);
continue;
}
}
match sc.not_on_or_after {
Some(nooa) if nooa > now_minus_skew => {}
_ => {
last_err = Some(Error::Expired);
continue;
}
}
if let Some(nb) = sc.not_before
&& nb > now_plus_skew
{
last_err = Some(Error::NotYetValid);
continue;
}
match (tracker_request_id, sc.in_response_to.as_deref()) {
(Some(tracker), Some(in_resp)) if tracker == in_resp => {}
(Some(_), _) => {
last_err = Some(Error::InResponseToMismatch);
continue;
}
(None, None) => {}
(None, Some(_)) => {
last_err = Some(Error::UnsolicitedNotAllowed);
continue;
}
}
if sc.method == SUBJECT_CONFIRMATION_HOLDER_OF_KEY {
match holder_of_key_cert {
Some(presenter) => {
if sc.key_info_certs.is_empty() {
last_err = Some(Error::HolderOfKeyConfirmation {
reason: "Holder-of-Key SubjectConfirmationData has no usable <ds:KeyInfo> key material",
});
continue;
}
if !sc
.key_info_certs
.iter()
.any(|kc| presenter.same_public_key_as(kc))
{
last_err = Some(Error::HolderOfKeyConfirmation {
reason: "presenter certificate key does not match the Holder-of-Key <ds:KeyInfo>",
});
continue;
}
}
None => {
last_err = Some(Error::HolderOfKeyConfirmation {
reason: "Holder-of-Key confirmation requires a presenter certificate",
});
continue;
}
}
}
return Ok(sc);
}
Err(last_err.unwrap_or(Error::RecipientMismatch))
}
fn check_authn_context(requested: &RequestedAuthnContext, actual_uri: &str) -> Result<(), Error> {
match StandardComparator.evaluate(requested, actual_uri) {
ComparatorOutcome::Satisfied => Ok(()),
ComparatorOutcome::NotSatisfied | ComparatorOutcome::NotComparable => {
Err(Error::AuthnContextDowngrade)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::Endpoint;
use crate::crypto::cert::X509Certificate;
use crate::crypto::cert::test_vectors::{RSA_CERT_PEM, RSA_KEY_PKCS8_PEM};
use crate::dsig::algorithms::{C14nAlgorithm, DigestAlgorithm, SignatureAlgorithm};
use crate::dsig::sign::{SignOptions, sign_element};
use crate::nameid::NameIdFormat;
use crate::response::parse::parse_response;
use crate::response::{SAML_NS, SAMLP_NS, saml_qname, samlp_qname};
use crate::xml::emit::emit_document;
use crate::xml::parse::{Document, Node, QName};
use std::time::{Duration, UNIX_EPOCH};
fn fixed_now() -> SystemTime {
UNIX_EPOCH
.checked_add(Duration::from_secs(1_779_796_830))
.expect("UNIX_EPOCH + small duration fits in SystemTime")
}
fn fixture_idp() -> IdpDescriptor {
IdpDescriptor {
entity_id: "https://idp.example.com".to_owned(),
sso_endpoints: vec![Endpoint::post("https://idp.example.com/sso", 0, true)],
slo_endpoints: vec![],
artifact_resolution_endpoints: vec![],
signing_certs: vec![X509Certificate::from_pem(RSA_CERT_PEM).unwrap()],
encryption_certs: vec![],
supported_name_id_formats: vec![],
want_authn_requests_signed: false,
valid_until: None,
cache_duration: None,
}
}
fn rsa_signing_key() -> KeyPair {
let kp = KeyPair::from_pkcs8_pem(RSA_KEY_PKCS8_PEM).unwrap();
let cert = X509Certificate::from_pem(RSA_CERT_PEM).unwrap();
kp.with_certificate(cert)
}
fn strong_policy() -> PeerCryptoPolicy {
PeerCryptoPolicy::strong_defaults()
}
struct BuildAssertionFixture<'a> {
id: &'a str,
issuer: &'a str,
recipient: &'a str,
in_response_to: Option<&'a str>,
audience: &'a str,
not_before: &'a str,
not_on_or_after: &'a str,
sc_not_on_or_after: &'a str,
authn_context: &'a str,
name_id_value: &'a str,
name_id_format_uri: &'a str,
}
fn build_assertion(p: &BuildAssertionFixture<'_>) -> Element {
let &BuildAssertionFixture {
id,
issuer,
recipient,
in_response_to,
audience,
not_before,
not_on_or_after,
sc_not_on_or_after,
authn_context,
name_id_value,
name_id_format_uri,
} = p;
let name_id = Element::build(saml_qname("NameID"))
.with_attribute(QName::new(None, "Format"), name_id_format_uri.to_owned())
.with_text(name_id_value.to_owned())
.finish();
let mut scd_builder = Element::build(saml_qname("SubjectConfirmationData"))
.with_attribute(QName::new(None, "Recipient"), recipient.to_owned())
.with_attribute(
QName::new(None, "NotOnOrAfter"),
sc_not_on_or_after.to_owned(),
);
if let Some(irt) = in_response_to {
scd_builder =
scd_builder.with_attribute(QName::new(None, "InResponseTo"), irt.to_owned());
}
let scd = scd_builder.finish();
let sc = Element::build(saml_qname("SubjectConfirmation"))
.with_attribute(
QName::new(None, "Method"),
SUBJECT_CONFIRMATION_BEARER.to_owned(),
)
.with_child(Node::Element(scd))
.finish();
let subject = Element::build(saml_qname("Subject"))
.with_child(Node::Element(name_id))
.with_child(Node::Element(sc))
.finish();
let audience_elem = Element::build(saml_qname("Audience"))
.with_text(audience.to_owned())
.finish();
let restriction = Element::build(saml_qname("AudienceRestriction"))
.with_child(Node::Element(audience_elem))
.finish();
let conditions = Element::build(saml_qname("Conditions"))
.with_attribute(QName::new(None, "NotBefore"), not_before.to_owned())
.with_attribute(QName::new(None, "NotOnOrAfter"), not_on_or_after.to_owned())
.with_child(Node::Element(restriction))
.finish();
let class_ref = Element::build(saml_qname("AuthnContextClassRef"))
.with_text(authn_context.to_owned())
.finish();
let authn_context = Element::build(saml_qname("AuthnContext"))
.with_child(Node::Element(class_ref))
.finish();
let authn_stmt = Element::build(saml_qname("AuthnStatement"))
.with_attribute(
QName::new(None, "AuthnInstant"),
"2026-05-26T11:59:30Z".to_owned(),
)
.with_attribute(QName::new(None, "SessionIndex"), "sess-1")
.with_child(Node::Element(authn_context))
.finish();
let issuer_elem = Element::build(saml_qname("Issuer"))
.with_text(issuer.to_owned())
.finish();
Element::build(saml_qname("Assertion"))
.with_namespace(Some("saml".to_owned()), SAML_NS)
.with_attribute(QName::new(None, "ID"), id.to_owned())
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(
QName::new(None, "IssueInstant"),
"2026-05-26T12:00:00Z".to_owned(),
)
.with_child(Node::Element(issuer_elem))
.with_child(Node::Element(subject))
.with_child(Node::Element(conditions))
.with_child(Node::Element(authn_stmt))
.finish()
}
fn build_response_with_assertion(
response_id: &str,
in_response_to: Option<&str>,
destination: &str,
issuer: &str,
assertion: Element,
status_code: &str,
) -> Element {
let status_code_elem = Element::build(samlp_qname("StatusCode"))
.with_attribute(QName::new(None, "Value"), status_code.to_owned())
.finish();
let status = Element::build(samlp_qname("Status"))
.with_child(Node::Element(status_code_elem))
.finish();
let issuer_elem = Element::build(saml_qname("Issuer"))
.with_text(issuer.to_owned())
.finish();
let mut builder = Element::build(samlp_qname("Response"))
.with_namespace(Some("samlp".to_owned()), SAMLP_NS)
.with_namespace(Some("saml".to_owned()), SAML_NS)
.with_attribute(QName::new(None, "ID"), response_id.to_owned())
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(
QName::new(None, "IssueInstant"),
"2026-05-26T12:00:00Z".to_owned(),
)
.with_attribute(QName::new(None, "Destination"), destination.to_owned());
if let Some(irt) = in_response_to {
builder = builder.with_attribute(QName::new(None, "InResponseTo"), irt.to_owned());
}
builder
.with_child(Node::Element(issuer_elem))
.with_child(Node::Element(status))
.with_child(Node::Element(assertion))
.finish()
}
fn build_signed_hok_response(embed_cert: &X509Certificate) -> Vec<u8> {
let name_id = Element::build(saml_qname("NameID"))
.with_attribute(
QName::new(None, "Format"),
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress".to_owned(),
)
.with_text("alice@example.com")
.finish();
let ds = |local: &str| QName::new(Some(DS_NS.to_owned()), local);
let x509_cert = ds("X509Certificate");
let key_info = Element::build(ds("KeyInfo"))
.with_namespace(Some("ds".to_owned()), DS_NS)
.with_child(Node::Element(
Element::build(ds("X509Data"))
.with_child(Node::Element(
Element::build(x509_cert)
.with_text(embed_cert.to_base64_x509())
.finish(),
))
.finish(),
))
.finish();
let scd = Element::build(saml_qname("SubjectConfirmationData"))
.with_attribute(
QName::new(None, "Recipient"),
"https://sp.example.com/acs".to_owned(),
)
.with_attribute(
QName::new(None, "NotOnOrAfter"),
"2026-05-26T12:05:00Z".to_owned(),
)
.with_attribute(QName::new(None, "InResponseTo"), "_req1".to_owned())
.with_child(Node::Element(key_info))
.finish();
let sc = Element::build(saml_qname("SubjectConfirmation"))
.with_attribute(
QName::new(None, "Method"),
SUBJECT_CONFIRMATION_HOLDER_OF_KEY.to_owned(),
)
.with_child(Node::Element(scd))
.finish();
let subject = Element::build(saml_qname("Subject"))
.with_child(Node::Element(name_id))
.with_child(Node::Element(sc))
.finish();
let audience = Element::build(saml_qname("Audience"))
.with_text("https://sp.example.com")
.finish();
let restriction = Element::build(saml_qname("AudienceRestriction"))
.with_child(Node::Element(audience))
.finish();
let conditions = Element::build(saml_qname("Conditions"))
.with_attribute(QName::new(None, "NotBefore"), "2026-05-26T11:59:00Z")
.with_attribute(QName::new(None, "NotOnOrAfter"), "2026-05-26T12:10:00Z")
.with_child(Node::Element(restriction))
.finish();
let class_ref = Element::build(saml_qname("AuthnContextClassRef"))
.with_text("urn:oasis:names:tc:SAML:2.0:ac:classes:Password")
.finish();
let actx = Element::build(saml_qname("AuthnContext"))
.with_child(Node::Element(class_ref))
.finish();
let astmt = Element::build(saml_qname("AuthnStatement"))
.with_attribute(QName::new(None, "AuthnInstant"), "2026-05-26T12:00:00Z")
.with_attribute(QName::new(None, "SessionIndex"), "sess-1")
.with_child(Node::Element(actx))
.finish();
let issuer = Element::build(saml_qname("Issuer"))
.with_text("https://idp.example.com")
.finish();
let assertion = Element::build(saml_qname("Assertion"))
.with_namespace(Some("saml".to_owned()), SAML_NS)
.with_attribute(QName::new(None, "ID"), "_a1")
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(QName::new(None, "IssueInstant"), "2026-05-26T12:00:00Z")
.with_child(Node::Element(issuer))
.with_child(Node::Element(subject))
.with_child(Node::Element(conditions))
.with_child(Node::Element(astmt))
.finish();
let kp = rsa_signing_key();
let d = Document::new(assertion).unwrap();
let signed = sign_element(
d.root().clone(),
&d,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap();
let response = build_response_with_assertion(
"_r",
Some("_req1"),
"https://sp.example.com/acs",
"https://idp.example.com",
signed,
STATUS_SUCCESS,
);
emit_document(&Document::new(response).unwrap())
.unwrap()
.into_bytes()
}
#[test]
fn holder_of_key_accepts_matching_presenter_cert() {
let cert = X509Certificate::from_pem(RSA_CERT_PEM).unwrap();
let xml = build_signed_hok_response(&cert);
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
input.holder_of_key_cert = Some(&cert);
let identity = validate_response(input).expect("HoK confirms with matching key");
assert_eq!(identity.name_id.value, "alice@example.com");
}
#[test]
fn holder_of_key_rejects_non_matching_presenter_cert() {
let embedded = X509Certificate::from_pem(RSA_CERT_PEM).unwrap();
let presenter =
X509Certificate::from_pem(crate::crypto::cert::test_vectors::EC_P256_CERT_PEM).unwrap();
let xml = build_signed_hok_response(&embedded);
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
input.holder_of_key_cert = Some(&presenter);
let err = validate_response(input).unwrap_err();
match err {
Error::HolderOfKeyConfirmation { reason } => {
assert!(reason.contains("does not match"), "got: {reason}");
}
other => panic!("expected HolderOfKeyConfirmation, got {other:?}"),
}
}
#[test]
fn holder_of_key_only_assertion_without_presenter_cert_is_rejected() {
let cert = X509Certificate::from_pem(RSA_CERT_PEM).unwrap();
let xml = build_signed_hok_response(&cert);
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let err = validate_response(default_input(&doc, parsed, &idp, &policy)).unwrap_err();
match err {
Error::HolderOfKeyConfirmation { reason } => {
assert!(reason.contains("no presenter certificate"), "got: {reason}");
}
other => panic!("expected HolderOfKeyConfirmation, got {other:?}"),
}
}
struct BuildAndSignResponseFixture<'a> {
kp: &'a KeyPair,
sign_response: bool,
sign_assertion: bool,
in_response_to: Option<&'a str>,
audience: &'a str,
recipient: &'a str,
not_before: &'a str,
not_on_or_after: &'a str,
}
fn build_and_sign_response(p: &BuildAndSignResponseFixture<'_>) -> Vec<u8> {
let &BuildAndSignResponseFixture {
kp,
sign_response,
sign_assertion,
in_response_to,
audience,
recipient,
not_before,
not_on_or_after,
} = p;
let mut assertion = build_assertion(&BuildAssertionFixture {
id: "_a1",
issuer: "https://idp.example.com",
recipient,
in_response_to,
audience,
not_before,
not_on_or_after,
sc_not_on_or_after: "2026-05-26T12:05:00Z",
authn_context: "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
name_id_value: "alice@example.com",
name_id_format_uri: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
});
if sign_assertion {
let stash_doc = Document::new(assertion.clone()).unwrap();
assertion = sign_element(
stash_doc.root().clone(),
&stash_doc,
SignOptions {
signing_key: kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap();
}
let mut response = build_response_with_assertion(
"_resp1",
in_response_to,
recipient,
"https://idp.example.com",
assertion,
STATUS_SUCCESS,
);
if sign_response {
let stash_doc = Document::new(response.clone()).unwrap();
response = sign_element(
stash_doc.root().clone(),
&stash_doc,
SignOptions {
signing_key: kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap();
}
let doc = Document::new(response).unwrap();
emit_document(&doc).unwrap().into_bytes()
}
fn default_input<'a>(
document: &'a Document,
parsed: ParsedResponse,
idp: &'a IdpDescriptor,
policy: &'a PeerCryptoPolicy,
) -> ValidateResponse<'a> {
ValidateResponse {
document,
parsed,
idp,
peer_crypto_policy: policy,
#[cfg(feature = "xmlenc")]
decryption_keys: &[],
sp_entity_id: "https://sp.example.com",
expected_destination: "https://sp.example.com/acs",
tracker_request_id: Some("_req1"),
allow_unsolicited: false,
want_response_signed: false,
want_assertions_signed: true,
now: fixed_now(),
clock_skew: Duration::from_secs(30),
requested_authn_context: None,
holder_of_key_cert: None,
}
}
#[test]
fn happy_path_validates_signed_assertion() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc = Document::parse(&xml).expect("re-parse");
let (parsed, _) = parse_response(&doc).expect("parse");
let idp = fixture_idp();
let policy = strong_policy();
let identity =
validate_response(default_input(&doc, parsed, &idp, &policy)).expect("validate");
assert_eq!(identity.assertion_id, "_a1");
assert_eq!(identity.name_id.value, "alice@example.com");
assert_eq!(identity.name_id.format, NameIdFormat::EmailAddress);
assert_eq!(identity.session_index.as_deref(), Some("sess-1"));
assert_eq!(
identity.authn_context_class_ref.as_deref(),
Some("urn:oasis:names:tc:SAML:2.0:ac:classes:Password")
);
let cert = X509Certificate::from_pem(RSA_CERT_PEM).unwrap();
assert_eq!(
identity.verifying_cert_fingerprint,
cert.fingerprint_sha256()
);
}
#[test]
fn rejects_issuer_mismatch() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let mut idp = fixture_idp();
idp.entity_id = "https://other-idp.example.com".to_owned();
let policy = strong_policy();
let err = validate_response(default_input(&doc, parsed, &idp, &policy)).unwrap_err();
assert!(matches!(err, Error::IssuerMismatch { .. }));
}
#[test]
fn rejects_status_not_success() {
let assertion = build_assertion(&BuildAssertionFixture {
id: "_a1",
issuer: "https://idp.example.com",
recipient: "https://sp.example.com/acs",
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
sc_not_on_or_after: "2026-05-26T12:05:00Z",
authn_context: "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
name_id_value: "alice@example.com",
name_id_format_uri: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
});
let response = build_response_with_assertion(
"_r",
Some("_req1"),
"https://sp.example.com/acs",
"https://idp.example.com",
assertion,
"urn:oasis:names:tc:SAML:2.0:status:Responder",
);
let doc = Document::new(response).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let err = validate_response(default_input(&doc, parsed, &idp, &policy)).unwrap_err();
match err {
Error::StatusNotSuccess { code, .. } => {
assert_eq!(code, "urn:oasis:names:tc:SAML:2.0:status:Responder");
}
other => panic!("expected StatusNotSuccess, got {other:?}"),
}
}
#[test]
fn rejects_in_response_to_mismatch() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_wrong-id"),
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let err = validate_response(default_input(&doc, parsed, &idp, &policy)).unwrap_err();
assert!(matches!(err, Error::InResponseToMismatch));
}
#[test]
fn unsolicited_response_requires_allow_flag() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: None,
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
input.tracker_request_id = None;
input.allow_unsolicited = false;
let err = validate_response(input).unwrap_err();
assert!(matches!(err, Error::UnsolicitedNotAllowed));
}
#[test]
fn unsolicited_response_accepted_when_allow_flag_set() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: None,
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
input.tracker_request_id = None;
input.allow_unsolicited = true;
let identity = validate_response(input).expect("validate");
assert_eq!(identity.assertion_id, "_a1");
}
#[test]
fn rejects_audience_mismatch() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_req1"),
audience: "https://other.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let err = validate_response(default_input(&doc, parsed, &idp, &policy)).unwrap_err();
assert!(matches!(err, Error::AudienceMismatch));
}
#[test]
fn rejects_recipient_mismatch() {
let kp = rsa_signing_key();
let assertion = build_assertion(&BuildAssertionFixture {
id: "_a1",
issuer: "https://idp.example.com",
recipient: "https://wrong.example.com/acs",
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
sc_not_on_or_after: "2026-05-26T12:05:00Z",
authn_context: "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
name_id_value: "alice@example.com",
name_id_format_uri: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
});
let signed_assertion = {
let d = Document::new(assertion).unwrap();
sign_element(
d.root().clone(),
&d,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap()
};
let response = build_response_with_assertion(
"_resp1",
Some("_req1"),
"https://sp.example.com/acs",
"https://idp.example.com",
signed_assertion,
STATUS_SUCCESS,
);
let doc = Document::new(response).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let err = validate_response(default_input(&doc, parsed, &idp, &policy)).unwrap_err();
assert!(matches!(err, Error::RecipientMismatch));
}
#[test]
fn rejects_not_yet_valid() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T13:00:00Z", not_on_or_after: "2026-05-26T14:00:00Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let err = validate_response(default_input(&doc, parsed, &idp, &policy)).unwrap_err();
assert!(matches!(err, Error::NotYetValid));
}
#[test]
fn rejects_expired() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2025-01-01T00:00:00Z",
not_on_or_after: "2025-12-31T23:59:59Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let err = validate_response(default_input(&doc, parsed, &idp, &policy)).unwrap_err();
assert!(matches!(err, Error::Expired));
}
#[test]
fn rejects_when_no_signature_present() {
let assertion = build_assertion(&BuildAssertionFixture {
id: "_a1",
issuer: "https://idp.example.com",
recipient: "https://sp.example.com/acs",
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
sc_not_on_or_after: "2026-05-26T12:05:00Z",
authn_context: "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
name_id_value: "alice@example.com",
name_id_format_uri: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
});
let response = build_response_with_assertion(
"_resp1",
Some("_req1"),
"https://sp.example.com/acs",
"https://idp.example.com",
assertion,
STATUS_SUCCESS,
);
let doc = Document::new(response).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
input.want_response_signed = true;
input.want_assertions_signed = false;
let err = validate_response(input).unwrap_err();
assert!(matches!(err, Error::SignatureMissing));
}
#[test]
fn rejects_holder_of_key_when_no_presenter_cert_configured() {
let name_id = Element::build(saml_qname("NameID"))
.with_attribute(
QName::new(None, "Format"),
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress".to_owned(),
)
.with_text("alice@example.com")
.finish();
let scd = Element::build(saml_qname("SubjectConfirmationData"))
.with_attribute(
QName::new(None, "Recipient"),
"https://sp.example.com/acs".to_owned(),
)
.with_attribute(
QName::new(None, "NotOnOrAfter"),
"2026-05-26T12:05:00Z".to_owned(),
)
.with_attribute(QName::new(None, "InResponseTo"), "_req1".to_owned())
.finish();
let sc = Element::build(saml_qname("SubjectConfirmation"))
.with_attribute(
QName::new(None, "Method"),
"urn:oasis:names:tc:SAML:2.0:cm:holder-of-key".to_owned(),
)
.with_child(Node::Element(scd))
.finish();
let subject = Element::build(saml_qname("Subject"))
.with_child(Node::Element(name_id))
.with_child(Node::Element(sc))
.finish();
let audience = Element::build(saml_qname("Audience"))
.with_text("https://sp.example.com")
.finish();
let restriction = Element::build(saml_qname("AudienceRestriction"))
.with_child(Node::Element(audience))
.finish();
let conditions = Element::build(saml_qname("Conditions"))
.with_attribute(QName::new(None, "NotBefore"), "2026-05-26T11:59:00Z")
.with_attribute(QName::new(None, "NotOnOrAfter"), "2026-05-26T12:10:00Z")
.with_child(Node::Element(restriction))
.finish();
let class_ref = Element::build(saml_qname("AuthnContextClassRef"))
.with_text("urn:oasis:names:tc:SAML:2.0:ac:classes:Password")
.finish();
let actx = Element::build(saml_qname("AuthnContext"))
.with_child(Node::Element(class_ref))
.finish();
let astmt = Element::build(saml_qname("AuthnStatement"))
.with_attribute(QName::new(None, "AuthnInstant"), "2026-05-26T12:00:00Z")
.with_child(Node::Element(actx))
.finish();
let issuer = Element::build(saml_qname("Issuer"))
.with_text("https://idp.example.com")
.finish();
let assertion = Element::build(saml_qname("Assertion"))
.with_namespace(Some("saml".to_owned()), SAML_NS)
.with_attribute(QName::new(None, "ID"), "_a1")
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(QName::new(None, "IssueInstant"), "2026-05-26T12:00:00Z")
.with_child(Node::Element(issuer))
.with_child(Node::Element(subject))
.with_child(Node::Element(conditions))
.with_child(Node::Element(astmt))
.finish();
let kp = rsa_signing_key();
let d = Document::new(assertion).unwrap();
let signed_assertion = sign_element(
d.root().clone(),
&d,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap();
let response = build_response_with_assertion(
"_r",
Some("_req1"),
"https://sp.example.com/acs",
"https://idp.example.com",
signed_assertion,
STATUS_SUCCESS,
);
let doc = Document::new(response).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let err = validate_response(default_input(&doc, parsed, &idp, &policy)).unwrap_err();
match err {
Error::HolderOfKeyConfirmation { reason } => {
assert!(reason.contains("no presenter certificate"), "got: {reason}");
}
other => panic!("expected HolderOfKeyConfirmation, got {other:?}"),
}
}
#[test]
fn rejects_authn_context_downgrade() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::MultiFactorAuth],
comparison: AuthnContextComparison::Exact,
};
input.requested_authn_context = Some(&req);
let err = validate_response(input).unwrap_err();
assert!(matches!(err, Error::AuthnContextDowngrade));
}
#[test]
fn one_time_use_flag_surfaces_on_identity() {
let kp = rsa_signing_key();
let mut assertion = build_assertion(&BuildAssertionFixture {
id: "_a-one-time",
issuer: "https://idp.example.com",
recipient: "https://sp.example.com/acs",
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
sc_not_on_or_after: "2026-05-26T12:05:00Z",
authn_context: "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
name_id_value: "alice@example.com",
name_id_format_uri: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
});
let conditions_idx = assertion
.children
.iter()
.position(|n| match n {
Node::Element(e) => {
e.qname().namespace() == Some(SAML_NS) && e.qname().local() == "Conditions"
}
_ => false,
})
.expect("assertion has <saml:Conditions>");
let Node::Element(conditions) = &mut assertion.children[conditions_idx] else {
panic!("expected Conditions element node");
};
let one_time_use = Element::build(saml_qname("OneTimeUse")).finish();
conditions.children.push(Node::Element(one_time_use));
let stash_doc = Document::new(assertion).unwrap();
let signed_assertion = sign_element(
stash_doc.root().clone(),
&stash_doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap();
let response = build_response_with_assertion(
"_resp-one-time",
Some("_req1"),
"https://sp.example.com/acs",
"https://idp.example.com",
signed_assertion,
STATUS_SUCCESS,
);
let doc = Document::new(response).unwrap();
let xml = emit_document(&doc).unwrap().into_bytes();
let doc = Document::parse(&xml).expect("re-parse");
let (parsed, _) = parse_response(&doc).expect("parse_response");
let idp = fixture_idp();
let policy = strong_policy();
let identity =
validate_response(default_input(&doc, parsed, &idp, &policy)).expect("validate");
assert_eq!(identity.assertion_id, "_a-one-time");
assert!(
identity.is_one_time_use,
"Identity.is_one_time_use must surface when <saml:OneTimeUse/> is present in Conditions"
);
let xml2 = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc2 = Document::parse(&xml2).expect("re-parse");
let (parsed2, _) = parse_response(&doc2).expect("parse");
let identity2 =
validate_response(default_input(&doc2, parsed2, &idp, &policy)).expect("validate");
assert!(
!identity2.is_one_time_use,
"Identity.is_one_time_use must be false when <saml:OneTimeUse/> is absent"
);
}
#[test]
fn accepts_authn_context_minimum_when_actual_is_stronger() {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::Password],
comparison: AuthnContextComparison::Minimum,
};
input.requested_authn_context = Some(&req);
let identity = validate_response(input).expect("validate");
assert_eq!(identity.assertion_id, "_a1");
}
fn signed_password_response_doc() -> (Document, ParsedResponse) {
let kp = rsa_signing_key();
let xml = build_and_sign_response(&BuildAndSignResponseFixture {
kp: &kp,
sign_response: false,
sign_assertion: true,
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
recipient: "https://sp.example.com/acs",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
});
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
(doc, parsed)
}
fn validate_with_requested(
req: &RequestedAuthnContext,
) -> Result<crate::response::Identity, Error> {
let (doc, parsed) = signed_password_response_doc();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
input.requested_authn_context = Some(req);
validate_response(input)
}
#[test]
fn comparator_exact_accepts_matching_class_ref() {
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::Password],
comparison: AuthnContextComparison::Exact,
};
let identity = validate_with_requested(&req).expect("validate");
assert_eq!(identity.assertion_id, "_a1");
}
#[test]
fn comparator_exact_accepts_when_actual_is_any_of_requested() {
let req = RequestedAuthnContext {
class_refs: vec![
AuthnContextClassRef::Password,
AuthnContextClassRef::MultiFactorAuth,
],
comparison: AuthnContextComparison::Exact,
};
validate_with_requested(&req).expect("validate");
}
#[test]
fn comparator_exact_rejects_when_actual_is_not_in_set() {
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::MultiFactorAuth],
comparison: AuthnContextComparison::Exact,
};
let err = validate_with_requested(&req).unwrap_err();
assert!(matches!(err, Error::AuthnContextDowngrade));
}
#[test]
fn comparator_minimum_accepts_when_actual_equals_floor() {
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::Password],
comparison: AuthnContextComparison::Minimum,
};
validate_with_requested(&req).expect("validate");
}
#[test]
fn comparator_minimum_rejects_weaker_actual() {
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::PasswordProtectedTransport],
comparison: AuthnContextComparison::Minimum,
};
let err = validate_with_requested(&req).unwrap_err();
assert!(matches!(err, Error::AuthnContextDowngrade));
}
#[test]
fn comparator_minimum_uses_weakest_among_multiple_requested() {
let req = RequestedAuthnContext {
class_refs: vec![
AuthnContextClassRef::PreviousSession,
AuthnContextClassRef::MultiFactorAuth,
],
comparison: AuthnContextComparison::Minimum,
};
validate_with_requested(&req).expect("validate");
}
#[test]
fn comparator_maximum_accepts_when_actual_is_weaker_or_equal() {
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::Smartcard],
comparison: AuthnContextComparison::Maximum,
};
validate_with_requested(&req).expect("validate");
}
#[test]
fn comparator_maximum_rejects_stronger_actual() {
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::PreviousSession],
comparison: AuthnContextComparison::Maximum,
};
let err = validate_with_requested(&req).unwrap_err();
assert!(matches!(err, Error::AuthnContextDowngrade));
}
#[test]
fn comparator_maximum_uses_strongest_among_multiple_requested() {
let req = RequestedAuthnContext {
class_refs: vec![
AuthnContextClassRef::Password,
AuthnContextClassRef::Smartcard,
],
comparison: AuthnContextComparison::Maximum,
};
validate_with_requested(&req).expect("validate");
}
#[test]
fn comparator_better_rejects_equal_strength() {
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::Password],
comparison: AuthnContextComparison::Better,
};
let err = validate_with_requested(&req).unwrap_err();
assert!(matches!(err, Error::AuthnContextDowngrade));
}
#[test]
fn comparator_better_accepts_strictly_stronger_actual() {
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::PreviousSession],
comparison: AuthnContextComparison::Better,
};
validate_with_requested(&req).expect("validate");
}
#[test]
fn comparator_better_requires_exceeding_every_requested() {
let req = RequestedAuthnContext {
class_refs: vec![
AuthnContextClassRef::PreviousSession,
AuthnContextClassRef::Smartcard,
],
comparison: AuthnContextComparison::Better,
};
let err = validate_with_requested(&req).unwrap_err();
assert!(matches!(err, Error::AuthnContextDowngrade));
}
#[test]
fn comparator_minimum_with_unknown_requested_uri_fails_closed() {
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::Custom(
"urn:example:vendor:strong".into(),
)],
comparison: AuthnContextComparison::Minimum,
};
let err = validate_with_requested(&req).unwrap_err();
assert!(matches!(err, Error::AuthnContextDowngrade));
}
#[test]
fn comparator_exact_works_with_custom_uri_match() {
let kp = rsa_signing_key();
let custom = "urn:example:vendor:strong";
let assertion = build_assertion(&BuildAssertionFixture {
id: "_a1",
issuer: "https://idp.example.com",
recipient: "https://sp.example.com/acs",
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
sc_not_on_or_after: "2026-05-26T12:05:00Z",
authn_context: custom,
name_id_value: "alice@example.com",
name_id_format_uri: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
});
let stash_doc = Document::new(assertion).unwrap();
let signed = sign_element(
stash_doc.root().clone(),
&stash_doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap();
let response = build_response_with_assertion(
"_resp1",
Some("_req1"),
"https://sp.example.com/acs",
"https://idp.example.com",
signed,
STATUS_SUCCESS,
);
let doc = Document::new(response).unwrap();
let xml = emit_document(&doc).unwrap().into_bytes();
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::Custom(custom.into())],
comparison: AuthnContextComparison::Exact,
};
input.requested_authn_context = Some(&req);
validate_response(input).expect("validate");
}
#[test]
fn comparator_rejects_when_actual_authn_context_class_ref_missing() {
let kp = rsa_signing_key();
let mut assertion = build_assertion(&BuildAssertionFixture {
id: "_a1",
issuer: "https://idp.example.com",
recipient: "https://sp.example.com/acs",
in_response_to: Some("_req1"),
audience: "https://sp.example.com",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2026-05-26T12:10:00Z",
sc_not_on_or_after: "2026-05-26T12:05:00Z",
authn_context: "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
name_id_value: "alice@example.com",
name_id_format_uri: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
});
let astmt_idx = assertion
.children
.iter()
.position(|n| match n {
Node::Element(e) => {
e.qname().namespace() == Some(SAML_NS) && e.qname().local() == "AuthnStatement"
}
_ => false,
})
.expect("assertion has <saml:AuthnStatement>");
let Node::Element(astmt) = &mut assertion.children[astmt_idx] else {
panic!("expected AuthnStatement element node");
};
let actx_idx = astmt
.children
.iter()
.position(|n| match n {
Node::Element(e) => {
e.qname().namespace() == Some(SAML_NS) && e.qname().local() == "AuthnContext"
}
_ => false,
})
.expect("AuthnStatement has <saml:AuthnContext>");
let Node::Element(actx) = &mut astmt.children[actx_idx] else {
panic!("expected AuthnContext element node");
};
actx.children.clear();
let stash_doc = Document::new(assertion).unwrap();
let signed = sign_element(
stash_doc.root().clone(),
&stash_doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap();
let response = build_response_with_assertion(
"_resp1",
Some("_req1"),
"https://sp.example.com/acs",
"https://idp.example.com",
signed,
STATUS_SUCCESS,
);
let doc = Document::new(response).unwrap();
let xml = emit_document(&doc).unwrap().into_bytes();
let doc = Document::parse(&xml).unwrap();
let (parsed, _) = parse_response(&doc).unwrap();
let idp = fixture_idp();
let policy = strong_policy();
let mut input = default_input(&doc, parsed, &idp, &policy);
let req = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::Password],
comparison: AuthnContextComparison::Exact,
};
input.requested_authn_context = Some(&req);
let err = validate_response(input).unwrap_err();
assert!(matches!(err, Error::AuthnContextDowngrade));
}
}