Skip to main content

saml_rs/
entity.rs

1//! Entity base settings shared by [`crate::sp::ServiceProvider`] and
2//! [`crate::idp::IdentityProvider`].
3
4use core::fmt;
5
6use crate::binding::MAX_DEFLATE_RAW_DECODE_BYTES;
7use crate::constants::{
8    data_encryption_algorithm, key_encryption_algorithm, signature_algorithm, transform_algorithm,
9    MessageSignatureOrder,
10};
11use crate::error::{SamlError, TimeWindowField};
12use crate::xml::XmlLimits;
13
14/// Runtime configuration for an entity (keys, algorithms, flags).
15///
16/// Use [`EntitySetting::default`] and tweak the fields you need.
17#[non_exhaustive]
18#[derive(Clone)]
19pub struct EntitySetting {
20    /// Override entity ID (otherwise taken from metadata).
21    pub entity_id: Option<String>,
22    /// Signature algorithm URI for outgoing signatures.
23    pub request_signature_algorithm: String,
24    /// Data encryption algorithm URI.
25    pub data_encryption_algorithm: String,
26    /// Key encryption algorithm URI.
27    pub key_encryption_algorithm: String,
28    /// Sign-then-encrypt vs encrypt-then-sign.
29    pub message_signing_order: MessageSignatureOrder,
30    /// `AllowCreate` for the NameIDPolicy.
31    pub allow_create: bool,
32    /// Whether assertions are encrypted.
33    pub is_assertion_encrypted: bool,
34    /// Allow XML-Enc RSA key-transport decryption with the bundled RustCrypto
35    /// software RSA backend.
36    ///
37    /// This is disabled by default for `crypto-rustcrypto` because that provider
38    /// reaches the RustCrypto `rsa` crate, which is affected by
39    /// `RUSTSEC-2023-0071` when an attacker can observe timing. AWS-LC and FIPS
40    /// ignore this flag because they use `aws-lc-rs`. Prefer an external/HSM
41    /// decryptor once one is exposed through the public API; enable this only as
42    /// an explicit compatibility exception for RustCrypto deployments that
43    /// accept that risk.
44    pub allow_insecure_software_rsa_key_transport_decryption: bool,
45    /// Default RelayState.
46    pub relay_state: String,
47    /// SP: signs its AuthnRequests.
48    pub authn_requests_signed: bool,
49    /// SP: requires signed assertions.
50    pub want_assertions_signed: bool,
51    /// SP: reject a `<Response>` whose `<Audience>` is not this entity (default `true`).
52    pub validate_audience: bool,
53    /// SP: reject a bearer assertion that omits `<AudienceRestriction>`.
54    ///
55    /// Applies only when `validate_audience` is set. Default `true` preserves
56    /// raw audience checking. Typed Web Browser SSO acceptance can evaluate
57    /// restrictions that are present without requiring the element.
58    pub(crate) require_audience_restriction: bool,
59    /// SP: requires signed messages.
60    pub want_message_signed: bool,
61    /// Typed SP: enforce the strict RSA-SHA2 profile on embedded signatures that
62    /// authenticate accepted SSO and Single Logout messages.
63    pub(crate) strict_xml_signature_profile: bool,
64    /// Typed SP: requires outer integrity for CBC-encrypted Assertions.
65    pub(crate) want_encrypted_cbc_response_signed: bool,
66    /// IdP: requires signed AuthnRequests.
67    pub want_authn_requests_signed: bool,
68    /// IdP: verify an AuthnRequest signature when one is present, without
69    /// requiring a signature.
70    pub(crate) verify_authn_request_signature_if_present: bool,
71    /// Requires signed LogoutRequest (default `true`).
72    pub want_logout_request_signed: bool,
73    /// Requires signed LogoutResponse.
74    pub want_logout_response_signed: bool,
75    /// Supported NameID formats.
76    pub name_id_format: Vec<String>,
77    /// Signing private key (PEM).
78    pub private_key: Option<String>,
79    /// Passphrase for `private_key`.
80    pub private_key_pass: Option<String>,
81    /// Signing certificate (PEM/base64).
82    pub signing_cert: Option<String>,
83    /// Encryption certificate (PEM/base64).
84    pub encrypt_cert: Option<String>,
85    /// Decryption private key (PEM).
86    pub enc_private_key: Option<String>,
87    /// Passphrase for `enc_private_key`.
88    pub enc_private_key_pass: Option<String>,
89    /// Clock drift tolerance `(not_before_ms, not_on_or_after_ms)`.
90    pub clock_drifts: (i64, i64),
91    /// Maximum decoded compressed and inflated raw-DEFLATE bytes accepted for
92    /// HTTP-Redirect input.
93    ///
94    /// SAML does not define this limit; the default is a conservative
95    /// resource-exhaustion guard for unauthenticated Redirect messages.
96    pub redirect_inflate_max_bytes: usize,
97    /// XML parser resource limits for inbound messages and metadata parsing.
98    pub xml_limits: XmlLimits,
99    /// IdP: protocol tag prefix for generated IdP messages (default `samlp`).
100    pub tag_prefix_protocol: String,
101    /// IdP: assertion tag prefix for generated IdP messages (default `saml`).
102    pub tag_prefix_assertion: String,
103    /// IdP: tag prefix for the `<EncryptedAssertion>` element (default `saml`).
104    pub tag_prefix_encrypted_assertion: String,
105    /// IdP: login `<Response>` template + attribute configuration.
106    pub login_response_template: Option<crate::template::LoginResponseTemplate>,
107    /// SP: custom `<AuthnRequest>` template (`None` uses the default).
108    pub login_request_template: Option<String>,
109    /// Custom `<LogoutRequest>` template (`None` uses the default).
110    ///
111    /// Typed Session Authority generation requires a complete unqualified
112    /// `NotOnOrAfter="{NotOnOrAfter}"` root attribute. Raw compatibility
113    /// generation keeps that optional placeholder omitted.
114    pub logout_request_template: Option<String>,
115    /// Custom `<LogoutResponse>` template (`None` uses the default).
116    ///
117    /// After prefix and placeholder substitution, the final outbound XML must
118    /// satisfy the enforced LogoutResponse structure, issuer, destination, and
119    /// request-correlation requirements. A root `<ds:Signature>` is rejected
120    /// before signing so the library owns signature construction. When
121    /// `InResponseTo` is `None`, an attribute whose complete value is the
122    /// `{InResponseTo}` placeholder is omitted.
123    pub logout_response_template: Option<String>,
124    /// Custom embedded-signature placement/prefix (`None` uses the default).
125    pub signature_config: Option<SignatureConfig>,
126    /// XML-DSig transforms applied to signed references (default
127    /// enveloped-signature + exclusive C14N).
128    pub transformation_algorithms: Vec<String>,
129}
130
131fn redacted_option(value: &Option<String>) -> Option<&'static str> {
132    value.as_ref().map(|_| "<redacted>")
133}
134
135impl fmt::Debug for EntitySetting {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.debug_struct("EntitySetting")
138            .field("entity_id", &self.entity_id)
139            .field(
140                "request_signature_algorithm",
141                &self.request_signature_algorithm,
142            )
143            .field("data_encryption_algorithm", &self.data_encryption_algorithm)
144            .field("key_encryption_algorithm", &self.key_encryption_algorithm)
145            .field("message_signing_order", &self.message_signing_order)
146            .field("allow_create", &self.allow_create)
147            .field("is_assertion_encrypted", &self.is_assertion_encrypted)
148            .field(
149                "allow_insecure_software_rsa_key_transport_decryption",
150                &self.allow_insecure_software_rsa_key_transport_decryption,
151            )
152            .field("relay_state", &self.relay_state)
153            .field("authn_requests_signed", &self.authn_requests_signed)
154            .field("want_assertions_signed", &self.want_assertions_signed)
155            .field("validate_audience", &self.validate_audience)
156            .field(
157                "require_audience_restriction",
158                &self.require_audience_restriction,
159            )
160            .field("want_message_signed", &self.want_message_signed)
161            .field(
162                "strict_xml_signature_profile",
163                &self.strict_xml_signature_profile,
164            )
165            .field(
166                "want_encrypted_cbc_response_signed",
167                &self.want_encrypted_cbc_response_signed,
168            )
169            .field(
170                "want_authn_requests_signed",
171                &self.want_authn_requests_signed,
172            )
173            .field(
174                "verify_authn_request_signature_if_present",
175                &self.verify_authn_request_signature_if_present,
176            )
177            .field(
178                "want_logout_request_signed",
179                &self.want_logout_request_signed,
180            )
181            .field(
182                "want_logout_response_signed",
183                &self.want_logout_response_signed,
184            )
185            .field("name_id_format", &self.name_id_format)
186            .field("private_key", &redacted_option(&self.private_key))
187            .field("private_key_pass", &redacted_option(&self.private_key_pass))
188            .field("signing_cert", &redacted_option(&self.signing_cert))
189            .field("encrypt_cert", &redacted_option(&self.encrypt_cert))
190            .field("enc_private_key", &redacted_option(&self.enc_private_key))
191            .field(
192                "enc_private_key_pass",
193                &redacted_option(&self.enc_private_key_pass),
194            )
195            .field("clock_drifts", &self.clock_drifts)
196            .field(
197                "redirect_inflate_max_bytes",
198                &self.redirect_inflate_max_bytes,
199            )
200            .field("xml_limits", &self.xml_limits)
201            .field("tag_prefix_protocol", &self.tag_prefix_protocol)
202            .field("tag_prefix_assertion", &self.tag_prefix_assertion)
203            .field(
204                "tag_prefix_encrypted_assertion",
205                &self.tag_prefix_encrypted_assertion,
206            )
207            .field("login_response_template", &self.login_response_template)
208            .field("login_request_template", &self.login_request_template)
209            .field("logout_request_template", &self.logout_request_template)
210            .field("logout_response_template", &self.logout_response_template)
211            .field("signature_config", &self.signature_config)
212            .field("transformation_algorithms", &self.transformation_algorithms)
213            .finish()
214    }
215}
216
217/// Custom message rendering hook: given the resolved template, returns
218/// `(id, rendered_xml)`.
219pub type CustomTagReplacement<'a> = &'a dyn Fn(&str) -> (String, String);
220
221/// Where to place the `<Signature>` relative to the reference element.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
223pub enum SignatureAction {
224    /// Insert as the reference's next sibling.
225    #[default]
226    After,
227    /// Insert as the reference's previous sibling.
228    Before,
229    /// Insert as the reference's first child.
230    Prepend,
231    /// Insert as the reference's last child.
232    Append,
233}
234
235/// Customizes the embedded XML-DSig signature.
236#[derive(Debug, Clone)]
237pub struct SignatureConfig {
238    /// Element prefix for the signature (default `ds`).
239    pub prefix: String,
240    /// `local-name()` XPath of the reference element; `None` keeps the default
241    /// (after the signed target's `<Issuer>`).
242    pub reference: Option<String>,
243    /// Placement relative to `reference`.
244    pub action: SignatureAction,
245}
246
247impl Default for SignatureConfig {
248    fn default() -> Self {
249        Self {
250            prefix: "ds".to_string(),
251            reference: None,
252            action: SignatureAction::After,
253        }
254    }
255}
256
257impl Default for EntitySetting {
258    fn default() -> Self {
259        Self {
260            entity_id: None,
261            request_signature_algorithm: signature_algorithm::RSA_SHA256.to_string(),
262            data_encryption_algorithm: data_encryption_algorithm::AES_256.to_string(),
263            key_encryption_algorithm: key_encryption_algorithm::RSA_OAEP_MGF1P.to_string(),
264            message_signing_order: MessageSignatureOrder::SignThenEncrypt,
265            allow_create: false,
266            is_assertion_encrypted: false,
267            allow_insecure_software_rsa_key_transport_decryption: false,
268            relay_state: String::new(),
269            authn_requests_signed: false,
270            want_assertions_signed: false,
271            validate_audience: true,
272            require_audience_restriction: true,
273            want_message_signed: false,
274            strict_xml_signature_profile: false,
275            want_encrypted_cbc_response_signed: false,
276            want_authn_requests_signed: false,
277            verify_authn_request_signature_if_present: false,
278            want_logout_request_signed: true,
279            want_logout_response_signed: true,
280            name_id_format: Vec::new(),
281            private_key: None,
282            private_key_pass: None,
283            signing_cert: None,
284            encrypt_cert: None,
285            enc_private_key: None,
286            enc_private_key_pass: None,
287            clock_drifts: (0, 0),
288            redirect_inflate_max_bytes: MAX_DEFLATE_RAW_DECODE_BYTES,
289            xml_limits: XmlLimits::default(),
290            tag_prefix_protocol: "samlp".to_string(),
291            tag_prefix_assertion: "saml".to_string(),
292            tag_prefix_encrypted_assertion: "saml".to_string(),
293            login_response_template: None,
294            login_request_template: None,
295            logout_request_template: None,
296            logout_response_template: None,
297            signature_config: None,
298            transformation_algorithms: vec![
299                transform_algorithm::ENVELOPED_SIGNATURE.to_string(),
300                transform_algorithm::EXC_C14N.to_string(),
301            ],
302        }
303    }
304}
305
306/// Generate a SAML message ID (`_` + UUIDv4).
307pub fn generate_id() -> String {
308    format!("_{}", uuid::Uuid::new_v4())
309}
310
311/// The authenticated subject an IdP issues a response for.
312#[derive(Debug, Clone, Default)]
313pub struct User {
314    /// `<NameID>` value.
315    pub name_id: String,
316    /// Attribute values keyed by their `LoginResponseAttribute.value_tag`;
317    /// each fills the `{attr<Tag>}` placeholder produced for that attribute.
318    pub attributes: Vec<(String, String)>,
319    /// `SessionIndex` for Single Logout requests.
320    pub session_index: Option<String>,
321}
322
323impl User {
324    /// A subject with just a NameID and no attributes.
325    pub fn new(name_id: impl Into<String>) -> Self {
326        Self {
327            name_id: name_id.into(),
328            ..Default::default()
329        }
330    }
331}
332
333/// Current UTC time as an ISO-8601 `IssueInstant` (`YYYY-MM-DDTHH:MM:SSZ`).
334pub fn now_iso8601() -> String {
335    iso8601_offset(0)
336}
337
338/// UTC time `seconds` from now as ISO-8601 (`YYYY-MM-DDTHH:MM:SSZ`).
339pub fn iso8601_offset(seconds: i64) -> String {
340    let t = time::OffsetDateTime::now_utc() + time::Duration::seconds(seconds);
341    format_saml_utc_date_time(t)
342}
343
344#[derive(Debug, Clone)]
345pub(crate) struct IdpIssuanceWindow {
346    pub(crate) issue_instant: String,
347    pub(crate) expiration: String,
348}
349
350pub(crate) fn capture_idp_issuance_window(
351    lifetime: time::Duration,
352) -> Result<IdpIssuanceWindow, SamlError> {
353    let issue_instant = time::OffsetDateTime::now_utc();
354    let expiration = issue_instant
355        .checked_add(lifetime)
356        .ok_or(SamlError::TimeWindowInvalid {
357            field: TimeWindowField::IdpIssuanceExpiration,
358        })?;
359    let preserve_subseconds = lifetime.subsec_nanoseconds() != 0;
360    Ok(IdpIssuanceWindow {
361        issue_instant: format_idp_issuance_instant(issue_instant, preserve_subseconds),
362        expiration: format_idp_issuance_instant(expiration, preserve_subseconds),
363    })
364}
365
366fn format_idp_issuance_instant(t: time::OffsetDateTime, preserve_subseconds: bool) -> String {
367    if preserve_subseconds {
368        format!(
369            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:09}Z",
370            t.year(),
371            u8::from(t.month()),
372            t.day(),
373            t.hour(),
374            t.minute(),
375            t.second(),
376            t.nanosecond(),
377        )
378    } else {
379        format_saml_utc_date_time(t)
380    }
381}
382
383// `OffsetDateTime` represents seconds in the range 0..=59, so this outbound
384// formatter cannot emit the leap-second value prohibited by SAML Core ยง1.3.3.
385pub(crate) fn format_saml_utc_date_time(t: time::OffsetDateTime) -> String {
386    format!(
387        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
388        t.year(),
389        u8::from(t.month()),
390        t.day(),
391        t.hour(),
392        t.minute(),
393        t.second(),
394    )
395}
396
397#[cfg(test)]
398mod tests {
399    use super::format_saml_utc_date_time;
400
401    #[test]
402    fn outbound_saml_instants_do_not_generate_leap_seconds(
403    ) -> Result<(), Box<dyn std::error::Error>> {
404        // The final representable second before the 2016 leap-second boundary.
405        let last_second = time::OffsetDateTime::from_unix_timestamp(1_483_228_799)?;
406
407        assert_eq!(
408            format_saml_utc_date_time(last_second),
409            "2016-12-31T23:59:59Z"
410        );
411        Ok(())
412    }
413}
414
415/// The product of building an outbound message for a binding.
416#[derive(Debug, Clone)]
417pub struct BindingContext {
418    /// Generated message ID.
419    pub id: String,
420    /// Redirect: the full URL. POST/SimpleSign: the base64 message.
421    pub context: String,
422    /// RelayState, if any.
423    pub relay_state: Option<String>,
424    /// Destination endpoint.
425    pub entity_endpoint: String,
426    /// Binding used.
427    pub binding: crate::constants::Binding,
428    /// `SAMLRequest` or `SAMLResponse`.
429    pub request_type: &'static str,
430    /// Detached signature (redirect/SimpleSign signed messages), if computed.
431    pub signature: Option<String>,
432    /// Signature algorithm URI accompanying `signature`.
433    pub sig_alg: Option<String>,
434}
435
436impl BindingContext {
437    /// Build the POST/SimpleSign auto-submit form (the `context` must be base64).
438    ///
439    /// If exactly one of `sig_alg` or `signature` is present, this infallible
440    /// helper omits the detached SimpleSign fields. Use [`Self::try_post_form`]
441    /// to reject partial detached signature state.
442    pub fn post_form(&self) -> String {
443        crate::binding::saml_post_binding_form_with_signature(
444            &self.entity_endpoint,
445            self.request_type,
446            &self.context,
447            self.relay_state.as_deref(),
448            self.sig_alg.as_deref(),
449            self.signature.as_deref(),
450        )
451    }
452
453    /// Build the POST/SimpleSign auto-submit form after validating the endpoint.
454    ///
455    /// # Errors
456    ///
457    /// Returns [`crate::error::SamlError::Invalid`] when `entity_endpoint`
458    /// is not an absolute HTTP(S) URL, or when detached SimpleSign state has
459    /// only one of `sig_alg` or `signature`.
460    pub fn try_post_form(&self) -> Result<String, crate::error::SamlError> {
461        crate::binding::try_saml_post_binding_form_with_signature(
462            &self.entity_endpoint,
463            self.request_type,
464            &self.context,
465            self.relay_state.as_deref(),
466            self.sig_alg.as_deref(),
467            self.signature.as_deref(),
468        )
469    }
470}