Skip to main content

saml_rs/
idp.rs

1//! SAML Identity Provider entity.
2
3use crate::constants::{Binding, ParserType};
4use crate::entity::{
5    capture_idp_issuance_window, generate_id, BindingContext, CustomTagReplacement, EntitySetting,
6    User,
7};
8use crate::error::SamlError;
9use crate::flow::{FlowOptions, FlowResult, HttpRequest};
10use crate::metadata::{try_generate_idp_metadata, IdpMetadata, IdpMetadataConfig};
11use crate::model::{Status, TopLevelStatusCode};
12use crate::sp::{ServiceProvider, WebBrowserSsoProducer};
13use crate::template::{
14    apply_tag_prefixes, attr_tag, attribute_statement_builder, replace_tags_by_value,
15    validate_tag_prefix, ATTRIBUTE_STATEMENT_TEMPLATE, ATTRIBUTE_TEMPLATE, LOGIN_RESPONSE_TEMPLATE,
16};
17
18mod login_response;
19
20use login_response::{
21    render_default_login_response, render_error_login_response, ErrorLoginResponseXml,
22    LoginResponseXml,
23};
24use std::time::SystemTime;
25
26/// Optional inputs for [`IdentityProvider::create_login_response`].
27#[derive(Default)]
28pub struct LoginResponseOptions<'a> {
29    /// `InResponseTo` — the SP request id being answered.
30    pub in_response_to: Option<&'a str>,
31    /// RelayState to echo back to the SP.
32    pub relay_state: Option<&'a str>,
33    /// Request encrypt-then-sign ordering for encrypted responses.
34    ///
35    /// When message-signing encrypted assertions, the implementation resolves
36    /// to the sound order required for verifiable signatures.
37    pub encrypt_then_sign: bool,
38    /// Custom template rendering hook.
39    pub custom: Option<CustomTagReplacement<'a>>,
40}
41
42#[derive(Debug, Clone, Copy, Default)]
43pub(crate) struct LoginResponseOverrides<'a> {
44    pub(crate) acs: Option<&'a str>,
45    pub(crate) name_id_format: Option<&'a str>,
46    pub(crate) issuance_lifetime: Option<time::Duration>,
47    pub(crate) web_browser_sso_producer: WebBrowserSsoProducer,
48    pub(crate) status: Option<&'a Status>,
49}
50
51#[derive(Clone, Copy)]
52struct LoginResponseRendering<'a> {
53    name_id_format: Option<&'a str>,
54    custom: Option<CustomTagReplacement<'a>>,
55    issuance_lifetime: time::Duration,
56    web_browser_sso_producer: WebBrowserSsoProducer,
57    status: Option<&'a Status>,
58}
59
60/// A SAML 2.0 Identity Provider: runtime [`EntitySetting`] plus parsed [`IdpMetadata`].
61#[derive(Debug, Clone)]
62pub struct IdentityProvider {
63    /// Runtime configuration (keys, algorithms, flags).
64    pub setting: EntitySetting,
65    /// Parsed IdP metadata.
66    pub metadata: IdpMetadata,
67}
68
69impl IdentityProvider {
70    /// Build from IdP metadata XML, merging metadata-declared flags into `setting`.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error when `xml` is malformed, exceeds XML limits, or cannot
75    /// be parsed as IdP metadata. Metadata parser errors include invalid entity,
76    /// SSO endpoint, certificate, or signing flag declarations.
77    pub fn from_metadata(xml: &str, mut setting: EntitySetting) -> Result<Self, SamlError> {
78        let metadata = IdpMetadata::from_xml(xml)?;
79        setting.want_authn_requests_signed = metadata.is_want_authn_requests_signed();
80        let formats = metadata.get_name_id_format();
81        if !formats.is_empty() {
82            setting.name_id_format = formats;
83        }
84        if setting.entity_id.is_none() {
85            setting.entity_id = metadata.get_entity_id().map(str::to_string);
86        }
87        Ok(Self { setting, metadata })
88    }
89
90    /// Build by generating IdP metadata from `config`, then importing it.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if `config` cannot produce valid IdP metadata, such as
95    /// when required IdP SSO metadata is missing, or if the generated metadata
96    /// cannot be parsed back into IdP metadata.
97    pub fn from_config(
98        config: &IdpMetadataConfig,
99        setting: EntitySetting,
100    ) -> Result<Self, SamlError> {
101        let metadata_xml = try_generate_idp_metadata(config)?;
102        Self::from_metadata(&metadata_xml, setting)
103    }
104
105    /// The IdP metadata XML.
106    pub fn metadata_xml(&self) -> &str {
107        self.metadata.get_metadata()
108    }
109
110    fn entity_id(&self) -> String {
111        self.setting
112            .entity_id
113            .clone()
114            .or_else(|| self.metadata.get_entity_id().map(str::to_string))
115            .unwrap_or_default()
116    }
117
118    /// Render the login `<Response>` XML for `sp`, returning `(id, xml)`.
119    ///
120    /// `custom` overrides tag filling: it receives the template with the
121    /// `<AttributeStatement>` already injected.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error when configured XML tag prefixes are invalid, the
126    /// default response renderer cannot produce valid XML, or template
127    /// rendering validation fails.
128    fn render_login_response(
129        &self,
130        sp: &ServiceProvider,
131        in_response_to: Option<&str>,
132        user: &User,
133        acs: &str,
134        rendering: LoginResponseRendering<'_>,
135    ) -> Result<(String, String), SamlError> {
136        validate_tag_prefix("protocol", &self.setting.tag_prefix_protocol)?;
137        validate_tag_prefix("assertion", &self.setting.tag_prefix_assertion)?;
138        let tmpl = self.setting.login_response_template.as_ref();
139        let attributes = tmpl.map(|t| t.attributes.as_slice()).unwrap_or(&[]);
140        let has_custom_context = tmpl.and_then(|t| t.context.as_ref()).is_some();
141        let fallback_status = Status::success();
142        let status = rendering.status.unwrap_or(&fallback_status);
143        let error_response = status.top_level() != TopLevelStatusCode::Success;
144        let uses_template = rendering.custom.is_some() || has_custom_context;
145        if rendering.web_browser_sso_producer.follows() && uses_template {
146            return Err(SamlError::Invalid(
147                "Web Browser SSO generation rules require the built-in login response renderer"
148                    .into(),
149            ));
150        }
151        if error_response && uses_template {
152            return Err(SamlError::Invalid(
153                "an error Response omits assertions and cannot use a login response template"
154                    .into(),
155            ));
156        }
157        if status.subordinate().is_some() && uses_template {
158            return Err(SamlError::Invalid(
159                "login response template StatusCode placeholder cannot carry a subordinate status code"
160                    .into(),
161            ));
162        }
163        if error_response {
164            let window = capture_idp_issuance_window(rendering.issuance_lifetime)?;
165            let id = generate_id();
166            let issuer = self.entity_id();
167            let emitted_in_response_to =
168                response_in_response_to(rendering.web_browser_sso_producer, in_response_to);
169            let xml = render_error_login_response(&ErrorLoginResponseXml {
170                protocol_prefix: &self.setting.tag_prefix_protocol,
171                assertion_prefix: &self.setting.tag_prefix_assertion,
172                response_id: &id,
173                issue_instant: &window.issue_instant,
174                destination: acs,
175                issuer: &issuer,
176                status,
177                in_response_to: emitted_in_response_to,
178            })?;
179            return Ok((id, xml));
180        }
181        if rendering.custom.is_none() && !has_custom_context {
182            let window = capture_idp_issuance_window(rendering.issuance_lifetime)?;
183            let default_name_id_format = self
184                .setting
185                .name_id_format
186                .first()
187                .cloned()
188                .unwrap_or_default();
189            let name_id_format = rendering
190                .name_id_format
191                .unwrap_or(default_name_id_format.as_str());
192            let id = generate_id();
193            let assertion_id = generate_id();
194            let audience = sp.metadata.get_entity_id().unwrap_or_default().to_string();
195            let issuer = self.entity_id();
196            let emitted_in_response_to =
197                response_in_response_to(rendering.web_browser_sso_producer, in_response_to);
198            let supports_single_logout = !self.metadata.get_support_bindings().is_empty();
199            let session_index = (rendering.web_browser_sso_producer.follows()
200                && supports_single_logout)
201                .then_some(assertion_id.as_str());
202            let xml = render_default_login_response(&LoginResponseXml {
203                protocol_prefix: &self.setting.tag_prefix_protocol,
204                assertion_prefix: &self.setting.tag_prefix_assertion,
205                response_id: &id,
206                assertion_id: &assertion_id,
207                issue_instant: &window.issue_instant,
208                destination: acs,
209                subject_recipient: acs,
210                issuer: &issuer,
211                status,
212                subject_confirmation_not_on_or_after: &window.expiration,
213                conditions_not_before: &window.issue_instant,
214                conditions_not_on_or_after: &window.expiration,
215                audience: &audience,
216                name_id_format,
217                name_id: &user.name_id,
218                in_response_to: emitted_in_response_to,
219                authn_statement: rendering.web_browser_sso_producer.follows().then_some(
220                    login_response::AuthnStatementXml {
221                        authn_instant: &window.issue_instant,
222                        session_index,
223                    },
224                ),
225                attributes,
226                user_attributes: &user.attributes,
227            })?;
228            return Ok((id, xml));
229        }
230
231        let base = tmpl
232            .and_then(|t| t.context.as_deref())
233            .unwrap_or(LOGIN_RESPONSE_TEMPLATE);
234        let attribute_statement = if attributes.is_empty() {
235            String::new()
236        } else {
237            attribute_statement_builder(
238                attributes,
239                ATTRIBUTE_TEMPLATE,
240                ATTRIBUTE_STATEMENT_TEMPLATE,
241            )
242        };
243        let prepared = base.replacen("{AttributeStatement}", &attribute_statement, 1);
244        let prepared = apply_tag_prefixes(
245            &prepared,
246            &self.setting.tag_prefix_protocol,
247            &self.setting.tag_prefix_assertion,
248        );
249        if let Some(f) = rendering.custom {
250            return Ok(f(&prepared));
251        }
252        let window = capture_idp_issuance_window(rendering.issuance_lifetime)?;
253        let default_name_id_format = self
254            .setting
255            .name_id_format
256            .first()
257            .cloned()
258            .unwrap_or_default();
259        let name_id_format = rendering
260            .name_id_format
261            .unwrap_or(default_name_id_format.as_str());
262        let id = generate_id();
263        let mut tags: Vec<(&str, String)> = vec![
264            ("ID", id.clone()),
265            ("AssertionID", generate_id()),
266            ("Destination", acs.to_string()),
267            ("SubjectRecipient", acs.to_string()),
268            ("AssertionConsumerServiceURL", acs.to_string()),
269            (
270                "Audience",
271                sp.metadata.get_entity_id().unwrap_or_default().to_string(),
272            ),
273            ("Issuer", self.entity_id()),
274            ("IssueInstant", window.issue_instant.clone()),
275            ("StatusCode", status.top_level().as_uri().to_string()),
276            ("ConditionsNotBefore", window.issue_instant),
277            ("ConditionsNotOnOrAfter", window.expiration.clone()),
278            ("SubjectConfirmationDataNotOnOrAfter", window.expiration),
279            ("NameIDFormat", name_id_format.to_string()),
280            ("NameID", user.name_id.clone()),
281            (
282                "InResponseTo",
283                in_response_to.unwrap_or_default().to_string(),
284            ),
285            ("AuthnStatement", String::new()),
286        ];
287        // Attribute value placeholders ({attr<Tag>}) are filled from the user's
288        // attributes after the AttributeStatement is expanded into the legacy template.
289        let attr_pairs: Vec<(String, String)> = user
290            .attributes
291            .iter()
292            .map(|(tag, value)| (attr_tag(tag), value.clone()))
293            .collect();
294        for (key, value) in &attr_pairs {
295            tags.push((key.as_str(), value.clone()));
296        }
297        Ok((id, replace_tags_by_value(&prepared, &tags)))
298    }
299
300    /// Generate a login `<Response>` for `sp` over `binding`.
301    ///
302    /// Requires a crypto provider feature: the response is always signed
303    /// (assertion- or message-level) and optionally encrypted. Attributes are
304    /// taken from `user`; `options` carries `InResponseTo`, RelayState, the
305    /// encrypt-then-sign toggle, and an optional `customTagReplacement` hook.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if `binding` is unsupported, the SP metadata has no ACS
310    /// endpoint for `binding`, response template rendering fails, the IdP
311    /// signing key or certificate configuration is missing or invalid, the
312    /// no crypto provider feature is enabled, XML signature construction
313    /// fails, the SP encryption certificate is missing when assertion
314    /// encryption is enabled, XML encryption fails, or detached-signature
315    /// construction for Redirect/SimpleSign fails.
316    pub fn create_login_response(
317        &self,
318        sp: &ServiceProvider,
319        binding: Binding,
320        user: &User,
321        options: &LoginResponseOptions<'_>,
322    ) -> Result<BindingContext, SamlError> {
323        self.create_login_response_inner(
324            sp,
325            binding,
326            user,
327            options,
328            LoginResponseOverrides::default(),
329        )
330    }
331
332    pub(crate) fn create_login_response_with_overrides(
333        &self,
334        sp: &ServiceProvider,
335        binding: Binding,
336        user: &User,
337        options: &LoginResponseOptions<'_>,
338        overrides: LoginResponseOverrides<'_>,
339    ) -> Result<BindingContext, SamlError> {
340        self.create_login_response_inner(sp, binding, user, options, overrides)
341    }
342
343    fn create_login_response_inner(
344        &self,
345        sp: &ServiceProvider,
346        binding: Binding,
347        user: &User,
348        options: &LoginResponseOptions<'_>,
349        overrides: LoginResponseOverrides<'_>,
350    ) -> Result<BindingContext, SamlError> {
351        if matches!(binding, Binding::Artifact) {
352            return Err(SamlError::UnsupportedBinding {
353                binding: Binding::Artifact,
354            });
355        }
356        let acs = match overrides.acs {
357            Some(acs) => acs.to_string(),
358            None => sp
359                .metadata
360                .get_assertion_consumer_service(binding)
361                .ok_or_else(|| SamlError::MissingMetadata("AssertionConsumerService".into()))?,
362        };
363        let (id, raw) = self.render_login_response(
364            sp,
365            options.in_response_to,
366            user,
367            &acs,
368            LoginResponseRendering {
369                name_id_format: overrides.name_id_format,
370                custom: options.custom,
371                issuance_lifetime: overrides
372                    .issuance_lifetime
373                    .unwrap_or(time::Duration::seconds(300)),
374                web_browser_sso_producer: overrides.web_browser_sso_producer,
375                status: overrides.status,
376            },
377        )?;
378        let error_response = overrides
379            .status
380            .is_some_and(|status| status.top_level() != TopLevelStatusCode::Success);
381        let signed = self.finalize_login_response(
382            sp,
383            binding,
384            &raw,
385            options.encrypt_then_sign,
386            error_response,
387        )?;
388        let relay = options.relay_state.map(str::to_string);
389        let (context, signature, sig_alg) =
390            self.bind_response(binding, &signed, &acs, relay.as_deref())?;
391        Ok(BindingContext {
392            id,
393            context,
394            relay_state: relay,
395            entity_endpoint: acs,
396            binding,
397            request_type: "SAMLResponse",
398            signature,
399            sig_alg,
400        })
401    }
402
403    /// Wrap the finalized response XML into the per-binding transport context.
404    #[cfg(any(
405        feature = "crypto-rustcrypto",
406        feature = "crypto-aws-lc",
407        feature = "crypto-fips"
408    ))]
409    fn bind_response(
410        &self,
411        binding: Binding,
412        xml: &str,
413        acs: &str,
414        relay_state: Option<&str>,
415    ) -> Result<(String, Option<String>, Option<String>), SamlError> {
416        use crate::binding::{append_signature, base64_encode, build_redirect_octet};
417        use crate::crypto::{construct_message_signature, keys::load_private_key};
418
419        match binding {
420            Binding::Post => Ok((base64_encode(xml.as_bytes()), None, None)),
421            Binding::Redirect => {
422                let sig_alg = &self.setting.request_signature_algorithm;
423                let key = load_private_key(
424                    self.setting.private_key.as_deref().unwrap_or_default(),
425                    self.setting.private_key_pass.as_deref(),
426                )?;
427                let octet =
428                    build_redirect_octet(ParserType::SamlResponse, xml, relay_state, sig_alg)?;
429                let sig = construct_message_signature(&octet, &key, sig_alg)?;
430                Ok((append_signature(acs, &octet, &sig), None, None))
431            }
432            Binding::SimpleSign => {
433                let sig_alg = &self.setting.request_signature_algorithm;
434                let key = load_private_key(
435                    self.setting.private_key.as_deref().unwrap_or_default(),
436                    self.setting.private_key_pass.as_deref(),
437                )?;
438                let octet = crate::binding::build_simplesign_octet(
439                    ParserType::SamlResponse.query_param(),
440                    xml,
441                    relay_state,
442                    sig_alg,
443                );
444                let sig = construct_message_signature(&octet, &key, sig_alg)?;
445                Ok((
446                    base64_encode(xml.as_bytes()),
447                    Some(sig),
448                    Some(sig_alg.clone()),
449                ))
450            }
451            Binding::Artifact => Err(SamlError::UnsupportedBinding {
452                binding: Binding::Artifact,
453            }),
454        }
455    }
456
457    #[cfg(not(any(
458        feature = "crypto-rustcrypto",
459        feature = "crypto-aws-lc",
460        feature = "crypto-fips"
461    )))]
462    fn bind_response(
463        &self,
464        _binding: Binding,
465        _xml: &str,
466        _acs: &str,
467        _relay_state: Option<&str>,
468    ) -> Result<(String, Option<String>, Option<String>), SamlError> {
469        Err(SamlError::Unsupported(
470            "createLoginResponse requires a crypto provider feature".into(),
471        ))
472    }
473
474    #[cfg(any(
475        feature = "crypto-rustcrypto",
476        feature = "crypto-aws-lc",
477        feature = "crypto-fips"
478    ))]
479    fn finalize_login_response(
480        &self,
481        sp: &ServiceProvider,
482        binding: Binding,
483        raw: &str,
484        _encrypt_then_sign: bool,
485        error_response: bool,
486    ) -> Result<String, SamlError> {
487        use crate::crypto::{construct_saml_signature, encrypt_assertion, keys::load_private_key};
488
489        let key_pem = self
490            .setting
491            .private_key
492            .as_deref()
493            .ok_or_else(|| SamlError::MissingKey("private_key".into()))?;
494        let cert = self
495            .setting
496            .signing_cert
497            .as_deref()
498            .ok_or_else(|| SamlError::MissingKey("signing_cert".into()))?;
499        let sig_alg = &self.setting.request_signature_algorithm;
500        let key = load_private_key(key_pem, self.setting.private_key_pass.as_deref())?;
501
502        let mut xml = raw.to_string();
503        if error_response {
504            // HTTP-POST signs the Response. There is no assertion to sign.
505            if binding == Binding::Post {
506                xml = construct_saml_signature(
507                    &xml,
508                    true,
509                    &key,
510                    cert,
511                    sig_alg,
512                    &sp.setting.transformation_algorithms,
513                    self.setting.signature_config.as_ref(),
514                )?;
515            }
516            return Ok(xml);
517        }
518
519        let want_assertions_signed = sp.metadata.is_want_assertions_signed();
520        // POST embeds an XML-DSig message signature; redirect/SimpleSign use a
521        // detached query signature added later in `bind_response`.
522        let sign_message =
523            binding == Binding::Post && (sp.setting.want_message_signed || !want_assertions_signed);
524
525        // step: sign assertion -> (encrypt) -> sign message
526        if want_assertions_signed {
527            xml = construct_saml_signature(
528                &xml,
529                false,
530                &key,
531                cert,
532                sig_alg,
533                &sp.setting.transformation_algorithms,
534                None,
535            )?;
536        }
537        // Sign-then-encrypt of a sub-element would invalidate an outer message
538        // signature, so when encrypting we always sign the message *after*
539        // encryption (sound encrypt-then-sign). Without encryption, sign here.
540        if sign_message && !self.setting.is_assertion_encrypted {
541            xml = construct_saml_signature(
542                &xml,
543                true,
544                &key,
545                cert,
546                sig_alg,
547                &sp.setting.transformation_algorithms,
548                self.setting.signature_config.as_ref(),
549            )?;
550        }
551        if self.setting.is_assertion_encrypted {
552            let encrypt_cert = sp
553                .metadata
554                .get_x509_certificate(crate::constants::CertUse::Encryption)
555                .ok_or_else(|| SamlError::MissingMetadata("encryption certificate".into()))?;
556            xml = encrypt_assertion(
557                &xml,
558                &encrypt_cert,
559                &self.setting.data_encryption_algorithm,
560                &self.setting.key_encryption_algorithm,
561                &self.setting.tag_prefix_encrypted_assertion,
562            )?;
563        }
564        if sign_message && self.setting.is_assertion_encrypted {
565            xml = construct_saml_signature(
566                &xml,
567                true,
568                &key,
569                cert,
570                sig_alg,
571                &sp.setting.transformation_algorithms,
572                self.setting.signature_config.as_ref(),
573            )?;
574        }
575        Ok(xml)
576    }
577
578    #[cfg(not(any(
579        feature = "crypto-rustcrypto",
580        feature = "crypto-aws-lc",
581        feature = "crypto-fips"
582    )))]
583    fn finalize_login_response(
584        &self,
585        _sp: &ServiceProvider,
586        _binding: Binding,
587        _raw: &str,
588        _encrypt_then_sign: bool,
589        _error_response: bool,
590    ) -> Result<String, SamlError> {
591        Err(SamlError::Unsupported(
592            "createLoginResponse requires a crypto provider feature".into(),
593        ))
594    }
595
596    /// Parse and validate an SP login `<AuthnRequest>`.
597    ///
598    /// # Errors
599    ///
600    /// Returns an error if `binding` is unsupported, the request is missing
601    /// required binding parameters, the SAML payload cannot be base64/DEFLATE
602    /// decoded, XML parsing or extraction fails, the status is invalid for the
603    /// parser, the SP issuer does not match metadata, or AuthnRequest signature
604    /// validation fails when this IdP metadata requires signed requests.
605    /// Signature failures include missing signatures, untrusted SP signing
606    /// certificates, invalid detached signatures, and XML signature validation
607    /// errors.
608    pub fn parse_login_request(
609        &self,
610        sp: &ServiceProvider,
611        binding: Binding,
612        request: &HttpRequest,
613    ) -> Result<FlowResult, SamlError> {
614        Ok(self
615            .parse_login_request_at_inner(sp, binding, request, None, self.setting.clock_drifts)?
616            .0)
617    }
618
619    pub(crate) fn parse_login_request_at(
620        &self,
621        sp: &ServiceProvider,
622        binding: Binding,
623        request: &HttpRequest,
624        now: SystemTime,
625        clock_drifts: (i64, i64),
626    ) -> Result<(FlowResult, bool), SamlError> {
627        self.parse_login_request_at_inner(sp, binding, request, Some(now), clock_drifts)
628    }
629
630    fn parse_login_request_at_inner(
631        &self,
632        sp: &ServiceProvider,
633        binding: Binding,
634        request: &HttpRequest,
635        now: Option<SystemTime>,
636        clock_drifts: (i64, i64),
637    ) -> Result<(FlowResult, bool), SamlError> {
638        let signing_certs = sp
639            .metadata
640            .x509_certificates(crate::constants::CertUse::Signing);
641        crate::flow::flow_with_authentication(
642            &FlowOptions {
643                binding: Some(binding),
644                parser_type: Some(ParserType::SamlRequest),
645                check_signature: self.metadata.is_want_authn_requests_signed(),
646                verify_signature_if_present: self.setting.verify_authn_request_signature_if_present,
647                require_audience_restriction: true,
648                strict_xml_signature_profile: false,
649                from_issuer: sp.metadata.get_entity_id(),
650                signing_certs: &signing_certs,
651                decrypt_key: None,
652                decrypt_key_pass: None,
653                allow_insecure_software_rsa_key_transport_decryption: false,
654                clock_drifts,
655                now,
656                redirect_inflate_max_bytes: self.setting.redirect_inflate_max_bytes,
657                xml_limits: self.setting.xml_limits,
658                expected_audience: None,
659                expected_in_response_to: None,
660            },
661            request,
662        )
663    }
664}
665
666fn response_in_response_to(
667    producer: WebBrowserSsoProducer,
668    in_response_to: Option<&str>,
669) -> Option<&str> {
670    let raw = in_response_to.unwrap_or("");
671    if producer.follows() && raw.is_empty() {
672        None
673    } else {
674        Some(raw)
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use crate::constants::Binding;
682    use crate::metadata::{Endpoint, SpMetadataConfig};
683
684    const IDPMETA: &str = include_str!("../tests/fixtures/idpmeta.xml");
685
686    fn unsigned_idp() -> Result<IdentityProvider, SamlError> {
687        IdentityProvider::from_config(
688            &IdpMetadataConfig {
689                entity_id: "https://idp.example.com/metadata".into(),
690                single_sign_on_service: vec![Endpoint::new(Binding::Post, "https://idp/sso")],
691                ..Default::default()
692            },
693            EntitySetting::default(),
694        )
695    }
696
697    fn unsigned_sp(entity_id: &str) -> Result<ServiceProvider, SamlError> {
698        ServiceProvider::from_config(
699            &SpMetadataConfig {
700                entity_id: entity_id.into(),
701                assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
702                ..Default::default()
703            },
704            EntitySetting::default(),
705        )
706    }
707
708    #[test]
709    fn from_metadata_merges_flags() -> Result<(), Box<dyn std::error::Error>> {
710        let idp = IdentityProvider::from_metadata(IDPMETA, EntitySetting::default())?;
711        assert!(idp.setting.want_authn_requests_signed);
712        assert_eq!(
713            idp.metadata
714                .get_single_sign_on_service(Binding::Redirect)
715                .as_deref(),
716            Some("https://idp.example.org/sso/SingleSignOnService")
717        );
718        Ok(())
719    }
720
721    #[test]
722    fn idp_from_config_rejects_missing_sso() {
723        let cfg = IdpMetadataConfig {
724            entity_id: "https://idp.example.com/metadata".into(),
725            ..Default::default()
726        };
727
728        let result = IdentityProvider::from_config(&cfg, EntitySetting::default());
729
730        assert!(matches!(
731            result,
732            Err(SamlError::MissingMetadata(name)) if name == "SingleSignOnService"
733        ));
734    }
735
736    #[test]
737    fn parse_login_request_accepts_matching_sp_issuer() -> Result<(), Box<dyn std::error::Error>> {
738        let idp = unsigned_idp()?;
739        let sp = unsigned_sp("https://sp.example.com/metadata")?;
740        let ctx = sp.create_login_request(&idp, Binding::Post, None)?;
741        let request = HttpRequest::post(vec![("SAMLRequest".into(), ctx.context)]);
742
743        let result = idp.parse_login_request(&sp, Binding::Post, &request)?;
744
745        assert_eq!(
746            result.extract.get_str("issuer"),
747            Some("https://sp.example.com/metadata")
748        );
749        Ok(())
750    }
751
752    #[test]
753    fn parse_login_request_rejects_unexpected_sp_issuer() -> Result<(), Box<dyn std::error::Error>>
754    {
755        let idp = unsigned_idp()?;
756        let expected_sp = unsigned_sp("https://expected-sp.example.com/metadata")?;
757        let attacker_sp = unsigned_sp("https://attacker-sp.example.com/metadata")?;
758        let ctx = attacker_sp.create_login_request(&idp, Binding::Post, None)?;
759        let request = HttpRequest::post(vec![("SAMLRequest".into(), ctx.context)]);
760
761        let result = idp.parse_login_request(&expected_sp, Binding::Post, &request);
762
763        assert!(matches!(result, Err(SamlError::IssuerMismatch { .. })));
764        Ok(())
765    }
766}
767
768#[cfg(all(
769    test,
770    any(
771        feature = "crypto-rustcrypto",
772        feature = "crypto-aws-lc",
773        feature = "crypto-fips"
774    )
775))]
776mod crypto_tests {
777    use super::*;
778    use crate::constants::signature_algorithm::RSA_SHA256;
779    use crate::metadata::{Endpoint, SpMetadataConfig};
780
781    // A working RSA keypair (used as both IdP and SP signing material in tests).
782    const PRIVKEY: &str = include_str!("../tests/fixtures/key/sp_privkey.pem");
783    const CERT: &str = include_str!("../tests/fixtures/key/sp_signing_cert.cer");
784
785    fn signing_setting() -> EntitySetting {
786        EntitySetting {
787            private_key: Some(PRIVKEY.into()),
788            signing_cert: Some(CERT.into()),
789            request_signature_algorithm: RSA_SHA256.into(),
790            ..Default::default()
791        }
792    }
793
794    fn idp_with_setting(setting: EntitySetting) -> Result<IdentityProvider, SamlError> {
795        IdentityProvider::from_config(
796            &IdpMetadataConfig {
797                entity_id: "https://idp.example.com/metadata".into(),
798                signing_certs: vec![CERT.into()],
799                want_authn_requests_signed: true,
800                single_sign_on_service: vec![Endpoint::new(Binding::Post, "https://idp/sso")],
801                ..Default::default()
802            },
803            setting,
804        )
805    }
806
807    fn idp() -> Result<IdentityProvider, SamlError> {
808        idp_with_setting(signing_setting())
809    }
810
811    fn signed_sp(entity_id: &str) -> Result<ServiceProvider, SamlError> {
812        ServiceProvider::from_config(
813            &SpMetadataConfig {
814                entity_id: entity_id.into(),
815                authn_requests_signed: true,
816                want_assertions_signed: true,
817                signing_certs: vec![CERT.into()],
818                assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
819                ..Default::default()
820            },
821            signing_setting(),
822        )
823    }
824
825    fn sp() -> Result<ServiceProvider, SamlError> {
826        signed_sp("https://sp.example.com/metadata")
827    }
828
829    #[test]
830    fn idp_response_consumed_by_sp() -> Result<(), Box<dyn std::error::Error>> {
831        let (idp, sp) = (idp()?, sp()?);
832        let ctx = idp.create_login_response(
833            &sp,
834            Binding::Post,
835            &User::new("user@example.com"),
836            &LoginResponseOptions {
837                in_response_to: Some("_req123"),
838                ..Default::default()
839            },
840        )?;
841        let request = HttpRequest::post(vec![("SAMLResponse".into(), ctx.context)]);
842        let result =
843            sp.parse_login_response_with_request_id(&idp, Binding::Post, &request, "_req123")?;
844        assert_eq!(result.extract.get_str("nameID"), Some("user@example.com"));
845        assert_eq!(
846            result.extract.get_str("issuer"),
847            Some("https://idp.example.com/metadata")
848        );
849        Ok(())
850    }
851
852    #[test]
853    fn raw_login_response_defaults_to_five_minute_issuance_window_for_standard_renderers(
854    ) -> Result<(), Box<dyn std::error::Error>> {
855        use crate::binding::base64_decode;
856        use crate::template::{LoginResponseTemplate, LOGIN_RESPONSE_TEMPLATE};
857        use crate::xml::dom::parse;
858        use time::{format_description::well_known::Rfc3339, OffsetDateTime};
859
860        let mut custom_template = signing_setting();
861        custom_template.login_response_template = Some(LoginResponseTemplate {
862            context: Some(LOGIN_RESPONSE_TEMPLATE.into()),
863            attributes: Vec::new(),
864        });
865
866        for setting in [signing_setting(), custom_template] {
867            let idp = idp_with_setting(setting)?;
868            let sp = sp()?;
869            let ctx = idp.create_login_response(
870                &sp,
871                Binding::Post,
872                &User::new("user@example.com"),
873                &LoginResponseOptions {
874                    in_response_to: Some("_req123"),
875                    ..Default::default()
876                },
877            )?;
878            let xml = String::from_utf8(base64_decode(&ctx.context)?)?;
879            let document = parse(&xml)?;
880            let response_issue_instant = document
881                .root
882                .attr("IssueInstant")
883                .ok_or("missing Response IssueInstant")?;
884            let assertion = document
885                .root
886                .children
887                .iter()
888                .find(|node| node.local_name == "Assertion")
889                .ok_or("missing Assertion")?;
890            let assertion_issue_instant = assertion
891                .attr("IssueInstant")
892                .ok_or("missing Assertion IssueInstant")?;
893            let conditions = assertion
894                .children
895                .iter()
896                .find(|node| node.local_name == "Conditions")
897                .ok_or("missing Conditions")?;
898            let conditions_not_before = conditions
899                .attr("NotBefore")
900                .ok_or("missing Conditions NotBefore")?;
901            let conditions_expiration = conditions
902                .attr("NotOnOrAfter")
903                .ok_or("missing Conditions NotOnOrAfter")?;
904            let bearer_expiration = assertion
905                .children
906                .iter()
907                .find(|node| node.local_name == "Subject")
908                .and_then(|node| {
909                    node.children
910                        .iter()
911                        .find(|node| node.local_name == "SubjectConfirmation")
912                })
913                .and_then(|node| {
914                    node.children
915                        .iter()
916                        .find(|node| node.local_name == "SubjectConfirmationData")
917                })
918                .and_then(|node| node.attr("NotOnOrAfter"))
919                .ok_or("missing SubjectConfirmationData NotOnOrAfter")?;
920
921            assert_eq!(response_issue_instant, assertion_issue_instant);
922            assert_eq!(response_issue_instant, conditions_not_before);
923            assert_eq!(conditions_expiration, bearer_expiration);
924            assert_eq!(
925                OffsetDateTime::parse(conditions_expiration, &Rfc3339)?
926                    - OffsetDateTime::parse(response_issue_instant, &Rfc3339)?,
927                time::Duration::minutes(5)
928            );
929        }
930        Ok(())
931    }
932
933    #[test]
934    fn login_response_with_attributes() -> Result<(), Box<dyn std::error::Error>> {
935        use crate::template::{LoginResponseAttribute, LoginResponseTemplate};
936        let mut setting = signing_setting();
937        setting.login_response_template = Some(LoginResponseTemplate {
938            context: None,
939            attributes: vec![LoginResponseAttribute {
940                name: "mail".into(),
941                name_format: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic".into(),
942                value_xsi_type: "xs:string".into(),
943                value_tag: "email".into(),
944                value_xmlns_xs: None,
945                value_xmlns_xsi: None,
946            }],
947        });
948        let idp = IdentityProvider::from_config(
949            &IdpMetadataConfig {
950                entity_id: "https://idp.example.com/metadata".into(),
951                signing_certs: vec![CERT.into()],
952                want_authn_requests_signed: true,
953                single_sign_on_service: vec![Endpoint::new(Binding::Post, "https://idp/sso")],
954                ..Default::default()
955            },
956            setting,
957        )?;
958        let sp = sp()?;
959        let user = User {
960            name_id: "alice@example.com".into(),
961            attributes: vec![("email".into(), "alice@example.com".into())],
962            session_index: None,
963        };
964        let ctx = idp.create_login_response(
965            &sp,
966            Binding::Post,
967            &user,
968            &LoginResponseOptions {
969                in_response_to: Some("_r1"),
970                ..Default::default()
971            },
972        )?;
973        let request = HttpRequest::post(vec![("SAMLResponse".into(), ctx.context)]);
974        let parsed =
975            sp.parse_login_response_with_request_id(&idp, Binding::Post, &request, "_r1")?;
976        assert_eq!(
977            parsed.extract.get_str("attributes.mail"),
978            Some("alice@example.com")
979        );
980        Ok(())
981    }
982
983    #[test]
984    fn login_response_escapes_attribute_xml_markup() -> Result<(), Box<dyn std::error::Error>> {
985        use crate::binding::base64_decode;
986        use crate::template::{LoginResponseAttribute, LoginResponseTemplate};
987
988        let mut setting = signing_setting();
989        setting.login_response_template = Some(LoginResponseTemplate {
990            context: None,
991            attributes: vec![LoginResponseAttribute {
992                name: "mail".into(),
993                name_format: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic".into(),
994                value_xsi_type: "xs:string".into(),
995                value_tag: "email".into(),
996                value_xmlns_xs: None,
997                value_xmlns_xsi: None,
998            }],
999        });
1000        let idp = IdentityProvider::from_config(
1001            &IdpMetadataConfig {
1002                entity_id: "https://idp.example.com/metadata".into(),
1003                signing_certs: vec![CERT.into()],
1004                want_authn_requests_signed: true,
1005                single_sign_on_service: vec![Endpoint::new(Binding::Post, "https://idp/sso")],
1006                ..Default::default()
1007            },
1008            setting,
1009        )?;
1010        let sp = sp()?;
1011        let injection = "alpha</saml:AttributeValue><saml:AttributeValue xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"xs:string\">omega";
1012        let user = User {
1013            name_id: "alice@example.com".into(),
1014            attributes: vec![("email".into(), injection.into())],
1015            session_index: None,
1016        };
1017        let ctx = idp.create_login_response(
1018            &sp,
1019            Binding::Post,
1020            &user,
1021            &LoginResponseOptions {
1022                in_response_to: Some("_r1"),
1023                ..Default::default()
1024            },
1025        )?;
1026
1027        let xml = String::from_utf8(base64_decode(&ctx.context)?)?;
1028        assert!(xml.contains("<ds:Signature"));
1029        assert!(xml.contains("alpha&lt;/saml:AttributeValue&gt;"));
1030        assert!(xml.contains("&lt;saml:AttributeValue xmlns:xs=&quot;"));
1031        assert!(!xml.contains("alpha</saml:AttributeValue><saml:AttributeValue"));
1032        assert_eq!(xml.matches("<saml:AttributeValue ").count(), 1);
1033
1034        let request = HttpRequest::post(vec![("SAMLResponse".into(), ctx.context)]);
1035        let parsed =
1036            sp.parse_login_response_with_request_id(&idp, Binding::Post, &request, "_r1")?;
1037        assert_eq!(parsed.extract.get_str("attributes.mail"), Some(injection));
1038        Ok(())
1039    }
1040
1041    #[test]
1042    fn parse_signed_login_request() -> Result<(), Box<dyn std::error::Error>> {
1043        use crate::binding::base64_decode;
1044        let (idp, sp) = (idp()?, sp()?);
1045        let ctx = sp.create_login_request(&idp, Binding::Post, None)?;
1046        let request = HttpRequest::post(vec![("SAMLRequest".into(), ctx.context.clone())]);
1047        let result = idp.parse_login_request(&sp, Binding::Post, &request)?;
1048        let signed_xml = String::from_utf8(base64_decode(&ctx.context)?)?;
1049        assert!(signed_xml.contains("<ds:Signature"));
1050        assert_eq!(result.extract.get_str("request.id"), Some(ctx.id.as_str()));
1051        Ok(())
1052    }
1053
1054    #[test]
1055    fn parse_signed_login_request_rejects_unexpected_sp_issuer(
1056    ) -> Result<(), Box<dyn std::error::Error>> {
1057        let idp = idp()?;
1058        let expected_sp = sp()?;
1059        let attacker_sp = signed_sp("https://attacker-sp.example.com/metadata")?;
1060        let ctx = attacker_sp.create_login_request(&idp, Binding::Post, None)?;
1061        let request = HttpRequest::post(vec![("SAMLRequest".into(), ctx.context)]);
1062
1063        let result = idp.parse_login_request(&expected_sp, Binding::Post, &request);
1064
1065        assert!(matches!(result, Err(SamlError::IssuerMismatch { .. })));
1066        Ok(())
1067    }
1068}