saml-rs 0.6.1

Pure-Rust SAML 2.0 Service Provider and Identity Provider support.
Documentation
//! Typed HTTP-Redirect and HTTP-POST-SimpleSign binding rules.
//!
//! Bindings §3.4.3 and §3.5.3, and HTTP-POST-SimpleSign CD-04 §2.3, prohibit a
//! RelayState longer than 80 bytes. Bindings §3.4.4.1 requires HTTP-Redirect to
//! remove a protocol XML signature before DEFLATE and to verify the detached
//! signature over parameters in a fixed order. CD-04 §2.5 and §2.6 require
//! SimpleSign to sign the raw XML octets, including RelayState when present.
#![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()),
    }
}