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