use std::time::{Duration, SystemTime};
use crate::authn::request_build::{AcsRequest, BuildAuthnRequest, build_authn_request_xml};
use crate::authn_context::RequestedAuthnContext;
use crate::binding::post::encode_request as post_encode_request;
#[cfg(feature = "slo")]
use crate::binding::post::{decode as post_decode, encode_response as post_encode_response};
#[cfg(feature = "slo")]
use crate::binding::redirect::decode as redirect_decode;
use crate::binding::redirect::{
RedirectDirection, encode_signed as redirect_encode_signed,
encode_unsigned as redirect_encode_unsigned,
};
use crate::binding::{Binding, Dispatch, Endpoint, SsoResponseBinding, SsoResponseEndpoint};
use crate::crypto::keypair::KeyPair;
use crate::descriptor::IdpDescriptor;
use crate::dsig::algorithms::{
C14nAlgorithm, DigestAlgorithm, PeerCryptoPolicy, SignatureAlgorithm,
};
#[cfg(feature = "slo")]
use crate::dsig::reference::DS_NS;
use crate::dsig::sign::{SignOptions, sign_detached_query, sign_element};
#[cfg(feature = "slo")]
use crate::dsig::verify::{verify_detached_signature, verify_signature};
use crate::error::Error;
#[cfg(feature = "slo")]
use crate::http::{HttpClient, HttpRequest};
#[cfg(feature = "slo")]
use crate::logout::request_build::{BuildLogoutRequest, build_logout_request_xml};
#[cfg(feature = "slo")]
use crate::logout::request_parse::parse_logout_request;
#[cfg(feature = "slo")]
use crate::logout::response_build::{BuildLogoutResponse, build_logout_response_xml};
#[cfg(feature = "slo")]
use crate::logout::response_parse::parse_logout_response;
#[cfg(feature = "slo")]
use crate::logout::{
ConsumeLogoutRequest, ConsumeLogoutResponse, LogoutDispatch, LogoutOutcome, LogoutStatus,
LogoutTracker, ParsedLogoutRequest, StartLogout,
};
use crate::metadata::MetadataExtras;
use crate::metadata::emit_sp::{SpMetadataInputs, emit_sp_metadata};
use crate::nameid::NameIdFormat;
use crate::replay::{ReplayCache, ReplayMode};
use crate::response::Identity;
use crate::response::parse::parse_response;
use crate::response::validate::{ValidateResponse, validate_response};
use crate::xml::emit::emit_document;
use crate::xml::parse::Document;
#[cfg(feature = "xmlenc")]
use crate::xmlenc::algorithms::DataEncryptionAlgorithm;
#[derive(Debug, Clone, Copy, Default)]
pub struct SpWantSigned {
pub response: bool,
pub assertions: bool,
}
#[cfg(feature = "slo")]
#[derive(Debug, Clone, Copy, Default)]
pub struct SpLogoutSigning {
pub sign_requests: bool,
pub sign_responses: bool,
}
#[cfg(feature = "slo")]
#[derive(Debug, Clone, Copy, Default)]
pub struct SpLogoutWantSigned {
pub requests: bool,
pub responses: bool,
}
#[derive(Debug, Clone)]
pub struct ServiceProviderConfig {
pub entity_id: String,
pub acs: Vec<SsoResponseEndpoint>,
pub slo: Vec<Endpoint>,
pub name_id_formats: Vec<NameIdFormat>,
pub signing_key: Option<KeyPair>,
pub decryption_key: Option<KeyPair>,
pub sign_authn_requests: bool,
pub want_signed: SpWantSigned,
pub allow_unsolicited: bool,
#[cfg(feature = "slo")]
pub logout_signing: SpLogoutSigning,
#[cfg(feature = "slo")]
pub logout_want_signed: SpLogoutWantSigned,
pub default_peer_crypto_policy: PeerCryptoPolicy,
pub outbound_signature_algorithm: SignatureAlgorithm,
pub outbound_digest_algorithm: DigestAlgorithm,
}
#[derive(Debug, Clone)]
pub struct ServiceProvider {
config: ServiceProviderConfig,
}
impl ServiceProvider {
pub fn new(config: ServiceProviderConfig) -> Result<Self, Error> {
if config.entity_id.is_empty() || config.entity_id.chars().any(char::is_whitespace) {
return Err(Error::InvalidConfiguration {
reason: "entity_id must be a non-empty, whitespace-free xs:anyURI",
});
}
if config.acs.is_empty() {
return Err(Error::InvalidConfiguration {
reason: "acs must contain at least one endpoint",
});
}
let needs_signing_key = config.sign_authn_requests || {
#[cfg(feature = "slo")]
{
config.logout_signing.sign_requests || config.logout_signing.sign_responses
}
#[cfg(not(feature = "slo"))]
{
false
}
};
if needs_signing_key && config.signing_key.is_none() {
return Err(Error::InvalidConfiguration {
reason: "signing flag enabled but signing_key is None",
});
}
Ok(Self { config })
}
pub fn config(&self) -> &ServiceProviderConfig {
&self.config
}
pub fn entity_id(&self) -> &str {
&self.config.entity_id
}
}
pub struct StartLogin<'a> {
pub relay_state: Option<&'a str>,
pub binding: Binding,
pub force_authn: bool,
pub is_passive: bool,
pub requested_name_id_format: Option<NameIdFormat>,
pub requested_authn_context: Option<RequestedAuthnContext>,
pub acs_index: Option<u16>,
pub acs_url: Option<&'a str>,
pub response_binding: Option<SsoResponseBinding>,
}
#[derive(Debug, Clone)]
pub struct StartLoginResult {
pub tracker: LoginTracker,
pub dispatch: Dispatch,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LoginTracker {
pub request_id: String,
pub issued_at: SystemTime,
pub idp_entity_id: String,
pub acs_endpoint: SsoResponseEndpoint,
pub requested_authn_context: Option<RequestedAuthnContext>,
pub requested_name_id_format: Option<NameIdFormat>,
}
impl ServiceProvider {
pub fn start_login(
&self,
idp: &IdpDescriptor,
opts: StartLogin<'_>,
) -> Result<StartLoginResult, Error> {
let sso_endpoint = idp
.sso_endpoint(opts.binding)
.ok_or(Error::UnsupportedByPeer {
binding: opts.binding,
})?;
let destination_url =
url::Url::parse(&sso_endpoint.url).map_err(|_err| Error::InvalidConfiguration {
reason: "IdP SSO endpoint URL is not a valid URL",
})?;
let request_id = crate::binding::random_xml_id()?;
let issued_at = SystemTime::now();
if opts.acs_index.is_some() && opts.acs_url.is_some() {
return Err(Error::InvalidConfiguration {
reason: "StartLogin: acs_index and acs_url are mutually exclusive",
});
}
let acs_endpoint = match (opts.acs_index, opts.acs_url) {
(Some(idx), _) => self
.config
.acs
.iter()
.find(|e| e.index == Some(idx))
.cloned()
.ok_or(Error::InvalidConfiguration {
reason: "acs_index does not match any configured ACS endpoint",
})?,
(_, Some(url)) => self
.config
.acs
.iter()
.find(|e| e.url == url)
.cloned()
.ok_or_else(|| Error::UnregisteredAcs {
entity_id: self.config.entity_id.clone(),
})?,
(None, None) => self
.config
.acs
.iter()
.find(|e| e.is_default)
.or_else(|| self.config.acs.first())
.cloned()
.ok_or(Error::InvalidConfiguration {
reason: "no ACS endpoint configured (config validated empty list)",
})?,
};
let response_binding = opts.response_binding.unwrap_or(acs_endpoint.binding);
if response_binding != acs_endpoint.binding {
return Err(Error::IllegalResponseBinding {
requested: response_binding.as_binding(),
});
}
let acs_selection = match (opts.acs_index, opts.acs_url) {
(Some(idx), _) => AcsRequest::Index(idx),
(_, Some(url)) => AcsRequest::Url(url),
(None, None) => AcsRequest::Default,
};
let build = BuildAuthnRequest {
id: &request_id,
issue_instant: issued_at,
issuer_entity_id: &self.config.entity_id,
destination: &sso_endpoint.url,
force_authn: opts.force_authn,
is_passive: opts.is_passive,
acs_selection,
protocol_binding: Some(response_binding),
requested_name_id_format: opts.requested_name_id_format.clone(),
requested_authn_context: opts.requested_authn_context.as_ref(),
};
let unsigned_xml = build_authn_request_xml(&build)?;
let dispatch = match opts.binding {
Binding::HttpRedirect => {
if self.config.sign_authn_requests {
let signing_key = self.signing_key()?;
let sig_alg = self.config.outbound_signature_algorithm;
redirect_encode_signed(
&destination_url,
RedirectDirection::Request,
&unsigned_xml,
opts.relay_state,
sig_alg.uri(),
|bytes| sign_detached_query(bytes, signing_key, sig_alg),
)?
} else {
redirect_encode_unsigned(
&destination_url,
RedirectDirection::Request,
&unsigned_xml,
opts.relay_state,
)?
}
}
Binding::HttpPost => {
let xml_to_post = if self.config.sign_authn_requests {
self.sign_protocol_xml(&unsigned_xml)?
} else {
unsigned_xml
};
post_encode_request(&destination_url, &xml_to_post, opts.relay_state)
}
Binding::HttpArtifact | Binding::Soap => {
return Err(Error::UnsupportedByPeer {
binding: opts.binding,
});
}
};
let tracker = LoginTracker {
request_id,
issued_at,
idp_entity_id: idp.entity_id.clone(),
acs_endpoint,
requested_authn_context: opts.requested_authn_context,
requested_name_id_format: opts.requested_name_id_format,
};
Ok(StartLoginResult { tracker, dispatch })
}
}
pub struct ConsumeResponse<'a> {
pub idp: &'a IdpDescriptor,
pub peer_crypto_policy: Option<&'a PeerCryptoPolicy>,
pub saml_response: &'a [u8],
pub binding: SsoResponseBinding,
pub relay_state: Option<&'a str>,
pub tracker: Option<&'a LoginTracker>,
pub expected_destination: &'a str,
pub now: SystemTime,
pub clock_skew: Duration,
pub replay_cache: Option<&'a dyn ReplayCache>,
pub replay_mode: ReplayMode,
pub holder_of_key_cert: Option<&'a crate::crypto::cert::X509Certificate>,
}
#[cfg(all(feature = "artifact-binding", feature = "weak-algos"))]
pub struct ConsumeArtifactResponse<'a> {
pub idp: &'a crate::descriptor::IdpDescriptor,
pub peer_crypto_policy: Option<&'a PeerCryptoPolicy>,
pub artifact: &'a str,
pub relay_state: Option<&'a str>,
pub tracker: Option<&'a LoginTracker>,
pub expected_destination: &'a str,
pub now: SystemTime,
pub clock_skew: Duration,
pub replay_cache: Option<&'a dyn ReplayCache>,
pub replay_mode: ReplayMode,
pub holder_of_key_cert: Option<&'a crate::crypto::cert::X509Certificate>,
pub backchannel: Option<ArtifactBackchannel<'a>>,
}
#[cfg(all(feature = "artifact-binding", feature = "weak-algos"))]
#[derive(Default)]
pub struct ArtifactBackchannel<'a> {
pub sign: Option<crate::binding::artifact::SignConfig<'a>>,
pub verify: Option<crate::binding::artifact::VerifyConfig<'a>>,
}
impl ServiceProvider {
pub fn consume_response(&self, input: ConsumeResponse<'_>) -> Result<Identity, Error> {
if !self
.config
.acs
.iter()
.any(|e| e.url == input.expected_destination)
{
return Err(Error::InvalidConfiguration {
reason: "expected_destination is not a registered ACS URL",
});
}
if let Some(tracker) = input.tracker
&& tracker.acs_endpoint.url != input.expected_destination
{
return Err(Error::DestinationMismatch);
}
let document = Document::parse(input.saml_response)?;
let (parsed, _root_id) = parse_response(&document)?;
let policy = input
.peer_crypto_policy
.unwrap_or(&self.config.default_peer_crypto_policy);
#[cfg(feature = "xmlenc")]
let decryption_keys_owned: Vec<&KeyPair> = self
.config
.decryption_key
.as_ref()
.map(|k| vec![k])
.unwrap_or_default();
let identity = validate_response(ValidateResponse {
document: &document,
parsed,
idp: input.idp,
peer_crypto_policy: policy,
#[cfg(feature = "xmlenc")]
decryption_keys: &decryption_keys_owned,
sp_entity_id: &self.config.entity_id,
expected_destination: input.expected_destination,
tracker_request_id: input.tracker.map(|t| t.request_id.as_str()),
allow_unsolicited: self.config.allow_unsolicited,
want_response_signed: self.config.want_signed.response,
want_assertions_signed: self.config.want_signed.assertions,
now: input.now,
clock_skew: input.clock_skew,
requested_authn_context: input
.tracker
.and_then(|t| t.requested_authn_context.as_ref()),
holder_of_key_cert: input.holder_of_key_cert,
})?;
if let Some(cache) = input.replay_cache
&& replay_check_needed(input.replay_mode, identity.is_one_time_use)
{
let fresh = cache.check_and_insert(&identity.assertion_id, identity.not_on_or_after)?;
if !fresh {
return Err(Error::AssertionReplay);
}
}
Ok(identity)
}
#[cfg(all(feature = "artifact-binding", feature = "weak-algos"))]
pub async fn consume_response_artifact<H: crate::http::HttpClient>(
&self,
http: &H,
input: ConsumeArtifactResponse<'_>,
) -> Result<Identity, Error> {
let ars = input
.idp
.artifact_resolution_endpoint()
.ok_or(Error::UnsupportedByPeer {
binding: Binding::HttpArtifact,
})?;
let mut client = crate::binding::artifact::BackchannelClient::new(http);
if let Some(bc) = input.backchannel {
if let Some(sign) = bc.sign {
client = client.sign_with(sign);
}
if let Some(verify) = bc.verify {
client = client.verify_with(verify);
}
}
let inner_xml = client
.resolve_artifact(ars.url.as_str(), &self.config.entity_id, input.artifact)
.await?
.payload_xml;
self.consume_response(ConsumeResponse {
idp: input.idp,
peer_crypto_policy: input.peer_crypto_policy,
saml_response: &inner_xml,
binding: SsoResponseBinding::HttpArtifact,
relay_state: input.relay_state,
tracker: input.tracker,
expected_destination: input.expected_destination,
now: input.now,
clock_skew: input.clock_skew,
replay_cache: input.replay_cache,
replay_mode: input.replay_mode,
holder_of_key_cert: input.holder_of_key_cert,
})
}
}
fn replay_check_needed(mode: ReplayMode, is_one_time_use: bool) -> bool {
match mode {
ReplayMode::All => true,
ReplayMode::OneTimeUseOnly => is_one_time_use,
ReplayMode::Off => false,
}
}
#[cfg(feature = "slo")]
impl ServiceProvider {
pub fn start_logout(
&self,
idp: &IdpDescriptor,
opts: StartLogout<'_>,
) -> Result<LogoutDispatch, Error> {
let slo_endpoint = idp
.slo_endpoint(opts.binding)
.ok_or(Error::UnsupportedByPeer {
binding: opts.binding,
})?;
let destination_url =
url::Url::parse(&slo_endpoint.url).map_err(|_err| Error::InvalidConfiguration {
reason: "IdP SLO endpoint URL is not a valid URL",
})?;
let request_id = crate::binding::random_xml_id()?;
let issued_at = SystemTime::now();
let build = BuildLogoutRequest {
id: &request_id,
issue_instant: issued_at,
issuer_entity_id: &self.config.entity_id,
destination: Some(&slo_endpoint.url),
not_on_or_after: None,
reason: opts.reason,
name_id: opts.name_id,
session_index: opts.session_index,
};
let unsigned_xml = build_logout_request_xml(&build)?;
let dispatch = match opts.binding {
Binding::HttpRedirect => {
if self.config.logout_signing.sign_requests {
let signing_key = self.signing_key()?;
let sig_alg = self.config.outbound_signature_algorithm;
redirect_encode_signed(
&destination_url,
RedirectDirection::Request,
&unsigned_xml,
opts.relay_state,
sig_alg.uri(),
|bytes| sign_detached_query(bytes, signing_key, sig_alg),
)?
} else {
redirect_encode_unsigned(
&destination_url,
RedirectDirection::Request,
&unsigned_xml,
opts.relay_state,
)?
}
}
Binding::HttpPost => {
let xml_to_post = if self.config.logout_signing.sign_requests {
self.sign_protocol_xml(&unsigned_xml)?
} else {
unsigned_xml
};
post_encode_request(&destination_url, &xml_to_post, opts.relay_state)
}
Binding::Soap => {
return Err(Error::InvalidConfiguration {
reason: "SOAP logout uses send_soap_logout_request, not start_logout",
});
}
Binding::HttpArtifact => {
return Err(Error::UnsupportedByPeer {
binding: opts.binding,
});
}
};
Ok(LogoutDispatch {
tracker: LogoutTracker {
request_id,
issued_at,
peer_entity_id: idp.entity_id.clone(),
},
dispatch,
})
}
pub fn consume_logout_response(
&self,
idp: &IdpDescriptor,
input: ConsumeLogoutResponse<'_>,
) -> Result<LogoutOutcome, Error> {
let ConsumeLogoutResponse {
peer_crypto_policy,
body,
binding,
detached_signature: _,
tracker,
expected_destination,
now,
clock_skew,
} = input;
let policy = peer_crypto_policy.unwrap_or(&self.config.default_peer_crypto_policy);
let decoded = decode_logout_wire(body, binding, false)?;
let document = Document::parse(&decoded.xml)?;
let (parsed, _) = parse_logout_response(&document)?;
if !self
.config
.slo
.iter()
.any(|e| e.url == expected_destination)
{
return Err(Error::InvalidConfiguration {
reason: "expected_destination is not a registered SLO URL",
});
}
if let Some(dest) = parsed.destination.as_deref()
&& dest != expected_destination
{
return Err(Error::DestinationMismatch);
}
if parsed.issuer != idp.entity_id {
return Err(Error::IssuerMismatch {
expected: idp.entity_id.clone(),
got: Some(parsed.issuer.clone()),
});
}
verify_inbound_signature(
&document,
&decoded,
binding,
&idp.signing_certs,
&policy.allowed_signature_algorithms,
self.config.logout_want_signed.responses,
)?;
if parsed.in_response_to != tracker.request_id {
return Err(Error::InResponseToMismatch);
}
let _ = (now, clock_skew);
Ok(parsed.to_outcome())
}
pub fn consume_logout_request(
&self,
idp: &IdpDescriptor,
input: ConsumeLogoutRequest<'_>,
) -> Result<ParsedLogoutRequest, Error> {
let ConsumeLogoutRequest {
peer_crypto_policy,
body,
binding,
detached_signature: _,
expected_destination,
now,
clock_skew,
} = input;
let policy = peer_crypto_policy.unwrap_or(&self.config.default_peer_crypto_policy);
let decoded = decode_logout_wire(body, binding, true)?;
let document = Document::parse(&decoded.xml)?;
let (mut parsed, _) = parse_logout_request(&document)?;
parsed.relay_state.clone_from(&decoded.relay_state);
if !self
.config
.slo
.iter()
.any(|e| e.url == expected_destination)
{
return Err(Error::InvalidConfiguration {
reason: "expected_destination is not a registered SLO URL",
});
}
if let Some(dest) = parsed.destination.as_deref()
&& dest != expected_destination
{
return Err(Error::DestinationMismatch);
}
if parsed.issuer != idp.entity_id {
return Err(Error::IssuerMismatch {
expected: idp.entity_id.clone(),
got: Some(parsed.issuer.clone()),
});
}
verify_inbound_signature(
&document,
&decoded,
binding,
&idp.signing_certs,
&policy.allowed_signature_algorithms,
self.config.logout_want_signed.requests,
)?;
#[cfg(feature = "xmlenc")]
{
let decryption_keys: Vec<&KeyPair> = self
.config
.decryption_key
.as_ref()
.map(|k| vec![k])
.unwrap_or_default();
if let Some(name_id) = crate::logout::request_parse::decrypt_encrypted_name_id(
&document,
&decryption_keys,
policy,
)? {
parsed.name_id = name_id;
}
}
if let Some(nooa) = parsed.not_on_or_after
&& nooa <= now.checked_sub(clock_skew).unwrap_or(now)
{
return Err(Error::Expired);
}
Ok(parsed)
}
pub fn build_logout_response(
&self,
idp: &IdpDescriptor,
in_response_to: &ParsedLogoutRequest,
status: LogoutStatus,
relay_state: Option<&str>,
binding: Binding,
) -> Result<Dispatch, Error> {
let slo_endpoint = idp
.slo_endpoint(binding)
.ok_or(Error::UnsupportedByPeer { binding })?;
let destination_url =
url::Url::parse(&slo_endpoint.url).map_err(|_err| Error::InvalidConfiguration {
reason: "IdP SLO endpoint URL is not a valid URL",
})?;
let response_id = crate::binding::random_xml_id()?;
let issue_instant = SystemTime::now();
let build = BuildLogoutResponse {
id: &response_id,
issue_instant,
issuer_entity_id: &self.config.entity_id,
destination: Some(&slo_endpoint.url),
in_response_to: &in_response_to.id,
status,
status_message: None,
};
let unsigned_xml = build_logout_response_xml(&build)?;
let dispatch = match binding {
Binding::HttpRedirect => {
if self.config.logout_signing.sign_responses {
let signing_key = self.signing_key()?;
let sig_alg = self.config.outbound_signature_algorithm;
redirect_encode_signed(
&destination_url,
RedirectDirection::Response,
&unsigned_xml,
relay_state,
sig_alg.uri(),
|bytes| sign_detached_query(bytes, signing_key, sig_alg),
)?
} else {
redirect_encode_unsigned(
&destination_url,
RedirectDirection::Response,
&unsigned_xml,
relay_state,
)?
}
}
Binding::HttpPost => {
let xml_to_post = if self.config.logout_signing.sign_responses {
self.sign_protocol_xml(&unsigned_xml)?
} else {
unsigned_xml
};
post_encode_response(&destination_url, &xml_to_post, relay_state)
}
Binding::Soap | Binding::HttpArtifact => {
return Err(Error::UnsupportedByPeer { binding });
}
};
Ok(dispatch)
}
pub async fn send_soap_logout_request<H: HttpClient>(
&self,
http: &H,
idp: &IdpDescriptor,
peer_crypto_policy: Option<&PeerCryptoPolicy>,
opts: StartLogout<'_>,
) -> Result<LogoutOutcome, Error> {
let slo_endpoint = idp
.slo_endpoint(Binding::Soap)
.ok_or(Error::UnsupportedByPeer {
binding: Binding::Soap,
})?;
let policy = peer_crypto_policy.unwrap_or(&self.config.default_peer_crypto_policy);
let request_id = crate::binding::random_xml_id()?;
let issue_instant = SystemTime::now();
let build = BuildLogoutRequest {
id: &request_id,
issue_instant,
issuer_entity_id: &self.config.entity_id,
destination: Some(&slo_endpoint.url),
not_on_or_after: None,
reason: opts.reason,
name_id: opts.name_id,
session_index: opts.session_index,
};
let unsigned_xml = build_logout_request_xml(&build)?;
let logout_request_xml = if self.config.logout_signing.sign_requests {
self.sign_protocol_xml(&unsigned_xml)?
} else {
unsigned_xml
};
let logout_request_str = std::str::from_utf8(&logout_request_xml)
.map_err(|_err| Error::XmlEmit("logout request XML is not UTF-8".to_string()))?;
let soap_envelope = crate::binding::soap::wrap(logout_request_str)?;
let request = HttpRequest {
method: http::Method::POST,
url: slo_endpoint.url.clone(),
headers: crate::binding::soap::request_headers(),
body: soap_envelope.into_bytes(),
};
let response = http.send(request).await.map_err(Error::Http)?;
let inner_xml = crate::binding::soap::unwrap(&response.body)?.payload_xml()?;
let inner_doc = Document::parse(&inner_xml)?;
let (parsed, _) = parse_logout_response(&inner_doc)?;
if parsed.issuer != idp.entity_id {
return Err(Error::IssuerMismatch {
expected: idp.entity_id.clone(),
got: Some(parsed.issuer.clone()),
});
}
if parsed.in_response_to != request_id {
return Err(Error::InResponseToMismatch);
}
if self.config.logout_want_signed.responses {
let sig = inner_doc
.root()
.child_element(Some(DS_NS), "Signature")
.ok_or(Error::SignatureMissing)?;
let verified = verify_signature(
&inner_doc,
sig,
&idp.signing_certs,
&policy.allowed_signature_algorithms,
)?;
if verified.signed_element != inner_doc.root().id() {
return Err(Error::SignatureVerification {
reason: "signature does not cover LogoutResponse root",
});
}
} else if let Some(sig) = inner_doc.root().child_element(Some(DS_NS), "Signature") {
let _ = verify_signature(
&inner_doc,
sig,
&idp.signing_certs,
&policy.allowed_signature_algorithms,
)?;
}
Ok(parsed.to_outcome())
}
}
impl ServiceProvider {
pub fn metadata_xml(&self, sign: bool) -> Result<String, Error> {
self.emit_metadata(sign, None)
}
pub fn metadata_xml_with_extras(
&self,
sign: bool,
extras: &MetadataExtras,
) -> Result<String, Error> {
self.emit_metadata(sign, Some(extras))
}
fn emit_metadata(&self, sign: bool, extras: Option<&MetadataExtras>) -> Result<String, Error> {
let signing_cert = self
.config
.signing_key
.as_ref()
.and_then(|k| k.certificate());
#[cfg(feature = "xmlenc")]
let decryption_cert = self
.config
.decryption_key
.as_ref()
.and_then(|k| k.certificate());
#[cfg(feature = "xmlenc")]
let encryption_algorithms: &[DataEncryptionAlgorithm] = &[
DataEncryptionAlgorithm::Aes256Gcm,
DataEncryptionAlgorithm::Aes128Gcm,
];
let inputs = SpMetadataInputs {
entity_id: &self.config.entity_id,
acs: &self.config.acs,
slo: &self.config.slo,
name_id_formats: &self.config.name_id_formats,
signing_cert,
#[cfg(feature = "xmlenc")]
encryption_cert: decryption_cert,
#[cfg(feature = "xmlenc")]
encryption_algorithms,
authn_requests_signed: self.config.sign_authn_requests,
want_assertions_signed: self.config.want_signed.assertions,
valid_until: None,
cache_duration: None,
extras,
};
let signer = if sign {
let key = self.signing_key()?;
Some((
key,
self.config.outbound_signature_algorithm,
self.config.outbound_digest_algorithm,
C14nAlgorithm::ExclusiveCanonical,
))
} else {
None
};
emit_sp_metadata(&inputs, signer)
}
}
impl ServiceProvider {
fn signing_key(&self) -> Result<&KeyPair, Error> {
self.config
.signing_key
.as_ref()
.ok_or(Error::InvalidConfiguration {
reason: "signing requested but signing_key is None",
})
}
fn sign_protocol_xml(&self, xml: &[u8]) -> Result<Vec<u8>, Error> {
let key = self.signing_key()?;
let doc = Document::parse(xml)?;
let signed_root = sign_element(
doc.root().clone(),
&doc,
SignOptions {
signing_key: key,
sig_alg: self.config.outbound_signature_algorithm,
digest_alg: self.config.outbound_digest_algorithm,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)?;
let signed_doc = Document::new(signed_root)?;
Ok(emit_document(&signed_doc)?.into_bytes())
}
}
#[cfg(feature = "slo")]
struct DecodedSlo {
xml: Vec<u8>,
relay_state: Option<String>,
signed_query_string: Option<String>,
detached_signature: Option<Vec<u8>>,
detached_sig_alg: Option<String>,
}
#[cfg(feature = "slo")]
fn decode_logout_wire(
body: &[u8],
binding: Binding,
is_request: bool,
) -> Result<DecodedSlo, Error> {
match binding {
Binding::HttpRedirect => {
let qs = std::str::from_utf8(body).map_err(|_err| Error::Base64Decode)?;
let direction = if is_request {
RedirectDirection::Request
} else {
RedirectDirection::Response
};
let decoded = redirect_decode(qs, direction)?;
Ok(DecodedSlo {
xml: decoded.xml,
relay_state: decoded.relay_state,
signed_query_string: decoded.signed_query_string,
detached_signature: decoded.signature,
detached_sig_alg: decoded.sig_alg,
})
}
Binding::HttpPost => {
let b64 = std::str::from_utf8(body).map_err(|_err| Error::Base64Decode)?;
let decoded = post_decode(b64, None)?;
Ok(DecodedSlo {
xml: decoded.xml,
relay_state: decoded.relay_state,
signed_query_string: None,
detached_signature: None,
detached_sig_alg: None,
})
}
Binding::Soap => {
let _ = is_request;
let xml = crate::binding::soap::unwrap(body)?.payload_xml()?;
Ok(DecodedSlo {
xml,
relay_state: None,
signed_query_string: None,
detached_signature: None,
detached_sig_alg: None,
})
}
Binding::HttpArtifact => Err(Error::UnsupportedByPeer { binding }),
}
}
#[cfg(feature = "slo")]
fn verify_inbound_signature(
document: &Document,
decoded: &DecodedSlo,
binding: Binding,
signing_certs: &[crate::crypto::cert::X509Certificate],
allowed_algorithms: &[SignatureAlgorithm],
require_signature: bool,
) -> Result<(), Error> {
match binding {
Binding::HttpRedirect => {
match (
&decoded.signed_query_string,
&decoded.detached_signature,
&decoded.detached_sig_alg,
) {
(Some(qs), Some(sig), Some(alg)) => {
let sig_alg = SignatureAlgorithm::from_uri(alg)?;
verify_detached_signature(
qs.as_bytes(),
sig,
sig_alg,
signing_certs,
allowed_algorithms,
)?;
Ok(())
}
_ => {
if require_signature {
Err(Error::SignatureMissing)
} else {
Ok(())
}
}
}
}
Binding::HttpPost | Binding::Soap => {
let sig_elem = document.root().child_element(Some(DS_NS), "Signature");
match sig_elem {
Some(sig) => {
let verified =
verify_signature(document, sig, signing_certs, allowed_algorithms)?;
if verified.signed_element != document.root().id() {
return Err(Error::SignatureVerification {
reason: "signature does not cover message root",
});
}
Ok(())
}
None => {
if require_signature {
Err(Error::SignatureMissing)
} else {
Ok(())
}
}
}
}
Binding::HttpArtifact => Err(Error::UnsupportedByPeer { binding }),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::{Endpoint, PostForm, SsoResponseBinding, SsoResponseEndpoint};
use crate::crypto::cert::X509Certificate;
use crate::crypto::cert::test_vectors::{RSA_CERT_PEM, RSA_KEY_PKCS8_PEM};
use crate::dsig::sign::sign_element;
#[cfg(feature = "slo")]
use crate::nameid::NameId;
use crate::nameid::NameIdFormat;
use crate::response::SAML_NS as RESPONSE_SAML_NS;
use crate::response::SAMLP_NS as RESPONSE_SAMLP_NS;
use crate::response::parse::SUBJECT_CONFIRMATION_BEARER as RESPONSE_SUBJECT_CONFIRMATION_BEARER;
use crate::xml::emit::emit_document;
use crate::xml::parse::{Document, Element, Node, QName};
use std::time::{Duration, UNIX_EPOCH};
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 fixture_idp() -> IdpDescriptor {
IdpDescriptor {
entity_id: "https://idp.example.com".to_owned(),
sso_endpoints: vec![
Endpoint::redirect("https://idp.example.com/sso/redirect", 0, true),
Endpoint::post("https://idp.example.com/sso/post", 1, false),
],
slo_endpoints: vec![
Endpoint::redirect("https://idp.example.com/slo", 0, true),
Endpoint::post("https://idp.example.com/slo/post", 1, false),
],
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 fixture_sp_config(
signing_key: Option<KeyPair>,
allow_unsolicited: bool,
sign_authn_requests: bool,
) -> ServiceProviderConfig {
ServiceProviderConfig {
entity_id: "https://sp.example.com".to_owned(),
acs: vec![SsoResponseEndpoint::post(
"https://sp.example.com/acs",
0,
true,
)],
slo: vec![
Endpoint::redirect("https://sp.example.com/slo", 0, true),
Endpoint::post("https://sp.example.com/slo/post", 1, false),
],
name_id_formats: vec![NameIdFormat::EmailAddress, NameIdFormat::Persistent],
signing_key,
decryption_key: None,
sign_authn_requests,
want_signed: SpWantSigned {
response: false,
assertions: true,
},
allow_unsolicited,
#[cfg(feature = "slo")]
logout_signing: SpLogoutSigning::default(),
#[cfg(feature = "slo")]
logout_want_signed: SpLogoutWantSigned::default(),
default_peer_crypto_policy: PeerCryptoPolicy::strong_defaults(),
outbound_signature_algorithm: SignatureAlgorithm::RsaSha256,
outbound_digest_algorithm: DigestAlgorithm::Sha256,
}
}
#[test]
fn rejects_empty_entity_id() {
let mut cfg = fixture_sp_config(None, false, false);
cfg.entity_id = String::new();
let err = ServiceProvider::new(cfg).unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn rejects_whitespace_entity_id() {
let mut cfg = fixture_sp_config(None, false, false);
cfg.entity_id = "has space".to_owned();
let err = ServiceProvider::new(cfg).unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn accepts_bare_xs_anyuri_entity_id() {
let mut cfg = fixture_sp_config(None, false, false);
cfg.entity_id = "example.com".to_owned();
ServiceProvider::new(cfg).expect("bare anyURI accepted");
}
#[test]
fn rejects_empty_acs() {
let mut cfg = fixture_sp_config(None, false, false);
cfg.acs.clear();
let err = ServiceProvider::new(cfg).unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn rejects_sign_authn_without_key() {
let cfg = fixture_sp_config(None, false, true);
let err = ServiceProvider::new(cfg).unwrap_err();
match err {
Error::InvalidConfiguration { reason } => {
assert!(reason.contains("signing"), "got: {reason}");
}
other => panic!("expected InvalidConfiguration, got {other:?}"),
}
}
#[cfg(feature = "slo")]
#[test]
fn rejects_sign_logout_without_key() {
let mut cfg = fixture_sp_config(None, false, false);
cfg.logout_signing.sign_requests = true;
let err = ServiceProvider::new(cfg).unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
let mut cfg = fixture_sp_config(None, false, false);
cfg.logout_signing.sign_responses = true;
let err = ServiceProvider::new(cfg).unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn accepts_valid_config() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).expect("valid config");
assert_eq!(sp.entity_id(), "https://sp.example.com");
}
#[test]
fn start_login_redirect_returns_dispatch_and_tracker() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let result = sp
.start_login(
&idp,
StartLogin {
relay_state: Some("opaque-rs"),
binding: Binding::HttpRedirect,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: None,
acs_url: None,
response_binding: None,
},
)
.expect("start_login");
assert!(result.tracker.request_id.starts_with('_'));
assert!(result.tracker.request_id.len() > 1);
assert_eq!(result.tracker.idp_entity_id, "https://idp.example.com");
assert_eq!(
result.tracker.acs_endpoint.url,
"https://sp.example.com/acs"
);
assert_eq!(
result.tracker.acs_endpoint.binding,
SsoResponseBinding::HttpPost
);
match result.dispatch {
Dispatch::Redirect(url) => {
assert_eq!(url.host_str(), Some("idp.example.com"));
assert_eq!(url.path(), "/sso/redirect");
let q = url.query().expect("query");
assert!(q.contains("SAMLRequest="), "query: {q}");
assert!(q.contains("RelayState=opaque-rs"), "query: {q}");
}
other @ Dispatch::Post(_) => panic!("expected Redirect, got {other:?}"),
}
}
#[test]
fn start_login_signed_redirect_includes_signature_in_query() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(Some(kp), false, true);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let result = sp
.start_login(
&idp,
StartLogin {
relay_state: None,
binding: Binding::HttpRedirect,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: None,
acs_url: None,
response_binding: None,
},
)
.unwrap();
match result.dispatch {
Dispatch::Redirect(url) => {
let q = url.query().expect("query");
assert!(q.contains("SigAlg="), "missing SigAlg: {q}");
assert!(q.contains("Signature="), "missing Signature: {q}");
}
other @ Dispatch::Post(_) => panic!("expected Redirect, got {other:?}"),
}
}
#[test]
fn start_login_post_binding_returns_post_form() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let result = sp
.start_login(
&idp,
StartLogin {
relay_state: Some("rs"),
binding: Binding::HttpPost,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: None,
acs_url: None,
response_binding: None,
},
)
.unwrap();
match result.dispatch {
Dispatch::Post(PostForm {
action,
saml_request,
saml_response,
relay_state,
}) => {
assert_eq!(action.path(), "/sso/post");
assert!(saml_request.is_some());
assert!(saml_response.is_none());
assert_eq!(relay_state.as_deref(), Some("rs"));
}
other @ Dispatch::Redirect(_) => panic!("expected Post, got {other:?}"),
}
}
#[test]
fn start_login_missing_idp_binding_returns_unsupported() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let mut idp = fixture_idp();
idp.sso_endpoints.clear();
let err = sp
.start_login(
&idp,
StartLogin {
relay_state: None,
binding: Binding::HttpRedirect,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: None,
acs_url: None,
response_binding: None,
},
)
.unwrap_err();
match err {
Error::UnsupportedByPeer { binding } => assert_eq!(binding, Binding::HttpRedirect),
other => panic!("expected UnsupportedByPeer, got {other:?}"),
}
}
#[test]
fn start_login_rejects_artifact_outbound() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let mut idp = fixture_idp();
idp.sso_endpoints.push(Endpoint::artifact(
"https://idp.example.com/sso/artifact",
2,
false,
));
let err = sp
.start_login(
&idp,
StartLogin {
relay_state: None,
binding: Binding::HttpArtifact,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: None,
acs_url: None,
response_binding: None,
},
)
.unwrap_err();
assert!(matches!(err, Error::UnsupportedByPeer { .. }));
}
#[test]
fn start_login_rejects_response_binding_mismatch() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let err = sp
.start_login(
&idp,
StartLogin {
relay_state: None,
binding: Binding::HttpRedirect,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: None,
acs_url: None,
response_binding: Some(SsoResponseBinding::HttpArtifact),
},
)
.unwrap_err();
assert!(matches!(err, Error::IllegalResponseBinding { .. }));
}
#[test]
fn start_login_unknown_acs_index_is_invalid_configuration() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let err = sp
.start_login(
&idp,
StartLogin {
relay_state: None,
binding: Binding::HttpRedirect,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: Some(42),
acs_url: None,
response_binding: None,
},
)
.unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn start_login_acs_url_resolves_to_registered_endpoint() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let acs_url = sp.config().acs[0].url.clone();
let res = sp
.start_login(
&idp,
StartLogin {
relay_state: None,
binding: Binding::HttpRedirect,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: None,
acs_url: Some(&acs_url),
response_binding: None,
},
)
.expect("acs_url resolves");
assert_eq!(res.tracker.acs_endpoint.url, acs_url);
}
#[test]
fn start_login_unregistered_acs_url_returns_unregistered_acs() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let err = sp
.start_login(
&idp,
StartLogin {
relay_state: None,
binding: Binding::HttpRedirect,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: None,
acs_url: Some("https://attacker.example.com/acs"),
response_binding: None,
},
)
.unwrap_err();
assert!(matches!(err, Error::UnregisteredAcs { .. }));
}
#[test]
fn start_login_rejects_both_acs_index_and_url() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let err = sp
.start_login(
&idp,
StartLogin {
relay_state: None,
binding: Binding::HttpRedirect,
force_authn: false,
is_passive: false,
requested_name_id_format: None,
requested_authn_context: None,
acs_index: Some(0),
acs_url: Some("https://sp.example.com/acs"),
response_binding: None,
},
)
.unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
struct ResponseFixtureOptions<'a> {
in_response_to: Option<&'a str>,
recipient_url: &'a str,
audience: &'a str,
not_before: &'a str,
not_on_or_after: &'a str,
assertion_id: &'a str,
one_time_use: bool,
}
fn build_signed_response_xml(
kp: &KeyPair,
in_response_to: Option<&str>,
recipient_url: &str,
audience: &str,
not_before: &str,
not_on_or_after: &str,
) -> Vec<u8> {
build_signed_response_xml_with_options(
kp,
&ResponseFixtureOptions {
in_response_to,
recipient_url,
audience,
not_before,
not_on_or_after,
assertion_id: "_a1",
one_time_use: false,
},
)
}
fn build_signed_response_xml_with_options(
kp: &KeyPair,
opts: &ResponseFixtureOptions<'_>,
) -> Vec<u8> {
let in_response_to = opts.in_response_to;
let recipient_url = opts.recipient_url;
let audience = opts.audience;
let not_before = opts.not_before;
let not_on_or_after = opts.not_on_or_after;
let assertion_id = opts.assertion_id;
let one_time_use = opts.one_time_use;
let saml_ns = RESPONSE_SAML_NS;
let samlp_ns = RESPONSE_SAMLP_NS;
let bearer = RESPONSE_SUBJECT_CONFIRMATION_BEARER;
let mut scd_builder = Element::build(QName::new(
Some(saml_ns.to_owned()),
"SubjectConfirmationData",
))
.with_attribute(QName::new(None, "Recipient"), recipient_url.to_owned())
.with_attribute(
QName::new(None, "NotOnOrAfter"),
"2026-05-26T12:05:00Z".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(QName::new(Some(saml_ns.to_owned()), "SubjectConfirmation"))
.with_attribute(QName::new(None, "Method"), bearer.to_owned())
.with_child(Node::Element(scd))
.finish();
let name_id = Element::build(QName::new(Some(saml_ns.to_owned()), "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 subject = Element::build(QName::new(Some(saml_ns.to_owned()), "Subject"))
.with_child(Node::Element(name_id))
.with_child(Node::Element(sc))
.finish();
let aud_el = Element::build(QName::new(Some(saml_ns.to_owned()), "Audience"))
.with_text(audience)
.finish();
let aud_restr = Element::build(QName::new(Some(saml_ns.to_owned()), "AudienceRestriction"))
.with_child(Node::Element(aud_el))
.finish();
let mut conditions_builder =
Element::build(QName::new(Some(saml_ns.to_owned()), "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(aud_restr));
if one_time_use {
let one_time_use_el =
Element::build(QName::new(Some(saml_ns.to_owned()), "OneTimeUse")).finish();
conditions_builder = conditions_builder.with_child(Node::Element(one_time_use_el));
}
let conditions = conditions_builder.finish();
let class_ref =
Element::build(QName::new(Some(saml_ns.to_owned()), "AuthnContextClassRef"))
.with_text("urn:oasis:names:tc:SAML:2.0:ac:classes:Password")
.finish();
let actx = Element::build(QName::new(Some(saml_ns.to_owned()), "AuthnContext"))
.with_child(Node::Element(class_ref))
.finish();
let astmt = Element::build(QName::new(Some(saml_ns.to_owned()), "AuthnStatement"))
.with_attribute(QName::new(None, "AuthnInstant"), "2026-05-26T11:59:30Z")
.with_attribute(QName::new(None, "SessionIndex"), "sess-1")
.with_child(Node::Element(actx))
.finish();
let assertion_issuer = Element::build(QName::new(Some(saml_ns.to_owned()), "Issuer"))
.with_text("https://idp.example.com")
.finish();
let assertion = Element::build(QName::new(Some(saml_ns.to_owned()), "Assertion"))
.with_namespace(Some("saml".to_owned()), saml_ns)
.with_attribute(QName::new(None, "ID"), assertion_id.to_owned())
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(QName::new(None, "IssueInstant"), "2026-05-26T12:00:00Z")
.with_child(Node::Element(assertion_issuer))
.with_child(Node::Element(subject))
.with_child(Node::Element(conditions))
.with_child(Node::Element(astmt))
.finish();
let assertion_doc = Document::new(assertion).unwrap();
let signed_assertion = sign_element(
assertion_doc.root().clone(),
&assertion_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 status_code = Element::build(QName::new(Some(samlp_ns.to_owned()), "StatusCode"))
.with_attribute(
QName::new(None, "Value"),
"urn:oasis:names:tc:SAML:2.0:status:Success".to_owned(),
)
.finish();
let status = Element::build(QName::new(Some(samlp_ns.to_owned()), "Status"))
.with_child(Node::Element(status_code))
.finish();
let response_issuer = Element::build(QName::new(Some(saml_ns.to_owned()), "Issuer"))
.with_text("https://idp.example.com")
.finish();
let mut response = Element::build(QName::new(Some(samlp_ns.to_owned()), "Response"))
.with_namespace(Some("samlp".to_owned()), samlp_ns)
.with_namespace(Some("saml".to_owned()), saml_ns)
.with_attribute(QName::new(None, "ID"), "_resp1".to_owned())
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(QName::new(None, "IssueInstant"), "2026-05-26T12:00:00Z")
.with_attribute(QName::new(None, "Destination"), recipient_url.to_owned());
if let Some(irt) = in_response_to {
response = response.with_attribute(QName::new(None, "InResponseTo"), irt.to_owned());
}
let response = response
.with_child(Node::Element(response_issuer))
.with_child(Node::Element(status))
.with_child(Node::Element(signed_assertion))
.finish();
let doc = Document::new(response).unwrap();
emit_document(&doc).unwrap().into_bytes()
}
fn fixed_now() -> SystemTime {
UNIX_EPOCH
.checked_add(Duration::from_secs(1_779_796_830))
.expect("static UNIX_EPOCH + bounded Duration cannot overflow")
}
#[test]
fn consume_response_solicited_returns_identity() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let tracker = LoginTracker {
request_id: "_req1".to_owned(),
issued_at: fixed_now(),
idp_entity_id: idp.entity_id.clone(),
acs_endpoint: sp.config.acs[0].clone(),
requested_authn_context: None,
requested_name_id_format: None,
};
let xml = build_signed_response_xml(
&kp,
Some("_req1"),
"https://sp.example.com/acs",
"https://sp.example.com",
"2026-05-26T11:59:00Z",
"2026-05-26T12:10:00Z",
);
let identity = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: None,
replay_mode: ReplayMode::All,
holder_of_key_cert: None,
})
.expect("consume_response");
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"));
}
#[test]
fn consume_response_unsolicited_when_allowed() {
let kp = rsa_signing_key();
let mut cfg = fixture_sp_config(None, true, false);
cfg.allow_unsolicited = true;
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let xml = build_signed_response_xml(
&kp,
None, "https://sp.example.com/acs",
"https://sp.example.com",
"2026-05-26T11:59:00Z",
"2026-05-26T12:10:00Z",
);
let identity = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: None,
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: None,
replay_mode: ReplayMode::All,
holder_of_key_cert: None,
})
.expect("consume_response (unsolicited)");
assert_eq!(identity.assertion_id, "_a1");
}
#[test]
fn consume_response_solicited_in_response_to_mismatch() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let tracker = LoginTracker {
request_id: "_req1".to_owned(),
issued_at: fixed_now(),
idp_entity_id: idp.entity_id.clone(),
acs_endpoint: sp.config.acs[0].clone(),
requested_authn_context: None,
requested_name_id_format: None,
};
let xml = build_signed_response_xml(
&kp,
Some("_wrong"),
"https://sp.example.com/acs",
"https://sp.example.com",
"2026-05-26T11:59:00Z",
"2026-05-26T12:10:00Z",
);
let err = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: None,
replay_mode: ReplayMode::All,
holder_of_key_cert: None,
})
.unwrap_err();
assert!(matches!(err, Error::InResponseToMismatch));
}
#[test]
fn consume_response_destination_not_registered() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let tracker = LoginTracker {
request_id: "_req1".to_owned(),
issued_at: fixed_now(),
idp_entity_id: idp.entity_id.clone(),
acs_endpoint: sp.config.acs[0].clone(),
requested_authn_context: None,
requested_name_id_format: None,
};
let xml = build_signed_response_xml(
&kp,
Some("_req1"),
"https://sp.example.com/acs",
"https://sp.example.com",
"2026-05-26T11:59:00Z",
"2026-05-26T12:10:00Z",
);
let err = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://other.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: None,
replay_mode: ReplayMode::All,
holder_of_key_cert: None,
})
.unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn consume_response_rejects_replay() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let tracker = LoginTracker {
request_id: "_req1".to_owned(),
issued_at: fixed_now(),
idp_entity_id: idp.entity_id.clone(),
acs_endpoint: sp.config.acs[0].clone(),
requested_authn_context: None,
requested_name_id_format: None,
};
let xml = build_signed_response_xml(
&kp,
Some("_req1"),
"https://sp.example.com/acs",
"https://sp.example.com",
"2026-05-26T11:59:00Z",
"2099-05-26T12:10:00Z",
);
let cache = crate::replay::InMemoryReplayCache::new(32);
let identity = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: Some(&cache),
replay_mode: ReplayMode::All,
holder_of_key_cert: None,
})
.expect("first consume_response succeeds");
assert_eq!(identity.assertion_id, "_a1");
assert_eq!(cache.len(), 1, "cache populated by first consume");
let err = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: Some(&cache),
replay_mode: ReplayMode::All,
holder_of_key_cert: None,
})
.expect_err("second consume_response is a replay");
assert!(
matches!(err, Error::AssertionReplay),
"expected Error::AssertionReplay, got {err:?}"
);
assert_eq!(cache.len(), 1, "cache size unchanged after replay");
}
#[test]
fn replay_mode_one_time_use_only_accepts_repeated_non_one_time_use() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let tracker = LoginTracker {
request_id: "_req-otu-1".to_owned(),
issued_at: fixed_now(),
idp_entity_id: idp.entity_id.clone(),
acs_endpoint: sp.config.acs[0].clone(),
requested_authn_context: None,
requested_name_id_format: None,
};
let xml = build_signed_response_xml_with_options(
&kp,
&ResponseFixtureOptions {
in_response_to: Some("_req-otu-1"),
recipient_url: "https://sp.example.com/acs",
audience: "https://sp.example.com",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2099-05-26T12:10:00Z",
assertion_id: "_a-otu-skip",
one_time_use: false,
},
);
let cache = crate::replay::InMemoryReplayCache::new(32);
let first = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: Some(&cache),
replay_mode: ReplayMode::OneTimeUseOnly,
holder_of_key_cert: None,
})
.expect("first consume succeeds");
assert_eq!(first.assertion_id, "_a-otu-skip");
assert!(!first.is_one_time_use);
assert_eq!(
cache.len(),
0,
"non-OneTimeUse assertion bypasses the cache"
);
let second = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: Some(&cache),
replay_mode: ReplayMode::OneTimeUseOnly,
holder_of_key_cert: None,
})
.expect("second consume must succeed under OneTimeUseOnly");
assert_eq!(second.assertion_id, "_a-otu-skip");
assert_eq!(cache.len(), 0, "cache still untouched");
}
#[test]
fn replay_mode_one_time_use_only_rejects_repeated_one_time_use() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let tracker = LoginTracker {
request_id: "_req-otu-2".to_owned(),
issued_at: fixed_now(),
idp_entity_id: idp.entity_id.clone(),
acs_endpoint: sp.config.acs[0].clone(),
requested_authn_context: None,
requested_name_id_format: None,
};
let xml = build_signed_response_xml_with_options(
&kp,
&ResponseFixtureOptions {
in_response_to: Some("_req-otu-2"),
recipient_url: "https://sp.example.com/acs",
audience: "https://sp.example.com",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2099-05-26T12:10:00Z",
assertion_id: "_a-otu-must",
one_time_use: true,
},
);
let cache = crate::replay::InMemoryReplayCache::new(32);
let first = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: Some(&cache),
replay_mode: ReplayMode::OneTimeUseOnly,
holder_of_key_cert: None,
})
.expect("first OneTimeUse consume succeeds");
assert!(first.is_one_time_use);
assert_eq!(
cache.len(),
1,
"OneTimeUse assertion was offered to the cache"
);
let err = sp
.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: Some(&cache),
replay_mode: ReplayMode::OneTimeUseOnly,
holder_of_key_cert: None,
})
.expect_err("replay of OneTimeUse assertion must reject");
assert!(
matches!(err, Error::AssertionReplay),
"expected Error::AssertionReplay, got {err:?}"
);
}
#[test]
fn replay_mode_off_accepts_repeated_assertion() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let tracker = LoginTracker {
request_id: "_req-off".to_owned(),
issued_at: fixed_now(),
idp_entity_id: idp.entity_id.clone(),
acs_endpoint: sp.config.acs[0].clone(),
requested_authn_context: None,
requested_name_id_format: None,
};
let xml = build_signed_response_xml_with_options(
&kp,
&ResponseFixtureOptions {
in_response_to: Some("_req-off"),
recipient_url: "https://sp.example.com/acs",
audience: "https://sp.example.com",
not_before: "2026-05-26T11:59:00Z",
not_on_or_after: "2099-05-26T12:10:00Z",
assertion_id: "_a-off",
one_time_use: true,
},
);
let cache = crate::replay::InMemoryReplayCache::new(32);
sp.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: Some(&cache),
replay_mode: ReplayMode::Off,
holder_of_key_cert: None,
})
.expect("first consume under Off mode succeeds");
sp.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: Some(&tracker),
expected_destination: "https://sp.example.com/acs",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
replay_cache: Some(&cache),
replay_mode: ReplayMode::Off,
holder_of_key_cert: None,
})
.expect("second consume under Off mode also succeeds — cache never consulted");
assert_eq!(cache.len(), 0, "cache stays untouched under Off mode");
}
#[test]
fn replay_check_needed_truth_table() {
assert!(replay_check_needed(ReplayMode::All, false));
assert!(replay_check_needed(ReplayMode::All, true));
assert!(!replay_check_needed(ReplayMode::OneTimeUseOnly, false));
assert!(replay_check_needed(ReplayMode::OneTimeUseOnly, true));
assert!(!replay_check_needed(ReplayMode::Off, false));
assert!(!replay_check_needed(ReplayMode::Off, true));
}
#[cfg(feature = "slo")]
#[test]
fn start_logout_redirect_returns_dispatch_with_samlrequest() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let nid = NameId::email("alice@example.com");
let dispatch = sp
.start_logout(
&idp,
StartLogout {
name_id: &nid,
session_index: Some("sess-1"),
relay_state: Some("rs"),
reason: None,
binding: Binding::HttpRedirect,
},
)
.expect("start_logout");
assert!(dispatch.tracker.request_id.starts_with('_'));
assert_eq!(dispatch.tracker.peer_entity_id, "https://idp.example.com");
match dispatch.dispatch {
Dispatch::Redirect(url) => {
let q = url.query().unwrap();
assert!(q.contains("SAMLRequest="));
assert!(q.contains("RelayState=rs"));
}
other @ Dispatch::Post(_) => panic!("expected Redirect, got {other:?}"),
}
}
#[cfg(feature = "slo")]
#[test]
fn start_logout_missing_slo_endpoint_is_unsupported() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let mut idp = fixture_idp();
idp.slo_endpoints.clear();
let nid = NameId::email("alice@example.com");
let err = sp
.start_logout(
&idp,
StartLogout {
name_id: &nid,
session_index: None,
relay_state: None,
reason: None,
binding: Binding::HttpRedirect,
},
)
.unwrap_err();
assert!(matches!(err, Error::UnsupportedByPeer { .. }));
}
#[cfg(feature = "slo")]
fn build_logout_response_post_form(in_response_to: &str, destination: &str) -> Vec<u8> {
use crate::logout::response_build::build_logout_response_xml;
let xml = build_logout_response_xml(&BuildLogoutResponse {
id: "_lr1",
issue_instant: fixed_now(),
issuer_entity_id: "https://idp.example.com",
destination: Some(destination),
in_response_to,
status: LogoutStatus::Success,
status_message: None,
})
.unwrap();
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
BASE64.encode(&xml).into_bytes()
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_response_post_returns_success() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let logout_tracker = LogoutTracker {
request_id: "_req-logout".to_owned(),
issued_at: fixed_now(),
peer_entity_id: idp.entity_id.clone(),
};
let body = build_logout_response_post_form(
&logout_tracker.request_id,
"https://sp.example.com/slo/post",
);
let outcome = sp
.consume_logout_response(
&idp,
ConsumeLogoutResponse {
peer_crypto_policy: None,
body: &body,
binding: Binding::HttpPost,
detached_signature: None,
tracker: &logout_tracker,
expected_destination: "https://sp.example.com/slo/post",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
},
)
.expect("consume_logout_response");
assert!(matches!(outcome, LogoutOutcome::Success));
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_response_in_response_to_mismatch() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let logout_tracker = LogoutTracker {
request_id: "_expected".to_owned(),
issued_at: fixed_now(),
peer_entity_id: idp.entity_id.clone(),
};
let body = build_logout_response_post_form("_wrong", "https://sp.example.com/slo/post");
let err = sp
.consume_logout_response(
&idp,
ConsumeLogoutResponse {
peer_crypto_policy: None,
body: &body,
binding: Binding::HttpPost,
detached_signature: None,
tracker: &logout_tracker,
expected_destination: "https://sp.example.com/slo/post",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
},
)
.unwrap_err();
assert!(matches!(err, Error::InResponseToMismatch));
}
#[cfg(feature = "slo")]
fn build_logout_request_post_form(destination: &str) -> Vec<u8> {
let nid = NameId::email("alice@example.com");
let xml = build_logout_request_xml(&BuildLogoutRequest {
id: "_idp-req-1",
issue_instant: fixed_now(),
issuer_entity_id: "https://idp.example.com",
destination: Some(destination),
not_on_or_after: None,
reason: None,
name_id: &nid,
session_index: Some("sess-1"),
})
.unwrap();
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
BASE64.encode(&xml).into_bytes()
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_request_post_parses_and_validates() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let body = build_logout_request_post_form("https://sp.example.com/slo/post");
let parsed = sp
.consume_logout_request(
&idp,
ConsumeLogoutRequest {
peer_crypto_policy: None,
body: &body,
binding: Binding::HttpPost,
detached_signature: None,
expected_destination: "https://sp.example.com/slo/post",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
},
)
.expect("consume_logout_request");
assert_eq!(parsed.id, "_idp-req-1");
assert_eq!(parsed.issuer, "https://idp.example.com");
assert_eq!(parsed.name_id.value, "alice@example.com");
assert_eq!(parsed.session_index, vec!["sess-1".to_string()]);
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_request_issuer_mismatch_rejected() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let mut idp = fixture_idp();
idp.entity_id = "https://other-idp.example.com".to_owned();
let body = build_logout_request_post_form("https://sp.example.com/slo/post");
let err = sp
.consume_logout_request(
&idp,
ConsumeLogoutRequest {
peer_crypto_policy: None,
body: &body,
binding: Binding::HttpPost,
detached_signature: None,
expected_destination: "https://sp.example.com/slo/post",
now: fixed_now(),
clock_skew: Duration::from_secs(30),
},
)
.unwrap_err();
assert!(matches!(err, Error::IssuerMismatch { .. }));
}
#[cfg(feature = "slo")]
#[test]
fn build_logout_response_returns_post_dispatch() {
let cfg = fixture_sp_config(None, false, false);
let sp = ServiceProvider::new(cfg).unwrap();
let idp = fixture_idp();
let parsed = ParsedLogoutRequest {
id: "_idp-req-1".to_owned(),
issuer: idp.entity_id.clone(),
issue_instant: fixed_now(),
destination: Some("https://sp.example.com/slo/post".to_owned()),
not_on_or_after: None,
reason: None,
name_id: NameId::email("alice@example.com"),
session_index: vec!["sess-1".to_owned()],
relay_state: None,
};
let dispatch = sp
.build_logout_response(
&idp,
&parsed,
LogoutStatus::Success,
Some("rs"),
Binding::HttpPost,
)
.expect("build_logout_response");
match dispatch {
Dispatch::Post(PostForm {
saml_response,
saml_request,
action,
relay_state,
}) => {
assert!(saml_response.is_some());
assert!(saml_request.is_none());
assert_eq!(action.path(), "/slo/post");
assert_eq!(relay_state.as_deref(), Some("rs"));
}
other @ Dispatch::Redirect(_) => panic!("expected Post, got {other:?}"),
}
}
#[test]
fn metadata_xml_reparses_as_sp_descriptor() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(Some(kp), false, true);
let sp = ServiceProvider::new(cfg).unwrap();
let xml = sp.metadata_xml(false).expect("metadata_xml");
let descriptor =
crate::descriptor::SpDescriptor::from_metadata_xml(xml.as_bytes()).expect("reparse");
assert_eq!(descriptor.entity_id, "https://sp.example.com");
assert_eq!(descriptor.assertion_consumer_services.len(), 1);
assert_eq!(
descriptor.assertion_consumer_services[0].url,
"https://sp.example.com/acs"
);
assert_eq!(descriptor.single_logout_services.len(), 2);
assert!(descriptor.authn_requests_signed);
assert!(descriptor.want_assertions_signed);
assert_eq!(descriptor.signing_certs.len(), 1);
}
#[test]
fn metadata_xml_signed_carries_signature_child() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(Some(kp), false, true);
let sp = ServiceProvider::new(cfg).unwrap();
let xml = sp.metadata_xml(true).expect("signed metadata");
let doc = Document::parse(xml.as_bytes()).expect("parse");
let sig = doc
.root()
.child_element(Some("http://www.w3.org/2000/09/xmldsig#"), "Signature");
assert!(sig.is_some(), "signed metadata must carry <ds:Signature>");
}
#[test]
fn metadata_xml_with_extras_includes_organization() {
let kp = rsa_signing_key();
let cfg = fixture_sp_config(Some(kp), false, true);
let sp = ServiceProvider::new(cfg).unwrap();
let extras = crate::metadata::MetadataExtras {
organization: Some(crate::metadata::MetadataOrganization {
name: "Example".into(),
display_name: "Example Corp".into(),
url: "https://example.com".into(),
language: "en".into(),
}),
contacts: vec![],
#[cfg(feature = "idp-disco")]
discovery_response_endpoints: vec![],
};
let xml = sp
.metadata_xml_with_extras(false, &extras)
.expect("metadata_xml_with_extras");
let doc = Document::parse(xml.as_bytes()).expect("parse");
let org = doc
.root()
.child_element(Some("urn:oasis:names:tc:SAML:2.0:metadata"), "Organization")
.expect("Organization");
let _ = org;
}
#[cfg(all(feature = "artifact-binding", feature = "weak-algos"))]
mod artifact_backchannel {
use super::*;
use crate::binding::artifact::VerifyConfig;
use crate::binding::soap;
use crate::dsig::algorithms::C14nAlgorithm;
use crate::http::{HttpRequest, HttpResponse};
use std::future::Future;
use std::time::Duration;
const SAMLP_NS: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const SAML_NS: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
const ARS_URL: &str = "https://idp.example.com/ars";
struct MockClient {
response: Vec<u8>,
}
impl HttpClient for MockClient {
fn send(
&self,
_request: HttpRequest,
) -> impl Future<
Output = Result<HttpResponse, Box<dyn std::error::Error + Send + Sync>>,
> + Send {
let body = self.response.clone();
async move {
Ok(HttpResponse {
status: 200,
headers: vec![("Content-Type".to_owned(), "text/xml".to_owned())],
body,
})
}
}
}
fn artifact_idp() -> IdpDescriptor {
let mut idp = fixture_idp();
idp.artifact_resolution_endpoints = vec![Endpoint::post(ARS_URL, 0, true)];
idp
}
fn artifact_sp() -> ServiceProvider {
let mut cfg = fixture_sp_config(None, false, false);
cfg.acs = vec![SsoResponseEndpoint::artifact(
"https://sp.example.com/acs",
0,
true,
)];
ServiceProvider::new(cfg).expect("sp builds")
}
fn signed_envelope(tamper: bool) -> Vec<u8> {
let kp = rsa_signing_key();
let inner = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_inner-art" Version="2.0" IssueInstant="2026-01-01T00:00:00Z"><saml:Issuer>https://idp.example.com</saml:Issuer></samlp:Response>"#;
let inner_doc = Document::parse(inner.as_bytes()).expect("inner parse");
let inner_elem = inner_doc.root().clone();
let issuer = Element::build(QName::new(Some(SAML_NS.to_owned()), "Issuer"))
.with_text("https://idp.example.com".to_owned())
.finish();
let status_code = Element::build(QName::new(Some(SAMLP_NS.to_owned()), "StatusCode"))
.with_attribute(QName::new(None, "Value"), STATUS_SUCCESS.to_owned())
.finish();
let status = Element::build(QName::new(Some(SAMLP_NS.to_owned()), "Status"))
.with_child(Node::Element(status_code))
.finish();
let ar = Element::build(QName::new(Some(SAMLP_NS.to_owned()), "ArtifactResponse"))
.with_namespace(Some("samlp".to_owned()), SAMLP_NS)
.with_namespace(Some("saml".to_owned()), SAML_NS)
.with_attribute(QName::new(None, "ID"), "_art-resp".to_owned())
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(QName::new(None, "IssueInstant"), "2026-01-01T00:00:00Z")
.with_child(Node::Element(issuer))
.with_child(Node::Element(status))
.with_child(Node::Element(inner_elem))
.finish();
let stash = Document::new(ar).expect("stash doc");
let signed = sign_element(
stash.root().clone(),
&stash,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.expect("sign");
let envelope = soap::wrap_element(signed).expect("wrap");
if tamper {
envelope.replace("_art-resp", "_art-TAMP").into_bytes()
} else {
envelope.into_bytes()
}
}
fn consume_input<'a>(
idp: &'a IdpDescriptor,
backchannel: Option<ArtifactBackchannel<'a>>,
) -> ConsumeArtifactResponse<'a> {
ConsumeArtifactResponse {
idp,
peer_crypto_policy: None,
artifact: "AAQAA-sample",
relay_state: None,
tracker: None,
expected_destination: "https://sp.example.com/acs",
now: SystemTime::UNIX_EPOCH
.checked_add(Duration::from_hours(490_896))
.expect("fixed now fits"),
clock_skew: Duration::from_mins(2),
replay_cache: None,
replay_mode: ReplayMode::All,
holder_of_key_cert: None,
backchannel,
}
}
#[tokio::test]
async fn sp_verifies_signed_envelope_end_to_end() {
let sp = artifact_sp();
let idp = artifact_idp();
let certs = idp.signing_certs.clone();
let client = MockClient {
response: signed_envelope(false),
};
let bc = ArtifactBackchannel {
sign: None,
verify: Some(VerifyConfig {
certs: &certs,
allowed_algorithms: &[SignatureAlgorithm::RsaSha256],
require_signed: true,
}),
};
let err = sp
.consume_response_artifact(&client, consume_input(&idp, Some(bc)))
.await
.expect_err("inner Response is minimal -> downstream rejects");
assert!(
!matches!(
err,
Error::SignatureMissing | Error::SignatureVerification { .. }
),
"envelope signature must have verified; got {err:?}"
);
}
#[tokio::test]
async fn sp_rejects_tampered_envelope_signature() {
let sp = artifact_sp();
let idp = artifact_idp();
let certs = idp.signing_certs.clone();
let client = MockClient {
response: signed_envelope(true),
};
let bc = ArtifactBackchannel {
sign: None,
verify: Some(VerifyConfig {
certs: &certs,
allowed_algorithms: &[SignatureAlgorithm::RsaSha256],
require_signed: true,
}),
};
let err = sp
.consume_response_artifact(&client, consume_input(&idp, Some(bc)))
.await
.expect_err("tampered envelope signature must be rejected");
assert!(
matches!(err, Error::SignatureVerification { .. }),
"got {err:?}"
);
}
#[tokio::test]
async fn sp_require_signed_rejects_unsigned_envelope() {
let sp = artifact_sp();
let idp = artifact_idp();
let certs = idp.signing_certs.clone();
let inner = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_inner-art" Version="2.0" IssueInstant="2026-01-01T00:00:00Z"/>"#;
let unsigned = crate::binding::artifact::build_artifact_response(
"https://idp.example.com",
"_req1",
inner,
)
.expect("build unsigned envelope")
.into_bytes();
let client = MockClient { response: unsigned };
let bc = ArtifactBackchannel {
sign: None,
verify: Some(VerifyConfig {
certs: &certs,
allowed_algorithms: &[SignatureAlgorithm::RsaSha256],
require_signed: true,
}),
};
let err = sp
.consume_response_artifact(&client, consume_input(&idp, Some(bc)))
.await
.expect_err("require_signed must reject an unsigned envelope");
assert!(matches!(err, Error::SignatureMissing), "got {err:?}");
}
#[tokio::test]
async fn sp_default_is_unchanged_no_envelope_check() {
let sp = artifact_sp();
let idp = artifact_idp();
let inner = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_inner-art" Version="2.0" IssueInstant="2026-01-01T00:00:00Z"/>"#;
let unsigned = crate::binding::artifact::build_artifact_response(
"https://idp.example.com",
"_req1",
inner,
)
.expect("build unsigned envelope")
.into_bytes();
let client = MockClient { response: unsigned };
let err = sp
.consume_response_artifact(&client, consume_input(&idp, None))
.await
.expect_err("minimal inner Response -> downstream rejects");
assert!(
!matches!(err, Error::SignatureMissing),
"default path must not require an envelope signature; got {err:?}"
);
}
}
}