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,
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    /// `crypto-bergshamra` 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(feature = "crypto-bergshamra")]
393    fn signed_request_context(
394        &self,
395        binding: Binding,
396        xml: &str,
397        destination: String,
398        relay_state: Option<String>,
399        id: String,
400    ) -> Result<BindingContext, SamlError> {
401        use crate::binding::{append_signature, build_redirect_octet};
402        use crate::crypto::{
403            construct_message_signature, construct_saml_signature, keys::load_private_key,
404        };
405
406        let key_pem = self
407            .setting
408            .private_key
409            .as_deref()
410            .ok_or_else(|| SamlError::MissingKey("private_key".into()))?;
411        let cert = self
412            .setting
413            .signing_cert
414            .as_deref()
415            .ok_or_else(|| SamlError::MissingKey("signing_cert".into()))?;
416        let sig_alg = &self.setting.request_signature_algorithm;
417        let key = load_private_key(key_pem, self.setting.private_key_pass.as_deref())?;
418
419        let (context, signature, sig_alg_out) = match binding {
420            Binding::Redirect => {
421                let octet = build_redirect_octet(
422                    ParserType::SamlRequest,
423                    xml,
424                    relay_state.as_deref(),
425                    sig_alg,
426                )?;
427                let sig = construct_message_signature(&octet, &key, sig_alg)?;
428                (append_signature(&destination, &octet, &sig), None, None)
429            }
430            Binding::Post => {
431                let signed = construct_saml_signature(
432                    xml,
433                    true,
434                    &key,
435                    cert,
436                    sig_alg,
437                    &self.setting.transformation_algorithms,
438                    self.setting.signature_config.as_ref(),
439                )?;
440                (base64_encode(signed.as_bytes()), None, None)
441            }
442            Binding::SimpleSign => {
443                let octet = crate::binding::build_simplesign_octet(
444                    ParserType::SamlRequest.query_param(),
445                    xml,
446                    relay_state.as_deref(),
447                    sig_alg,
448                );
449                let sig = construct_message_signature(&octet, &key, sig_alg)?;
450                (
451                    base64_encode(xml.as_bytes()),
452                    Some(sig),
453                    Some(sig_alg.clone()),
454                )
455            }
456            Binding::Artifact => {
457                return Err(SamlError::UnsupportedBinding {
458                    binding: Binding::Artifact,
459                });
460            }
461        };
462        Ok(BindingContext {
463            id,
464            context,
465            relay_state,
466            entity_endpoint: destination,
467            binding,
468            request_type: "SAMLRequest",
469            signature,
470            sig_alg: sig_alg_out,
471        })
472    }
473
474    #[cfg(not(feature = "crypto-bergshamra"))]
475    fn signed_request_context(
476        &self,
477        _binding: Binding,
478        _xml: &str,
479        _destination: String,
480        _relay_state: Option<String>,
481        _id: String,
482    ) -> Result<BindingContext, SamlError> {
483        Err(SamlError::Unsupported(
484            "signing AuthnRequest requires feature crypto-bergshamra".into(),
485        ))
486    }
487
488    /// Parse and validate an unsolicited IdP login `<Response>` (signature required).
489    ///
490    /// This mode is for IdP-initiated SSO. It rejects a non-empty
491    /// `InResponseTo`; for SP-initiated SSO use
492    /// [`Self::parse_login_response_with_request_id`] to bind the response to
493    /// the AuthnRequest ID you issued.
494    ///
495    /// When `setting.validate_audience` is set, the assertion's `<Audience>`
496    /// must include this SP's entity ID.
497    ///
498    /// # Errors
499    ///
500    /// Returns an error for all unsolicited-response validation failures from
501    /// [`Self::parse_unsolicited_login_response`], including malformed browser
502    /// input, unsupported bindings, missing ACS metadata, XML parsing failures,
503    /// missing or invalid signatures, untrusted signing certificates, issuer,
504    /// destination, recipient, audience, status, time-window, and unexpected
505    /// `InResponseTo` failures.
506    pub fn parse_login_response(
507        &self,
508        idp: &IdentityProvider,
509        binding: Binding,
510        request: &HttpRequest,
511    ) -> Result<FlowResult, SamlError> {
512        self.parse_unsolicited_login_response(idp, binding, request)
513    }
514
515    /// Parse and validate an IdP-initiated login `<Response>` that is not bound
516    /// to an outbound AuthnRequest.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if the SP metadata has no ACS endpoint for `binding`,
521    /// `binding` is unsupported, the request is missing required binding
522    /// parameters, the SAML payload cannot be base64/DEFLATE decoded, XML
523    /// parsing or extraction fails, the status is not success, the required
524    /// signature is missing or invalid, no trusted IdP signing key is available,
525    /// or issuer, destination, bearer recipient, audience, subject-confirmation,
526    /// or time-window validation fails. Because this path is unsolicited, any
527    /// non-empty response or bearer `InResponseTo` also returns an error.
528    pub fn parse_unsolicited_login_response(
529        &self,
530        idp: &IdentityProvider,
531        binding: Binding,
532        request: &HttpRequest,
533    ) -> Result<FlowResult, SamlError> {
534        self.parse_login_response_inner(
535            idp,
536            binding,
537            request,
538            LoginResponseCorrelation::Unsolicited,
539            LoginResponseParseOptions::compatibility(self.setting.clock_drifts),
540        )
541    }
542
543    pub(crate) fn parse_unsolicited_login_response_at(
544        &self,
545        idp: &IdentityProvider,
546        binding: Binding,
547        request: &HttpRequest,
548        now: SystemTime,
549        clock_drifts: (i64, i64),
550    ) -> Result<FlowResult, SamlError> {
551        self.parse_login_response_inner(
552            idp,
553            binding,
554            request,
555            LoginResponseCorrelation::Unsolicited,
556            LoginResponseParseOptions::at(now, clock_drifts),
557        )
558    }
559
560    /// Like [`Self::parse_login_response`] but also requires `InResponseTo` to
561    /// equal `request_id` (anti-replay: bind the response to a request you sent).
562    ///
563    /// An empty caller-provided `request_id` is rejected as
564    /// [`SamlError::InvalidInResponseTo`]. A non-empty `request_id` that does
565    /// not match the SAML response returns [`SamlError::InResponseToMismatch`].
566    ///
567    /// # Errors
568    ///
569    /// Returns an error if `request_id` is empty, the SP metadata has no ACS
570    /// endpoint for `binding`, `binding` is unsupported, the request is missing
571    /// required binding parameters, the SAML payload cannot be base64/DEFLATE
572    /// decoded, XML parsing or extraction fails, the status is not success, the
573    /// required signature is missing or invalid, no trusted IdP signing key is
574    /// available, or issuer, destination, bearer recipient, audience,
575    /// `InResponseTo`, subject-confirmation, or time-window validation fails.
576    pub fn parse_login_response_with_request_id(
577        &self,
578        idp: &IdentityProvider,
579        binding: Binding,
580        request: &HttpRequest,
581        request_id: &str,
582    ) -> Result<FlowResult, SamlError> {
583        if request_id.is_empty() {
584            return Err(SamlError::InvalidInResponseTo);
585        }
586        self.parse_login_response_inner(
587            idp,
588            binding,
589            request,
590            LoginResponseCorrelation::MessageId(request_id),
591            LoginResponseParseOptions::compatibility(self.setting.clock_drifts),
592        )
593    }
594
595    pub(crate) fn parse_login_response_with_request_id_at(
596        &self,
597        idp: &IdentityProvider,
598        binding: Binding,
599        request: &HttpRequest,
600        request_id: &str,
601        options: LoginResponseParseOptions<'_>,
602    ) -> Result<FlowResult, SamlError> {
603        if request_id.is_empty() {
604            return Err(SamlError::InvalidInResponseTo);
605        }
606        self.parse_login_response_inner(
607            idp,
608            binding,
609            request,
610            LoginResponseCorrelation::MessageId(request_id),
611            options,
612        )
613    }
614
615    fn parse_login_response_inner(
616        &self,
617        idp: &IdentityProvider,
618        binding: Binding,
619        request: &HttpRequest,
620        correlation: LoginResponseCorrelation<'_>,
621        options: LoginResponseParseOptions<'_>,
622    ) -> Result<FlowResult, SamlError> {
623        let signing_certs = idp.metadata.x509_certificates(CertUse::Signing);
624        let decrypt_key = if self.setting.is_assertion_encrypted {
625            self.setting.enc_private_key.as_deref()
626        } else {
627            None
628        };
629        let audience = self.entity_id();
630        let expected_in_response_to = match correlation {
631            LoginResponseCorrelation::MessageId(request_id) => Some(request_id),
632            LoginResponseCorrelation::Unsolicited => None,
633        };
634        let recipient = match options.expected_recipient {
635            Some(recipient) => recipient.to_string(),
636            None => self
637                .metadata
638                .get_assertion_consumer_service(binding)
639                .ok_or_else(|| SamlError::MissingMetadata("AssertionConsumerService".into()))?,
640        };
641        let result = flow_with_expected_recipient(
642            &FlowOptions {
643                binding: Some(binding),
644                parser_type: Some(ParserType::SamlResponse),
645                check_signature: true,
646                from_issuer: idp.metadata.get_entity_id(),
647                signing_certs: &signing_certs,
648                decrypt_key,
649                decrypt_key_pass: self.setting.enc_private_key_pass.as_deref(),
650                allow_insecure_software_rsa_key_transport_decryption: self
651                    .setting
652                    .allow_insecure_software_rsa_key_transport_decryption,
653                clock_drifts: options.clock_drifts,
654                now: options.now,
655                redirect_inflate_max_bytes: self.setting.redirect_inflate_max_bytes,
656                xml_limits: self.setting.xml_limits,
657                expected_audience: self.setting.validate_audience.then_some(audience.as_str()),
658                expected_in_response_to,
659            },
660            request,
661            recipient.as_str(),
662            if self.setting.want_assertions_signed {
663                AssertionSignatureRequirement::Direct
664            } else {
665                AssertionSignatureRequirement::Compatible
666            },
667        )?;
668        if matches!(correlation, LoginResponseCorrelation::Unsolicited)
669            && result
670                .extract
671                .get_str("response.inResponseTo")
672                .is_some_and(|actual| !actual.is_empty())
673        {
674            return Err(SamlError::in_response_to_mismatch(
675                None,
676                result.extract.get_str("response.inResponseTo"),
677            ));
678        }
679        if matches!(correlation, LoginResponseCorrelation::Unsolicited) {
680            reject_unsolicited_request_bound_bearer_confirmations(
681                &result.extract,
682                self.setting.xml_limits,
683            )?;
684        }
685        Ok(result)
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::binding::{base64_decode, deflate_raw_decode};
693    use crate::metadata::{Endpoint, IdpMetadataConfig};
694    use url::Url;
695
696    fn unsigned_idp() -> Result<IdentityProvider, SamlError> {
697        IdentityProvider::from_config(
698            &IdpMetadataConfig {
699                entity_id: "https://idp.example.com/metadata".into(),
700                single_sign_on_service: vec![
701                    Endpoint::new(Binding::Redirect, "https://idp.example.com/sso"),
702                    Endpoint::new(Binding::Post, "https://idp.example.com/sso"),
703                ],
704                ..Default::default()
705            },
706            EntitySetting::default(),
707        )
708    }
709
710    fn unsigned_sp() -> Result<ServiceProvider, SamlError> {
711        ServiceProvider::from_config(
712            &SpMetadataConfig {
713                entity_id: "https://sp.example.com/metadata".into(),
714                assertion_consumer_service: vec![Endpoint::new(
715                    Binding::Post,
716                    "https://sp.example.com/acs",
717                )],
718                ..Default::default()
719            },
720            EntitySetting::default(),
721        )
722    }
723
724    #[test]
725    fn create_unsigned_login_request_redirect_round_trips() -> Result<(), Box<dyn std::error::Error>>
726    {
727        let ctx = unsigned_sp()?.create_login_request(&unsigned_idp()?, Binding::Redirect, None)?;
728        let url = Url::parse(&ctx.context)?;
729        let (_, value) = url
730            .query_pairs()
731            .find(|(k, _)| k == "SAMLRequest")
732            .ok_or("missing SAMLRequest")?;
733        let xml = String::from_utf8(deflate_raw_decode(&base64_decode(&value)?)?)?;
734        assert!(xml.contains("AssertionConsumerServiceURL=\"https://sp.example.com/acs\""));
735        assert!(url.query_pairs().all(|(k, _)| k != "Signature"));
736        Ok(())
737    }
738
739    #[test]
740    fn create_unsigned_login_request_post_is_base64() -> Result<(), Box<dyn std::error::Error>> {
741        let ctx = unsigned_sp()?.create_login_request(&unsigned_idp()?, Binding::Post, None)?;
742        let xml = String::from_utf8(base64_decode(&ctx.context)?)?;
743        assert!(xml.starts_with("<samlp:AuthnRequest"));
744        Ok(())
745    }
746
747    #[test]
748    fn custom_tag_replacement_overrides_request() -> Result<(), Box<dyn std::error::Error>> {
749        let replace = |_t: &str| {
750            (
751                "_custom".to_string(),
752                "<samlp:AuthnRequest ID=\"_custom\"/>".to_string(),
753            )
754        };
755        let ctx = unsigned_sp()?.create_login_request(
756            &unsigned_idp()?,
757            Binding::Post,
758            Some(&replace as &dyn Fn(&str) -> (String, String)),
759        )?;
760        assert_eq!(ctx.id, "_custom");
761        let xml = String::from_utf8(base64_decode(&ctx.context)?)?;
762        assert!(xml.contains("ID=\"_custom\""));
763        Ok(())
764    }
765}
766
767#[cfg(all(test, feature = "crypto-bergshamra"))]
768mod crypto_tests {
769    use super::*;
770    use crate::binding::base64_decode;
771    use crate::constants::signature_algorithm::RSA_SHA256;
772    use crate::crypto::verify_signature;
773    use crate::entity::User;
774    use crate::idp::LoginResponseOptions;
775    use crate::metadata::{Endpoint, IdpMetadataConfig};
776
777    const IDP_CERT: &str = include_str!("../tests/fixtures/key/idp_cert.cer");
778    const SP_PRIVKEY: &str = include_str!("../tests/fixtures/key/sp_privkey.pem");
779    const SP_SIGNING_CERT: &str = include_str!("../tests/fixtures/key/sp_signing_cert.cer");
780
781    fn signing_idp() -> Result<IdentityProvider, SamlError> {
782        IdentityProvider::from_config(
783            &IdpMetadataConfig {
784                entity_id: "https://idp.example.com/metadata".into(),
785                signing_certs: vec![IDP_CERT.into()],
786                want_authn_requests_signed: true,
787                single_sign_on_service: vec![
788                    Endpoint::new(Binding::Redirect, "https://idp/sso"),
789                    Endpoint::new(Binding::Post, "https://idp/sso"),
790                ],
791                ..Default::default()
792            },
793            EntitySetting::default(),
794        )
795    }
796
797    #[test]
798    fn parse_signed_response_extracts_name_id() -> Result<(), Box<dyn std::error::Error>> {
799        let idp = IdentityProvider::from_config(
800            &IdpMetadataConfig {
801                entity_id: "https://idp.example.com/metadata".into(),
802                signing_certs: vec![SP_SIGNING_CERT.into()],
803                single_sign_on_service: vec![Endpoint::new(Binding::Post, "https://idp/sso")],
804                ..Default::default()
805            },
806            EntitySetting {
807                private_key: Some(SP_PRIVKEY.into()),
808                signing_cert: Some(SP_SIGNING_CERT.into()),
809                request_signature_algorithm: RSA_SHA256.into(),
810                ..Default::default()
811            },
812        )?;
813        let sp = ServiceProvider::from_config(
814            &SpMetadataConfig {
815                entity_id: "https://sp.example.com/metadata".into(),
816                assertion_consumer_service: vec![Endpoint::new(
817                    Binding::Post,
818                    "http://sp.example.com/demo1/index.php?acs",
819                )],
820                ..Default::default()
821            },
822            EntitySetting::default(),
823        )?;
824        let request_id = "_41e758fee373d51639552c4b040b1090e97f6685";
825        let name_id = "_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7";
826        let ctx = idp.create_login_response(
827            &sp,
828            Binding::Post,
829            &User::new(name_id),
830            &LoginResponseOptions {
831                in_response_to: Some(request_id),
832                ..Default::default()
833            },
834        )?;
835        let request = HttpRequest::post(vec![("SAMLResponse".into(), ctx.context)]);
836        let result =
837            sp.parse_login_response_with_request_id(&idp, Binding::Post, &request, request_id)?;
838        assert_eq!(result.extract.get_str("nameID"), Some(name_id));
839        Ok(())
840    }
841
842    #[test]
843    fn create_signed_post_request_verifies() -> Result<(), Box<dyn std::error::Error>> {
844        let sp = ServiceProvider::from_config(
845            &SpMetadataConfig {
846                entity_id: "https://sp.example.com/metadata".into(),
847                authn_requests_signed: true,
848                assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
849                ..Default::default()
850            },
851            EntitySetting {
852                private_key: Some(SP_PRIVKEY.into()),
853                signing_cert: Some(SP_SIGNING_CERT.into()),
854                request_signature_algorithm: RSA_SHA256.into(),
855                ..Default::default()
856            },
857        )?;
858        let ctx = sp.create_login_request(&signing_idp()?, Binding::Post, None)?;
859        let signed_xml = String::from_utf8(base64_decode(&ctx.context)?)?;
860        let (verified, _) = verify_signature(&signed_xml, &[SP_SIGNING_CERT.to_string()])?;
861        assert!(
862            verified,
863            "signed AuthnRequest should verify with the SP cert"
864        );
865        Ok(())
866    }
867
868    #[test]
869    fn create_signed_redirect_request_has_signature() -> Result<(), Box<dyn std::error::Error>> {
870        let sp = ServiceProvider::from_config(
871            &SpMetadataConfig {
872                entity_id: "https://sp.example.com/metadata".into(),
873                authn_requests_signed: true,
874                assertion_consumer_service: vec![Endpoint::new(Binding::Post, "https://sp/acs")],
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 ctx = sp.create_login_request(&signing_idp()?, Binding::Redirect, None)?;
885        assert!(ctx.context.contains("&SigAlg="));
886        assert!(ctx.context.contains("&Signature="));
887        Ok(())
888    }
889}