use std::time::{Duration, SystemTime};
#[cfg(feature = "slo")]
use base64::Engine as _;
#[cfg(feature = "slo")]
use base64::engine::general_purpose::STANDARD as BASE64;
use crate::attribute::Attribute;
use crate::authn::request_parse::parse_authn_request;
use crate::authn::request_validate::validate_authn_request;
use crate::authn_context::AuthnContextClassRef;
#[cfg(any(feature = "slo", test))]
use crate::binding::Dispatch;
use crate::binding::{Binding, Endpoint, SsoResponseDispatch};
use crate::crypto::keypair::KeyPair;
use crate::descriptor::SpDescriptor;
use crate::dsig::algorithms::{
C14nAlgorithm, DigestAlgorithm, PeerCryptoPolicy, SignatureAlgorithm,
};
use crate::dsig::reference::DS_NS;
use crate::dsig::verify::{verify_detached_signature, verify_signature};
use crate::error::Error;
#[cfg(feature = "slo")]
use crate::http::{HttpClient, HttpRequest, HttpResponse};
#[cfg(feature = "slo")]
use crate::logout::request_build::{BuildLogoutRequest, build_logout_request_element};
#[cfg(feature = "slo")]
use crate::logout::request_parse::parse_logout_request;
#[cfg(feature = "slo")]
use crate::logout::response_build::{BuildLogoutResponse, build_logout_response_element};
#[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_idp::{IdpMetadataInputs, emit_idp_metadata};
use crate::nameid::{NameId, NameIdFormat};
use crate::response::issue::{
IssueErrorResponseInputs, IssueResponseInputs, SamlStatusCode, issue_error_response,
issue_response,
};
#[cfg(feature = "slo")]
use crate::xml::emit::emit_document;
use crate::xml::parse::{Document, Element};
pub use crate::authn::request_validate::{AcsSelection, ParsedAuthnRequest};
#[cfg(test)]
const SAMLP_NS: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
#[derive(Debug, Clone, Copy, Default)]
pub struct IdpAssertionSigning {
pub sign_responses: bool,
pub sign_assertions: bool,
}
#[cfg(feature = "slo")]
#[derive(Debug, Clone, Copy, Default)]
pub struct IdpLogoutSigning {
pub sign_requests: bool,
pub sign_responses: bool,
}
#[cfg(feature = "slo")]
#[derive(Debug, Clone, Copy, Default)]
pub struct IdpLogoutWantSigned {
pub requests: bool,
pub responses: bool,
}
#[derive(Debug, Clone)]
pub struct IdentityProviderConfig {
pub entity_id: String,
pub sso: Vec<Endpoint>,
pub slo: Vec<Endpoint>,
pub artifact_resolution: Vec<Endpoint>,
pub supported_name_id_formats: Vec<NameIdFormat>,
pub default_name_id_format: NameIdFormat,
pub signing_key: KeyPair,
pub decryption_key: Option<KeyPair>,
pub want_authn_requests_signed: bool,
pub assertion_signing: IdpAssertionSigning,
pub encrypt_assertions_when_possible: bool,
#[cfg(feature = "slo")]
pub logout_signing: IdpLogoutSigning,
#[cfg(feature = "slo")]
pub logout_want_signed: IdpLogoutWantSigned,
pub default_session_duration: Duration,
pub default_peer_crypto_policy: PeerCryptoPolicy,
pub outbound_signature_algorithm: SignatureAlgorithm,
pub outbound_digest_algorithm: DigestAlgorithm,
pub outbound_c14n: C14nAlgorithm,
#[cfg(feature = "xmlenc")]
pub outbound_data_encryption_algorithm: crate::xmlenc::algorithms::DataEncryptionAlgorithm,
#[cfg(feature = "xmlenc")]
pub outbound_key_transport_algorithm: crate::xmlenc::algorithms::KeyTransportAlgorithm,
}
#[derive(Debug, Clone)]
pub struct IdentityProvider {
config: IdentityProviderConfig,
}
impl IdentityProvider {
pub fn new(config: IdentityProviderConfig) -> Result<Self, Error> {
if config.entity_id.is_empty() || config.entity_id.chars().any(char::is_whitespace) {
return Err(Error::InvalidConfiguration {
reason: "IdentityProviderConfig.entity_id must be a non-empty, whitespace-free xs:anyURI",
});
}
if config.sso.is_empty() {
return Err(Error::InvalidConfiguration {
reason: "IdentityProviderConfig.sso must contain at least one endpoint",
});
}
Ok(Self { config })
}
pub fn config(&self) -> &IdentityProviderConfig {
&self.config
}
pub fn entity_id(&self) -> &str {
&self.config.entity_id
}
}
pub struct ConsumeAuthnRequest<'a> {
pub sp: &'a SpDescriptor,
pub peer_crypto_policy: Option<&'a PeerCryptoPolicy>,
pub saml_request: &'a [u8],
pub binding: Binding,
pub relay_state: Option<&'a str>,
pub detached_signature: Option<DetachedSignature<'a>>,
pub expected_destination: &'a str,
pub now: SystemTime,
pub clock_skew: Duration,
}
pub struct DetachedSignature<'a> {
pub signature: &'a [u8],
pub sig_alg: &'a str,
pub raw_query_string: &'a str,
}
impl IdentityProvider {
pub fn consume_authn_request(
&self,
input: ConsumeAuthnRequest<'_>,
) -> Result<ParsedAuthnRequest, Error> {
let doc = Document::parse(input.saml_request)?;
let (raw, root) = parse_authn_request(&doc)?;
let root_id = root.id();
let sso_urls: Vec<String> = self.config.sso.iter().map(|e| e.url.clone()).collect();
let mut parsed =
validate_authn_request(raw, input.sp, input.expected_destination, &sso_urls)?;
let policy = input
.peer_crypto_policy
.unwrap_or(&self.config.default_peer_crypto_policy);
let signature_required =
self.config.want_authn_requests_signed || input.sp.authn_requests_signed;
match input.binding {
Binding::HttpRedirect => verify_redirect_request_signature(
signature_required,
input.detached_signature.as_ref(),
&input.sp.signing_certs,
&policy.allowed_signature_algorithms,
)?,
Binding::HttpPost | Binding::Soap => {
verify_envelope_signature(
signature_required,
&doc,
root,
root_id,
&input.sp.signing_certs,
&policy.allowed_signature_algorithms,
)?;
}
Binding::HttpArtifact => {
return Err(Error::UnsupportedByPeer {
binding: Binding::HttpArtifact,
});
}
}
let _ = (input.now, input.clock_skew);
parsed.relay_state = input.relay_state.map(str::to_owned);
Ok(parsed)
}
pub fn consume_authn_request_wire(
&self,
input: ConsumeAuthnRequestWire<'_>,
) -> Result<ParsedAuthnRequest, Error> {
let decoded = crate::binding::decode_wire(
input.wire_body,
input.binding,
crate::binding::WireDirection::Request,
)?;
let detached_signature = decoded.as_detached_signature();
let resolved_relay_state = input.relay_state.or(decoded.relay_state.as_deref());
self.consume_authn_request(ConsumeAuthnRequest {
sp: input.sp,
peer_crypto_policy: input.peer_crypto_policy,
saml_request: &decoded.xml,
binding: input.binding,
relay_state: resolved_relay_state,
detached_signature,
expected_destination: input.expected_destination,
now: input.now,
clock_skew: input.clock_skew,
})
}
}
pub struct ConsumeAuthnRequestWire<'a> {
pub sp: &'a SpDescriptor,
pub peer_crypto_policy: Option<&'a PeerCryptoPolicy>,
pub wire_body: &'a [u8],
pub binding: Binding,
pub relay_state: Option<&'a str>,
pub expected_destination: &'a str,
pub now: SystemTime,
pub clock_skew: Duration,
}
fn verify_redirect_request_signature(
required: bool,
detached: Option<&DetachedSignature<'_>>,
candidate_certs: &[crate::crypto::cert::X509Certificate],
allowed_algorithms: &[SignatureAlgorithm],
) -> Result<(), Error> {
match detached {
None if required => Err(Error::SignatureMissing),
None => Ok(()),
Some(d) => {
let sig_alg = SignatureAlgorithm::from_uri(d.sig_alg)?;
verify_detached_signature(
d.raw_query_string.as_bytes(),
d.signature,
sig_alg,
candidate_certs,
allowed_algorithms,
)?;
Ok(())
}
}
}
fn verify_envelope_signature(
required: bool,
doc: &Document,
root: &Element,
root_id: crate::xml::parse::ElementId,
candidate_certs: &[crate::crypto::cert::X509Certificate],
allowed_algorithms: &[SignatureAlgorithm],
) -> Result<(), Error> {
let sig_elem = root.child_element(Some(DS_NS), "Signature");
match sig_elem {
None if required => Err(Error::SignatureMissing),
None => Ok(()),
Some(sig) => {
let verified = verify_signature(doc, sig, candidate_certs, allowed_algorithms)?;
if verified.signed_element != root_id {
return Err(Error::SignatureVerification {
reason: "signature covers a different element than the message root (XSW)",
});
}
Ok(())
}
}
}
pub struct IssueResponse<'a> {
pub sp: &'a SpDescriptor,
pub in_response_to: &'a ParsedAuthnRequest,
pub name_id: NameId,
pub attributes: Vec<Attribute>,
pub authn_instant: SystemTime,
pub session_index: String,
pub session_not_on_or_after: Option<SystemTime>,
pub authn_context_class_ref: AuthnContextClassRef,
pub force_encrypt_assertion: Option<bool>,
pub now: SystemTime,
pub assertion_lifetime: Duration,
pub subject_confirmation_lifetime: Duration,
pub holder_of_key_cert: Option<&'a crate::crypto::cert::X509Certificate>,
}
pub struct IssueErrorResponse<'a> {
pub sp: &'a SpDescriptor,
pub in_response_to: &'a ParsedAuthnRequest,
pub status_code: SamlStatusCode,
pub second_level_status_code: Option<SamlStatusCode>,
pub message: Option<String>,
pub now: SystemTime,
}
impl IdentityProvider {
pub fn issue_response(&self, input: IssueResponse<'_>) -> Result<SsoResponseDispatch, Error> {
let acs_endpoint = &input.in_response_to.assertion_consumer_service;
let relay_state = input.in_response_to.relay_state.as_deref();
let chosen_format = pick_name_id_format(
input.in_response_to.requested_name_id_format.as_ref(),
&self.config.supported_name_id_formats,
&self.config.default_name_id_format,
);
let mut name_id = input.name_id;
name_id.format = chosen_format;
let inputs = IssueResponseInputs {
sp: input.sp,
idp_entity_id: &self.config.entity_id,
in_response_to: Some(input.in_response_to.id.as_str()),
name_id,
attributes: input.attributes,
authn_instant: input.authn_instant,
session_index: input.session_index,
session_not_on_or_after: input.session_not_on_or_after,
authn_context_class_ref: input.authn_context_class_ref,
force_encrypt_assertion: input.force_encrypt_assertion,
encrypt_assertions_when_possible: self.config.encrypt_assertions_when_possible,
now: input.now,
assertion_lifetime: input.assertion_lifetime,
subject_confirmation_lifetime: input.subject_confirmation_lifetime,
signing_key: &self.config.signing_key,
sign_responses: self.config.assertion_signing.sign_responses,
sign_assertions: self.config.assertion_signing.sign_assertions,
outbound_signature_algorithm: self.config.outbound_signature_algorithm,
outbound_digest_algorithm: self.config.outbound_digest_algorithm,
outbound_c14n: self.config.outbound_c14n,
#[cfg(feature = "xmlenc")]
outbound_data_encryption_algorithm: self.config.outbound_data_encryption_algorithm,
#[cfg(feature = "xmlenc")]
outbound_key_transport_algorithm: self.config.outbound_key_transport_algorithm,
acs_endpoint,
relay_state,
holder_of_key_cert: input.holder_of_key_cert,
};
issue_response(inputs)
}
pub fn issue_error_response(
&self,
input: IssueErrorResponse<'_>,
) -> Result<SsoResponseDispatch, Error> {
let acs_endpoint = &input.in_response_to.assertion_consumer_service;
let relay_state = input.in_response_to.relay_state.as_deref();
let inputs = IssueErrorResponseInputs {
idp_entity_id: &self.config.entity_id,
in_response_to: Some(input.in_response_to.id.as_str()),
now: input.now,
status_code: input.status_code,
second_level_status_code: input.second_level_status_code,
message: input.message,
signing_key: &self.config.signing_key,
sign_responses: self.config.assertion_signing.sign_responses,
outbound_signature_algorithm: self.config.outbound_signature_algorithm,
outbound_digest_algorithm: self.config.outbound_digest_algorithm,
outbound_c14n: self.config.outbound_c14n,
acs_endpoint,
relay_state,
};
issue_error_response(inputs)
}
#[cfg(all(feature = "artifact-binding", feature = "weak-algos"))]
pub fn parse_artifact_resolve(
&self,
sp: &SpDescriptor,
soap_envelope: &[u8],
) -> Result<crate::binding::artifact::ArtifactResolveRequest, Error> {
let req = crate::binding::artifact::parse_artifact_resolve(soap_envelope)?;
if req.issuer != sp.entity_id {
return Err(Error::IssuerMismatch {
expected: sp.entity_id.clone(),
got: Some(req.issuer.clone()),
});
}
Ok(req)
}
#[cfg(all(feature = "artifact-binding", feature = "weak-algos"))]
pub fn build_artifact_response(
&self,
request: &crate::binding::artifact::ArtifactResolveRequest,
payload_xml: &str,
) -> Result<String, Error> {
crate::binding::artifact::build_artifact_response(
&self.config.entity_id,
&request.request_id,
payload_xml,
)
}
}
fn pick_name_id_format(
requested: Option<&NameIdFormat>,
supported: &[NameIdFormat],
default: &NameIdFormat,
) -> NameIdFormat {
match requested {
Some(fmt) if supported.contains(fmt) => fmt.clone(),
_ => default.clone(),
}
}
#[cfg(feature = "slo")]
impl IdentityProvider {
pub fn consume_logout_request(
&self,
sp: &SpDescriptor,
input: ConsumeLogoutRequest<'_>,
) -> Result<ParsedLogoutRequest, Error> {
let ConsumeLogoutRequest {
peer_crypto_policy,
body,
binding,
detached_signature,
expected_destination,
now,
clock_skew,
} = input;
let doc = Document::parse(body)?;
let (mut parsed, root_id) = parse_logout_request(&doc)?;
if parsed.issuer != sp.entity_id {
return Err(Error::IssuerMismatch {
expected: sp.entity_id.clone(),
got: Some(parsed.issuer.clone()),
});
}
if !self
.config
.slo
.iter()
.any(|e| e.url == expected_destination)
{
return Err(Error::InvalidConfiguration {
reason: "expected_destination is not a registered SLO endpoint",
});
}
if let Some(dest) = parsed.destination.as_deref()
&& dest != expected_destination
{
return Err(Error::DestinationMismatch);
}
let policy = peer_crypto_policy.unwrap_or(&self.config.default_peer_crypto_policy);
let signature_required = self.config.logout_want_signed.requests;
verify_logout_signature(
signature_required,
binding,
&doc,
root_id,
detached_signature.as_ref(),
&sp.signing_certs,
&policy.allowed_signature_algorithms,
)?;
#[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(
&doc,
&decryption_keys,
policy,
)? {
parsed.name_id = name_id;
}
}
if let Some(noa) = parsed.not_on_or_after
&& noa <= now.checked_sub(clock_skew).unwrap_or(now)
{
return Err(Error::Expired);
}
parsed.relay_state = None;
Ok(parsed)
}
pub fn build_logout_response(
&self,
sp: &SpDescriptor,
in_response_to: &ParsedLogoutRequest,
status: LogoutStatus,
relay_state: Option<&str>,
binding: Binding,
) -> Result<Dispatch, Error> {
let destination_endpoint = sp
.slo_endpoint(binding)
.ok_or(Error::UnsupportedByPeer { binding })?;
let id = crate::binding::random_xml_id()?;
let build = BuildLogoutResponse {
id: &id,
issue_instant: SystemTime::now(),
issuer_entity_id: &self.config.entity_id,
destination: Some(destination_endpoint.url.as_str()),
in_response_to: in_response_to.id.as_str(),
status,
status_message: None,
};
let element = build_logout_response_element(&build)?;
let element =
self.maybe_sign_outbound(element, self.config.logout_signing.sign_responses)?;
let xml = serialize_element(element)?;
encode_logout_dispatch_response(binding, &destination_endpoint.url, &xml, relay_state)
}
pub fn start_logout(
&self,
sp: &SpDescriptor,
opts: StartLogout<'_>,
) -> Result<LogoutDispatch, Error> {
let destination_endpoint =
sp.slo_endpoint(opts.binding)
.ok_or(Error::UnsupportedByPeer {
binding: opts.binding,
})?;
let id = crate::binding::random_xml_id()?;
let issue_instant = SystemTime::now();
let build = BuildLogoutRequest {
id: &id,
issue_instant,
issuer_entity_id: &self.config.entity_id,
destination: Some(destination_endpoint.url.as_str()),
not_on_or_after: None,
reason: opts.reason,
name_id: opts.name_id,
session_index: opts.session_index,
};
let element = build_logout_request_element(&build)?;
let dispatch = match opts.binding {
Binding::HttpRedirect => {
let xml = serialize_element(element)?;
encode_logout_redirect_request(
&destination_endpoint.url,
&xml,
opts.relay_state,
self.config.logout_signing.sign_requests.then_some(self),
)?
}
Binding::HttpPost => {
let element =
self.maybe_sign_outbound(element, self.config.logout_signing.sign_requests)?;
let xml = serialize_element(element)?;
crate::binding::post::encode_request(
&parse_url(&destination_endpoint.url)?,
&xml,
opts.relay_state,
)
}
Binding::Soap => {
return Err(Error::InvalidConfiguration {
reason: "Soap SLO must go through send_soap_logout_request, not start_logout",
});
}
Binding::HttpArtifact => {
return Err(Error::UnsupportedByPeer {
binding: Binding::HttpArtifact,
});
}
};
let tracker = LogoutTracker {
request_id: id,
issued_at: issue_instant,
peer_entity_id: sp.entity_id.clone(),
};
Ok(LogoutDispatch { tracker, dispatch })
}
pub fn consume_logout_response(
&self,
sp: &SpDescriptor,
input: ConsumeLogoutResponse<'_>,
) -> Result<LogoutOutcome, Error> {
let ConsumeLogoutResponse {
peer_crypto_policy,
body,
binding,
detached_signature,
tracker,
expected_destination,
now,
clock_skew,
} = input;
let _ = (now, clock_skew);
let doc = Document::parse(body)?;
let (parsed, root_id) = parse_logout_response(&doc)?;
if parsed.issuer != sp.entity_id {
return Err(Error::IssuerMismatch {
expected: sp.entity_id.clone(),
got: Some(parsed.issuer.clone()),
});
}
if !self
.config
.slo
.iter()
.any(|e| e.url == expected_destination)
{
return Err(Error::InvalidConfiguration {
reason: "expected_destination is not a registered SLO endpoint",
});
}
if let Some(dest) = parsed.destination.as_deref()
&& dest != expected_destination
{
return Err(Error::DestinationMismatch);
}
let policy = peer_crypto_policy.unwrap_or(&self.config.default_peer_crypto_policy);
let signature_required = self.config.logout_want_signed.responses;
verify_logout_signature(
signature_required,
binding,
&doc,
root_id,
detached_signature.as_ref(),
&sp.signing_certs,
&policy.allowed_signature_algorithms,
)?;
if parsed.in_response_to != tracker.request_id {
return Err(Error::InResponseToMismatch);
}
Ok(parsed.to_outcome())
}
pub fn consume_logout_request_wire(
&self,
input: ConsumeLogoutRequestWire<'_>,
) -> Result<ParsedLogoutRequest, Error> {
let decoded = crate::binding::decode_wire(
input.wire_body,
input.binding,
crate::binding::WireDirection::Request,
)?;
let detached_signature = decoded.as_detached_signature();
self.consume_logout_request(
input.sp,
ConsumeLogoutRequest {
peer_crypto_policy: input.peer_crypto_policy,
body: &decoded.xml,
binding: input.binding,
detached_signature,
expected_destination: input.expected_destination,
now: input.now,
clock_skew: input.clock_skew,
},
)
}
pub fn consume_logout_response_wire(
&self,
input: ConsumeLogoutResponseWire<'_>,
) -> Result<LogoutOutcome, Error> {
let decoded = crate::binding::decode_wire(
input.wire_body,
input.binding,
crate::binding::WireDirection::Response,
)?;
let detached_signature = decoded.as_detached_signature();
self.consume_logout_response(
input.sp,
ConsumeLogoutResponse {
peer_crypto_policy: input.peer_crypto_policy,
body: &decoded.xml,
binding: input.binding,
detached_signature,
tracker: input.tracker,
expected_destination: input.expected_destination,
now: input.now,
clock_skew: input.clock_skew,
},
)
}
pub async fn send_soap_logout_request<H: HttpClient>(
&self,
http: &H,
sp: &SpDescriptor,
peer_crypto_policy: Option<&PeerCryptoPolicy>,
opts: StartLogout<'_>,
) -> Result<LogoutOutcome, Error> {
if !matches!(opts.binding, Binding::Soap) {
return Err(Error::InvalidConfiguration {
reason: "send_soap_logout_request requires StartLogout.binding = Soap",
});
}
let destination_endpoint =
sp.slo_endpoint(Binding::Soap)
.ok_or(Error::UnsupportedByPeer {
binding: Binding::Soap,
})?;
let id = crate::binding::random_xml_id()?;
let issue_instant = SystemTime::now();
let build = BuildLogoutRequest {
id: &id,
issue_instant,
issuer_entity_id: &self.config.entity_id,
destination: Some(destination_endpoint.url.as_str()),
not_on_or_after: None,
reason: opts.reason,
name_id: opts.name_id,
session_index: opts.session_index,
};
let element = build_logout_request_element(&build)?;
let element =
self.maybe_sign_outbound(element, self.config.logout_signing.sign_requests)?;
let xml = serialize_element(element)?;
let xml_str = std::str::from_utf8(&xml)
.map_err(|_err| Error::XmlEmit("non-UTF-8 outbound XML".to_string()))?;
let envelope = wrap_soap_envelope(xml_str)?;
let request = HttpRequest {
method: http::Method::POST,
url: destination_endpoint.url.clone(),
headers: crate::binding::soap::request_headers(),
body: envelope.into_bytes(),
};
let HttpResponse { body, .. } = http.send(request).await.map_err(Error::Http)?;
let response_xml = unwrap_soap_envelope(&body)?;
let tracker = LogoutTracker {
request_id: id,
issued_at: issue_instant,
peer_entity_id: sp.entity_id.clone(),
};
let expected_destination = self
.config
.slo
.first()
.map(|e| e.url.clone())
.unwrap_or_default();
self.consume_logout_response(
sp,
ConsumeLogoutResponse {
peer_crypto_policy,
body: &response_xml,
binding: Binding::Soap,
detached_signature: None,
tracker: &tracker,
expected_destination: &expected_destination,
now: SystemTime::now(),
clock_skew: Duration::ZERO,
},
)
}
fn maybe_sign_outbound(&self, element: Element, should_sign: bool) -> Result<Element, Error> {
if !should_sign {
return Ok(element);
}
let stash = Document::new(element)?;
crate::dsig::sign::sign_element(
stash.root().clone(),
&stash,
crate::dsig::sign::SignOptions {
signing_key: &self.config.signing_key,
sig_alg: self.config.outbound_signature_algorithm,
digest_alg: self.config.outbound_digest_algorithm,
c14n_alg: self.config.outbound_c14n,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
}
}
#[cfg(feature = "slo")]
pub struct ConsumeLogoutRequestWire<'a> {
pub sp: &'a SpDescriptor,
pub peer_crypto_policy: Option<&'a PeerCryptoPolicy>,
pub wire_body: &'a [u8],
pub binding: Binding,
pub expected_destination: &'a str,
pub now: SystemTime,
pub clock_skew: Duration,
}
#[cfg(feature = "slo")]
pub struct ConsumeLogoutResponseWire<'a> {
pub sp: &'a SpDescriptor,
pub peer_crypto_policy: Option<&'a PeerCryptoPolicy>,
pub wire_body: &'a [u8],
pub binding: Binding,
pub tracker: &'a LogoutTracker,
pub expected_destination: &'a str,
pub now: SystemTime,
pub clock_skew: Duration,
}
#[cfg(feature = "slo")]
fn verify_logout_signature(
required: bool,
binding: Binding,
doc: &Document,
root_id: crate::xml::parse::ElementId,
detached: Option<&DetachedSignature<'_>>,
candidate_certs: &[crate::crypto::cert::X509Certificate],
allowed_algorithms: &[SignatureAlgorithm],
) -> Result<(), Error> {
match binding {
Binding::HttpRedirect => verify_redirect_request_signature(
required,
detached,
candidate_certs,
allowed_algorithms,
),
Binding::HttpPost | Binding::Soap => {
let root = doc.element(root_id).ok_or(Error::SignatureVerification {
reason: "could not locate root element for signature check",
})?;
verify_envelope_signature(
required,
doc,
root,
root_id,
candidate_certs,
allowed_algorithms,
)
}
Binding::HttpArtifact => Err(Error::UnsupportedByPeer {
binding: Binding::HttpArtifact,
}),
}
}
#[cfg(feature = "slo")]
fn wrap_soap_envelope(saml_xml: &str) -> Result<String, Error> {
crate::binding::soap::wrap(saml_xml)
}
#[cfg(feature = "slo")]
fn unwrap_soap_envelope(envelope_bytes: &[u8]) -> Result<Vec<u8>, Error> {
crate::binding::soap::unwrap(envelope_bytes)?.payload_xml()
}
#[cfg(feature = "slo")]
fn encode_logout_dispatch_response(
binding: Binding,
destination_url: &str,
xml: &[u8],
relay_state: Option<&str>,
) -> Result<Dispatch, Error> {
let dest = parse_url(destination_url)?;
match binding {
Binding::HttpPost => Ok(crate::binding::post::encode_response(
&dest,
xml,
relay_state,
)),
Binding::HttpRedirect => crate::binding::redirect::encode_unsigned(
&dest,
crate::binding::redirect::RedirectDirection::Response,
xml,
relay_state,
),
Binding::Soap => {
let xml_str = std::str::from_utf8(xml)
.map_err(|_err| Error::XmlEmit("non-UTF-8 outbound XML".to_string()))?;
let envelope = wrap_soap_envelope(xml_str)?;
Ok(Dispatch::Post(crate::binding::PostForm {
action: dest,
saml_request: None,
saml_response: Some(BASE64.encode(envelope.as_bytes())),
relay_state: relay_state.map(str::to_owned),
}))
}
Binding::HttpArtifact => Err(Error::UnsupportedByPeer {
binding: Binding::HttpArtifact,
}),
}
}
#[cfg(feature = "slo")]
fn encode_logout_redirect_request(
destination_url: &str,
xml: &[u8],
relay_state: Option<&str>,
signer: Option<&IdentityProvider>,
) -> Result<Dispatch, Error> {
let dest = parse_url(destination_url)?;
match signer {
None => crate::binding::redirect::encode_unsigned(
&dest,
crate::binding::redirect::RedirectDirection::Request,
xml,
relay_state,
),
Some(idp) => {
let sig_alg = idp.config.outbound_signature_algorithm;
let sig_alg_uri = sig_alg.uri().to_owned();
let signing_key = &idp.config.signing_key;
crate::binding::redirect::encode_signed(
&dest,
crate::binding::redirect::RedirectDirection::Request,
xml,
relay_state,
&sig_alg_uri,
|to_sign| crate::dsig::sign::sign_detached_query(to_sign, signing_key, sig_alg),
)
}
}
}
#[cfg(feature = "slo")]
fn parse_url(url: &str) -> Result<url::Url, Error> {
url::Url::parse(url).map_err(|_err| Error::InvalidConfiguration {
reason: "endpoint URL is not a valid URL",
})
}
#[cfg(feature = "slo")]
fn serialize_element(element: Element) -> Result<Vec<u8>, Error> {
let doc = Document::new(element)?;
Ok(emit_document(&doc)?.into_bytes())
}
impl IdentityProvider {
pub fn metadata_xml(&self, sign: bool) -> Result<String, Error> {
self.metadata_xml_with_extras(sign, &MetadataExtras::default())
}
pub fn metadata_xml_with_extras(
&self,
sign: bool,
extras: &MetadataExtras,
) -> Result<String, Error> {
let signing_cert =
self.config
.signing_key
.certificate()
.ok_or(Error::InvalidConfiguration {
reason: "signing_key must carry a certificate for metadata emission",
})?;
#[cfg(feature = "xmlenc")]
let encryption_cert = self
.config
.decryption_key
.as_ref()
.and_then(|k| k.certificate());
#[cfg(feature = "xmlenc")]
let encryption_algorithms = [self.config.outbound_data_encryption_algorithm];
let inputs = IdpMetadataInputs {
entity_id: &self.config.entity_id,
sso: &self.config.sso,
slo: &self.config.slo,
artifact_resolution: &self.config.artifact_resolution,
name_id_formats: &self.config.supported_name_id_formats,
signing_cert,
#[cfg(feature = "xmlenc")]
encryption_cert,
#[cfg(feature = "xmlenc")]
encryption_algorithms: &encryption_algorithms,
want_authn_requests_signed: self.config.want_authn_requests_signed,
valid_until: None,
cache_duration: None,
extras: Some(extras),
};
let signer = if sign {
Some((
&self.config.signing_key,
self.config.outbound_signature_algorithm,
self.config.outbound_digest_algorithm,
self.config.outbound_c14n,
))
} else {
None
};
emit_idp_metadata(&inputs, signer)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attribute::Attribute;
use crate::authn::request_build::{AcsRequest, BuildAuthnRequest, build_authn_request_element};
use crate::authn_context::AuthnContextClassRef;
use crate::binding::{Binding, Endpoint, SsoResponseDispatch, SsoResponseEndpoint};
use crate::crypto::cert::X509Certificate;
use crate::crypto::cert::test_vectors::{RSA_CERT_PEM, RSA_KEY_PKCS8_PEM};
use crate::descriptor::IdpDescriptor;
use crate::dsig::algorithms::{
C14nAlgorithm, DigestAlgorithm, PeerCryptoPolicy, SignatureAlgorithm,
};
#[cfg(feature = "slo")]
use crate::logout::request_build::BuildLogoutRequest;
#[cfg(feature = "slo")]
use crate::logout::{LogoutOutcome, LogoutStatus, StartLogout};
use crate::nameid::{NameId, NameIdFormat};
use crate::response::issue::SamlStatusCode;
use crate::xml::emit::emit_document;
use crate::xml::parse::Node;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn rsa_keypair_with_cert() -> 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 rsa_cert() -> X509Certificate {
X509Certificate::from_pem(RSA_CERT_PEM).unwrap()
}
fn idp_with(want_authn_requests_signed: bool, sign_responses: bool) -> IdentityProvider {
IdentityProvider::new(IdentityProviderConfig {
entity_id: "https://idp.example.com/saml".into(),
sso: vec![
Endpoint::post("https://idp.example.com/sso", 0, true),
Endpoint::redirect("https://idp.example.com/sso", 1, false),
],
slo: vec![Endpoint::post("https://idp.example.com/slo", 0, true)],
artifact_resolution: vec![],
supported_name_id_formats: vec![NameIdFormat::Persistent, NameIdFormat::EmailAddress],
default_name_id_format: NameIdFormat::Persistent,
signing_key: rsa_keypair_with_cert(),
decryption_key: None,
want_authn_requests_signed,
assertion_signing: IdpAssertionSigning {
sign_responses,
sign_assertions: true,
},
encrypt_assertions_when_possible: false,
#[cfg(feature = "slo")]
logout_signing: IdpLogoutSigning::default(),
#[cfg(feature = "slo")]
logout_want_signed: IdpLogoutWantSigned::default(),
default_session_duration: Duration::from_hours(1),
default_peer_crypto_policy: PeerCryptoPolicy::strong_defaults(),
outbound_signature_algorithm: SignatureAlgorithm::RsaSha256,
outbound_digest_algorithm: DigestAlgorithm::Sha256,
outbound_c14n: C14nAlgorithm::ExclusiveCanonical,
#[cfg(feature = "xmlenc")]
outbound_data_encryption_algorithm:
crate::xmlenc::algorithms::DataEncryptionAlgorithm::Aes256Gcm,
#[cfg(feature = "xmlenc")]
outbound_key_transport_algorithm:
crate::xmlenc::algorithms::KeyTransportAlgorithm::RsaOaep,
})
.expect("idp config valid")
}
fn sp_descriptor(authn_requests_signed: bool) -> SpDescriptor {
SpDescriptor {
entity_id: "https://sp.example.com/saml".into(),
assertion_consumer_services: vec![SsoResponseEndpoint::post(
"https://sp.example.com/acs",
0,
true,
)],
single_logout_services: vec![Endpoint::post("https://sp.example.com/slo", 0, true)],
signing_certs: vec![rsa_cert()],
encryption_certs: vec![],
supported_name_id_formats: vec![],
want_assertions_signed: false,
authn_requests_signed,
valid_until: None,
cache_duration: None,
#[cfg(feature = "idp-disco")]
discovery_response_endpoints: vec![],
}
}
fn fixed_now() -> SystemTime {
UNIX_EPOCH
.checked_add(Duration::from_hours(494_388))
.expect("static UNIX_EPOCH + bounded Duration cannot overflow")
}
fn build_unsigned_authn_request(id: &str, with_destination: bool) -> Vec<u8> {
let build = BuildAuthnRequest {
id,
issue_instant: fixed_now(),
issuer_entity_id: "https://sp.example.com/saml",
destination: if with_destination {
"https://idp.example.com/sso"
} else {
""
},
force_authn: false,
is_passive: false,
acs_selection: AcsRequest::Index(0),
protocol_binding: None,
requested_name_id_format: Some(NameIdFormat::Persistent),
requested_authn_context: None,
};
let element = build_authn_request_element(&build).unwrap();
let doc = Document::new(element).unwrap();
emit_document(&doc).unwrap().into_bytes()
}
fn build_signed_authn_request(id: &str) -> Vec<u8> {
let build = BuildAuthnRequest {
id,
issue_instant: fixed_now(),
issuer_entity_id: "https://sp.example.com/saml",
destination: "https://idp.example.com/sso",
force_authn: false,
is_passive: false,
acs_selection: AcsRequest::Index(0),
protocol_binding: None,
requested_name_id_format: Some(NameIdFormat::Persistent),
requested_authn_context: None,
};
let element = build_authn_request_element(&build).unwrap();
let stash = Document::new(element).unwrap();
let kp = rsa_keypair_with_cert();
let signed = crate::dsig::sign::sign_element(
stash.root().clone(),
&stash,
crate::dsig::sign::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 final_doc = Document::new(signed).unwrap();
emit_document(&final_doc).unwrap().into_bytes()
}
#[test]
fn new_rejects_empty_entity_id() {
let mut cfg = idp_with(false, false).config.clone();
cfg.entity_id = String::new();
let err = IdentityProvider::new(cfg).unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn new_rejects_whitespace_entity_id() {
let mut cfg = idp_with(false, false).config.clone();
cfg.entity_id = "not a uri".into();
let err = IdentityProvider::new(cfg).unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn new_accepts_bare_xs_anyuri_entity_id() {
let mut cfg = idp_with(false, false).config.clone();
cfg.entity_id = "example.com".into();
IdentityProvider::new(cfg).expect("bare anyURI accepted");
}
#[test]
fn new_rejects_empty_sso() {
let mut cfg = idp_with(false, false).config.clone();
cfg.sso = vec![];
let err = IdentityProvider::new(cfg).unwrap_err();
assert!(matches!(err, Error::InvalidConfiguration { .. }));
}
#[test]
fn new_accessors_return_config_fields() {
let idp = idp_with(true, false);
assert_eq!(idp.entity_id(), "https://idp.example.com/saml");
assert!(idp.config().want_authn_requests_signed);
}
#[test]
fn consume_signed_post_request_succeeds_and_resolves_acs() {
let idp = idp_with(true, false);
let sp = sp_descriptor(true);
let xml = build_signed_authn_request("_req-1");
let parsed = idp
.consume_authn_request(ConsumeAuthnRequest {
sp: &sp,
peer_crypto_policy: None,
saml_request: &xml,
binding: Binding::HttpPost,
relay_state: Some("opaque-state"),
detached_signature: None,
expected_destination: "https://idp.example.com/sso",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.expect("consume ok");
assert_eq!(parsed.id, "_req-1");
assert_eq!(parsed.issuer, "https://sp.example.com/saml");
assert_eq!(
parsed.assertion_consumer_service.url,
"https://sp.example.com/acs"
);
assert_eq!(parsed.relay_state.as_deref(), Some("opaque-state"));
}
#[test]
fn consume_unsigned_post_request_rejected_when_required() {
let idp = idp_with(true, false);
let sp = sp_descriptor(false); let xml = build_unsigned_authn_request("_req-2", true);
let err = idp
.consume_authn_request(ConsumeAuthnRequest {
sp: &sp,
peer_crypto_policy: None,
saml_request: &xml,
binding: Binding::HttpPost,
relay_state: None,
detached_signature: None,
expected_destination: "https://idp.example.com/sso",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.unwrap_err();
assert!(matches!(err, Error::SignatureMissing));
}
#[test]
fn consume_unsigned_post_request_accepted_when_not_required() {
let idp = idp_with(false, false);
let sp = sp_descriptor(false);
let xml = build_unsigned_authn_request("_req-3", true);
let parsed = idp
.consume_authn_request(ConsumeAuthnRequest {
sp: &sp,
peer_crypto_policy: None,
saml_request: &xml,
binding: Binding::HttpPost,
relay_state: None,
detached_signature: None,
expected_destination: "https://idp.example.com/sso",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.expect("consume ok");
assert_eq!(parsed.id, "_req-3");
}
#[test]
fn consume_redirect_request_missing_detached_sig_rejected() {
let idp = idp_with(true, false);
let sp = sp_descriptor(false);
let xml = build_unsigned_authn_request("_req-4", true);
let err = idp
.consume_authn_request(ConsumeAuthnRequest {
sp: &sp,
peer_crypto_policy: None,
saml_request: &xml,
binding: Binding::HttpRedirect,
relay_state: None,
detached_signature: None,
expected_destination: "https://idp.example.com/sso",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.unwrap_err();
assert!(matches!(err, Error::SignatureMissing));
}
#[test]
fn consume_destination_mismatch_rejected() {
let idp = idp_with(false, false);
let sp = sp_descriptor(false);
let xml = build_unsigned_authn_request("_req-5", true);
let err = idp
.consume_authn_request(ConsumeAuthnRequest {
sp: &sp,
peer_crypto_policy: None,
saml_request: &xml,
binding: Binding::HttpPost,
relay_state: None,
detached_signature: None,
expected_destination: "https://idp.example.com/sso-other",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.unwrap_err();
match err {
Error::InvalidConfiguration { .. } | Error::DestinationMismatch => {}
other => panic!("expected destination-binding error, got {other:?}"),
}
}
fn parsed_authn_request_fixture() -> ParsedAuthnRequest {
use crate::authn::request_parse::parse_authn_request;
let xml = build_unsigned_authn_request("_req-issue", true);
let doc = Document::parse(&xml).unwrap();
let (raw, _root) = parse_authn_request(&doc).unwrap();
let sp = sp_descriptor(false);
let sso_urls = vec!["https://idp.example.com/sso".to_string()];
let mut parsed = validate_authn_request(raw, &sp, "https://idp.example.com/sso", &sso_urls)
.expect("validate");
parsed.relay_state = Some("rs-token".into());
parsed
}
#[test]
fn issue_response_round_trips_via_parse_response() {
use crate::response::parse::parse_response;
let idp = idp_with(false, false);
let sp = sp_descriptor(false);
let parsed_req = parsed_authn_request_fixture();
let dispatch = idp
.issue_response(IssueResponse {
sp: &sp,
in_response_to: &parsed_req,
name_id: NameId::email("alice@example.com"),
attributes: vec![Attribute::email("alice@example.com")],
authn_instant: fixed_now(),
session_index: "sess-1".into(),
session_not_on_or_after: Some(fixed_now() + Duration::from_hours(1)),
authn_context_class_ref: AuthnContextClassRef::PasswordProtectedTransport,
force_encrypt_assertion: Some(false),
now: fixed_now(),
assertion_lifetime: Duration::from_mins(10),
subject_confirmation_lifetime: Duration::from_mins(5),
holder_of_key_cert: None,
})
.expect("issue ok");
let form = match dispatch {
SsoResponseDispatch::Post(f) => f,
other @ SsoResponseDispatch::Artifact(_) => {
panic!("expected POST dispatch, got {other:?}")
}
};
assert_eq!(form.action.as_str(), "https://sp.example.com/acs");
assert_eq!(form.relay_state.as_deref(), Some("rs-token"));
let decoded = crate::binding::post::decode(&form.saml_response, None).unwrap();
let doc = Document::parse(&decoded.xml).unwrap();
let (parsed_resp, _) = parse_response(&doc).expect("parse");
let assertion = parsed_resp.assertion.expect("assertion");
let crate::response::parse::AssertionWrapper::Cleartext(assertion_id) = assertion else {
panic!("expected cleartext assertion")
};
let assertion_elem = doc.element(assertion_id).unwrap();
let parsed_assertion =
crate::response::parse::parse_assertion(assertion_elem).expect("parse assertion");
assert_eq!(parsed_assertion.subject_name_id.value, "alice@example.com");
assert_eq!(parsed_assertion.attributes.len(), 1);
}
#[test]
fn issue_error_response_carries_status_code_chain() {
let idp = idp_with(false, false);
let sp = sp_descriptor(false);
let parsed_req = parsed_authn_request_fixture();
let dispatch = idp
.issue_error_response(IssueErrorResponse {
sp: &sp,
in_response_to: &parsed_req,
status_code: SamlStatusCode::AuthnFailed,
second_level_status_code: Some(SamlStatusCode::InvalidNameIdPolicy),
message: Some("policy denied".into()),
now: fixed_now(),
})
.expect("issue error");
let form = match dispatch {
SsoResponseDispatch::Post(f) => f,
other @ SsoResponseDispatch::Artifact(_) => {
panic!("expected POST dispatch, got {other:?}")
}
};
let decoded = crate::binding::post::decode(&form.saml_response, None).unwrap();
let doc = Document::parse(&decoded.xml).unwrap();
let response = doc.root();
let status = response
.child_element(Some(SAMLP_NS), "Status")
.expect("status");
let code = status
.child_element(Some(SAMLP_NS), "StatusCode")
.expect("status code");
assert_eq!(
code.attribute(None, "Value"),
Some(SamlStatusCode::AuthnFailed.uri())
);
let nested = code
.child_element(Some(SAMLP_NS), "StatusCode")
.expect("nested status code");
assert_eq!(
nested.attribute(None, "Value"),
Some(SamlStatusCode::InvalidNameIdPolicy.uri())
);
}
#[cfg(feature = "slo")]
fn build_signed_logout_request(id: &str) -> Vec<u8> {
let nid = NameId::email("alice@example.com");
let build = BuildLogoutRequest {
id,
issue_instant: fixed_now(),
issuer_entity_id: "https://sp.example.com/saml",
destination: Some("https://idp.example.com/slo"),
not_on_or_after: None,
reason: None,
name_id: &nid,
session_index: Some("sess-1"),
};
let element = crate::logout::request_build::build_logout_request_element(&build).unwrap();
let stash = Document::new(element).unwrap();
let kp = rsa_keypair_with_cert();
let signed = crate::dsig::sign::sign_element(
stash.root().clone(),
&stash,
crate::dsig::sign::SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap();
let final_doc = Document::new(signed).unwrap();
emit_document(&final_doc).unwrap().into_bytes()
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_request_signed_post_succeeds() {
let mut idp = idp_with(false, false);
idp.config.logout_want_signed.requests = true;
let sp = sp_descriptor(false);
let xml = build_signed_logout_request("_lo-req-1");
let parsed = idp
.consume_logout_request(
&sp,
ConsumeLogoutRequest {
peer_crypto_policy: None,
body: &xml,
binding: Binding::HttpPost,
detached_signature: None,
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
},
)
.expect("consume ok");
assert_eq!(parsed.id, "_lo-req-1");
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_unsigned_rejected_when_required() {
let mut idp = idp_with(false, false);
idp.config.logout_want_signed.requests = true;
let sp = sp_descriptor(false);
let nid = NameId::email("alice@example.com");
let xml = crate::logout::request_build::build_logout_request_xml(&BuildLogoutRequest {
id: "_lo-req-2",
issue_instant: fixed_now(),
issuer_entity_id: "https://sp.example.com/saml",
destination: Some("https://idp.example.com/slo"),
not_on_or_after: None,
reason: None,
name_id: &nid,
session_index: None,
})
.unwrap();
let err = idp
.consume_logout_request(
&sp,
ConsumeLogoutRequest {
peer_crypto_policy: None,
body: &xml,
binding: Binding::HttpPost,
detached_signature: None,
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
},
)
.unwrap_err();
assert!(matches!(err, Error::SignatureMissing));
}
#[cfg(feature = "slo")]
fn build_signed_redirect_logout_request(id: &str) -> (Vec<u8>, String, Vec<u8>, String) {
use crate::binding::redirect::{
RedirectDirection, decode as redirect_decode, encode_signed,
};
let nid = NameId::email("alice@example.com");
let xml = crate::logout::request_build::build_logout_request_xml(&BuildLogoutRequest {
id,
issue_instant: fixed_now(),
issuer_entity_id: "https://sp.example.com/saml",
destination: Some("https://idp.example.com/slo"),
not_on_or_after: None,
reason: None,
name_id: &nid,
session_index: Some("sess-1"),
})
.unwrap();
let kp = rsa_keypair_with_cert();
let sig_alg = SignatureAlgorithm::RsaSha256;
let dest = url::Url::parse("https://idp.example.com/slo").unwrap();
let dispatch = encode_signed(
&dest,
RedirectDirection::Request,
&xml,
None,
sig_alg.uri(),
|to_sign| crate::dsig::sign::sign_detached_query(to_sign, &kp, sig_alg),
)
.unwrap();
let url = match dispatch {
Dispatch::Redirect(u) => u,
other @ Dispatch::Post(_) => panic!("expected Redirect dispatch, got {other:?}"),
};
let raw_query = url.query().unwrap().to_owned();
let decoded = redirect_decode(&raw_query, RedirectDirection::Request).unwrap();
let mut signature_raw = String::new();
let mut sig_alg_raw = String::new();
for pair in raw_query.split('&') {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
match k {
"Signature" => signature_raw = v.to_owned(),
"SigAlg" => sig_alg_raw = v.to_owned(),
_ => {}
}
}
let signed_query_string = decoded
.signed_query_string
.expect("decoder returned canonical signed query string");
let signature_b64 = percent_encoding::percent_decode_str(&signature_raw)
.decode_utf8()
.unwrap()
.into_owned();
let signature_bytes = BASE64.decode(signature_b64.as_bytes()).unwrap();
let sig_alg_decoded = percent_encoding::percent_decode_str(&sig_alg_raw)
.decode_utf8()
.unwrap()
.into_owned();
(
decoded.xml,
signed_query_string,
signature_bytes,
sig_alg_decoded,
)
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_request_signed_redirect_succeeds() {
let mut idp = idp_with(false, false);
idp.config.logout_want_signed.requests = true;
let sp = sp_descriptor(false);
let (xml, signed_qs, signature, sig_alg) =
build_signed_redirect_logout_request("_lo-redir-1");
let parsed = idp
.consume_logout_request(
&sp,
ConsumeLogoutRequest {
peer_crypto_policy: None,
body: &xml,
binding: Binding::HttpRedirect,
detached_signature: Some(DetachedSignature {
signature: &signature,
sig_alg: &sig_alg,
raw_query_string: &signed_qs,
}),
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
},
)
.expect("signed redirect logout request must verify");
assert_eq!(parsed.id, "_lo-redir-1");
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_redirect_without_detached_payload_rejected() {
let mut idp = idp_with(false, false);
idp.config.logout_want_signed.requests = true;
let sp = sp_descriptor(false);
let (xml, _signed_qs, _signature, _sig_alg) =
build_signed_redirect_logout_request("_lo-redir-2");
let err = idp
.consume_logout_request(
&sp,
ConsumeLogoutRequest {
peer_crypto_policy: None,
body: &xml,
binding: Binding::HttpRedirect,
detached_signature: None,
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
},
)
.unwrap_err();
assert!(matches!(err, Error::SignatureMissing));
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_request_redirect_tampered_signature_rejected() {
let mut idp = idp_with(false, false);
idp.config.logout_want_signed.requests = true;
let sp = sp_descriptor(false);
let (xml, signed_qs, signature, sig_alg) =
build_signed_redirect_logout_request("_lo-redir-3");
let tampered_qs = format!("{signed_qs}&Tamper=1");
let err = idp
.consume_logout_request(
&sp,
ConsumeLogoutRequest {
peer_crypto_policy: None,
body: &xml,
binding: Binding::HttpRedirect,
detached_signature: Some(DetachedSignature {
signature: &signature,
sig_alg: &sig_alg,
raw_query_string: &tampered_qs,
}),
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
},
)
.unwrap_err();
assert!(matches!(err, Error::SignatureVerification { .. }));
}
#[cfg(feature = "slo")]
#[test]
fn build_logout_response_xml_round_trips_via_parse() {
use crate::logout::response_parse::parse_logout_response;
let idp = idp_with(false, false);
let sp = sp_descriptor(false);
let nid = NameId::email("alice@example.com");
let xml = crate::logout::request_build::build_logout_request_xml(&BuildLogoutRequest {
id: "_lo-req-3",
issue_instant: fixed_now(),
issuer_entity_id: "https://sp.example.com/saml",
destination: Some("https://idp.example.com/slo"),
not_on_or_after: None,
reason: None,
name_id: &nid,
session_index: None,
})
.unwrap();
let doc = Document::parse(&xml).unwrap();
let (parsed_req, _) = crate::logout::request_parse::parse_logout_request(&doc).unwrap();
let dispatch = idp
.build_logout_response(
&sp,
&parsed_req,
LogoutStatus::Success,
Some("rs"),
Binding::HttpPost,
)
.expect("build ok");
let form = match dispatch {
Dispatch::Post(f) => f,
other @ Dispatch::Redirect(_) => panic!("expected POST dispatch, got {other:?}"),
};
let saml_response = form.saml_response.expect("saml_response in POST form");
let decoded = crate::binding::post::decode(&saml_response, Some("rs")).unwrap();
let resp_doc = Document::parse(&decoded.xml).unwrap();
let (parsed_resp, _) = parse_logout_response(&resp_doc).unwrap();
assert_eq!(parsed_resp.in_response_to, "_lo-req-3");
assert_eq!(
parsed_resp.status_code,
"urn:oasis:names:tc:SAML:2.0:status:Success"
);
}
#[cfg(feature = "slo")]
#[test]
fn start_logout_produces_tracker_and_dispatch() {
let idp = idp_with(false, false);
let sp = sp_descriptor(false);
let nid = NameId::email("alice@example.com");
let dispatch = idp
.start_logout(
&sp,
StartLogout {
name_id: &nid,
session_index: Some("sess-1"),
relay_state: Some("rs"),
reason: None,
binding: Binding::HttpPost,
},
)
.expect("start ok");
assert_eq!(
dispatch.tracker.peer_entity_id,
"https://sp.example.com/saml"
);
assert!(matches!(dispatch.dispatch, Dispatch::Post(_)));
}
#[test]
fn metadata_xml_round_trips_via_idp_descriptor() {
let idp = idp_with(true, false);
let xml = idp.metadata_xml(false).expect("emit metadata");
let parsed = IdpDescriptor::from_metadata_xml(xml.as_bytes()).expect("parse metadata");
assert_eq!(parsed.entity_id, "https://idp.example.com/saml");
assert!(parsed.want_authn_requests_signed);
assert_eq!(parsed.sso_endpoints.len(), 2);
assert_eq!(parsed.slo_endpoints.len(), 1);
assert_eq!(parsed.signing_certs.len(), 1);
assert_eq!(
parsed.supported_name_id_formats,
vec![NameIdFormat::Persistent, NameIdFormat::EmailAddress]
);
}
#[test]
fn metadata_xml_signed_carries_signature_child() {
let idp = idp_with(true, false);
let xml = idp.metadata_xml(true).expect("emit signed metadata");
let doc = Document::parse(xml.as_bytes()).unwrap();
let root = doc.root();
let first_elem = root
.children()
.find_map(|n| match n {
Node::Element(e) => Some(e),
_ => None,
})
.expect("at least one child element");
assert_eq!(first_elem.qname().namespace(), Some(DS_NS));
assert_eq!(first_elem.qname().local(), "Signature");
}
#[test]
fn pick_name_id_format_honors_supported_request() {
let supported = vec![NameIdFormat::Persistent, NameIdFormat::EmailAddress];
let default = NameIdFormat::Persistent;
assert_eq!(
pick_name_id_format(Some(&NameIdFormat::EmailAddress), &supported, &default),
NameIdFormat::EmailAddress
);
assert_eq!(
pick_name_id_format(Some(&NameIdFormat::Transient), &supported, &default),
NameIdFormat::Persistent
);
assert_eq!(
pick_name_id_format(None, &supported, &default),
NameIdFormat::Persistent
);
}
fn build_unsigned_redirect_authn_request_raw_query(id: &str) -> String {
use crate::binding::redirect::{RedirectDirection, encode_unsigned};
let xml = build_unsigned_authn_request(id, true);
let dest = url::Url::parse("https://idp.example.com/sso").unwrap();
let dispatch = encode_unsigned(
&dest,
RedirectDirection::Request,
&xml,
Some("rs-wire-authn"),
)
.unwrap();
let url = match dispatch {
Dispatch::Redirect(u) => u,
other @ Dispatch::Post(_) => panic!("expected Redirect dispatch, got {other:?}"),
};
url.query().unwrap().to_owned()
}
fn build_signed_redirect_authn_request_raw_query(id: &str) -> String {
use crate::binding::redirect::{RedirectDirection, encode_signed};
let xml = build_unsigned_authn_request(id, true);
let kp = rsa_keypair_with_cert();
let sig_alg = SignatureAlgorithm::RsaSha256;
let dest = url::Url::parse("https://idp.example.com/sso").unwrap();
let dispatch = encode_signed(
&dest,
RedirectDirection::Request,
&xml,
Some("rs-wire-authn"),
sig_alg.uri(),
|to_sign| crate::dsig::sign::sign_detached_query(to_sign, &kp, sig_alg),
)
.unwrap();
let url = match dispatch {
Dispatch::Redirect(u) => u,
other @ Dispatch::Post(_) => panic!("expected Redirect dispatch, got {other:?}"),
};
url.query().unwrap().to_owned()
}
#[test]
fn consume_authn_request_wire_matches_two_step_for_signed_redirect() {
let idp = idp_with(true, false);
let sp = sp_descriptor(false);
let raw_query = build_signed_redirect_authn_request_raw_query("_wire-authn-1");
let decoded = crate::binding::decode_wire(
raw_query.as_bytes(),
Binding::HttpRedirect,
crate::binding::WireDirection::Request,
)
.expect("decode_wire");
let two_step = idp
.consume_authn_request(ConsumeAuthnRequest {
sp: &sp,
peer_crypto_policy: None,
saml_request: &decoded.xml,
binding: Binding::HttpRedirect,
relay_state: decoded.relay_state.as_deref(),
detached_signature: decoded.as_detached_signature(),
expected_destination: "https://idp.example.com/sso",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.expect("two-step consume must succeed");
let one_call = idp
.consume_authn_request_wire(ConsumeAuthnRequestWire {
sp: &sp,
peer_crypto_policy: None,
wire_body: raw_query.as_bytes(),
binding: Binding::HttpRedirect,
relay_state: None,
expected_destination: "https://idp.example.com/sso",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.expect("wire helper must succeed");
assert_eq!(one_call.id, two_step.id);
assert_eq!(one_call.issuer, two_step.issuer);
assert_eq!(one_call.relay_state, two_step.relay_state);
assert_eq!(one_call.relay_state.as_deref(), Some("rs-wire-authn"));
assert_eq!(
one_call.assertion_consumer_service.url,
two_step.assertion_consumer_service.url
);
}
#[test]
fn consume_authn_request_wire_unsigned_redirect_when_not_required() {
let idp = idp_with(false, false);
let sp = sp_descriptor(false);
let raw_query = build_unsigned_redirect_authn_request_raw_query("_wire-authn-unsigned");
let parsed = idp
.consume_authn_request_wire(ConsumeAuthnRequestWire {
sp: &sp,
peer_crypto_policy: None,
wire_body: raw_query.as_bytes(),
binding: Binding::HttpRedirect,
relay_state: None,
expected_destination: "https://idp.example.com/sso",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.expect("unsigned wire consume must succeed");
assert_eq!(parsed.id, "_wire-authn-unsigned");
assert_eq!(parsed.relay_state.as_deref(), Some("rs-wire-authn"));
}
fn tamper_redirect_signature_param(raw_query: &str) -> String {
let mut pieces: Vec<String> = Vec::new();
for pair in raw_query.split('&') {
if pair.starts_with("Signature=") {
let bogus = "A".repeat(256);
pieces.push(format!("Signature={bogus}"));
} else {
pieces.push(pair.to_owned());
}
}
pieces.join("&")
}
#[test]
fn consume_authn_request_wire_signed_redirect_rejects_tampered_signature() {
let idp = idp_with(true, false);
let sp = sp_descriptor(false);
let raw_query = build_signed_redirect_authn_request_raw_query("_wire-authn-tamper");
let tampered = tamper_redirect_signature_param(&raw_query);
let err = idp
.consume_authn_request_wire(ConsumeAuthnRequestWire {
sp: &sp,
peer_crypto_policy: None,
wire_body: tampered.as_bytes(),
binding: Binding::HttpRedirect,
relay_state: None,
expected_destination: "https://idp.example.com/sso",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.unwrap_err();
assert!(matches!(err, Error::SignatureVerification { .. }));
}
#[cfg(feature = "slo")]
fn build_signed_redirect_logout_request_raw_query(id: &str) -> String {
use crate::binding::redirect::{RedirectDirection, encode_signed};
let nid = NameId::email("alice@example.com");
let xml = crate::logout::request_build::build_logout_request_xml(&BuildLogoutRequest {
id,
issue_instant: fixed_now(),
issuer_entity_id: "https://sp.example.com/saml",
destination: Some("https://idp.example.com/slo"),
not_on_or_after: None,
reason: None,
name_id: &nid,
session_index: Some("sess-1"),
})
.unwrap();
let kp = rsa_keypair_with_cert();
let sig_alg = SignatureAlgorithm::RsaSha256;
let dest = url::Url::parse("https://idp.example.com/slo").unwrap();
let dispatch = encode_signed(
&dest,
RedirectDirection::Request,
&xml,
None,
sig_alg.uri(),
|to_sign| crate::dsig::sign::sign_detached_query(to_sign, &kp, sig_alg),
)
.unwrap();
let url = match dispatch {
Dispatch::Redirect(u) => u,
other @ Dispatch::Post(_) => panic!("expected Redirect dispatch, got {other:?}"),
};
url.query().unwrap().to_owned()
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_request_wire_matches_two_step_for_signed_redirect() {
let mut idp = idp_with(false, false);
idp.config.logout_want_signed.requests = true;
let sp = sp_descriptor(false);
let raw_query = build_signed_redirect_logout_request_raw_query("_wire-lo-req-1");
let (xml, signed_qs, signature, sig_alg) =
build_signed_redirect_logout_request("_wire-lo-req-1");
let two_step = idp
.consume_logout_request(
&sp,
ConsumeLogoutRequest {
peer_crypto_policy: None,
body: &xml,
binding: Binding::HttpRedirect,
detached_signature: Some(DetachedSignature {
signature: &signature,
sig_alg: &sig_alg,
raw_query_string: &signed_qs,
}),
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
},
)
.expect("two-step consume must succeed");
let one_call = idp
.consume_logout_request_wire(ConsumeLogoutRequestWire {
sp: &sp,
peer_crypto_policy: None,
wire_body: raw_query.as_bytes(),
binding: Binding::HttpRedirect,
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.expect("wire helper must succeed");
assert_eq!(one_call.id, two_step.id);
assert_eq!(one_call.issuer, two_step.issuer);
assert_eq!(one_call.name_id.value, two_step.name_id.value);
assert_eq!(one_call.session_index, two_step.session_index);
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_request_wire_rejects_tampered_signed_redirect() {
let mut idp = idp_with(false, false);
idp.config.logout_want_signed.requests = true;
let sp = sp_descriptor(false);
let raw_query = build_signed_redirect_logout_request_raw_query("_wire-lo-req-2");
let tampered = tamper_redirect_signature_param(&raw_query);
let err = idp
.consume_logout_request_wire(ConsumeLogoutRequestWire {
sp: &sp,
peer_crypto_policy: None,
wire_body: tampered.as_bytes(),
binding: Binding::HttpRedirect,
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.unwrap_err();
assert!(matches!(err, Error::SignatureVerification { .. }));
}
#[cfg(feature = "slo")]
fn build_signed_redirect_logout_response_raw_query(id: &str, in_response_to: &str) -> String {
use crate::binding::redirect::{RedirectDirection, encode_signed};
let xml = crate::logout::response_build::build_logout_response_xml(&BuildLogoutResponse {
id,
issue_instant: fixed_now(),
issuer_entity_id: "https://sp.example.com/saml",
destination: Some("https://idp.example.com/slo"),
in_response_to,
status: LogoutStatus::Success,
status_message: None,
})
.unwrap();
let kp = rsa_keypair_with_cert();
let sig_alg = SignatureAlgorithm::RsaSha256;
let dest = url::Url::parse("https://idp.example.com/slo").unwrap();
let dispatch = encode_signed(
&dest,
RedirectDirection::Response,
&xml,
None,
sig_alg.uri(),
|to_sign| crate::dsig::sign::sign_detached_query(to_sign, &kp, sig_alg),
)
.unwrap();
let url = match dispatch {
Dispatch::Redirect(u) => u,
other @ Dispatch::Post(_) => panic!("expected Redirect dispatch, got {other:?}"),
};
url.query().unwrap().to_owned()
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_response_wire_matches_two_step_for_signed_redirect() {
let mut idp = idp_with(false, false);
idp.config.logout_want_signed.responses = true;
let sp = sp_descriptor(false);
let in_response_to = "_wire-lo-resp-anchor";
let tracker = LogoutTracker {
request_id: in_response_to.to_owned(),
issued_at: fixed_now(),
peer_entity_id: sp.entity_id.clone(),
};
let raw_query =
build_signed_redirect_logout_response_raw_query("_wire-lo-resp-1", in_response_to);
let decoded = crate::binding::decode_wire(
raw_query.as_bytes(),
Binding::HttpRedirect,
crate::binding::WireDirection::Response,
)
.expect("decode_wire response");
let two_step = idp
.consume_logout_response(
&sp,
ConsumeLogoutResponse {
peer_crypto_policy: None,
body: &decoded.xml,
binding: Binding::HttpRedirect,
detached_signature: decoded.as_detached_signature(),
tracker: &tracker,
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
},
)
.expect("two-step consume_logout_response must succeed");
let one_call = idp
.consume_logout_response_wire(ConsumeLogoutResponseWire {
sp: &sp,
peer_crypto_policy: None,
wire_body: raw_query.as_bytes(),
binding: Binding::HttpRedirect,
tracker: &tracker,
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.expect("wire helper must succeed");
assert!(matches!(one_call, LogoutOutcome::Success));
assert!(matches!(two_step, LogoutOutcome::Success));
}
#[cfg(feature = "slo")]
#[test]
fn consume_logout_response_wire_rejects_tampered_signed_redirect() {
let mut idp = idp_with(false, false);
idp.config.logout_want_signed.responses = true;
let sp = sp_descriptor(false);
let in_response_to = "_wire-lo-resp-tamper-anchor";
let tracker = LogoutTracker {
request_id: in_response_to.to_owned(),
issued_at: fixed_now(),
peer_entity_id: sp.entity_id.clone(),
};
let raw_query =
build_signed_redirect_logout_response_raw_query("_wire-lo-resp-tamper", in_response_to);
let tampered = tamper_redirect_signature_param(&raw_query);
let err = idp
.consume_logout_response_wire(ConsumeLogoutResponseWire {
sp: &sp,
peer_crypto_policy: None,
wire_body: tampered.as_bytes(),
binding: Binding::HttpRedirect,
tracker: &tracker,
expected_destination: "https://idp.example.com/slo",
now: fixed_now(),
clock_skew: Duration::from_mins(1),
})
.unwrap_err();
assert!(matches!(err, Error::SignatureVerification { .. }));
}
#[cfg(feature = "slo")]
#[test]
fn soap_envelope_round_trip_extracts_payload() {
let saml = r#"<samlp:LogoutResponse xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_x" Version="2.0" IssueInstant="2026-05-26T12:34:56Z" InResponseTo="_y"><saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">idp</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status></samlp:LogoutResponse>"#;
let envelope = wrap_soap_envelope(saml).unwrap();
let unwrapped = unwrap_soap_envelope(envelope.as_bytes()).unwrap();
let doc = Document::parse(&unwrapped).unwrap();
assert_eq!(doc.root().qname().local(), "LogoutResponse");
}
}