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