Skip to main content

saml_rs/metadata/
generate.rs

1//! SAML SP and IdP metadata generation.
2
3use crate::constants::{elements_order, name_id_format, namespace, Binding};
4use crate::error::SamlError;
5use crate::metadata::write::MetadataWriter;
6use crate::util::{is_non_empty_array, normalize_cert_string};
7
8/// A protocol endpoint (`SingleSignOnService` / `SingleLogoutService` / ACS).
9#[derive(Debug, Clone)]
10pub struct Endpoint {
11    /// Protocol binding.
12    pub binding: Binding,
13    /// Endpoint URL.
14    pub location: String,
15    /// ACS index for `AssertionConsumerService`; ignored for other endpoint kinds.
16    pub index: Option<u16>,
17    /// Whether this is the default endpoint.
18    pub is_default: bool,
19}
20
21impl Endpoint {
22    /// Convenience constructor (non-default).
23    pub fn new(binding: Binding, location: impl Into<String>) -> Self {
24        Self {
25            binding,
26            location: location.into(),
27            index: None,
28            is_default: false,
29        }
30    }
31}
32
33/// SP metadata generation input.
34#[derive(Debug, Clone, Default)]
35pub struct SpMetadataConfig {
36    /// `entityID`.
37    pub entity_id: String,
38    /// Signing certificates (PEM or bare base64).
39    pub signing_certs: Vec<String>,
40    /// Encryption certificates (PEM or bare base64).
41    pub encrypt_certs: Vec<String>,
42    /// `AuthnRequestsSigned`.
43    pub authn_requests_signed: bool,
44    /// `WantAssertionsSigned`.
45    pub want_assertions_signed: bool,
46    /// `<NameIDFormat>` values (defaults to email address when empty).
47    pub name_id_format: Vec<String>,
48    /// `SingleLogoutService` endpoints.
49    pub single_logout_service: Vec<Endpoint>,
50    /// `AssertionConsumerService` endpoints.
51    pub assertion_consumer_service: Vec<Endpoint>,
52    /// Element ordering profile (defaults to [`elements_order::DEFAULT`]).
53    pub elements_order: Option<Vec<String>>,
54}
55
56/// IdP metadata generation input.
57#[derive(Debug, Clone, Default)]
58pub struct IdpMetadataConfig {
59    /// `entityID`.
60    pub entity_id: String,
61    /// Signing certificates.
62    pub signing_certs: Vec<String>,
63    /// Encryption certificates.
64    pub encrypt_certs: Vec<String>,
65    /// `WantAuthnRequestsSigned`.
66    pub want_authn_requests_signed: bool,
67    /// `<NameIDFormat>` values.
68    pub name_id_format: Vec<String>,
69    /// `SingleSignOnService` endpoints (required by SAML).
70    pub single_sign_on_service: Vec<Endpoint>,
71    /// `SingleLogoutService` endpoints.
72    pub single_logout_service: Vec<Endpoint>,
73    /// Element ordering profile (defaults to [`elements_order::idp::DEFAULT`]).
74    pub elements_order: Option<Vec<String>>,
75}
76
77fn write_key_descriptor(w: &mut MetadataWriter, use_: &str, cert: &str) {
78    let cert = normalize_cert_string(cert);
79    w.start("KeyDescriptor", &[("use", use_)]);
80    w.start("ds:KeyInfo", &[("xmlns:ds", namespace::DSIG)]);
81    w.start("ds:X509Data", &[]);
82    w.text_element("ds:X509Certificate", &cert);
83    w.end("ds:X509Data");
84    w.end("ds:KeyInfo");
85    w.end("KeyDescriptor");
86}
87
88fn write_key_descriptors(w: &mut MetadataWriter, signing: &[String], encrypt: &[String]) {
89    for cert in signing {
90        write_key_descriptor(w, "signing", cert);
91    }
92    for cert in encrypt {
93        write_key_descriptor(w, "encryption", cert);
94    }
95}
96
97fn write_name_id_formats(w: &mut MetadataWriter, formats: &[String], default_to_email: bool) {
98    if is_non_empty_array(formats) {
99        for format in formats {
100            w.text_element("NameIDFormat", format);
101        }
102    } else if default_to_email {
103        w.text_element("NameIDFormat", name_id_format::EMAIL_ADDRESS);
104    }
105}
106
107fn write_endpoint_attrs(w: &mut MetadataWriter, name: &str, e: &Endpoint, index: Option<String>) {
108    let mut attrs = Vec::with_capacity(4);
109    if let Some(index) = index.as_deref() {
110        attrs.push(("index", index));
111    }
112    if e.is_default {
113        attrs.push(("isDefault", "true"));
114    }
115    attrs.push(("Binding", e.binding.urn()));
116    attrs.push(("Location", e.location.as_str()));
117    w.empty(name, &attrs);
118}
119
120fn write_single_logout(w: &mut MetadataWriter, endpoints: &[Endpoint]) {
121    for endpoint in endpoints {
122        write_endpoint_attrs(w, "SingleLogoutService", endpoint, None);
123    }
124}
125
126fn write_assertion_consumer_service(w: &mut MetadataWriter, endpoints: &[Endpoint]) {
127    for (i, endpoint) in endpoints.iter().enumerate() {
128        let index = endpoint
129            .index
130            .map(|index| index.to_string())
131            .unwrap_or_else(|| i.to_string());
132        write_endpoint_attrs(w, "AssertionConsumerService", endpoint, Some(index));
133    }
134}
135
136fn write_single_sign_on_service(w: &mut MetadataWriter, endpoints: &[Endpoint]) {
137    for endpoint in endpoints {
138        write_endpoint_attrs(w, "SingleSignOnService", endpoint, None);
139    }
140}
141
142fn write_sp_group(w: &mut MetadataWriter, cfg: &SpMetadataConfig, name: &str) {
143    match name {
144        "KeyDescriptor" => write_key_descriptors(w, &cfg.signing_certs, &cfg.encrypt_certs),
145        "NameIDFormat" => write_name_id_formats(w, &cfg.name_id_format, true),
146        "SingleLogoutService" => write_single_logout(w, &cfg.single_logout_service),
147        "AssertionConsumerService" => {
148            write_assertion_consumer_service(w, &cfg.assertion_consumer_service)
149        }
150        _ => {}
151    }
152}
153
154fn idp_group_has_content(cfg: &IdpMetadataConfig, name: &str) -> bool {
155    match name {
156        "KeyDescriptor" => {
157            is_non_empty_array(&cfg.signing_certs) || is_non_empty_array(&cfg.encrypt_certs)
158        }
159        "NameIDFormat" => is_non_empty_array(&cfg.name_id_format),
160        "SingleSignOnService" => is_non_empty_array(&cfg.single_sign_on_service),
161        "SingleLogoutService" => is_non_empty_array(&cfg.single_logout_service),
162        _ => false,
163    }
164}
165
166fn write_idp_group(w: &mut MetadataWriter, cfg: &IdpMetadataConfig, name: &str) {
167    match name {
168        "KeyDescriptor" => write_key_descriptors(w, &cfg.signing_certs, &cfg.encrypt_certs),
169        "NameIDFormat" => write_name_id_formats(w, &cfg.name_id_format, false),
170        "SingleSignOnService" => write_single_sign_on_service(w, &cfg.single_sign_on_service),
171        "SingleLogoutService" => write_single_logout(w, &cfg.single_logout_service),
172        _ => {}
173    }
174}
175
176fn elements_order_or_default(order: &Option<Vec<String>>, default: &[&str]) -> Vec<String> {
177    if let Some(order) = order {
178        order.clone()
179    } else {
180        default.iter().map(|s| s.to_string()).collect()
181    }
182}
183
184fn bool_str(value: bool) -> &'static str {
185    if value {
186        "true"
187    } else {
188        "false"
189    }
190}
191
192/// Generate SP metadata XML.
193pub fn generate_sp_metadata(cfg: &SpMetadataConfig) -> String {
194    let is_custom_order = cfg.elements_order.is_some();
195    let order = elements_order_or_default(&cfg.elements_order, elements_order::DEFAULT);
196    let mut w = MetadataWriter::new();
197    w.start(
198        "EntityDescriptor",
199        &[
200            ("entityID", cfg.entity_id.as_str()),
201            ("xmlns", namespace::METADATA),
202            ("xmlns:assertion", namespace::ASSERTION),
203            ("xmlns:ds", namespace::DSIG),
204        ],
205    );
206    w.start(
207        "SPSSODescriptor",
208        &[
209            ("AuthnRequestsSigned", bool_str(cfg.authn_requests_signed)),
210            ("WantAssertionsSigned", bool_str(cfg.want_assertions_signed)),
211            ("protocolSupportEnumeration", namespace::PROTOCOL),
212        ],
213    );
214    for name in &order {
215        write_sp_group(&mut w, cfg, name);
216    }
217    if is_custom_order
218        && !order
219            .iter()
220            .any(|ordered| ordered == "AssertionConsumerService")
221    {
222        write_sp_group(&mut w, cfg, "AssertionConsumerService");
223    }
224    w.end("SPSSODescriptor");
225    w.end("EntityDescriptor");
226    w.finish()
227}
228
229/// Generate IdP metadata XML.
230pub fn generate_idp_metadata(cfg: &IdpMetadataConfig) -> String {
231    let is_custom_order = cfg.elements_order.is_some();
232    let order = elements_order_or_default(&cfg.elements_order, elements_order::idp::DEFAULT);
233    let descriptors = [
234        "KeyDescriptor",
235        "NameIDFormat",
236        "SingleSignOnService",
237        "SingleLogoutService",
238    ];
239    let mut w = MetadataWriter::new();
240    w.start(
241        "EntityDescriptor",
242        &[
243            ("entityID", cfg.entity_id.as_str()),
244            ("xmlns", namespace::METADATA),
245            ("xmlns:assertion", namespace::ASSERTION),
246            ("xmlns:ds", namespace::DSIG),
247        ],
248    );
249    w.start(
250        "IDPSSODescriptor",
251        &[
252            (
253                "WantAuthnRequestsSigned",
254                bool_str(cfg.want_authn_requests_signed),
255            ),
256            ("protocolSupportEnumeration", namespace::PROTOCOL),
257        ],
258    );
259    for name in &order {
260        if descriptors.contains(&name.as_str()) && idp_group_has_content(cfg, name) {
261            write_idp_group(&mut w, cfg, name);
262        }
263    }
264    if is_custom_order {
265        if !order.iter().any(|ordered| ordered == "SingleSignOnService") {
266            write_idp_group(&mut w, cfg, "SingleSignOnService");
267        }
268    } else {
269        for name in descriptors {
270            if idp_group_has_content(cfg, name) && !order.iter().any(|ordered| ordered == name) {
271                write_idp_group(&mut w, cfg, name);
272            }
273        }
274    }
275    w.end("IDPSSODescriptor");
276    w.end("EntityDescriptor");
277    w.finish()
278}
279
280/// Generate IdP metadata XML, validating required config-driven metadata first.
281///
282/// # Errors
283///
284/// Returns [`SamlError::MissingMetadata`] when the config does not include at
285/// least one `SingleSignOnService` endpoint.
286pub fn try_generate_idp_metadata(cfg: &IdpMetadataConfig) -> Result<String, SamlError> {
287    if !is_non_empty_array(&cfg.single_sign_on_service) {
288        return Err(SamlError::MissingMetadata(
289            "SingleSignOnService".to_string(),
290        ));
291    }
292    Ok(generate_idp_metadata(cfg))
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::constants::CertUse;
299    use crate::entity::EntitySetting;
300    use crate::metadata::{IdpMetadata, SpMetadata};
301    use crate::sp::ServiceProvider;
302
303    type TestResult = Result<(), Box<dyn std::error::Error>>;
304
305    #[test]
306    fn sp_metadata_round_trips() -> Result<(), Box<dyn std::error::Error>> {
307        let cfg = SpMetadataConfig {
308            entity_id: "https://sp.example.com/metadata".into(),
309            signing_certs: vec!["MIIBsigning".into()],
310            encrypt_certs: vec!["MIIBencrypt".into()],
311            authn_requests_signed: true,
312            want_assertions_signed: true,
313            name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
314            single_logout_service: vec![Endpoint::new(
315                Binding::Redirect,
316                "https://sp.example.com/slo",
317            )],
318            assertion_consumer_service: vec![
319                Endpoint {
320                    binding: Binding::Post,
321                    location: "https://sp.example.com/acs".into(),
322                    index: None,
323                    is_default: true,
324                },
325                Endpoint::new(Binding::Redirect, "https://sp.example.com/acs-redirect"),
326            ],
327            elements_order: None,
328        };
329        let xml = generate_sp_metadata(&cfg);
330        let parsed = SpMetadata::from_xml(&xml)?;
331        assert_eq!(
332            parsed.get_entity_id(),
333            Some("https://sp.example.com/metadata")
334        );
335        assert!(parsed.is_authn_request_signed());
336        assert!(parsed.is_want_assertions_signed());
337        assert_eq!(
338            parsed
339                .get_assertion_consumer_service(Binding::Post)
340                .as_deref(),
341            Some("https://sp.example.com/acs")
342        );
343        assert_eq!(
344            parsed
345                .get_single_logout_service(Binding::Redirect)
346                .as_deref(),
347            Some("https://sp.example.com/slo")
348        );
349        assert_eq!(
350            parsed.get_x509_certificate(CertUse::Signing).as_deref(),
351            Some("MIIBsigning")
352        );
353        assert_eq!(
354            parsed.get_x509_certificate(CertUse::Encryption).as_deref(),
355            Some("MIIBencrypt")
356        );
357        Ok(())
358    }
359
360    #[test]
361    fn sp_elements_order_respected() {
362        let cfg = SpMetadataConfig {
363            entity_id: "x".into(),
364            single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://sp/slo")],
365            assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
366            ..Default::default()
367        };
368        let xml = generate_sp_metadata(&cfg);
369        let slo = xml.find("SingleLogoutService").unwrap_or(usize::MAX);
370        let acs = xml.find("AssertionConsumerService").unwrap_or(0);
371        // default order places SingleLogoutService before AssertionConsumerService
372        assert!(slo < acs);
373    }
374
375    #[test]
376    fn idp_metadata_round_trips() -> Result<(), Box<dyn std::error::Error>> {
377        let cfg = IdpMetadataConfig {
378            entity_id: "https://idp.example.com/metadata".into(),
379            signing_certs: vec!["MIIBidp".into()],
380            want_authn_requests_signed: true,
381            single_sign_on_service: vec![Endpoint::new(
382                Binding::Redirect,
383                "https://idp.example.com/sso",
384            )],
385            ..Default::default()
386        };
387        let xml = generate_idp_metadata(&cfg);
388        let parsed = IdpMetadata::from_xml(&xml)?;
389        assert!(parsed.is_want_authn_requests_signed());
390        assert_eq!(
391            parsed
392                .get_single_sign_on_service(Binding::Redirect)
393                .as_deref(),
394            Some("https://idp.example.com/sso")
395        );
396        assert_eq!(
397            parsed.get_x509_certificate(CertUse::Signing).as_deref(),
398            Some("MIIBidp")
399        );
400        Ok(())
401    }
402
403    #[test]
404    fn try_generate_idp_metadata_rejects_missing_sso() {
405        let cfg = IdpMetadataConfig {
406            entity_id: "https://idp.example.com/metadata".into(),
407            ..Default::default()
408        };
409
410        let result = try_generate_idp_metadata(&cfg);
411
412        assert!(matches!(
413            result,
414            Err(SamlError::MissingMetadata(name)) if name == "SingleSignOnService"
415        ));
416    }
417
418    #[test]
419    fn sp_from_config_escapes_name_id_format_xml_markup() -> TestResult {
420        let injected_format = format!(
421            "{}</NameIDFormat><SingleLogoutService Binding=\"{}\" Location=\"https://evil.example/slo\"/><NameIDFormat>",
422            name_id_format::EMAIL_ADDRESS,
423            Binding::Redirect.urn()
424        );
425        let cfg = SpMetadataConfig {
426            entity_id: "https://sp.example.com/metadata".into(),
427            name_id_format: vec![injected_format.clone()],
428            assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
429            ..Default::default()
430        };
431
432        let sp = ServiceProvider::from_config(&cfg, EntitySetting::default())?;
433
434        assert!(sp
435            .metadata
436            .get_single_logout_service(Binding::Redirect)
437            .is_none());
438        assert_eq!(sp.setting.name_id_format, vec![injected_format]);
439        assert!(sp.metadata_xml().contains("&lt;SingleLogoutService"));
440        Ok(())
441    }
442
443    #[test]
444    fn sp_metadata_escapes_entity_id_attribute_markup() -> TestResult {
445        let entity_id = "https://sp.example.com/metadata\" ID=\"_evil";
446        let cfg = SpMetadataConfig {
447            entity_id: entity_id.into(),
448            assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
449            ..Default::default()
450        };
451
452        let xml = generate_sp_metadata(&cfg);
453        let parsed = SpMetadata::from_xml(&xml)?;
454
455        assert!(!xml.contains(" ID=\"_evil\""));
456        assert!(xml.contains("&quot; ID=&quot;_evil"));
457        assert_eq!(parsed.get_entity_id(), Some(entity_id));
458        Ok(())
459    }
460
461    #[test]
462    fn sp_metadata_escapes_certificate_text_markup() -> TestResult {
463        let injected_cert = concat!(
464            "MIIB</ds:X509Certificate></ds:X509Data></ds:KeyInfo></KeyDescriptor>",
465            "<NameIDFormat>evil</NameIDFormat>",
466            "<KeyDescriptor><ds:KeyInfo><ds:X509Data><ds:X509Certificate>tail"
467        );
468        let cfg = SpMetadataConfig {
469            entity_id: "https://sp.example.com/metadata".into(),
470            signing_certs: vec![injected_cert.into()],
471            assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
472            elements_order: Some(vec![
473                "KeyDescriptor".into(),
474                "AssertionConsumerService".into(),
475            ]),
476            ..Default::default()
477        };
478
479        let xml = generate_sp_metadata(&cfg);
480        let parsed = SpMetadata::from_xml(&xml)?;
481
482        assert!(xml.contains("&lt;NameIDFormat&gt;evil&lt;/NameIDFormat&gt;"));
483        assert!(parsed.get_name_id_format().is_empty());
484        assert_eq!(parsed.x509_certificates(CertUse::Signing).len(), 1);
485        Ok(())
486    }
487
488    #[test]
489    fn sp_custom_elements_order_preserves_omitted_populated_acs() {
490        let cfg = SpMetadataConfig {
491            entity_id: "https://sp.example.com/metadata".into(),
492            signing_certs: vec!["MIIBsigning".into()],
493            name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
494            single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://sp/slo")],
495            assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
496            elements_order: Some(vec!["KeyDescriptor".into()]),
497            ..Default::default()
498        };
499
500        let xml = generate_sp_metadata(&cfg);
501        let key = xml.find("<KeyDescriptor").unwrap_or(usize::MAX);
502        let acs = xml.find("<AssertionConsumerService").unwrap_or(usize::MAX);
503
504        assert_eq!(xml.matches("<AssertionConsumerService").count(), 1);
505        assert!(key < acs);
506        assert!(!xml.contains("<NameIDFormat"));
507        assert!(!xml.contains("<SingleLogoutService"));
508    }
509
510    #[test]
511    fn idp_metadata_escapes_endpoint_location_attribute_markup() -> TestResult {
512        let injected_location = format!(
513            "https://idp/sso\"/><SingleLogoutService Binding=\"{}\" Location=\"https://evil.example/slo",
514            Binding::Redirect.urn()
515        );
516        let cfg = IdpMetadataConfig {
517            entity_id: "https://idp.example.com/metadata".into(),
518            single_sign_on_service: vec![Endpoint::new(
519                Binding::Redirect,
520                injected_location.clone(),
521            )],
522            ..Default::default()
523        };
524
525        let xml = try_generate_idp_metadata(&cfg)?;
526        let parsed = IdpMetadata::from_xml(&xml)?;
527
528        assert!(!xml.contains("<SingleLogoutService"));
529        assert_eq!(
530            parsed
531                .get_single_sign_on_service(Binding::Redirect)
532                .as_deref(),
533            Some(injected_location.as_str())
534        );
535        assert!(parsed
536            .get_single_logout_service(Binding::Redirect)
537            .is_none());
538        Ok(())
539    }
540
541    #[test]
542    fn idp_default_elements_order_matches_historical_output() {
543        let cfg = IdpMetadataConfig {
544            entity_id: "https://idp.example.com/metadata".into(),
545            signing_certs: vec!["MIIBsigning".into()],
546            name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
547            single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
548            single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://idp/slo")],
549            ..Default::default()
550        };
551
552        let xml = generate_idp_metadata(&cfg);
553        let key = xml.find("<KeyDescriptor").unwrap_or(usize::MAX);
554        let name_id = xml.find("<NameIDFormat").unwrap_or(usize::MAX);
555        let sso = xml.find("<SingleSignOnService").unwrap_or(usize::MAX);
556        let slo = xml.find("<SingleLogoutService").unwrap_or(usize::MAX);
557
558        assert!(key < name_id);
559        assert!(name_id < sso);
560        assert!(sso < slo);
561    }
562
563    #[test]
564    fn idp_custom_elements_order_can_place_sso_before_key_descriptor() {
565        let cfg = IdpMetadataConfig {
566            entity_id: "https://idp.example.com/metadata".into(),
567            signing_certs: vec!["MIIBsigning".into()],
568            name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
569            single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
570            single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://idp/slo")],
571            elements_order: Some(vec![
572                "SingleSignOnService".into(),
573                "KeyDescriptor".into(),
574                "NameIDFormat".into(),
575                "SingleLogoutService".into(),
576            ]),
577            ..Default::default()
578        };
579
580        let xml = generate_idp_metadata(&cfg);
581        let sso = xml.find("<SingleSignOnService").unwrap_or(usize::MAX);
582        let key = xml.find("<KeyDescriptor").unwrap_or(usize::MAX);
583        let name_id = xml.find("<NameIDFormat").unwrap_or(usize::MAX);
584        let slo = xml.find("<SingleLogoutService").unwrap_or(usize::MAX);
585
586        assert!(sso < key);
587        assert!(key < name_id);
588        assert!(name_id < slo);
589    }
590
591    #[test]
592    fn idp_elements_order_filters_empty_groups() {
593        let cfg = IdpMetadataConfig {
594            entity_id: "https://idp.example.com/metadata".into(),
595            single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
596            single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://idp/slo")],
597            elements_order: Some(vec![
598                "KeyDescriptor".into(),
599                "NameIDFormat".into(),
600                "SingleSignOnService".into(),
601                "SingleLogoutService".into(),
602            ]),
603            ..Default::default()
604        };
605
606        let xml = generate_idp_metadata(&cfg);
607
608        assert!(!xml.contains("<KeyDescriptor"));
609        assert!(!xml.contains("<NameIDFormat"));
610        assert!(xml.contains("<SingleSignOnService"));
611        assert!(xml.contains("<SingleLogoutService"));
612    }
613
614    #[test]
615    fn idp_custom_elements_order_omits_unlisted_optional_groups() {
616        let cfg = IdpMetadataConfig {
617            entity_id: "https://idp.example.com/metadata".into(),
618            signing_certs: vec!["MIIBsigning".into()],
619            name_id_format: vec![name_id_format::EMAIL_ADDRESS.to_string()],
620            single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
621            single_logout_service: vec![Endpoint::new(Binding::Redirect, "https://idp/slo")],
622            elements_order: Some(vec!["SingleSignOnService".into()]),
623            ..Default::default()
624        };
625
626        let xml = generate_idp_metadata(&cfg);
627
628        assert_eq!(xml.matches("<SingleSignOnService").count(), 1);
629        assert!(!xml.contains("<KeyDescriptor"));
630        assert!(!xml.contains("<NameIDFormat"));
631        assert!(!xml.contains("<SingleLogoutService"));
632    }
633
634    #[test]
635    fn idp_custom_elements_order_preserves_omitted_populated_sso() {
636        let cfg = IdpMetadataConfig {
637            entity_id: "https://idp.example.com/metadata".into(),
638            signing_certs: vec!["MIIBsigning".into()],
639            single_sign_on_service: vec![Endpoint::new(Binding::Redirect, "https://idp/sso")],
640            elements_order: Some(vec!["KeyDescriptor".into()]),
641            ..Default::default()
642        };
643
644        let xml = generate_idp_metadata(&cfg);
645        let key = xml.find("<KeyDescriptor").unwrap_or(usize::MAX);
646        let sso = xml.find("<SingleSignOnService").unwrap_or(usize::MAX);
647
648        assert_eq!(xml.matches("<SingleSignOnService").count(), 1);
649        assert!(key < sso);
650    }
651
652    #[test]
653    fn idp_elements_order_profiles_match_upstream() {
654        assert_eq!(
655            elements_order::idp::DEFAULT,
656            [
657                "KeyDescriptor",
658                "NameIDFormat",
659                "SingleSignOnService",
660                "SingleLogoutService",
661            ]
662        );
663        assert_eq!(
664            elements_order::idp::ONELOGIN,
665            [
666                "KeyDescriptor",
667                "NameIDFormat",
668                "SingleLogoutService",
669                "SingleSignOnService",
670            ]
671        );
672        assert_eq!(
673            elements_order::idp::SHIBBOLETH,
674            [
675                "KeyDescriptor",
676                "SingleLogoutService",
677                "NameIDFormat",
678                "SingleSignOnService",
679            ]
680        );
681    }
682}