Skip to main content

saml_rs/
sp.rs

1//! SAML Service Provider entity.
2
3use crate::binding::{base64_encode, build_redirect_url};
4use crate::constants::{namespace, Binding, CertUse, ParserType};
5use crate::entity::{
6    generate_id, now_iso8601, BindingContext, CustomTagReplacement, EntitySetting,
7};
8use crate::error::SamlError;
9use crate::flow::{
10    flow_with_expected_recipient_and_signature_evidence, AssertionSignatureRequirement,
11    FlowOptions, FlowResult, FlowResultWithSignatureEvidence, HttpRequest,
12    ResponseSignatureRequirement,
13};
14use crate::idp::IdentityProvider;
15use crate::metadata::{generate_sp_metadata, SpMetadata, SpMetadataConfig};
16use crate::template::{replace_tags_by_optional_value, LOGIN_REQUEST_TEMPLATE};
17use crate::xml::write::XmlWriter;
18use std::time::SystemTime;
19
20/// A SAML 2.0 Service Provider: runtime [`EntitySetting`] plus parsed [`SpMetadata`].
21#[derive(Debug, Clone)]
22pub struct ServiceProvider {
23    /// Runtime configuration (keys, algorithms, flags).
24    pub setting: EntitySetting,
25    /// Parsed SP metadata.
26    pub metadata: SpMetadata,
27}
28
29/// Per-call options for [`ServiceProvider::create_login_request_with_options`].
30#[derive(Default)]
31pub struct LoginRequestOptions<'a> {
32    /// RelayState for this request. `Some("")` is preserved as an empty RelayState.
33    pub relay_state: Option<&'a str>,
34    /// Custom request renderer.
35    pub custom: Option<CustomTagReplacement<'a>>,
36    /// Optional `ForceAuthn` attribute.
37    pub force_authn: Option<bool>,
38    /// Optional `AssertionConsumerServiceIndex`; when present, ACS URL and
39    /// `ProtocolBinding` are omitted.
40    pub assertion_consumer_service_index: Option<u16>,
41    /// Expected SAML Response binding. Defaults to HTTP-POST.
42    pub response_binding: Option<Binding>,
43}
44
45struct AuthnRequestXml<'a> {
46    id: &'a str,
47    issue_instant: &'a str,
48    destination: &'a str,
49    force_authn: Option<bool>,
50    protocol_binding: Option<&'a str>,
51    assertion_consumer_service_url: Option<&'a str>,
52    assertion_consumer_service_index: Option<u16>,
53    issuer: &'a str,
54    name_id_format: Option<&'a str>,
55    allow_create: AllowCreateAttribute,
56}
57
58enum AllowCreateAttribute {
59    Include(bool),
60    Omit,
61}
62
63/// Whether typed Web Browser SSO generation follows producer rules.
64///
65/// [`Self::Compatibility`] is the samlify-port output kept for a caller
66/// leaving the raw API. [`Self::Follow`] applies the producer obligations
67/// recorded for this flow. It is not a validation preset.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub(crate) enum WebBrowserSsoProducer {
70    /// Keep the samlify-port generation.
71    #[default]
72    Compatibility,
73    /// Emit the producer obligations for this flow.
74    Follow,
75}
76
77impl WebBrowserSsoProducer {
78    pub(crate) fn follows(self) -> bool {
79        matches!(self, Self::Follow)
80    }
81}
82
83#[derive(Debug, Clone, Copy)]
84enum LoginResponseCorrelation<'a> {
85    Unsolicited,
86    MessageId(&'a str),
87}
88
89pub(crate) struct LoginResponseParseOptions<'a> {
90    expected_recipient: Option<&'a str>,
91    now: Option<SystemTime>,
92    clock_drifts: (i64, i64),
93}
94
95impl<'a> LoginResponseParseOptions<'a> {
96    fn compatibility(clock_drifts: (i64, i64)) -> Self {
97        Self {
98            expected_recipient: None,
99            now: None,
100            clock_drifts,
101        }
102    }
103
104    pub(crate) fn at(now: SystemTime, clock_drifts: (i64, i64)) -> Self {
105        Self {
106            expected_recipient: None,
107            now: Some(now),
108            clock_drifts,
109        }
110    }
111
112    pub(crate) fn with_expected_recipient(mut self, expected_recipient: &'a str) -> Self {
113        self.expected_recipient = Some(expected_recipient);
114        self
115    }
116}
117
118fn render_default_authn_request_xml(input: &AuthnRequestXml<'_>) -> String {
119    let force_authn = input.force_authn.map(|value| value.to_string());
120    let assertion_consumer_service_index = input
121        .assertion_consumer_service_index
122        .map(|value| value.to_string());
123    let allow_create = match input.allow_create {
124        AllowCreateAttribute::Include(true) => Some("true"),
125        AllowCreateAttribute::Include(false) => Some("false"),
126        AllowCreateAttribute::Omit => None,
127    };
128    let mut name_id_policy_attrs = Vec::new();
129    if let Some(format) = input.name_id_format {
130        name_id_policy_attrs.push(("Format", format));
131    }
132    if let Some(allow_create) = allow_create {
133        name_id_policy_attrs.push(("AllowCreate", allow_create));
134    }
135
136    let mut attrs = Vec::with_capacity(8);
137    attrs.push(("xmlns:samlp", namespace::PROTOCOL));
138    attrs.push(("xmlns:saml", namespace::ASSERTION));
139    attrs.push(("ID", input.id));
140    attrs.push(("Version", "2.0"));
141    attrs.push(("IssueInstant", input.issue_instant));
142    attrs.push(("Destination", input.destination));
143    if let Some(force_authn) = force_authn.as_deref() {
144        attrs.push(("ForceAuthn", force_authn));
145    }
146    if let Some(protocol_binding) = input.protocol_binding {
147        attrs.push(("ProtocolBinding", protocol_binding));
148    }
149    if let Some(assertion_consumer_service_url) = input.assertion_consumer_service_url {
150        attrs.push((
151            "AssertionConsumerServiceURL",
152            assertion_consumer_service_url,
153        ));
154    }
155    if let Some(assertion_consumer_service_index) = assertion_consumer_service_index.as_deref() {
156        attrs.push((
157            "AssertionConsumerServiceIndex",
158            assertion_consumer_service_index,
159        ));
160    }
161
162    let mut writer = XmlWriter::new();
163    writer.start("samlp:AuthnRequest", &attrs);
164    writer.text_element("saml:Issuer", &[], input.issuer);
165    writer.empty("samlp:NameIDPolicy", &name_id_policy_attrs);
166    writer.end("samlp:AuthnRequest");
167    writer.finish()
168}
169
170impl ServiceProvider {
171    /// Build from SP metadata XML, merging the metadata-declared flags into `setting`.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error when `xml` is malformed, exceeds XML limits, or cannot
176    /// be parsed as SP metadata. Metadata parser errors include invalid entity,
177    /// endpoint, certificate, or signing flag declarations.
178    pub fn from_metadata(xml: &str, mut setting: EntitySetting) -> Result<Self, SamlError> {
179        let metadata = SpMetadata::from_xml(xml)?;
180        setting.authn_requests_signed = metadata.is_authn_request_signed();
181        setting.want_assertions_signed = metadata.is_want_assertions_signed();
182        let formats = metadata.get_name_id_format();
183        if !formats.is_empty() {
184            setting.name_id_format = formats;
185        }
186        if setting.entity_id.is_none() {
187            setting.entity_id = metadata.get_entity_id().map(str::to_string);
188        }
189        Ok(Self { setting, metadata })
190    }
191
192    /// Build by generating SP metadata from `config`, then importing it.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if the generated metadata cannot be parsed back into SP
197    /// metadata, including invalid endpoint or certificate declarations.
198    pub fn from_config(
199        config: &SpMetadataConfig,
200        setting: EntitySetting,
201    ) -> Result<Self, SamlError> {
202        Self::from_metadata(&generate_sp_metadata(config), setting)
203    }
204
205    /// The SP metadata XML.
206    pub fn metadata_xml(&self) -> &str {
207        self.metadata.get_metadata()
208    }
209
210    fn entity_id(&self) -> String {
211        self.setting
212            .entity_id
213            .clone()
214            .or_else(|| self.metadata.get_entity_id().map(str::to_string))
215            .unwrap_or_default()
216    }
217
218    /// Build a login `<AuthnRequest>` for `idp` over `binding`.
219    ///
220    /// When both sides require signing, the request is signed (requires the
221    /// a crypto provider feature and the SP's `private_key`/`signing_cert`).
222    /// `custom` overrides template rendering, receiving the resolved template
223    /// and returning `(id, xml)`.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if SP and IdP signing requirements conflict, the IdP
228    /// metadata has no SSO endpoint for `binding`, the SP metadata has no ACS
229    /// endpoint for the requested response binding, or `binding` is unsupported
230    /// for AuthnRequest generation. When signing is required, key loading,
231    /// missing `private_key`/`signing_cert`, unsupported crypto features, XML
232    /// signature construction, and detached-signature construction errors are
233    /// propagated.
234    pub fn create_login_request(
235        &self,
236        idp: &IdentityProvider,
237        binding: Binding,
238        custom: Option<CustomTagReplacement<'_>>,
239    ) -> Result<BindingContext, SamlError> {
240        let options = LoginRequestOptions {
241            custom,
242            ..Default::default()
243        };
244        self.create_login_request_with_options(idp, binding, &options)
245    }
246
247    /// Build a login `<AuthnRequest>` for `idp` over `binding` with per-call options.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if SP and IdP signing requirements conflict, the IdP
252    /// metadata has no SSO endpoint for `binding`, the SP metadata has no ACS
253    /// endpoint for `options.response_binding` when ACS index mode is not used,
254    /// or `binding` is unsupported. When signing is required, key loading,
255    /// missing `private_key`/`signing_cert`, unsupported crypto features, XML
256    /// signature construction, and detached-signature construction errors are
257    /// propagated.
258    pub fn create_login_request_with_options(
259        &self,
260        idp: &IdentityProvider,
261        binding: Binding,
262        options: &LoginRequestOptions<'_>,
263    ) -> Result<BindingContext, SamlError> {
264        self.create_login_request_inner(idp, binding, options, WebBrowserSsoProducer::Compatibility)
265    }
266
267    pub(crate) fn create_typed_login_request(
268        &self,
269        idp: &IdentityProvider,
270        binding: Binding,
271        options: &LoginRequestOptions<'_>,
272        producer: WebBrowserSsoProducer,
273    ) -> Result<BindingContext, SamlError> {
274        self.create_login_request_inner(idp, binding, options, producer)
275    }
276
277    fn create_login_request_inner(
278        &self,
279        idp: &IdentityProvider,
280        binding: Binding,
281        options: &LoginRequestOptions<'_>,
282        producer: WebBrowserSsoProducer,
283    ) -> Result<BindingContext, SamlError> {
284        if self.metadata.is_authn_request_signed() != idp.metadata.is_want_authn_requests_signed() {
285            return Err(SamlError::Invalid(format!(
286                "ERR_METADATA_CONFLICT_REQUEST_SIGNED_FLAG: SP AuthnRequestsSigned={} but IdP WantAuthnRequestsSigned={}",
287                self.metadata.is_authn_request_signed(),
288                idp.metadata.is_want_authn_requests_signed()
289            )));
290        }
291        let destination = idp
292            .metadata
293            .get_single_sign_on_service(binding)
294            .ok_or_else(|| SamlError::MissingMetadata("SingleSignOnService".into()))?;
295        let custom_template = self.setting.login_request_template.as_deref();
296        if producer.follows() && (options.custom.is_some() || custom_template.is_some()) {
297            return Err(SamlError::Invalid(
298                "Web Browser SSO generation rules require the built-in AuthnRequest renderer"
299                    .into(),
300            ));
301        }
302        let template = custom_template.unwrap_or(LOGIN_REQUEST_TEMPLATE);
303        let (id, xml) = match (options.custom, custom_template) {
304            (Some(f), _) => f(template),
305            (None, _) => {
306                let uses_acs_index = options.assertion_consumer_service_index.is_some();
307                let response_binding = options.response_binding.unwrap_or(Binding::Post);
308                let acs_url = if uses_acs_index {
309                    None
310                } else {
311                    Some(
312                        self.metadata
313                            .get_assertion_consumer_service(response_binding)
314                            .ok_or_else(|| {
315                                SamlError::MissingMetadata("AssertionConsumerService".into())
316                            })?,
317                    )
318                };
319                let protocol_binding =
320                    (!uses_acs_index).then(|| response_binding.urn().to_string());
321                let acs_index = options
322                    .assertion_consumer_service_index
323                    .map(|index| index.to_string());
324                let name_id_format = self
325                    .setting
326                    .name_id_format
327                    .first()
328                    .cloned()
329                    .unwrap_or_default();
330                let name_id_format_attr = if producer.follows() && name_id_format.is_empty() {
331                    None
332                } else {
333                    Some(name_id_format.as_str())
334                };
335                let allow_create = if producer.follows()
336                    && name_id_format == crate::constants::name_id_format::TRANSIENT
337                {
338                    AllowCreateAttribute::Omit
339                } else {
340                    AllowCreateAttribute::Include(self.setting.allow_create)
341                };
342                let id = generate_id();
343                let xml = if custom_template.is_none() {
344                    let issue_instant = now_iso8601();
345                    let issuer = self.entity_id();
346                    render_default_authn_request_xml(&AuthnRequestXml {
347                        id: &id,
348                        issue_instant: &issue_instant,
349                        destination: &destination,
350                        force_authn: options.force_authn,
351                        protocol_binding: protocol_binding.as_deref(),
352                        assertion_consumer_service_url: acs_url.as_deref(),
353                        assertion_consumer_service_index: options.assertion_consumer_service_index,
354                        issuer: &issuer,
355                        name_id_format: name_id_format_attr,
356                        allow_create,
357                    })
358                } else {
359                    replace_tags_by_optional_value(
360                        template,
361                        &[
362                            ("ID", Some(id.clone())),
363                            ("IssueInstant", Some(now_iso8601())),
364                            ("Destination", Some(destination.clone())),
365                            (
366                                "ForceAuthn",
367                                options
368                                    .force_authn
369                                    .map(|force_authn| force_authn.to_string()),
370                            ),
371                            ("ProtocolBinding", protocol_binding),
372                            ("AssertionConsumerServiceURL", acs_url),
373                            ("AssertionConsumerServiceIndex", acs_index),
374                            ("Issuer", Some(self.entity_id())),
375                            ("NameIDFormat", Some(name_id_format)),
376                            ("AllowCreate", Some(self.setting.allow_create.to_string())),
377                        ],
378                    )
379                };
380                (id, xml)
381            }
382        };
383        let relay_state = match options.relay_state {
384            Some(value) => Some(value.to_string()),
385            None => {
386                (!self.setting.relay_state.is_empty()).then(|| self.setting.relay_state.clone())
387            }
388        };
389
390        if self.metadata.is_authn_request_signed() {
391            return self.signed_request_context(binding, &xml, destination, relay_state, id);
392        }
393
394        let context = match binding {
395            Binding::Redirect => build_redirect_url(
396                &destination,
397                ParserType::SamlRequest,
398                &xml,
399                relay_state.as_deref(),
400            )?,
401            Binding::Post | Binding::SimpleSign => base64_encode(xml.as_bytes()),
402            Binding::Artifact => {
403                return Err(SamlError::UnsupportedBinding {
404                    binding: Binding::Artifact,
405                });
406            }
407        };
408        Ok(BindingContext {
409            id,
410            context,
411            relay_state,
412            entity_endpoint: destination,
413            binding,
414            request_type: "SAMLRequest",
415            signature: None,
416            sig_alg: None,
417        })
418    }
419
420    #[cfg(any(
421        feature = "crypto-rustcrypto",
422        feature = "crypto-aws-lc",
423        feature = "crypto-fips"
424    ))]
425    fn signed_request_context(
426        &self,
427        binding: Binding,
428        xml: &str,
429        destination: String,
430        relay_state: Option<String>,
431        id: String,
432    ) -> Result<BindingContext, SamlError> {
433        use crate::binding::{append_signature, build_redirect_octet};
434        use crate::crypto::{
435            construct_message_signature, construct_saml_signature, keys::load_private_key,
436        };
437
438        let key_pem = self
439            .setting
440            .private_key
441            .as_deref()
442            .ok_or_else(|| SamlError::MissingKey("private_key".into()))?;
443        let cert = self
444            .setting
445            .signing_cert
446            .as_deref()
447            .ok_or_else(|| SamlError::MissingKey("signing_cert".into()))?;
448        let sig_alg = &self.setting.request_signature_algorithm;
449        let key = load_private_key(key_pem, self.setting.private_key_pass.as_deref())?;
450
451        let (context, signature, sig_alg_out) = match binding {
452            Binding::Redirect => {
453                let octet = build_redirect_octet(
454                    ParserType::SamlRequest,
455                    xml,
456                    relay_state.as_deref(),
457                    sig_alg,
458                )?;
459                let sig = construct_message_signature(&octet, &key, sig_alg)?;
460                (append_signature(&destination, &octet, &sig), None, None)
461            }
462            Binding::Post => {
463                let signed = construct_saml_signature(
464                    xml,
465                    true,
466                    &key,
467                    cert,
468                    sig_alg,
469                    &self.setting.transformation_algorithms,
470                    self.setting.signature_config.as_ref(),
471                )?;
472                (base64_encode(signed.as_bytes()), None, None)
473            }
474            Binding::SimpleSign => {
475                let octet = crate::binding::build_simplesign_octet(
476                    ParserType::SamlRequest.query_param(),
477                    xml,
478                    relay_state.as_deref(),
479                    sig_alg,
480                );
481                let sig = construct_message_signature(&octet, &key, sig_alg)?;
482                (
483                    base64_encode(xml.as_bytes()),
484                    Some(sig),
485                    Some(sig_alg.clone()),
486                )
487            }
488            Binding::Artifact => {
489                return Err(SamlError::UnsupportedBinding {
490                    binding: Binding::Artifact,
491                });
492            }
493        };
494        Ok(BindingContext {
495            id,
496            context,
497            relay_state,
498            entity_endpoint: destination,
499            binding,
500            request_type: "SAMLRequest",
501            signature,
502            sig_alg: sig_alg_out,
503        })
504    }
505
506    #[cfg(not(any(
507        feature = "crypto-rustcrypto",
508        feature = "crypto-aws-lc",
509        feature = "crypto-fips"
510    )))]
511    fn signed_request_context(
512        &self,
513        _binding: Binding,
514        _xml: &str,
515        _destination: String,
516        _relay_state: Option<String>,
517        _id: String,
518    ) -> Result<BindingContext, SamlError> {
519        Err(SamlError::Unsupported(
520            "signing AuthnRequest requires a crypto provider feature".into(),
521        ))
522    }
523
524    /// Parse and validate an unsolicited IdP login `<Response>` (signature required).
525    ///
526    /// This mode is for IdP-initiated SSO. It rejects a non-empty
527    /// `InResponseTo`; for SP-initiated SSO use
528    /// [`Self::parse_login_response_with_request_id`] to bind the response to
529    /// the AuthnRequest ID you issued.
530    ///
531    /// When `setting.validate_audience` is set, the assertion's `<Audience>`
532    /// must include this SP's entity ID.
533    ///
534    /// # Errors
535    ///
536    /// Returns an error for all unsolicited-response validation failures from
537    /// [`Self::parse_unsolicited_login_response`], including malformed browser
538    /// input, unsupported bindings, missing ACS metadata, XML parsing failures,
539    /// missing or invalid signatures, untrusted signing certificates, issuer,
540    /// destination, recipient, audience, status, time-window, and unexpected
541    /// `InResponseTo` failures.
542    pub fn parse_login_response(
543        &self,
544        idp: &IdentityProvider,
545        binding: Binding,
546        request: &HttpRequest,
547    ) -> Result<FlowResult, SamlError> {
548        self.parse_unsolicited_login_response(idp, binding, request)
549    }
550
551    /// Parse and validate an IdP-initiated login `<Response>` that is not bound
552    /// to an outbound AuthnRequest.
553    ///
554    /// # Errors
555    ///
556    /// Returns an error if the SP metadata has no ACS endpoint for `binding`,
557    /// `binding` is unsupported, the request is missing required binding
558    /// parameters, the SAML payload cannot be base64/DEFLATE decoded, XML
559    /// parsing or extraction fails, the status is not success, the required
560    /// signature is missing or invalid, no trusted IdP signing key is available,
561    /// or issuer, destination, bearer recipient, audience, subject-confirmation,
562    /// or time-window validation fails. Because this path is unsolicited, any
563    /// non-empty response or bearer `InResponseTo` also returns an error.
564    pub fn parse_unsolicited_login_response(
565        &self,
566        idp: &IdentityProvider,
567        binding: Binding,
568        request: &HttpRequest,
569    ) -> Result<FlowResult, SamlError> {
570        self.parse_login_response_inner(
571            idp,
572            binding,
573            request,
574            LoginResponseCorrelation::Unsolicited,
575            LoginResponseParseOptions::compatibility(self.setting.clock_drifts),
576        )
577    }
578
579    pub(crate) fn parse_unsolicited_login_response_at(
580        &self,
581        idp: &IdentityProvider,
582        binding: Binding,
583        request: &HttpRequest,
584        now: SystemTime,
585        clock_drifts: (i64, i64),
586    ) -> Result<FlowResultWithSignatureEvidence, SamlError> {
587        self.parse_login_response_inner_with_signature_evidence(
588            idp,
589            binding,
590            request,
591            LoginResponseCorrelation::Unsolicited,
592            LoginResponseParseOptions::at(now, clock_drifts),
593        )
594    }
595
596    /// Like [`Self::parse_login_response`] but also requires `InResponseTo` to
597    /// equal `request_id` (anti-replay: bind the response to a request you sent).
598    ///
599    /// An empty caller-provided `request_id` is rejected as
600    /// [`SamlError::InvalidInResponseTo`]. A non-empty `request_id` that does
601    /// not match the SAML response returns [`SamlError::InResponseToMismatch`].
602    ///
603    /// # Errors
604    ///
605    /// Returns an error if `request_id` is empty, the SP metadata has no ACS
606    /// endpoint for `binding`, `binding` is unsupported, the request is missing
607    /// required binding parameters, the SAML payload cannot be base64/DEFLATE
608    /// decoded, XML parsing or extraction fails, the status is not success, the
609    /// required signature is missing or invalid, no trusted IdP signing key is
610    /// available, or issuer, destination, bearer recipient, audience,
611    /// `InResponseTo`, subject-confirmation, or time-window validation fails.
612    pub fn parse_login_response_with_request_id(
613        &self,
614        idp: &IdentityProvider,
615        binding: Binding,
616        request: &HttpRequest,
617        request_id: &str,
618    ) -> Result<FlowResult, SamlError> {
619        if request_id.is_empty() {
620            return Err(SamlError::InvalidInResponseTo);
621        }
622        self.parse_login_response_inner(
623            idp,
624            binding,
625            request,
626            LoginResponseCorrelation::MessageId(request_id),
627            LoginResponseParseOptions::compatibility(self.setting.clock_drifts),
628        )
629    }
630
631    pub(crate) fn parse_login_response_with_request_id_at(
632        &self,
633        idp: &IdentityProvider,
634        binding: Binding,
635        request: &HttpRequest,
636        request_id: &str,
637        options: LoginResponseParseOptions<'_>,
638    ) -> Result<FlowResultWithSignatureEvidence, SamlError> {
639        if request_id.is_empty() {
640            return Err(SamlError::InvalidInResponseTo);
641        }
642        self.parse_login_response_inner_with_signature_evidence(
643            idp,
644            binding,
645            request,
646            LoginResponseCorrelation::MessageId(request_id),
647            options,
648        )
649    }
650
651    fn parse_login_response_inner(
652        &self,
653        idp: &IdentityProvider,
654        binding: Binding,
655        request: &HttpRequest,
656        correlation: LoginResponseCorrelation<'_>,
657        options: LoginResponseParseOptions<'_>,
658    ) -> Result<FlowResult, SamlError> {
659        let result = self.parse_login_response_inner_with_signature_evidence(
660            idp,
661            binding,
662            request,
663            correlation,
664            options,
665        )?;
666        Ok(result.into_parts().0)
667    }
668
669    fn parse_login_response_inner_with_signature_evidence(
670        &self,
671        idp: &IdentityProvider,
672        binding: Binding,
673        request: &HttpRequest,
674        correlation: LoginResponseCorrelation<'_>,
675        options: LoginResponseParseOptions<'_>,
676    ) -> Result<FlowResultWithSignatureEvidence, SamlError> {
677        let signing_certs = idp.metadata.x509_certificates(CertUse::Signing);
678        let decrypt_key = if self.setting.is_assertion_encrypted {
679            self.setting.enc_private_key.as_deref()
680        } else {
681            None
682        };
683        let audience = self.entity_id();
684        let expected_in_response_to = match correlation {
685            LoginResponseCorrelation::MessageId(request_id) => Some(request_id),
686            LoginResponseCorrelation::Unsolicited => None,
687        };
688        let recipient = match options.expected_recipient {
689            Some(recipient) => recipient.to_string(),
690            None => self
691                .metadata
692                .get_assertion_consumer_service(binding)
693                .ok_or_else(|| SamlError::MissingMetadata("AssertionConsumerService".into()))?,
694        };
695        let result = flow_with_expected_recipient_and_signature_evidence(
696            &FlowOptions {
697                binding: Some(binding),
698                parser_type: Some(ParserType::SamlResponse),
699                check_signature: true,
700                verify_signature_if_present: false,
701                require_audience_restriction: self.setting.require_audience_restriction,
702                strict_xml_signature_profile: self.setting.strict_xml_signature_profile,
703                from_issuer: idp.metadata.get_entity_id(),
704                signing_certs: &signing_certs,
705                decrypt_key,
706                decrypt_key_pass: self.setting.enc_private_key_pass.as_deref(),
707                allow_insecure_software_rsa_key_transport_decryption: self
708                    .setting
709                    .allow_insecure_software_rsa_key_transport_decryption,
710                clock_drifts: options.clock_drifts,
711                now: options.now,
712                redirect_inflate_max_bytes: self.setting.redirect_inflate_max_bytes,
713                xml_limits: self.setting.xml_limits,
714                expected_audience: self.setting.validate_audience.then_some(audience.as_str()),
715                expected_in_response_to,
716            },
717            request,
718            recipient.as_str(),
719            if self.setting.want_assertions_signed {
720                AssertionSignatureRequirement::Direct
721            } else {
722                AssertionSignatureRequirement::Compatible
723            },
724            if self.setting.want_message_signed {
725                ResponseSignatureRequirement::Required
726            } else if self.setting.want_encrypted_cbc_response_signed {
727                ResponseSignatureRequirement::RequiredForEncryptedCbc
728            } else {
729                ResponseSignatureRequirement::Optional
730            },
731        )?;
732        if matches!(correlation, LoginResponseCorrelation::Unsolicited)
733            && result
734                .extract()
735                .get_str("response.inResponseTo")
736                .is_some_and(|actual| !actual.is_empty())
737        {
738            return Err(SamlError::in_response_to_mismatch(
739                None,
740                result.extract().get_str("response.inResponseTo"),
741            ));
742        }
743        if matches!(correlation, LoginResponseCorrelation::Unsolicited) {
744            crate::assertion_acceptance::reject_unsolicited_bearer_in_response_to(
745                result.saml_content(),
746                self.setting.xml_limits,
747            )?;
748        }
749        Ok(result)
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use crate::binding::{base64_decode, deflate_raw_decode};
757    use crate::metadata::{Endpoint, IdpMetadataConfig};
758    use url::Url;
759
760    fn unsigned_idp() -> Result<IdentityProvider, SamlError> {
761        IdentityProvider::from_config(
762            &IdpMetadataConfig {
763                entity_id: "https://idp.example.com/metadata".into(),
764                single_sign_on_service: vec![
765                    Endpoint::new(Binding::Redirect, "https://idp.example.com/sso"),
766                    Endpoint::new(Binding::Post, "https://idp.example.com/sso"),
767                ],
768                ..Default::default()
769            },
770            EntitySetting::default(),
771        )
772    }
773
774    fn unsigned_sp() -> Result<ServiceProvider, SamlError> {
775        ServiceProvider::from_config(
776            &SpMetadataConfig {
777                entity_id: "https://sp.example.com/metadata".into(),
778                assertion_consumer_service: vec![Endpoint::new(
779                    Binding::Post,
780                    "https://sp.example.com/acs",
781                )],
782                ..Default::default()
783            },
784            EntitySetting::default(),
785        )
786    }
787
788    #[test]
789    fn create_unsigned_login_request_redirect_round_trips() -> Result<(), Box<dyn std::error::Error>>
790    {
791        let ctx = unsigned_sp()?.create_login_request(&unsigned_idp()?, Binding::Redirect, None)?;
792        let url = Url::parse(&ctx.context)?;
793        let (_, value) = url
794            .query_pairs()
795            .find(|(k, _)| k == "SAMLRequest")
796            .ok_or("missing SAMLRequest")?;
797        let xml = String::from_utf8(deflate_raw_decode(&base64_decode(&value)?)?)?;
798        assert!(xml.contains("AssertionConsumerServiceURL=\"https://sp.example.com/acs\""));
799        assert!(url.query_pairs().all(|(k, _)| k != "Signature"));
800        Ok(())
801    }
802
803    #[test]
804    fn create_unsigned_login_request_post_is_base64() -> Result<(), Box<dyn std::error::Error>> {
805        let ctx = unsigned_sp()?.create_login_request(&unsigned_idp()?, Binding::Post, None)?;
806        let xml = String::from_utf8(base64_decode(&ctx.context)?)?;
807        assert!(xml.starts_with("<samlp:AuthnRequest"));
808        Ok(())
809    }
810
811    #[test]
812    fn custom_tag_replacement_overrides_request() -> Result<(), Box<dyn std::error::Error>> {
813        let replace = |_t: &str| {
814            (
815                "_custom".to_string(),
816                "<samlp:AuthnRequest ID=\"_custom\"/>".to_string(),
817            )
818        };
819        let ctx = unsigned_sp()?.create_login_request(
820            &unsigned_idp()?,
821            Binding::Post,
822            Some(&replace as &dyn Fn(&str) -> (String, String)),
823        )?;
824        assert_eq!(ctx.id, "_custom");
825        let xml = String::from_utf8(base64_decode(&ctx.context)?)?;
826        assert!(xml.contains("ID=\"_custom\""));
827        Ok(())
828    }
829}
830
831#[cfg(all(
832    test,
833    any(
834        feature = "crypto-rustcrypto",
835        feature = "crypto-aws-lc",
836        feature = "crypto-fips"
837    )
838))]
839mod crypto_tests {
840    use super::*;
841    use crate::binding::base64_decode;
842    use crate::constants::signature_algorithm::RSA_SHA256;
843    use crate::crypto::verify_signature;
844    use crate::entity::User;
845    use crate::idp::LoginResponseOptions;
846    use crate::metadata::{Endpoint, IdpMetadataConfig};
847
848    const IDP_CERT: &str = include_str!("../tests/fixtures/key/idp_cert.cer");
849    const SP_PRIVKEY: &str = include_str!("../tests/fixtures/key/sp_privkey.pem");
850    const SP_SIGNING_CERT: &str = include_str!("../tests/fixtures/key/sp_signing_cert.cer");
851
852    fn signing_idp() -> Result<IdentityProvider, SamlError> {
853        IdentityProvider::from_config(
854            &IdpMetadataConfig {
855                entity_id: "https://idp.example.com/metadata".into(),
856                signing_certs: vec![IDP_CERT.into()],
857                want_authn_requests_signed: true,
858                single_sign_on_service: vec![
859                    Endpoint::new(Binding::Redirect, "https://idp/sso"),
860                    Endpoint::new(Binding::Post, "https://idp/sso"),
861                ],
862                ..Default::default()
863            },
864            EntitySetting::default(),
865        )
866    }
867
868    #[test]
869    fn parse_signed_response_extracts_name_id() -> Result<(), Box<dyn std::error::Error>> {
870        let idp = IdentityProvider::from_config(
871            &IdpMetadataConfig {
872                entity_id: "https://idp.example.com/metadata".into(),
873                signing_certs: vec![SP_SIGNING_CERT.into()],
874                single_sign_on_service: vec![Endpoint::new(Binding::Post, "https://idp/sso")],
875                ..Default::default()
876            },
877            EntitySetting {
878                private_key: Some(SP_PRIVKEY.into()),
879                signing_cert: Some(SP_SIGNING_CERT.into()),
880                request_signature_algorithm: RSA_SHA256.into(),
881                ..Default::default()
882            },
883        )?;
884        let sp = ServiceProvider::from_config(
885            &SpMetadataConfig {
886                entity_id: "https://sp.example.com/metadata".into(),
887                assertion_consumer_service: vec![Endpoint::new(
888                    Binding::Post,
889                    "http://sp.example.com/demo1/index.php?acs",
890                )],
891                ..Default::default()
892            },
893            EntitySetting::default(),
894        )?;
895        let request_id = "_41e758fee373d51639552c4b040b1090e97f6685";
896        let name_id = "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7";
897        let ctx = idp.create_login_response(
898            &sp,
899            Binding::Post,
900            &User::new(name_id),
901            &LoginResponseOptions {
902                in_response_to: Some(request_id),
903                ..Default::default()
904            },
905        )?;
906        let request = HttpRequest::post(vec![("SAMLResponse".into(), ctx.context)]);
907        let result =
908            sp.parse_login_response_with_request_id(&idp, Binding::Post, &request, request_id)?;
909        assert_eq!(result.extract.get_str("nameID"), Some(name_id));
910        Ok(())
911    }
912
913    #[test]
914    fn create_signed_post_request_verifies() -> Result<(), Box<dyn std::error::Error>> {
915        let sp = ServiceProvider::from_config(
916            &SpMetadataConfig {
917                entity_id: "https://sp.example.com/metadata".into(),
918                authn_requests_signed: true,
919                assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
920                ..Default::default()
921            },
922            EntitySetting {
923                private_key: Some(SP_PRIVKEY.into()),
924                signing_cert: Some(SP_SIGNING_CERT.into()),
925                request_signature_algorithm: RSA_SHA256.into(),
926                ..Default::default()
927            },
928        )?;
929        let ctx = sp.create_login_request(&signing_idp()?, Binding::Post, None)?;
930        let signed_xml = String::from_utf8(base64_decode(&ctx.context)?)?;
931        let (verified, _) = verify_signature(&signed_xml, &[SP_SIGNING_CERT.to_string()])?;
932        assert!(
933            verified,
934            "signed AuthnRequest should verify with the SP cert"
935        );
936        Ok(())
937    }
938
939    #[test]
940    fn create_signed_redirect_request_has_signature() -> Result<(), Box<dyn std::error::Error>> {
941        let sp = ServiceProvider::from_config(
942            &SpMetadataConfig {
943                entity_id: "https://sp.example.com/metadata".into(),
944                authn_requests_signed: true,
945                assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
946                ..Default::default()
947            },
948            EntitySetting {
949                private_key: Some(SP_PRIVKEY.into()),
950                signing_cert: Some(SP_SIGNING_CERT.into()),
951                request_signature_algorithm: RSA_SHA256.into(),
952                ..Default::default()
953            },
954        )?;
955        let ctx = sp.create_login_request(&signing_idp()?, Binding::Redirect, None)?;
956        assert!(ctx.context.contains("&SigAlg="));
957        assert!(ctx.context.contains("&Signature="));
958        Ok(())
959    }
960}