#![allow(deprecated, reason = "one fixture uses the deprecated strict() preset")]
#![cfg(any(
feature = "crypto-rustcrypto",
feature = "crypto-aws-lc",
feature = "crypto-fips"
))]
use std::time::SystemTime;
use saml_rs::binding::{base64_decode, deflate_raw_decode};
use saml_rs::{
AcsEndpoint, AuthnRequest, BrowserInput, Credentials, EntityId, FormField, IdpConfig,
IdpDescriptor, IdpValidationPolicy, MetadataTrustPolicy, NameId, PrivateKeyPem,
RelayStateParam, ReplayPolicy, RespondSso, Saml, SamlError, SamlValidationContext, SpConfig,
SpDescriptor, SpValidationPolicy, SsoEndpoint, SsoResponse, StartSso, Subject, TemplatePolicy,
MAX_RELAY_STATE_BYTES,
};
use url::Url;
const SP_ENTITY_ID: &str = "https://sp.example.com/metadata";
const IDP_ENTITY_ID: &str = "https://idp.example.com/metadata";
const SP_ACS_POST: &str = "https://sp.example.com/acs/post";
const SP_ACS_SIMPLESIGN: &str = "https://sp.example.com/acs/simple-sign";
const IDP_SSO_POST: &str = "https://idp.example.com/sso/post";
const IDP_SSO_REDIRECT: &str = "https://idp.example.com/sso/redirect";
const IDP_SSO_SIMPLESIGN: &str = "https://idp.example.com/sso/simple-sign";
const PRIVKEY: &str = include_str!("fixtures/key/sp_privkey.pem");
const CERT: &str = include_str!("fixtures/key/sp_signing_cert.cer");
const SIGNED_AUTHN_REQUEST_TEMPLATE: &str = r#"
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}">
<saml:Issuer>{Issuer}</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignatureValue>protocol-signature</ds:SignatureValue>
</ds:Signature>
<saml:Assertion>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignatureValue>assertion-signature</ds:SignatureValue>
</ds:Signature>
</saml:Assertion>
</samlp:AuthnRequest>
"#;
fn credentials() -> Credentials {
Credentials {
signing_key: Some(PrivateKeyPem::new(PRIVKEY)),
signing_certificate: Some(saml_rs::CertificatePem::new(CERT)),
..Credentials::default()
}
}
fn validation() -> SamlValidationContext<'static> {
SamlValidationContext::new(SystemTime::now(), ReplayPolicy::DisabledForCompatibility)
}
fn subject() -> Subject {
Subject::new(NameId::new("alice@example.com", None), Vec::new())
}
fn sp_with(
validation: SpValidationPolicy,
templates: TemplatePolicy,
) -> Result<Saml<saml_rs::Sp>, SamlError> {
Saml::sp(
SpConfig::builder(EntityId::try_new(SP_ENTITY_ID)?)
.acs_endpoint(AcsEndpoint::post(SP_ACS_POST)?.mark_default())
.acs_endpoint(AcsEndpoint::simple_sign(SP_ACS_SIMPLESIGN)?)
.credentials(credentials())
.validation(validation)
.templates(templates)
.build()?,
)
}
fn idp_with(validation: IdpValidationPolicy) -> Result<Saml<saml_rs::Idp>, SamlError> {
Saml::idp(
IdpConfig::builder(EntityId::try_new(IDP_ENTITY_ID)?)
.sso_endpoint(SsoEndpoint::post(IDP_SSO_POST)?)
.sso_endpoint(SsoEndpoint::redirect(IDP_SSO_REDIRECT)?)
.sso_endpoint(SsoEndpoint::simple_sign(IDP_SSO_SIMPLESIGN)?)
.credentials(credentials())
.validation(validation)
.build()?,
)
}
fn descriptors(
sp: &Saml<saml_rs::Sp>,
idp: &Saml<saml_rs::Idp>,
) -> Result<(SpDescriptor, IdpDescriptor), SamlError> {
let sp_descriptor = SpDescriptor::from_metadata_xml_for(
EntityId::try_new(SP_ENTITY_ID)?,
sp.metadata_xml(),
MetadataTrustPolicy::UnsignedForCompatibility,
)?;
let idp_descriptor = IdpDescriptor::from_metadata_xml_for(
EntityId::try_new(IDP_ENTITY_ID)?,
idp.metadata_xml(),
MetadataTrustPolicy::UnsignedForCompatibility,
)?;
Ok((sp_descriptor, idp_descriptor))
}
fn redirect_xml(url: &str) -> Result<String, Box<dyn std::error::Error>> {
let parsed = Url::parse(url)?;
let encoded = parsed
.query_pairs()
.find(|(name, _)| name == "SAMLRequest")
.map(|(_, value)| value.into_owned())
.ok_or("missing SAMLRequest")?;
Ok(String::from_utf8(deflate_raw_decode(&base64_decode(
&encoded,
)?)?)?)
}
fn query_names(url: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let query = url.split_once('?').ok_or("missing query")?.1;
Ok(query
.split('&')
.filter(|segment| !segment.is_empty())
.map(|segment| {
segment
.split_once('=')
.map(|(name, _)| name)
.unwrap_or(segment)
})
.map(str::to_string)
.collect())
}
#[test]
fn typed_send_rejects_relay_state_longer_than_80_bytes() -> Result<(), Box<dyn std::error::Error>> {
assert!(RelayStateParam::try_from_option(Some("a".repeat(MAX_RELAY_STATE_BYTES + 1))).is_err());
let relay_state = RelayStateParam::try_from_option(Some("a".repeat(MAX_RELAY_STATE_BYTES)))?;
let sp = sp_with(SpValidationPolicy::recommended(), TemplatePolicy::default())?;
let idp = idp_with(IdpValidationPolicy::recommended())?;
let (_, idp_descriptor) = descriptors(&sp, &idp)?;
sp.start_sso(
&idp_descriptor,
StartSso::redirect().relay_state(relay_state),
)?;
let templates = TemplatePolicy {
relay_state: "b".repeat(MAX_RELAY_STATE_BYTES + 1),
..TemplatePolicy::default()
};
let sp = sp_with(SpValidationPolicy::recommended(), templates)?;
let (_, idp_descriptor) = descriptors(&sp, &idp)?;
match sp.start_sso(&idp_descriptor, StartSso::redirect()) {
Err(SamlError::Invalid(message)) if message.contains("80") => Ok(()),
other => Err(format!("expected the 80-byte RelayState prohibition, got {other:?}").into()),
}
}
#[test]
fn typed_receive_rejects_relay_state_longer_than_80_bytes() -> Result<(), Box<dyn std::error::Error>>
{
let sp = sp_with(SpValidationPolicy::strict(), TemplatePolicy::default())?;
let idp = idp_with(IdpValidationPolicy::strict())?;
let (sp_descriptor, idp_descriptor) = descriptors(&sp, &idp)?;
let started = sp.start_sso(&idp_descriptor, StartSso::redirect())?;
let mut query = started
.outbound
.redirect_url()?
.split_once('?')
.ok_or("missing query")?
.1
.to_string();
query.push_str("&RelayState=");
query.push_str(&"c".repeat(MAX_RELAY_STATE_BYTES + 1));
match idp.receive_sso(
&sp_descriptor,
BrowserInput::<AuthnRequest>::redirect(query),
validation(),
) {
Err(SamlError::Invalid(message)) if message.contains("80") => {}
other => {
return Err(
format!("expected the 80-byte RelayState prohibition, got {other:?}").into(),
)
}
}
let response = idp.initiate_sso(&sp_descriptor, subject(), RespondSso::post())?;
let mut fields = response.post_form()?.fields().to_vec();
fields.push(FormField::new(
"RelayState",
"d".repeat(MAX_RELAY_STATE_BYTES + 1),
));
match sp.accept_unsolicited_sso(
&idp_descriptor,
BrowserInput::<SsoResponse>::post(fields),
validation(),
) {
Err(SamlError::Invalid(message)) if message.contains("80") => Ok(()),
other => Err(format!("expected the 80-byte RelayState prohibition, got {other:?}").into()),
}
}
#[test]
fn typed_redirect_removes_protocol_signature_and_verifies_parameter_order(
) -> Result<(), Box<dyn std::error::Error>> {
let templates = TemplatePolicy {
login_request_template: Some(SIGNED_AUTHN_REQUEST_TEMPLATE.to_string()),
..TemplatePolicy::default()
};
let unsigned_sp = sp_with(SpValidationPolicy::recommended(), templates.clone())?;
let recommended_idp = idp_with(IdpValidationPolicy::recommended())?;
let (_, idp_descriptor) = descriptors(&unsigned_sp, &recommended_idp)?;
match unsigned_sp.start_sso(&idp_descriptor, StartSso::redirect()) {
Err(SamlError::ProtocolProfile(message)) if message.contains("HTTP-Redirect") => {}
other => {
return Err(format!(
"expected unsigned HTTP-Redirect to reject a protocol XML signature, got {other:?}"
)
.into())
}
}
let sp = sp_with(SpValidationPolicy::strict(), templates)?;
let idp = idp_with(IdpValidationPolicy::strict())?;
let (_, idp_descriptor) = descriptors(&sp, &idp)?;
let relay_state = RelayStateParam::try_from_option(Some("ordered-state".to_string()))?;
let started = sp.start_sso(
&idp_descriptor,
StartSso::redirect().relay_state(relay_state),
)?;
let url = started.outbound.redirect_url()?.to_string();
let xml = redirect_xml(&url)?;
saml_rs::xml::dom::parse(&xml)?;
if xml.contains("protocol-signature") || !xml.contains("assertion-signature") {
return Err(
"HTTP-Redirect must drop the protocol signature and keep an assertion signature".into(),
);
}
assert_eq!(
query_names(&url)?,
vec![
"SAMLRequest".to_string(),
"RelayState".to_string(),
"SigAlg".to_string(),
"Signature".to_string(),
]
);
let sp = sp_with(SpValidationPolicy::strict(), TemplatePolicy::default())?;
let (sp_descriptor, idp_descriptor) = descriptors(&sp, &idp)?;
let relay_state = RelayStateParam::try_from_option(Some("ordered-state".to_string()))?;
let started = sp.start_sso(
&idp_descriptor,
StartSso::redirect().relay_state(relay_state),
)?;
let query = started
.outbound
.redirect_url()?
.split_once('?')
.ok_or("missing query")?
.1
.to_string();
let mut segments: Vec<&str> = query.split('&').collect();
segments.rotate_left(1);
let reordered = segments.join("&");
let received = idp.receive_sso(
&sp_descriptor,
BrowserInput::<AuthnRequest>::redirect(reordered),
validation(),
)?;
assert_eq!(received.message().id(), started.pending.request_id());
assert_eq!(received.relay_state().as_deref(), Some("ordered-state"));
Ok(())
}
#[test]
fn typed_simplesign_binds_relay_state_to_the_raw_xml_signature(
) -> Result<(), Box<dyn std::error::Error>> {
let sp = sp_with(SpValidationPolicy::strict(), TemplatePolicy::default())?;
let idp = idp_with(IdpValidationPolicy::strict())?;
let (sp_descriptor, idp_descriptor) = descriptors(&sp, &idp)?;
let relay_state = RelayStateParam::try_from_option(Some("signed-state".to_string()))?;
let started = sp.start_sso(
&idp_descriptor,
StartSso::simple_sign().relay_state(relay_state),
)?;
let fields = started.outbound.post_form()?.fields().to_vec();
let received = idp.receive_sso(
&sp_descriptor,
BrowserInput::<AuthnRequest>::simple_sign(fields.clone()),
validation(),
)?;
assert_eq!(received.relay_state().as_deref(), Some("signed-state"));
let mut tampered = fields;
let relay = tampered
.iter_mut()
.find(|field| field.name() == "RelayState")
.ok_or("missing RelayState")?;
*relay = FormField::new("RelayState", "other-state");
match idp.receive_sso(
&sp_descriptor,
BrowserInput::<AuthnRequest>::simple_sign(tampered),
validation(),
) {
Err(SamlError::SignatureVerification { .. }) => Ok(()),
other => Err(format!(
"expected SimpleSign to reject a RelayState the octet string did not cover, got {other:?}"
)
.into()),
}
}