entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! SAML 2.0 Service Provider metadata generation.

use crate::util::log::info;
use crate::xml::xml_escape;

use super::config::SamlConfig;

/// Generates a SAML 2.0 SP metadata XML document for the given configuration.
///
/// The output contains an `<md:EntityDescriptor>` with an
/// `<md:SPSSODescriptor>` and an `<md:AssertionConsumerService>` element
/// using the HTTP-POST binding.
#[must_use]
pub fn generate_sp_metadata(config: &SamlConfig) -> String {
    let entity_id = xml_escape(config.entity_id());
    let acs_url = xml_escape(config.acs_url());

    info!(entity_id = %config.entity_id(), "saml: SP metadata generated");

    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
                     entityID="{entity_id}">
  <md:SPSSODescriptor AuthnRequestsSigned="false"
                      WantAssertionsSigned="true"
                      protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
                                Location="{acs_url}"
                                index="0"
                                isDefault="true"/>
  </md:SPSSODescriptor>
</md:EntityDescriptor>"#
    )
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::xml::parse_xml;

    fn test_config() -> SamlConfig {
        SamlConfig::new(
            "https://sp.example.com",
            "https://sp.example.com/acs",
            "https://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap()
    }

    #[test]
    fn metadata_is_valid_xml() {
        let xml = generate_sp_metadata(&test_config());
        let root = parse_xml(&xml).unwrap();
        assert_eq!(root.name(), "EntityDescriptor");
    }

    #[test]
    fn metadata_contains_entity_id() {
        let xml = generate_sp_metadata(&test_config());
        let root = parse_xml(&xml).unwrap();
        assert_eq!(root.attribute("entityID"), Some("https://sp.example.com"));
    }

    #[test]
    fn metadata_contains_sp_descriptor() {
        let xml = generate_sp_metadata(&test_config());
        let root = parse_xml(&xml).unwrap();
        let sp = root.find_child("SPSSODescriptor").unwrap();
        assert_eq!(sp.attribute("WantAssertionsSigned"), Some("true"));
        assert_eq!(
            sp.attribute("protocolSupportEnumeration"),
            Some("urn:oasis:names:tc:SAML:2.0:protocol")
        );
    }

    #[test]
    fn metadata_contains_acs() {
        let xml = generate_sp_metadata(&test_config());
        let root = parse_xml(&xml).unwrap();
        let sp = root.find_child("SPSSODescriptor").unwrap();
        let acs = sp.find_child("AssertionConsumerService").unwrap();
        assert_eq!(
            acs.attribute("Location"),
            Some("https://sp.example.com/acs")
        );
        assert_eq!(
            acs.attribute("Binding"),
            Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST")
        );
        assert_eq!(acs.attribute("index"), Some("0"));
        assert_eq!(acs.attribute("isDefault"), Some("true"));
    }

    #[test]
    fn metadata_escapes_special_chars() {
        let config = SamlConfig::new(
            "https://sp.example.com?a=1&b=2",
            "https://sp.example.com/acs?x=1&y=2",
            "https://idp.example.com/sso",
            "https://idp.example.com",
        )
        .unwrap();
        let xml = generate_sp_metadata(&config);
        // The raw XML should contain escaped ampersands.
        assert!(xml.contains("&amp;"));
        // But parsing should decode them back.
        let root = parse_xml(&xml).unwrap();
        assert_eq!(
            root.attribute("entityID"),
            Some("https://sp.example.com?a=1&b=2")
        );
    }
}