Skip to main content

saml_rs/
idp.rs

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