use chrono::{DateTime, TimeDelta, Utc};
use crate::core::assertion::types::Assertion;
use crate::core::constants::{CM_BEARER, NAMEID_ENTITY, NAMEID_PERSISTENT};
use crate::core::protocol::response::Response;
use crate::security::audience::evaluate_audience_restrictions;
use crate::security::clock::{is_not_before_valid, is_not_on_or_after_valid, is_within_age_limit};
use crate::security::conditions::{check_one_time_use, check_proxy_restriction};
use crate::security::config::SecurityConfig;
use crate::security::destination::verify_destination;
use crate::security::error::{SecurityError, ValidationCheck, ValidationResult};
use crate::security::name_id::PersistentIdStore;
use crate::security::recipient::verify_recipient;
use crate::security::relay_state::validate_relay_state_content;
use crate::security::replay::ReplayCache;
use crate::security::signature::contains_ds_object;
pub struct ValidationParams<'a> {
pub received_url: &'a str,
pub expected_idp_entity_id: &'a str,
pub sp_entity_id: &'a str,
pub acs_url: &'a str,
pub expected_request_id: Option<&'a str>,
pub client_address: Option<&'a str>,
pub relay_state: Option<&'a str>,
pub response_signature_xml: Option<&'a str>,
pub response_signature_verified: Option<bool>,
pub verified_signed_ids: &'a [&'a str],
pub current_proxy_depth: u32,
pub now: DateTime<Utc>,
}
pub struct AssertionValidator<'a> {
config: &'a SecurityConfig,
replay_cache: Option<&'a dyn ReplayCache>,
persistent_id_store: Option<&'a dyn PersistentIdStore>,
persistent_id_principal: Option<&'a str>,
}
impl<'a> AssertionValidator<'a> {
pub fn new(config: &'a SecurityConfig) -> Self {
Self {
config,
replay_cache: None,
persistent_id_store: None,
persistent_id_principal: None,
}
}
pub fn with_replay_cache(mut self, cache: &'a dyn ReplayCache) -> Self {
self.replay_cache = Some(cache);
self
}
pub fn with_persistent_id_store(
mut self,
store: &'a dyn PersistentIdStore,
principal: &'a str,
) -> Self {
self.persistent_id_store = Some(store);
self.persistent_id_principal = Some(principal);
self
}
pub fn validate_response(
&self,
response: &Response,
params: &ValidationParams<'_>,
) -> ValidationResult {
let mut result = ValidationResult::new();
self.check_response_level(response, params, &mut result);
self.check_response_success_and_assertions(response, &mut result);
for assertion in &response.assertions {
self.check_assertion_level(response, assertion, params, &mut result);
}
if let Some(relay_state) = params.relay_state {
self.check_relay_state(relay_state, &mut result);
}
result
}
fn check_response_success_and_assertions(
&self,
response: &Response,
result: &mut ValidationResult,
) {
if response.base.status.is_success() {
result.add(ValidationCheck::pass(33, "Response status success"));
} else {
result.add(ValidationCheck::fail(
33,
"Response status success",
SecurityError::ResponseNotSuccess(response.base.status.status_code.value.clone())
.to_string(),
));
}
if response.assertions.is_empty() {
let detail = if response.encrypted_assertions.is_empty() {
SecurityError::MissingRequired("Assertion".to_string()).to_string()
} else {
"response carries only EncryptedAssertion; decrypt before validation".to_string()
};
result.add(ValidationCheck::fail(
34,
"Response contains plaintext assertion",
detail,
));
} else {
result.add(ValidationCheck::pass(
34,
"Response contains plaintext assertion",
));
}
}
fn check_response_level(
&self,
response: &Response,
params: &ValidationParams<'_>,
result: &mut ValidationResult,
) {
let is_signed = response.base.has_signature;
if self.config.verify_destination {
match verify_destination(
response.base.destination.as_deref(),
params.received_url,
is_signed,
) {
Ok(()) => result.add(ValidationCheck::pass(1, "Destination matches URL")),
Err(e) => result.add(ValidationCheck::fail(1, "Destination matches URL", e)),
}
} else {
result.add(ValidationCheck::pass(
1,
"Destination matches URL (skipped)",
));
}
if let Some(ref issuer) = response.base.issuer {
if let Some(ref format) = issuer.format {
if format == NAMEID_ENTITY {
result.add(ValidationCheck::pass(2, "Issuer format valid"));
} else {
result.add(ValidationCheck::fail(
2,
"Issuer format valid",
format!("Issuer format must be entity or omitted, got '{}'", format),
));
}
} else {
result.add(ValidationCheck::pass(2, "Issuer format valid"));
}
} else {
result.add(ValidationCheck::pass(2, "Issuer format valid"));
}
match (
response.base.in_response_to.as_deref(),
params.expected_request_id,
) {
(Some(irt), Some(expected)) => {
if irt == expected {
result.add(ValidationCheck::pass(3, "InResponseTo matches"));
} else {
result.add(ValidationCheck::fail(
3,
"InResponseTo matches",
format!("Expected '{}', got '{}'", expected, irt),
));
}
}
(None, None) => {
if self.config.allow_unsolicited_responses {
result.add(ValidationCheck::pass(3, "InResponseTo matches"));
} else {
result.add(ValidationCheck::fail(
3,
"InResponseTo matches",
"Unsolicited response is not allowed by configuration",
));
}
}
(Some(irt), None) => {
result.add(ValidationCheck::fail(
3,
"InResponseTo matches",
format!(
"Response carries InResponseTo='{irt}' but no matching outstanding request"
),
));
}
(None, Some(expected)) => {
result.add(ValidationCheck::fail(
3,
"InResponseTo matches",
format!("Expected InResponseTo='{}' but none present", expected),
));
}
}
if is_signed {
match params.response_signature_verified {
Some(true) => {
result.add(ValidationCheck::pass(4, "Response signature valid"));
}
Some(false) => {
result.add(ValidationCheck::fail(
4,
"Response signature valid",
"Response signature verification failed",
));
}
None => {
result.add(ValidationCheck::fail(
4,
"Response signature valid",
"Response has signature but verification was not performed",
));
}
}
} else if self.config.require_signed_responses {
result.add(ValidationCheck::fail(
4,
"Response signature valid",
"Response signature required but not present",
));
} else {
result.add(ValidationCheck::pass(4, "Response signature valid"));
}
if let Some(sig_xml) = params.response_signature_xml {
if self.config.reject_signatures_with_ds_object {
match contains_ds_object(sig_xml) {
Ok(true) => {
result.add(ValidationCheck::fail(
7,
"No ds:Object in signature",
"Response signature contains ds:Object (E91)",
));
}
Ok(false) => {}
Err(e) => {
result.add(ValidationCheck::fail(
7,
"No ds:Object in signature",
format!("Could not parse response signature for E91 check: {e}"),
));
}
}
}
}
}
fn check_assertion_level(
&self,
_response: &Response,
assertion: &Assertion,
params: &ValidationParams<'_>,
result: &mut ValidationResult,
) {
if assertion.issuer.value == params.expected_idp_entity_id {
result.add(ValidationCheck::pass(5, "Issuer matches IdP"));
} else {
result.add(ValidationCheck::fail(
5,
"Issuer matches IdP",
format!(
"Expected '{}', got '{}'",
params.expected_idp_entity_id, assertion.issuer.value
),
));
}
if assertion.has_signature {
if params.verified_signed_ids.contains(&assertion.id.as_str()) {
result.add(ValidationCheck::pass(6, "Assertion signature verified"));
} else {
result.add(ValidationCheck::fail(
6,
"Assertion signature verified",
"Assertion has signature markup but no verified signature references this assertion",
));
}
} else if self.config.require_signed_assertions {
result.add(ValidationCheck::fail(
6,
"Assertion signature verified",
"Assertion signature required but no verified assertion signature was found",
));
} else {
result.add(ValidationCheck::pass(6, "Assertion signature not required"));
}
if !result.checks.iter().any(|c| c.check_number == 7) {
result.add(ValidationCheck::pass(7, "No ds:Object in signature"));
}
result.add(ValidationCheck::pass(8, "Signature algorithm supported"));
result.add(self.check_assertion_age(assertion.issue_instant, params.now));
if let Some(ref conditions) = assertion.conditions {
if let Some(not_before) = conditions.not_before {
if is_not_before_valid(params.now, not_before, self.config.clock_skew_seconds) {
result.add(ValidationCheck::pass(9, "NotBefore valid"));
} else {
result.add(ValidationCheck::fail(
9,
"NotBefore valid",
format!(
"Now {} is before NotBefore {} (skew: {}s)",
params.now, not_before, self.config.clock_skew_seconds
),
));
}
} else {
result.add(ValidationCheck::pass(9, "NotBefore valid"));
}
if let Some(not_on_or_after) = conditions.not_on_or_after {
if is_not_on_or_after_valid(
params.now,
not_on_or_after,
self.config.clock_skew_seconds,
) {
result.add(ValidationCheck::pass(10, "NotOnOrAfter valid"));
} else {
result.add(ValidationCheck::fail(
10,
"NotOnOrAfter valid",
format!(
"Now {} is at/after NotOnOrAfter {} (skew: {}s)",
params.now, not_on_or_after, self.config.clock_skew_seconds
),
));
}
} else {
result.add(ValidationCheck::pass(10, "NotOnOrAfter valid"));
}
if conditions.audience_restrictions.is_empty() {
result.add(ValidationCheck::fail(
11,
"AudienceRestriction satisfied",
"Assertion Conditions carry no AudienceRestriction binding this SP",
));
} else if evaluate_audience_restrictions(
&conditions.audience_restrictions,
params.sp_entity_id,
) {
result.add(ValidationCheck::pass(11, "AudienceRestriction satisfied"));
} else {
result.add(ValidationCheck::fail(
11,
"AudienceRestriction satisfied",
format!(
"SP '{}' is not in any audience restriction",
params.sp_entity_id
),
));
}
result.add(check_one_time_use(conditions.one_time_use));
let proxy_limit = conditions
.proxy_restriction
.as_ref()
.and_then(|pr| pr.count);
result.add(check_proxy_restriction(
proxy_limit,
params.current_proxy_depth,
));
} else {
result.add(ValidationCheck::pass(9, "NotBefore valid"));
result.add(ValidationCheck::fail(
10,
"NotOnOrAfter valid",
"Assertion Conditions with NotOnOrAfter are required",
));
result.add(ValidationCheck::fail(
11,
"AudienceRestriction satisfied",
"Assertion Conditions with AudienceRestriction are required",
));
result.add(ValidationCheck::pass(12, "OneTimeUse condition"));
result.add(ValidationCheck::pass(13, "ProxyRestriction count"));
}
self.check_subject_confirmation(assertion, params, result);
self.check_replay(assertion, params, result);
self.check_authn_statements(assertion, params, result);
result.add(ValidationCheck::pass(24, "CBC integrity check"));
self.check_name_id(assertion, params, result);
}
fn check_subject_confirmation(
&self,
assertion: &Assertion,
params: &ValidationParams<'_>,
result: &mut ValidationResult,
) {
let subject = match &assertion.subject {
Some(s) => s,
None => {
result.add(ValidationCheck::fail(
14,
"Bearer confirmation method",
"No Subject element in assertion",
));
return;
}
};
let bearer_confirmation = subject
.subject_confirmations
.iter()
.find(|sc| sc.method == CM_BEARER);
let sc = match bearer_confirmation {
Some(sc) => sc,
None => {
result.add(ValidationCheck::fail(
14,
"Bearer confirmation method",
"No bearer SubjectConfirmation found",
));
return;
}
};
result.add(ValidationCheck::pass(14, "Bearer confirmation method"));
let scd = match &sc.subject_confirmation_data {
Some(d) => d,
None => {
result.add(ValidationCheck::fail(
15,
"Recipient matches ACS URL",
"No SubjectConfirmationData in bearer confirmation",
));
return;
}
};
if self.config.verify_recipient {
match verify_recipient(scd.recipient.as_deref(), params.acs_url) {
Ok(()) => result.add(ValidationCheck::pass(15, "Recipient matches ACS URL")),
Err(e) => result.add(ValidationCheck::fail(15, "Recipient matches ACS URL", e)),
}
} else {
result.add(ValidationCheck::pass(
15,
"Recipient matches ACS URL (skipped)",
));
}
if let Some(not_on_or_after) = scd.not_on_or_after {
if is_not_on_or_after_valid(params.now, not_on_or_after, self.config.clock_skew_seconds)
{
result.add(ValidationCheck::pass(
16,
"SubjectConfirmation NotOnOrAfter",
));
} else {
result.add(ValidationCheck::fail(
16,
"SubjectConfirmation NotOnOrAfter",
format!(
"SubjectConfirmationData NotOnOrAfter {} has passed (skew: {}s)",
not_on_or_after, self.config.clock_skew_seconds
),
));
}
} else {
result.add(ValidationCheck::fail(
16,
"SubjectConfirmation NotOnOrAfter",
"Bearer SubjectConfirmationData NotOnOrAfter is required",
));
}
match (scd.in_response_to.as_deref(), params.expected_request_id) {
(Some(irt), Some(expected)) => {
if irt == expected {
result.add(ValidationCheck::pass(
17,
"SubjectConfirmation InResponseTo",
));
} else {
result.add(ValidationCheck::fail(
17,
"SubjectConfirmation InResponseTo",
format!("Expected '{}', got '{}'", expected, irt),
));
}
}
(None, None) => {
if self.config.allow_unsolicited_responses {
result.add(ValidationCheck::pass(
17,
"SubjectConfirmation InResponseTo",
));
} else {
result.add(ValidationCheck::fail(
17,
"SubjectConfirmation InResponseTo",
"Unsolicited response is not allowed by configuration",
));
}
}
(Some(irt), None) => {
result.add(ValidationCheck::fail(
17,
"SubjectConfirmation InResponseTo",
format!(
"SubjectConfirmation carries InResponseTo='{irt}' but no matching outstanding request"
),
));
}
(None, Some(expected)) => {
result.add(ValidationCheck::fail(
17,
"SubjectConfirmation InResponseTo",
format!("Expected InResponseTo='{}' but none present", expected),
));
}
}
if scd.not_before.is_some() {
result.add(ValidationCheck::fail(
18,
"Bearer NotBefore absent",
"NotBefore MUST NOT be present in bearer SubjectConfirmationData",
));
} else {
result.add(ValidationCheck::pass(18, "Bearer NotBefore absent"));
}
if self.config.check_client_address {
match (&scd.address, params.client_address) {
(Some(expected_addr), Some(actual_addr)) => {
if expected_addr == actual_addr {
result.add(ValidationCheck::pass(19, "Client address matches"));
} else {
result.add(ValidationCheck::fail(
19,
"Client address matches",
format!(
"Expected address '{}', got '{}'",
expected_addr, actual_addr
),
));
}
}
(Some(_expected_addr), None) => {
result.add(ValidationCheck::fail(
19,
"Client address matches",
"SubjectConfirmationData has Address but client address is unknown",
));
}
(None, _) => {
result.add(ValidationCheck::pass(19, "Client address matches"));
}
}
} else {
result.add(ValidationCheck::pass(
19,
"Client address matches (skipped)",
));
}
}
fn check_replay(
&self,
assertion: &Assertion,
params: &ValidationParams<'_>,
result: &mut ValidationResult,
) {
if let Some(cache) = self.replay_cache {
let skew = TimeDelta::try_seconds(
i64::try_from(self.config.clock_skew_seconds).unwrap_or(i64::MAX),
)
.unwrap_or(TimeDelta::MAX);
let max_age = TimeDelta::try_seconds(
i64::try_from(self.config.max_assertion_age_seconds).unwrap_or(i64::MAX),
)
.unwrap_or(TimeDelta::MAX);
let latest_accepted_issue_instant = params
.now
.checked_add_signed(skew)
.unwrap_or(DateTime::<Utc>::MAX_UTC);
let age_expiry = assertion
.issue_instant
.min(latest_accepted_issue_instant)
.checked_add_signed(max_age)
.and_then(|expiry| expiry.checked_add_signed(TimeDelta::seconds(1)))
.unwrap_or(DateTime::<Utc>::MAX_UTC);
let expiry = match assertion
.conditions
.as_ref()
.and_then(|c| c.not_on_or_after)
{
Some(expiry) => expiry
.checked_add_signed(skew)
.unwrap_or(DateTime::<Utc>::MAX_UTC)
.min(age_expiry),
None => age_expiry,
};
if cache.check_and_insert(&assertion.id, expiry) {
result.add(ValidationCheck::pass(20, "Assertion ID not replayed"));
} else {
result.add(ValidationCheck::fail(
20,
"Assertion ID not replayed",
format!("Assertion ID '{}' was previously used", assertion.id),
));
}
} else {
result.add(ValidationCheck::fail(
20,
"Assertion ID not replayed",
"Replay protection is required but no replay cache is configured",
));
}
}
fn check_authn_statements(
&self,
assertion: &Assertion,
params: &ValidationParams<'_>,
result: &mut ValidationResult,
) {
if assertion.authn_statements.is_empty() {
result.add(ValidationCheck::fail(
21,
"AuthnStatement present",
"No AuthnStatement in assertion",
));
return;
}
result.add(ValidationCheck::pass(21, "AuthnStatement present"));
let has_session_index = assertion
.authn_statements
.iter()
.any(|s| s.session_index.is_some());
if has_session_index {
result.add(ValidationCheck::pass(22, "SessionIndex present"));
} else {
result.add(ValidationCheck::pass(22, "SessionIndex present (optional)"));
}
for stmt in &assertion.authn_statements {
if let Some(session_not_on_or_after) = stmt.session_not_on_or_after {
if is_not_on_or_after_valid(
params.now,
session_not_on_or_after,
self.config.clock_skew_seconds,
) {
result.add(ValidationCheck::pass(23, "SessionNotOnOrAfter valid"));
} else {
result.add(ValidationCheck::fail(
23,
"SessionNotOnOrAfter valid",
format!(
"Session expired at {} (E79: upper bound)",
session_not_on_or_after
),
));
}
} else {
result.add(ValidationCheck::pass(23, "SessionNotOnOrAfter valid"));
}
}
}
fn check_name_id(
&self,
assertion: &Assertion,
params: &ValidationParams<'_>,
result: &mut ValidationResult,
) {
let subject = match &assertion.subject {
Some(s) => s,
None => {
result.add(ValidationCheck::pass(25, "NameIDPolicy Format"));
result.add(ValidationCheck::pass(26, "Persistent ID unique"));
result.add(ValidationCheck::pass(27, "AllowCreate semantics"));
return;
}
};
result.add(ValidationCheck::pass(25, "NameIDPolicy Format"));
if self.config.enforce_persistent_id_uniqueness {
if let Some(ref name_id) = subject.name_id {
match name_id {
crate::core::assertion::name_id::NameIdOrEncryptedId::NameId(nid) => {
if nid.format.as_deref() == Some(NAMEID_PERSISTENT) {
if let (Some(store), Some(principal)) =
(self.persistent_id_store, self.persistent_id_principal)
{
match store.check_and_record(
&nid.value,
params.sp_entity_id,
principal,
) {
Ok(()) => {
result
.add(ValidationCheck::pass(26, "Persistent ID unique"));
}
Err(e) => {
result.add(ValidationCheck::fail(
26,
"Persistent ID unique",
e,
));
}
}
} else {
result.add(ValidationCheck::fail(
26,
"Persistent ID unique",
"Persistent ID enforcement requires a store and an independent local principal",
));
}
} else {
result.add(ValidationCheck::pass(26, "Persistent ID unique"));
}
}
crate::core::assertion::name_id::NameIdOrEncryptedId::EncryptedId(_) => {
result.add(ValidationCheck::pass(
26,
"Persistent ID unique (encrypted)",
));
}
}
} else {
result.add(ValidationCheck::pass(26, "Persistent ID unique"));
}
} else {
result.add(ValidationCheck::pass(26, "Persistent ID unique (skipped)"));
}
result.add(ValidationCheck::pass(27, "AllowCreate semantics"));
}
fn check_relay_state(&self, relay_state: &str, result: &mut ValidationResult) {
if relay_state.len() <= crate::security::relay_state::MAX_RELAY_STATE_BYTES {
result.add(ValidationCheck::pass(31, "RelayState length"));
} else {
result.add(ValidationCheck::fail(
31,
"RelayState length",
format!(
"RelayState is {} bytes, exceeds 80-byte limit",
relay_state.len()
),
));
}
if self.config.sanitize_relay_state {
match validate_relay_state_content(relay_state) {
Ok(()) => result.add(ValidationCheck::pass(32, "RelayState sanitized")),
Err(e) => result.add(ValidationCheck::fail(32, "RelayState sanitized", e)),
}
} else {
result.add(ValidationCheck::pass(32, "RelayState sanitized (skipped)"));
}
}
pub fn validate_response_simple(
&self,
response: &Response,
params: &ValidationParams<'_>,
) -> Result<(), Vec<ValidationCheck>> {
let result = self.validate_response(response, params);
if result.is_valid() {
Ok(())
} else {
Err(result.failures().into_iter().cloned().collect())
}
}
pub fn check_assertion_age(
&self,
issue_instant: DateTime<Utc>,
now: DateTime<Utc>,
) -> ValidationCheck {
let skew = TimeDelta::try_seconds(
i64::try_from(self.config.clock_skew_seconds).unwrap_or(i64::MAX),
)
.unwrap_or(TimeDelta::MAX);
let latest_accepted = now
.checked_add_signed(skew)
.unwrap_or(DateTime::<Utc>::MAX_UTC);
if issue_instant > latest_accepted {
ValidationCheck::fail(
35,
"Assertion age within limit",
format!(
"Assertion issued at {} is in the future beyond accepted clock skew of {}s",
issue_instant, self.config.clock_skew_seconds
),
)
} else if is_within_age_limit(now, issue_instant, self.config.max_assertion_age_seconds) {
ValidationCheck::pass(35, "Assertion age within limit")
} else {
ValidationCheck::fail(
35,
"Assertion age within limit",
format!(
"Assertion issued at {} exceeds max age of {}s",
issue_instant, self.config.max_assertion_age_seconds
),
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::assertion::authn::{AuthnContext, AuthnStatement};
use crate::core::assertion::conditions::{AudienceRestriction, Conditions};
use crate::core::assertion::issuer::Issuer;
use crate::core::assertion::name_id::{NameId, NameIdOrEncryptedId};
use crate::core::assertion::subject::{Subject, SubjectConfirmation, SubjectConfirmationData};
use crate::core::constants::*;
use crate::core::identifiers::SamlVersion;
use crate::core::protocol::response::{Response, ResponseBase};
use crate::core::protocol::status::Status;
use crate::security::name_id::InMemoryPersistentIdStore;
use crate::security::replay::InMemoryReplayCache;
use chrono::TimeDelta;
use std::sync::Mutex;
#[derive(Default)]
struct RecordingReplayCache {
expiry: Mutex<Option<DateTime<Utc>>>,
}
impl ReplayCache for RecordingReplayCache {
fn check_and_insert(&self, _id: &str, expiry: DateTime<Utc>) -> bool {
*self.expiry.lock().unwrap() = Some(expiry);
true
}
fn cleanup(&self) {}
}
fn make_valid_response(now: chrono::DateTime<Utc>) -> Response {
Response {
base: ResponseBase {
id: "_resp_123".to_string(),
version: SamlVersion::V2_0,
issue_instant: now,
destination: Some("https://sp.example.com/acs".to_string()),
consent: None,
issuer: Some(Issuer::entity("https://idp.example.com")),
has_signature: false,
in_response_to: Some("_req_456".to_string()),
status: Status::success(),
},
assertions: vec![Assertion {
id: "_assertion_789".to_string(),
issue_instant: now,
version: SamlVersion::V2_0,
issuer: Issuer::entity("https://idp.example.com"),
has_signature: false,
subject: Some(Subject {
name_id: Some(NameIdOrEncryptedId::NameId(NameId {
value: "user@example.com".to_string(),
format: Some(NAMEID_EMAIL.to_string()),
name_qualifier: None,
sp_name_qualifier: None,
sp_provided_id: None,
})),
subject_confirmations: vec![SubjectConfirmation {
method: CM_BEARER.to_string(),
name_id: None,
subject_confirmation_data: Some(SubjectConfirmationData {
not_before: None,
not_on_or_after: Some(now + TimeDelta::seconds(300)),
recipient: Some("https://sp.example.com/acs".to_string()),
in_response_to: Some("_req_456".to_string()),
address: None,
key_info_x509_certs: vec![],
}),
}],
}),
conditions: Some(Conditions {
not_before: Some(now - TimeDelta::seconds(60)),
not_on_or_after: Some(now + TimeDelta::seconds(300)),
audience_restrictions: vec![AudienceRestriction {
audiences: vec!["https://sp.example.com".to_string()],
}],
one_time_use: false,
proxy_restriction: None,
}),
advice: None,
authn_statements: vec![AuthnStatement {
authn_instant: now,
session_index: Some("_session_001".to_string()),
session_not_on_or_after: Some(now + TimeDelta::seconds(3600)),
subject_locality: None,
authn_context: AuthnContext {
authn_context_class_ref: Some(
AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT.to_string(),
),
authn_context_decl_ref: None,
authenticating_authorities: vec![],
},
}],
authz_decision_statements: vec![],
attribute_statements: vec![],
}],
encrypted_assertions: vec![],
}
}
fn make_params(now: chrono::DateTime<Utc>) -> ValidationParams<'static> {
ValidationParams {
received_url: "https://sp.example.com/acs",
expected_idp_entity_id: "https://idp.example.com",
sp_entity_id: "https://sp.example.com",
acs_url: "https://sp.example.com/acs",
expected_request_id: Some("_req_456"),
client_address: None,
relay_state: None,
response_signature_xml: None,
response_signature_verified: None,
verified_signed_ids: &[],
current_proxy_depth: 0,
now,
}
}
#[test]
fn test_valid_response_passes_all_checks() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let replay_cache = InMemoryReplayCache::new();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let response = make_valid_response(now);
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(
failures.is_empty(),
"Expected no failures, got: {:?}",
failures
);
}
#[test]
fn test_response_signature_does_not_satisfy_required_assertion_signature() {
let now = Utc::now();
let config = SecurityConfig::default();
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.base.has_signature = true;
let response_id = response.base.id.as_str();
let verified_ids = [response_id];
let params = ValidationParams {
response_signature_verified: Some(true),
verified_signed_ids: &verified_ids,
..make_params(now)
};
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(
failures.iter().any(|c| c.check_number == 6),
"response signature must not satisfy direct assertion-signature requirement; failures: {failures:?}"
);
}
#[test]
fn test_required_assertion_signature_accepts_direct_verified_assertion_id() {
let now = Utc::now();
let config = SecurityConfig::default();
let replay_cache = InMemoryReplayCache::new();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
response.assertions[0].has_signature = true;
let assertion_id = response.assertions[0].id.as_str();
let verified_ids = [assertion_id];
let params = ValidationParams {
verified_signed_ids: &verified_ids,
..make_params(now)
};
let result = validator.validate_response(&response, ¶ms);
assert!(
result.is_valid(),
"directly verified assertion signature should pass; failures: {:?}",
result.failures()
);
}
#[test]
fn test_response_status_must_be_success() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.base.status = Status::requester(Some("cancelled".to_string()));
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
assert!(result.failures().iter().any(|c| c.check_number == 33));
assert!(validator
.validate_response_simple(&response, ¶ms)
.is_err());
}
#[test]
fn test_response_must_contain_assertion() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions.clear();
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
assert!(result.failures().iter().any(|c| c.check_number == 34));
assert!(validator
.validate_response_simple(&response, ¶ms)
.is_err());
}
#[test]
fn test_encrypted_only_response_reports_decrypt_needed() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions.clear();
response
.encrypted_assertions
.push(crate::core::assertion::types::EncryptedAssertion {
raw: b"<enc/>".to_vec(),
});
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let check_34 = result
.checks
.iter()
.find(|c| c.check_number == 34)
.expect("check 34 present");
assert!(!check_34.passed);
assert!(check_34
.detail
.as_deref()
.unwrap()
.contains("decrypt before validation"));
}
#[test]
fn test_destination_mismatch() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.base.destination = Some("https://evil.com/acs".to_string());
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 1));
}
#[test]
fn test_issuer_mismatch() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0].issuer = Issuer::entity("https://evil-idp.com");
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 5));
}
#[test]
fn test_audience_restriction_failed() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0]
.conditions
.as_mut()
.unwrap()
.audience_restrictions = vec![AudienceRestriction {
audiences: vec!["https://other-sp.com".to_string()],
}];
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 11));
}
#[test]
fn test_expired_assertion() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0]
.conditions
.as_mut()
.unwrap()
.not_on_or_after = Some(now - TimeDelta::seconds(600));
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 10));
}
#[test]
fn test_missing_conditions_fails_audience_and_expiry() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0].conditions = None;
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 10));
assert!(failures.iter().any(|c| c.check_number == 11));
}
#[test]
fn test_missing_bearer_not_on_or_after_fails() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0]
.subject
.as_mut()
.unwrap()
.subject_confirmations[0]
.subject_confirmation_data
.as_mut()
.unwrap()
.not_on_or_after = None;
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 16));
}
#[test]
fn test_replay_detection() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let cache = InMemoryReplayCache::new();
let validator = AssertionValidator::new(&config).with_replay_cache(&cache);
let response = make_valid_response(now);
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
assert!(result.is_valid());
let result2 = validator.validate_response(&response, ¶ms);
let failures = result2.failures();
assert!(failures.iter().any(|c| c.check_number == 20));
}
#[test]
fn test_bearer_not_before_present() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0]
.subject
.as_mut()
.unwrap()
.subject_confirmations[0]
.subject_confirmation_data
.as_mut()
.unwrap()
.not_before = Some(now - TimeDelta::seconds(60));
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 18));
}
#[test]
fn test_in_response_to_mismatch() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.base.in_response_to = Some("_wrong_id".to_string());
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 3));
}
#[test]
fn test_dangling_in_response_to_rejected() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let response = make_valid_response(now); let params = ValidationParams {
expected_request_id: None, ..make_params(now)
};
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(
failures
.iter()
.any(|c| c.check_number == 3 || c.check_number == 17),
"expected dangling InResponseTo to fail; failures: {failures:?}"
);
}
#[test]
fn test_conditions_without_audience_restriction_rejected() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0]
.conditions
.as_mut()
.unwrap()
.audience_restrictions = vec![];
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(
failures.iter().any(|c| c.check_number == 11),
"expected empty AudienceRestriction to fail check 11; failures: {failures:?}"
);
}
#[test]
fn test_no_authn_statement() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0].authn_statements.clear();
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 21));
}
#[test]
fn test_relay_state_too_long() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let response = make_valid_response(now);
let relay_state: &'static str = Box::leak("a".repeat(100).into_boxed_str());
let params = ValidationParams {
relay_state: Some(relay_state),
..make_params(now)
};
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 31));
}
#[test]
fn test_relay_state_xss() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let response = make_valid_response(now);
let relay_state: &'static str =
Box::leak("javascript:alert(1)".to_string().into_boxed_str());
let params = ValidationParams {
relay_state: Some(relay_state),
..make_params(now)
};
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 32));
}
#[test]
fn test_recipient_mismatch() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0]
.subject
.as_mut()
.unwrap()
.subject_confirmations[0]
.subject_confirmation_data
.as_mut()
.unwrap()
.recipient = Some("https://evil.com/acs".to_string());
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 15));
}
#[test]
fn test_session_expired() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0].authn_statements[0].session_not_on_or_after =
Some(now - TimeDelta::seconds(600));
let params = make_params(now);
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 23));
}
#[test]
fn test_validate_response_simple_ok() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let replay_cache = InMemoryReplayCache::new();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let response = make_valid_response(now);
let params = make_params(now);
assert!(validator
.validate_response_simple(&response, ¶ms)
.is_ok());
}
#[test]
fn test_validate_response_simple_err() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.base.destination = Some("https://evil.com".to_string());
let params = make_params(now);
let err = validator
.validate_response_simple(&response, ¶ms)
.unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn test_unsolicited_response() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
allow_unsolicited_responses: true,
..SecurityConfig::default()
};
let replay_cache = InMemoryReplayCache::new();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
response.base.in_response_to = None;
response.assertions[0]
.subject
.as_mut()
.unwrap()
.subject_confirmations[0]
.subject_confirmation_data
.as_mut()
.unwrap()
.in_response_to = None;
let params = ValidationParams {
expected_request_id: None,
..make_params(now)
};
let result = validator.validate_response(&response, ¶ms);
assert!(result.is_valid(), "Failures: {:?}", result.failures());
}
#[test]
fn unsolicited_response_is_rejected_by_default() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let replay_cache = InMemoryReplayCache::new();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
response.base.in_response_to = None;
response.assertions[0]
.subject
.as_mut()
.unwrap()
.subject_confirmations[0]
.subject_confirmation_data
.as_mut()
.unwrap()
.in_response_to = None;
let params = ValidationParams {
expected_request_id: None,
..make_params(now)
};
let result = validator.validate_response(&response, ¶ms);
assert!(!result.is_valid());
assert!(result.failures().iter().any(|check| {
(check.check_number == 3 || check.check_number == 17)
&& check
.detail
.as_deref()
.is_some_and(|detail| detail.contains("Unsolicited"))
}));
}
#[test]
fn replay_entry_covers_the_accepted_clock_skew_window() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
clock_skew_seconds: 180,
..SecurityConfig::default()
};
let replay_cache = InMemoryReplayCache::new();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
response.assertions[0]
.conditions
.as_mut()
.unwrap()
.not_on_or_after = Some(now - TimeDelta::seconds(1));
let params = make_params(now);
let first = validator.validate_response(&response, ¶ms);
assert!(first.is_valid(), "Failures: {:?}", first.failures());
let second = validator.validate_response(&response, ¶ms);
assert!(second
.failures()
.iter()
.any(|check| check.check_number == 20));
}
#[test]
fn replay_fallback_covers_future_issue_instant_age_window() {
let now = Utc::now();
let issue_instant = now + TimeDelta::seconds(30);
let config = SecurityConfig {
require_signed_assertions: false,
max_assertion_age_seconds: 60,
..SecurityConfig::default()
};
let replay_cache = RecordingReplayCache::default();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
let assertion = &mut response.assertions[0];
assertion.issue_instant = issue_instant;
assertion.conditions.as_mut().unwrap().not_on_or_after = None;
let params = make_params(now);
let mut result = ValidationResult::new();
validator.check_replay(assertion, ¶ms, &mut result);
assert!(result.is_valid());
assert_eq!(
*replay_cache.expiry.lock().unwrap(),
Some(issue_instant + TimeDelta::seconds(61))
);
}
#[test]
fn replay_conditions_expiry_is_capped_by_assertion_age() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
clock_skew_seconds: 180,
max_assertion_age_seconds: 60,
..SecurityConfig::default()
};
let replay_cache = RecordingReplayCache::default();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
let assertion = &mut response.assertions[0];
assertion.conditions.as_mut().unwrap().not_on_or_after = Some(DateTime::<Utc>::MAX_UTC);
let params = make_params(now);
let mut result = ValidationResult::new();
validator.check_replay(assertion, ¶ms, &mut result);
assert!(result.is_valid());
assert_eq!(
*replay_cache.expiry.lock().unwrap(),
Some(now + TimeDelta::seconds(61))
);
}
#[test]
fn replay_long_finite_conditions_expiry_is_capped_by_assertion_age() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
clock_skew_seconds: 180,
max_assertion_age_seconds: 60,
..SecurityConfig::default()
};
let replay_cache = RecordingReplayCache::default();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
let assertion = &mut response.assertions[0];
assertion.conditions.as_mut().unwrap().not_on_or_after = Some(now + TimeDelta::days(365));
let params = make_params(now);
let mut result = ValidationResult::new();
validator.check_replay(assertion, ¶ms, &mut result);
assert!(result.is_valid());
assert_eq!(
*replay_cache.expiry.lock().unwrap(),
Some(now + TimeDelta::seconds(61))
);
}
#[test]
fn replay_age_cap_uses_the_assertions_actual_issue_instant() {
let now = Utc::now();
let issue_instant = now - TimeDelta::seconds(59);
let config = SecurityConfig {
require_signed_assertions: false,
max_assertion_age_seconds: 60,
..SecurityConfig::default()
};
let replay_cache = RecordingReplayCache::default();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
let assertion = &mut response.assertions[0];
assertion.issue_instant = issue_instant;
assertion.conditions.as_mut().unwrap().not_on_or_after = Some(now + TimeDelta::days(365));
let params = make_params(now);
let mut result = ValidationResult::new();
validator.check_replay(assertion, ¶ms, &mut result);
assert!(result.is_valid());
assert_eq!(
*replay_cache.expiry.lock().unwrap(),
Some(issue_instant + TimeDelta::seconds(61))
);
}
#[test]
fn replay_short_conditions_expiry_remains_authoritative() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
clock_skew_seconds: 5,
max_assertion_age_seconds: 300,
..SecurityConfig::default()
};
let replay_cache = RecordingReplayCache::default();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
let assertion = &mut response.assertions[0];
assertion.conditions.as_mut().unwrap().not_on_or_after = Some(now + TimeDelta::seconds(10));
let params = make_params(now);
let mut result = ValidationResult::new();
validator.check_replay(assertion, ¶ms, &mut result);
assert!(result.is_valid());
assert_eq!(
*replay_cache.expiry.lock().unwrap(),
Some(now + TimeDelta::seconds(15))
);
}
#[test]
fn rejected_far_future_assertion_cannot_pin_replay_state_indefinitely() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
clock_skew_seconds: 180,
max_assertion_age_seconds: 60,
..SecurityConfig::default()
};
let replay_cache = RecordingReplayCache::default();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
let assertion = &mut response.assertions[0];
assertion.issue_instant = DateTime::<Utc>::MAX_UTC;
assertion.conditions.as_mut().unwrap().not_on_or_after = None;
let params = make_params(now);
let mut result = ValidationResult::new();
validator.check_replay(assertion, ¶ms, &mut result);
assert!(result.is_valid());
assert_eq!(
*replay_cache.expiry.lock().unwrap(),
Some(now + TimeDelta::seconds(241))
);
assert!(
!validator
.check_assertion_age(assertion.issue_instant, now)
.passed
);
}
#[test]
fn test_address_check_enabled() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
check_client_address: true,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.assertions[0]
.subject
.as_mut()
.unwrap()
.subject_confirmations[0]
.subject_confirmation_data
.as_mut()
.unwrap()
.address = Some("10.0.0.1".to_string());
let params = ValidationParams {
client_address: Some("10.0.0.2"),
..make_params(now)
};
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 19));
}
#[test]
fn test_ds_object_in_response_signature() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
..SecurityConfig::default()
};
let validator = AssertionValidator::new(&config);
let mut response = make_valid_response(now);
response.base.has_signature = true;
let sig_xml = r#"<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:Object>evil</ds:Object></ds:Signature>"#;
let params = ValidationParams {
response_signature_xml: Some(sig_xml),
response_signature_verified: Some(true),
..make_params(now)
};
let result = validator.validate_response(&response, ¶ms);
let failures = result.failures();
assert!(failures.iter().any(|c| c.check_number == 7));
}
#[test]
fn test_assertion_age_check() {
let now = Utc::now();
let config = SecurityConfig::default();
let validator = AssertionValidator::new(&config);
let recent = now - TimeDelta::seconds(60);
assert!(validator.check_assertion_age(recent, now).passed);
let old = now - TimeDelta::seconds(600);
assert!(!validator.check_assertion_age(old, now).passed);
let within_skew = now + TimeDelta::seconds(120);
assert!(validator.check_assertion_age(within_skew, now).passed);
let beyond_skew = now + TimeDelta::seconds(181);
assert!(!validator.check_assertion_age(beyond_skew, now).passed);
}
#[test]
fn test_assertion_age_is_enforced_during_response_validation() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
max_assertion_age_seconds: 60,
..SecurityConfig::default()
};
let replay_cache = InMemoryReplayCache::new();
let validator = AssertionValidator::new(&config).with_replay_cache(&replay_cache);
let mut response = make_valid_response(now);
response.assertions[0].issue_instant = now - TimeDelta::minutes(2);
let result = validator.validate_response(&response, &make_params(now));
assert!(result
.failures()
.iter()
.any(|check| check.check_number == 35));
}
#[test]
fn persistent_id_uses_independent_application_principal() {
let now = Utc::now();
let config = SecurityConfig {
require_signed_assertions: false,
enforce_persistent_id_uniqueness: true,
..SecurityConfig::default()
};
let store = InMemoryPersistentIdStore::new();
store
.check_and_record("_persistent", "https://sp.example.com", "alice")
.unwrap();
let replay_cache = InMemoryReplayCache::new();
let validator = AssertionValidator::new(&config)
.with_replay_cache(&replay_cache)
.with_persistent_id_store(&store, "bob");
let mut response = make_valid_response(now);
let NameIdOrEncryptedId::NameId(name_id) = response.assertions[0]
.subject
.as_mut()
.unwrap()
.name_id
.as_mut()
.unwrap()
else {
panic!("expected cleartext NameID")
};
name_id.value = "_persistent".into();
name_id.format = Some(NAMEID_PERSISTENT.into());
let result = validator.validate_response(&response, &make_params(now));
assert!(result
.failures()
.iter()
.any(|check| check.check_number == 26));
}
}