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