#![cfg(feature = "ecp")]
use crate::authn::request_build::{AcsRequest, BuildAuthnRequest, build_authn_request_element};
use crate::binding::soap::{self, SOAP_NS};
use crate::error::Error;
use crate::xml::parse::{Document, Element, Node, QName};
const SAMLP_NS: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const SAML_NS: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
pub const PAOS_NS: &str = "urn:liberty:paos:2003-08";
pub const ECP_NS: &str = "urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp";
pub const PAOS_BINDING: &str = "urn:oasis:names:tc:SAML:2.0:bindings:PAOS";
const ECP_SERVICE: &str = ECP_NS;
const SOAP_ACTOR_NEXT: &str = "http://schemas.xmlsoap.org/soap/actor/next";
pub const PAOS_HEADER: &str =
"ver=\"urn:liberty:paos:2003-08\";\"urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp\"";
pub const PAOS_ACCEPT: &str = "text/html, application/vnd.paos+xml";
pub const PAOS_CONTENT_TYPE: &str = "application/vnd.paos+xml";
#[must_use]
pub fn paos_request_headers() -> Vec<(String, String)> {
vec![
("Accept".to_owned(), PAOS_ACCEPT.to_owned()),
("PAOS".to_owned(), PAOS_HEADER.to_owned()),
]
}
#[must_use]
pub fn paos_content_type_headers() -> Vec<(String, String)> {
vec![("Content-Type".to_owned(), PAOS_CONTENT_TYPE.to_owned())]
}
fn paos_qname(local: &str) -> QName {
QName::new(Some(PAOS_NS.to_owned()), local)
}
fn ecp_qname(local: &str) -> QName {
QName::new(Some(ECP_NS.to_owned()), local)
}
fn saml_qname(local: &str) -> QName {
QName::new(Some(SAML_NS.to_owned()), local)
}
fn soap_attr(local: &str) -> QName {
QName::new(Some(SOAP_NS.to_owned()), local)
}
pub struct SpEcpEndpoints<'a> {
pub acs_url: &'a str,
pub response_consumer_url: &'a str,
}
#[derive(Debug, Clone)]
pub struct SpAuthnRequest {
pub soap_envelope: String,
pub message_id: String,
}
pub struct BuildEcpAuthnRequest<'a> {
pub sp_entity_id: &'a str,
pub idp_sso_url: &'a str,
pub endpoints: SpEcpEndpoints<'a>,
pub is_passive: bool,
pub provider_name: Option<&'a str>,
pub issue_instant: std::time::SystemTime,
}
pub struct SpEcp;
impl SpEcp {
pub fn build_authn_request(input: &BuildEcpAuthnRequest<'_>) -> Result<SpAuthnRequest, Error> {
let message_id = super::random_xml_id()?;
let request_id = super::random_xml_id()?;
let authn = build_authn_request_element(&BuildAuthnRequest {
id: &request_id,
issue_instant: input.issue_instant,
issuer_entity_id: input.sp_entity_id,
destination: input.idp_sso_url,
force_authn: false,
is_passive: input.is_passive,
acs_selection: AcsRequest::Url(input.endpoints.acs_url),
protocol_binding: None,
requested_name_id_format: None,
requested_authn_context: None,
})?;
let authn = set_protocol_binding_paos(authn);
let paos_request = Element::build(paos_qname("Request"))
.with_namespace(Some("paos".to_owned()), PAOS_NS)
.with_attribute(soap_attr("mustUnderstand"), "1")
.with_attribute(soap_attr("actor"), SOAP_ACTOR_NEXT)
.with_attribute(
QName::new(None, "responseConsumerURL"),
input.endpoints.response_consumer_url.to_owned(),
)
.with_attribute(QName::new(None, "service"), ECP_SERVICE)
.with_attribute(QName::new(None, "messageID"), message_id.clone())
.finish();
let issuer = Element::build(saml_qname("Issuer"))
.with_namespace(Some("saml".to_owned()), SAML_NS)
.with_text(input.sp_entity_id.to_owned())
.finish();
let mut ecp_request = Element::build(ecp_qname("Request"))
.with_namespace(Some("ecp".to_owned()), ECP_NS)
.with_attribute(soap_attr("mustUnderstand"), "1")
.with_attribute(soap_attr("actor"), SOAP_ACTOR_NEXT);
if input.is_passive {
ecp_request = ecp_request.with_attribute(QName::new(None, "IsPassive"), "1");
}
if let Some(name) = input.provider_name {
ecp_request =
ecp_request.with_attribute(QName::new(None, "ProviderName"), name.to_owned());
}
let ecp_request = ecp_request.with_child(Node::Element(issuer)).finish();
let soap_envelope = soap::wrap_with_header(vec![paos_request, ecp_request], authn)?;
Ok(SpAuthnRequest {
soap_envelope,
message_id,
})
}
pub fn consume_paos_response(
soap_envelope: &[u8],
expected_message_id: &str,
) -> Result<Vec<u8>, Error> {
let env = soap::unwrap_envelope(soap_envelope)?;
if env.header_block_count(PAOS_NS, "Response") > 1 {
return Err(Error::EcpDuplicatePaosHeader { header: "Response" });
}
let paos_response = env
.header_block(PAOS_NS, "Response")
.ok_or(Error::EcpMissingPaosHeader { header: "Response" })?;
let ref_to =
paos_response
.attribute(None, "refToMessageID")
.ok_or(Error::EcpMissingPaosHeader {
header: "Response/@refToMessageID",
})?;
if ref_to != expected_message_id {
return Err(Error::EcpMessageIdMismatch);
}
require_body_payload_qname(
&env,
"Response",
"ECP PAOS POST: soap:Body payload is not samlp:Response",
)?;
env.body_payload_xml()
}
}
fn require_body_payload_qname(
env: &soap::UnwrappedEnvelope,
local: &str,
err_msg: &str,
) -> Result<(), Error> {
let payload = env.body_payload().ok_or_else(|| {
Error::XmlParse("SOAP: soap:Body contains no payload element".to_string())
})?;
if payload.qname().namespace() != Some(SAMLP_NS) || payload.qname().local() != local {
return Err(Error::XmlParse(err_msg.to_string()));
}
Ok(())
}
fn set_protocol_binding_paos(mut authn: Element) -> Element {
authn.push_attribute(QName::new(None, "ProtocolBinding"), PAOS_BINDING.to_owned());
authn
}
#[derive(Debug, Clone)]
pub struct ParsedSpRequest {
pub response_consumer_url: String,
pub message_id: String,
pub authn_request_xml: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct ParsedIdpResponse {
pub assertion_consumer_service_url: String,
pub response_xml: Vec<u8>,
}
pub struct ClientEcp;
impl ClientEcp {
pub fn parse_sp_request(soap_envelope: &[u8]) -> Result<ParsedSpRequest, Error> {
let env = soap::unwrap_envelope(soap_envelope)?;
if env.header_block_count(PAOS_NS, "Request") > 1 {
return Err(Error::EcpDuplicatePaosHeader { header: "Request" });
}
let paos_request = env
.header_block(PAOS_NS, "Request")
.ok_or(Error::EcpMissingPaosHeader { header: "Request" })?;
let response_consumer_url = paos_request
.attribute(None, "responseConsumerURL")
.ok_or(Error::EcpMissingPaosHeader {
header: "Request/@responseConsumerURL",
})?
.to_owned();
let message_id = paos_request
.attribute(None, "messageID")
.ok_or(Error::EcpMissingPaosHeader {
header: "Request/@messageID",
})?
.to_owned();
require_body_payload_qname(
&env,
"AuthnRequest",
"ECP SP response: soap:Body payload is not samlp:AuthnRequest",
)?;
let payload = env.body_payload_xml()?;
Ok(ParsedSpRequest {
response_consumer_url,
message_id,
authn_request_xml: payload,
})
}
pub fn relay_request_to_idp(authn_request_xml: &[u8]) -> Result<String, Error> {
soap::wrap(std::str::from_utf8(authn_request_xml).map_err(|_err| {
Error::XmlParse("ECP relay: AuthnRequest XML is not UTF-8".to_string())
})?)
}
pub fn parse_idp_response(soap_envelope: &[u8]) -> Result<ParsedIdpResponse, Error> {
let env = soap::unwrap_envelope(soap_envelope)?;
if env.header_block_count(ECP_NS, "Response") > 1 {
return Err(Error::EcpDuplicatePaosHeader {
header: "ecp:Response",
});
}
let ecp_response =
env.header_block(ECP_NS, "Response")
.ok_or(Error::EcpMissingPaosHeader {
header: "ecp:Response",
})?;
let assertion_consumer_service_url = ecp_response
.attribute(None, "AssertionConsumerServiceURL")
.ok_or(Error::EcpMissingPaosHeader {
header: "ecp:Response/@AssertionConsumerServiceURL",
})?
.to_owned();
require_body_payload_qname(
&env,
"Response",
"ECP IdP response: soap:Body payload is not samlp:Response",
)?;
let response_xml = env.body_payload_xml()?;
Ok(ParsedIdpResponse {
assertion_consumer_service_url,
response_xml,
})
}
pub fn build_paos_post(
sp_request: &ParsedSpRequest,
idp_response: &ParsedIdpResponse,
) -> Result<String, Error> {
if sp_request.response_consumer_url != idp_response.assertion_consumer_service_url {
let fault = build_acs_mismatch_fault()?;
return Err(Error::EcpAcsUrlMismatch {
response_consumer_url: sp_request.response_consumer_url.clone(),
assertion_consumer_service_url: idp_response.assertion_consumer_service_url.clone(),
soap_fault: fault,
});
}
let response_doc = Document::parse(&idp_response.response_xml)?;
let response_elem = response_doc.root().clone();
let paos_response = Element::build(paos_qname("Response"))
.with_namespace(Some("paos".to_owned()), PAOS_NS)
.with_attribute(soap_attr("actor"), SOAP_ACTOR_NEXT)
.with_attribute(
QName::new(None, "refToMessageID"),
sp_request.message_id.clone(),
)
.finish();
soap::wrap_with_header(vec![paos_response], response_elem)
}
}
fn build_acs_mismatch_fault() -> Result<String, Error> {
let faultcode = Element::build(QName::new(None, "faultcode"))
.with_text("soap:Client")
.finish();
let faultstring = Element::build(QName::new(None, "faultstring"))
.with_text(
"ECP AssertionConsumerServiceURL does not match the SP responseConsumerURL \
(SAML 2.0 Profiles §4.2.4.2); assertion withheld",
)
.finish();
let fault = Element::build(QName::new(Some(SOAP_NS.to_owned()), "Fault"))
.with_child(Node::Element(faultcode))
.with_child(Node::Element(faultstring))
.finish();
soap::wrap_element(fault)
}
pub struct IdpEcp;
impl IdpEcp {
pub fn parse_authn_request(soap_envelope: &[u8]) -> Result<Vec<u8>, Error> {
let body = soap::unwrap(soap_envelope)?;
let xml = body.payload_xml()?;
let doc = Document::parse(&xml)?;
if doc.root().qname().namespace() != Some(SAMLP_NS)
|| doc.root().qname().local() != "AuthnRequest"
{
return Err(Error::XmlParse(
"ECP IdP request: soap:Body payload is not samlp:AuthnRequest".to_string(),
));
}
Ok(xml)
}
pub fn build_response(
assertion_consumer_service_url: &str,
response_xml: &[u8],
) -> Result<String, Error> {
let response_doc = Document::parse(response_xml)?;
let response_elem = response_doc.root().clone();
if response_elem.qname().namespace() != Some(SAMLP_NS)
|| response_elem.qname().local() != "Response"
{
return Err(Error::XmlParse(
"ECP IdP build_response: payload is not samlp:Response".to_string(),
));
}
let ecp_response = Element::build(ecp_qname("Response"))
.with_namespace(Some("ecp".to_owned()), ECP_NS)
.with_attribute(soap_attr("mustUnderstand"), "1")
.with_attribute(soap_attr("actor"), SOAP_ACTOR_NEXT)
.with_attribute(
QName::new(None, "AssertionConsumerServiceURL"),
assertion_consumer_service_url.to_owned(),
)
.finish();
soap::wrap_with_header(vec![ecp_response], response_elem)
}
}
#[must_use]
pub fn is_paos_binding(uri: &str) -> bool {
uri == PAOS_BINDING
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::cert::X509Certificate;
use crate::crypto::cert::test_vectors::{RSA_CERT_PEM, RSA_KEY_PKCS8_PEM};
use crate::crypto::keypair::KeyPair;
use crate::dsig::algorithms::{C14nAlgorithm, DigestAlgorithm, SignatureAlgorithm};
use crate::dsig::sign::{SignOptions, sign_element};
use crate::xml::emit::emit_element;
use std::time::{Duration, UNIX_EPOCH};
const SP_ENTITY_ID: &str = "https://sp.example.com/ecp";
const SP_ACS_URL: &str = "https://sp.example.com/ecp/acs";
const IDP_SSO_URL: &str = "https://idp.example.com/ecp/sso";
fn fixed_instant() -> std::time::SystemTime {
UNIX_EPOCH
.checked_add(Duration::from_secs(1_779_798_896))
.expect("epoch + offset")
}
fn endpoints() -> SpEcpEndpoints<'static> {
SpEcpEndpoints {
acs_url: SP_ACS_URL,
response_consumer_url: SP_ACS_URL,
}
}
fn sp_build() -> SpAuthnRequest {
SpEcp::build_authn_request(&BuildEcpAuthnRequest {
sp_entity_id: SP_ENTITY_ID,
idp_sso_url: IDP_SSO_URL,
endpoints: endpoints(),
is_passive: false,
provider_name: Some("Test SP"),
issue_instant: fixed_instant(),
})
.expect("build SP ECP request")
}
fn test_keypair() -> KeyPair {
let kp = KeyPair::from_pkcs8_pem(RSA_KEY_PKCS8_PEM).expect("key");
let cert = X509Certificate::from_pem(RSA_CERT_PEM).expect("cert");
kp.with_certificate(cert)
}
fn signed_response_xml(in_response_to: &str, acs_url: &str) -> Vec<u8> {
let kp = test_keypair();
let issuer = Element::build(saml_qname("Issuer"))
.with_text("https://idp.example.com/ecp".to_owned())
.finish();
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 = 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"), "_ecp-response".to_owned())
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(QName::new(None, "IssueInstant"), "2026-05-26T12:34:56Z")
.with_attribute(QName::new(None, "InResponseTo"), in_response_to.to_owned())
.with_attribute(QName::new(None, "Destination"), acs_url.to_owned())
.with_child(Node::Element(issuer))
.with_child(Node::Element(status))
.finish();
let stash = Document::new(response).expect("stash");
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 response");
let doc = Document::new(signed).expect("doc");
emit_element(doc.root()).expect("emit").into_bytes()
}
#[test]
fn sp_request_envelope_has_paos_and_ecp_headers_and_authn_body() {
let built = sp_build();
let doc = Document::parse(built.soap_envelope.as_bytes()).expect("parse envelope");
let env = doc.root();
assert_eq!(env.qname().namespace(), Some(SOAP_NS));
assert_eq!(env.qname().local(), "Envelope");
let header = env.child_element(Some(SOAP_NS), "Header").expect("header");
let paos = header
.child_element(Some(PAOS_NS), "Request")
.expect("paos");
assert_eq!(
paos.attribute(Some(SOAP_NS), "mustUnderstand"),
Some("1"),
"paos:Request must carry soap:mustUnderstand"
);
assert_eq!(
paos.attribute(Some(SOAP_NS), "actor"),
Some(SOAP_ACTOR_NEXT)
);
assert_eq!(
paos.attribute(None, "responseConsumerURL"),
Some(SP_ACS_URL)
);
assert_eq!(paos.attribute(None, "service"), Some(ECP_NS));
assert_eq!(
paos.attribute(None, "messageID"),
Some(built.message_id.as_str())
);
let ecp = header.child_element(Some(ECP_NS), "Request").expect("ecp");
assert_eq!(ecp.attribute(Some(SOAP_NS), "mustUnderstand"), Some("1"));
assert_eq!(ecp.attribute(None, "ProviderName"), Some("Test SP"));
let ecp_issuer = ecp.child_element(Some(SAML_NS), "Issuer").expect("issuer");
assert_eq!(ecp_issuer.text_content(), SP_ENTITY_ID);
let body = env.child_element(Some(SOAP_NS), "Body").expect("body");
let authn = body
.child_element(Some(SAMLP_NS), "AuthnRequest")
.expect("authn");
assert_eq!(
authn.attribute(None, "ProtocolBinding"),
Some(PAOS_BINDING),
"AuthnRequest ProtocolBinding must be PAOS"
);
assert_eq!(
authn.attribute(None, "AssertionConsumerServiceURL"),
Some(SP_ACS_URL)
);
}
#[test]
fn client_parses_sp_request_and_relays_bare_authn_to_idp() {
let built = sp_build();
let parsed = ClientEcp::parse_sp_request(built.soap_envelope.as_bytes()).expect("parse");
assert_eq!(parsed.response_consumer_url, SP_ACS_URL);
assert_eq!(parsed.message_id, built.message_id);
let authn_doc = Document::parse(&parsed.authn_request_xml).expect("authn parse");
assert_eq!(authn_doc.root().qname().local(), "AuthnRequest");
let to_idp = ClientEcp::relay_request_to_idp(&parsed.authn_request_xml).expect("relay");
let idp_env = Document::parse(to_idp.as_bytes()).expect("idp env parse");
assert!(
idp_env
.root()
.child_element(Some(SOAP_NS), "Header")
.is_none(),
"relay to IdP must have no soap:Header"
);
let idp_body = idp_env
.root()
.child_element(Some(SOAP_NS), "Body")
.expect("body");
assert!(
idp_body
.child_element(Some(SAMLP_NS), "AuthnRequest")
.is_some()
);
}
#[test]
fn full_three_leg_round_trip_delivers_assertion_to_sp() {
let sp = sp_build();
let parsed_sp = ClientEcp::parse_sp_request(sp.soap_envelope.as_bytes()).expect("parse sp");
let to_idp = ClientEcp::relay_request_to_idp(&parsed_sp.authn_request_xml).expect("relay");
let authn_xml = IdpEcp::parse_authn_request(to_idp.as_bytes()).expect("idp parse");
let authn_doc = Document::parse(&authn_xml).expect("authn doc");
let request_id = authn_doc
.root()
.attribute(None, "ID")
.expect("authn ID")
.to_owned();
let response_xml = signed_response_xml(&request_id, SP_ACS_URL);
let idp_envelope = IdpEcp::build_response(SP_ACS_URL, &response_xml).expect("idp build");
let parsed_idp = ClientEcp::parse_idp_response(idp_envelope.as_bytes()).expect("parse idp");
assert_eq!(parsed_idp.assertion_consumer_service_url, SP_ACS_URL);
let paos_post = ClientEcp::build_paos_post(&parsed_sp, &parsed_idp).expect("build paos");
let inner = SpEcp::consume_paos_response(paos_post.as_bytes(), &sp.message_id)
.expect("consume paos");
let inner_doc = Document::parse(&inner).expect("inner doc");
assert_eq!(inner_doc.root().qname().local(), "Response");
assert_eq!(
inner_doc.root().attribute(None, "ID"),
Some("_ecp-response")
);
let cert = X509Certificate::from_pem(RSA_CERT_PEM).unwrap();
let sig = inner_doc
.root()
.child_element(Some(crate::dsig::reference::DS_NS), "Signature")
.expect("signature present");
let verified = crate::dsig::verify::verify_signature(
&inner_doc,
sig,
std::slice::from_ref(&cert),
&[SignatureAlgorithm::RsaSha256],
)
.expect("verify recovered response signature");
assert_eq!(verified.signed_element, inner_doc.root().id());
}
#[test]
fn acs_url_mismatch_refuses_delivery_and_yields_soap_fault() {
let sp = sp_build();
let parsed_sp = ClientEcp::parse_sp_request(sp.soap_envelope.as_bytes()).expect("parse sp");
let response_xml = signed_response_xml("_anything", "https://attacker.example.com/exfil");
let attacker_url = "https://attacker.example.com/exfil";
let idp_envelope = IdpEcp::build_response(attacker_url, &response_xml).expect("idp build");
let parsed_idp = ClientEcp::parse_idp_response(idp_envelope.as_bytes()).expect("parse idp");
let err = ClientEcp::build_paos_post(&parsed_sp, &parsed_idp).unwrap_err();
match err {
Error::EcpAcsUrlMismatch {
response_consumer_url,
assertion_consumer_service_url,
soap_fault,
} => {
assert_eq!(response_consumer_url, SP_ACS_URL);
assert_eq!(assertion_consumer_service_url, attacker_url);
let fault_doc = Document::parse(soap_fault.as_bytes()).expect("fault doc");
let body = fault_doc
.root()
.child_element(Some(SOAP_NS), "Body")
.expect("body");
assert!(
body.child_element(Some(SOAP_NS), "Fault").is_some(),
"mismatch path must produce a soap:Fault"
);
assert!(
body.child_element(Some(SAMLP_NS), "Response").is_none(),
"the fault envelope must NOT carry the assertion/Response"
);
}
other => panic!("expected EcpAcsUrlMismatch, got {other:?}"),
}
}
#[test]
fn sp_rejects_ref_to_message_id_mismatch() {
let sp = sp_build();
let parsed_sp = ClientEcp::parse_sp_request(sp.soap_envelope.as_bytes()).expect("parse sp");
let response_xml = signed_response_xml("_req", SP_ACS_URL);
let idp_envelope = IdpEcp::build_response(SP_ACS_URL, &response_xml).expect("idp build");
let parsed_idp = ClientEcp::parse_idp_response(idp_envelope.as_bytes()).expect("parse idp");
let paos_post = ClientEcp::build_paos_post(&parsed_sp, &parsed_idp).expect("build paos");
let err = SpEcp::consume_paos_response(paos_post.as_bytes(), "_some-other-message-id")
.unwrap_err();
assert!(
matches!(err, Error::EcpMessageIdMismatch),
"expected EcpMessageIdMismatch, got {err:?}"
);
SpEcp::consume_paos_response(paos_post.as_bytes(), &sp.message_id)
.expect("matching messageID accepted");
}
#[test]
fn idp_response_soap_fault_is_surfaced() {
let fault = format!(
r#"<soap:Envelope xmlns:soap="{SOAP_NS}"><soap:Body><soap:Fault><faultcode>soap:Server</faultcode><faultstring>auth failed</faultstring></soap:Fault></soap:Body></soap:Envelope>"#
);
let err = ClientEcp::parse_idp_response(fault.as_bytes()).unwrap_err();
match err {
Error::SoapFault { faultcode, .. } => assert_eq!(faultcode, "soap:Server"),
other => panic!("expected SoapFault, got {other:?}"),
}
}
#[test]
fn client_rejects_sp_response_without_paos_request_header() {
let authn = r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_a" Version="2.0" IssueInstant="2026-05-26T12:34:56Z"><saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">x</saml:Issuer></samlp:AuthnRequest>"#;
let envelope = soap::wrap(authn).expect("wrap");
let err = ClientEcp::parse_sp_request(envelope.as_bytes()).unwrap_err();
match err {
Error::EcpMissingPaosHeader { header } => assert_eq!(header, "Request"),
other => panic!("expected EcpMissingPaosHeader, got {other:?}"),
}
}
#[test]
fn client_rejects_duplicate_paos_request_header() {
let envelope = format!(
r#"<soap:Envelope xmlns:soap="{SOAP_NS}" xmlns:paos="{PAOS_NS}" xmlns:samlp="{SAMLP_NS}"><soap:Header><paos:Request responseConsumerURL="https://sp.example.com/acs" messageID="_m1"/><paos:Request responseConsumerURL="https://attacker.example.com/exfil" messageID="_m2"/></soap:Header><soap:Body><samlp:AuthnRequest ID="_a"/></soap:Body></soap:Envelope>"#
);
let err = ClientEcp::parse_sp_request(envelope.as_bytes()).unwrap_err();
match err {
Error::EcpDuplicatePaosHeader { header } => assert_eq!(header, "Request"),
other => panic!("expected EcpDuplicatePaosHeader, got {other:?}"),
}
}
#[test]
fn client_rejects_duplicate_ecp_response_header() {
let envelope = format!(
r#"<soap:Envelope xmlns:soap="{SOAP_NS}" xmlns:ecp="{ECP_NS}" xmlns:samlp="{SAMLP_NS}"><soap:Header><ecp:Response AssertionConsumerServiceURL="https://sp.example.com/acs"/><ecp:Response AssertionConsumerServiceURL="https://attacker.example.com/exfil"/></soap:Header><soap:Body><samlp:Response ID="_r"/></soap:Body></soap:Envelope>"#
);
let err = ClientEcp::parse_idp_response(envelope.as_bytes()).unwrap_err();
match err {
Error::EcpDuplicatePaosHeader { header } => assert_eq!(header, "ecp:Response"),
other => panic!("expected EcpDuplicatePaosHeader, got {other:?}"),
}
}
#[test]
fn is_paos_binding_matches_only_paos() {
assert!(is_paos_binding(PAOS_BINDING));
assert!(!is_paos_binding(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
));
}
#[test]
fn paos_headers_carry_spec_values() {
let h = paos_request_headers();
assert!(h.iter().any(|(k, v)| k == "Accept" && v == PAOS_ACCEPT));
assert!(
h.iter()
.any(|(k, v)| k == "PAOS" && v.contains("urn:liberty:paos:2003-08"))
);
let ct = paos_content_type_headers();
assert!(
ct.iter()
.any(|(k, v)| k == "Content-Type" && v == PAOS_CONTENT_TYPE)
);
}
}