Skip to main content

rustauth_saml/
bridge.rs

1//! Maps RustAuth [`SamlConfig`] to [`opensaml`] entities (upstream `helpers.ts` parity).
2
3use std::time::Duration;
4
5use opensaml::constants::signature_algorithm;
6use opensaml::constants::Binding;
7use opensaml::entity::EntitySetting;
8#[cfg(feature = "saml-signed")]
9use opensaml::entity::{BindingContext, User};
10use opensaml::error::OpenSamlError;
11#[cfg(feature = "saml-signed")]
12use opensaml::flow::{flow, FlowOptions, FlowResult, HttpRequest};
13use opensaml::idp::IdentityProvider;
14#[cfg(feature = "saml-signed")]
15use opensaml::logout::{
16    create_logout_request_with_id, create_logout_response_with_id, parse_logout_request,
17    parse_logout_response,
18};
19use opensaml::metadata::{Endpoint, IdpMetadataConfig, SpMetadataConfig};
20use opensaml::sp::ServiceProvider;
21#[cfg(feature = "saml-signed")]
22use opensaml::util::Value;
23
24use crate::options::SamlConfig;
25use crate::saml_impl::authn_request::assertion_consumer_service_url;
26#[cfg(feature = "saml-signed")]
27use crate::saml_impl::security::SamlConditions;
28
29/// Runtime inputs when building a service provider entity.
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct SpBuildOptions {
32    pub relay_state: Option<String>,
33    pub clock_skew: Duration,
34    pub single_logout_enabled: bool,
35    pub want_logout_request_signed: bool,
36    pub want_logout_response_signed: bool,
37}
38
39/// Stable RustAuth error code for an [`OpenSamlError`].
40pub fn opensaml_error_code(error: &OpenSamlError) -> &'static str {
41    match error {
42        OpenSamlError::FailedToVerifySignature
43        | OpenSamlError::FailedMessageSignatureVerification
44        | OpenSamlError::PotentialWrappingAttack
45        | OpenSamlError::UnmatchCertificate => "SAML_SIGNATURE_INVALID",
46        OpenSamlError::MissingKey(_) | OpenSamlError::MissingMetadata(_) => {
47            "SAML_CERTIFICATE_REQUIRED"
48        }
49        OpenSamlError::Unsupported(_) => "SAML_SIGNATURE_VALIDATION_NOT_IMPLEMENTED",
50        OpenSamlError::InvalidInResponseTo => "INVALID_SAML_STATE",
51        OpenSamlError::UnmatchIssuer | OpenSamlError::UnmatchAudience => "INVALID_SAML_RESPONSE",
52        OpenSamlError::ExpiredSession | OpenSamlError::SubjectUnconfirmed => {
53            "INVALID_SAML_RESPONSE"
54        }
55        OpenSamlError::Crypto(_) => "SAML_ASSERTION_DECRYPTION_FAILED",
56        OpenSamlError::UndefinedStatus
57        | OpenSamlError::FailedStatus { .. }
58        | OpenSamlError::Invalid(_)
59        | OpenSamlError::Xml(_)
60        | OpenSamlError::Deflate(_)
61        | OpenSamlError::Base64(_)
62        | OpenSamlError::UndefinedBinding
63        | OpenSamlError::MissingSigAlg => "INVALID_SAML_RESPONSE",
64        _ => "INVALID_SAML_RESPONSE",
65    }
66}
67
68pub fn create_service_provider(
69    config: &SamlConfig,
70    base_url: &str,
71    provider_id: &str,
72    opts: &SpBuildOptions,
73) -> Result<ServiceProvider, OpenSamlError> {
74    if let Some(metadata) = config
75        .sp_metadata
76        .metadata
77        .as_deref()
78        .filter(|value| !value.trim().is_empty())
79    {
80        return ServiceProvider::from_metadata(metadata, sp_entity_setting(config, opts));
81    }
82
83    let acs = assertion_consumer_service_url(provider_id, base_url, config);
84    let mut slo = Vec::new();
85    if opts.single_logout_enabled {
86        let slo_url = format!(
87            "{}/sso/saml2/sp/slo/{}",
88            base_url.trim_end_matches('/'),
89            provider_id
90        );
91        slo.push(Endpoint::new(Binding::Post, slo_url.clone()));
92        slo.push(Endpoint::new(Binding::Redirect, slo_url));
93    }
94
95    let entity_id = config
96        .sp_metadata
97        .entity_id
98        .as_deref()
99        .unwrap_or(config.issuer.as_str())
100        .to_owned();
101
102    let mut name_id_format = Vec::new();
103    if let Some(format) = &config.identifier_format {
104        name_id_format.push(format.clone());
105    }
106
107    ServiceProvider::from_config(
108        &SpMetadataConfig {
109            entity_id,
110            signing_certs: sp_signing_certs(config),
111            encrypt_certs: Vec::new(),
112            authn_requests_signed: config.authn_requests_signed,
113            want_assertions_signed: config.want_assertions_signed,
114            name_id_format,
115            single_logout_service: slo,
116            assertion_consumer_service: vec![Endpoint::new(Binding::Post, acs)],
117            elements_order: None,
118        },
119        sp_entity_setting(config, opts),
120    )
121}
122
123pub fn create_identity_provider(config: &SamlConfig) -> Result<IdentityProvider, OpenSamlError> {
124    if let Some(metadata) = config
125        .idp_metadata
126        .as_ref()
127        .and_then(|idp| idp.metadata.as_deref())
128        .filter(|value| !value.trim().is_empty())
129    {
130        return IdentityProvider::from_metadata(metadata, idp_entity_setting(config));
131    }
132
133    let entity_id = idp_entity_id(config);
134
135    let mut single_sign_on_service = Vec::new();
136    if let Some(services) = config
137        .idp_metadata
138        .as_ref()
139        .and_then(|idp| idp.single_sign_on_service.as_ref())
140    {
141        for service in services {
142            if let Some(binding) = binding_from_urn(&service.binding) {
143                single_sign_on_service.push(Endpoint::new(binding, service.location.clone()));
144            }
145        }
146    }
147    if single_sign_on_service.is_empty() && !config.entry_point.is_empty() {
148        single_sign_on_service.push(Endpoint::new(Binding::Redirect, config.entry_point.clone()));
149    }
150
151    let mut single_logout_service = idp_logout_endpoints(config);
152    if single_logout_service.is_empty() && !config.entry_point.is_empty() {
153        single_logout_service.push(Endpoint::new(Binding::Redirect, config.entry_point.clone()));
154    }
155
156    IdentityProvider::from_config(
157        &IdpMetadataConfig {
158            entity_id,
159            signing_certs: idp_signing_certs(config),
160            encrypt_certs: Vec::new(),
161            want_authn_requests_signed: config.authn_requests_signed,
162            name_id_format: Vec::new(),
163            single_sign_on_service,
164            single_logout_service,
165        },
166        idp_entity_setting(config),
167    )
168}
169
170#[cfg(feature = "saml-signed")]
171pub fn parse_login_response(
172    sp: &ServiceProvider,
173    idp: &IdentityProvider,
174    encoded_response: &str,
175    in_response_to: Option<&str>,
176    check_signature: bool,
177) -> Result<FlowResult, OpenSamlError> {
178    let compact = encoded_response.split_whitespace().collect::<String>();
179    let request = HttpRequest::post(vec![("SAMLResponse".to_owned(), compact.clone())]);
180    let signing_certs = idp
181        .metadata
182        .x509_certificates(opensaml::constants::CertUse::Signing);
183    let encrypted = base64::Engine::decode(
184        &base64::engine::general_purpose::STANDARD,
185        compact.as_bytes(),
186    )
187    .ok()
188    .and_then(|bytes| String::from_utf8(bytes).ok())
189    .is_some_and(|xml| xml.contains("EncryptedAssertion"));
190    let decrypt_key = if encrypted {
191        sp.setting.enc_private_key.as_deref()
192    } else {
193        None
194    };
195    let audience = sp
196        .setting
197        .entity_id
198        .clone()
199        .or_else(|| sp.metadata.get_entity_id().map(str::to_string))
200        .unwrap_or_default();
201
202    flow(
203        &FlowOptions {
204            binding: Some(Binding::Post),
205            parser_type: Some(opensaml::constants::ParserType::SamlResponse),
206            check_signature,
207            from_issuer: idp.metadata.get_entity_id(),
208            signing_certs: &signing_certs,
209            decrypt_key,
210            decrypt_key_pass: decrypt_key.and(sp.setting.enc_private_key_pass.as_deref()),
211            clock_drifts: sp.setting.clock_drifts,
212            expected_audience: sp.setting.validate_audience.then_some(audience.as_str()),
213            expected_in_response_to: in_response_to.filter(|value| !value.is_empty()),
214        },
215        &request,
216    )
217}
218
219#[cfg(feature = "saml-signed")]
220fn sp_has_signing_key(config: &SamlConfig) -> bool {
221    config.private_key.is_some() || config.sp_metadata.private_key.is_some()
222}
223
224/// Build an outbound SP-initiated [`LogoutRequest`] via opensaml.
225#[cfg(feature = "saml-signed")]
226#[allow(clippy::too_many_arguments)]
227pub fn build_sp_logout_request(
228    config: &SamlConfig,
229    base_url: &str,
230    provider_id: &str,
231    opts: &SpBuildOptions,
232    request_id: &str,
233    name_id: &str,
234    session_index: Option<&str>,
235    relay_state: Option<&str>,
236    binding: Binding,
237) -> Result<BindingContext, OpenSamlError> {
238    let sp = create_service_provider(config, base_url, provider_id, opts)?;
239    let idp = create_identity_provider(config)?;
240    create_logout_request_with_id(
241        &sp.setting,
242        &sp.metadata,
243        &idp.metadata,
244        binding,
245        &User {
246            name_id: name_id.to_owned(),
247            session_index: session_index.map(str::to_owned),
248            attributes: Vec::new(),
249        },
250        relay_state,
251        sp_has_signing_key(config),
252        Some(request_id),
253    )
254}
255
256/// Build an outbound SP [`LogoutResponse`] via opensaml.
257#[cfg(feature = "saml-signed")]
258#[allow(clippy::too_many_arguments)]
259pub fn build_sp_logout_response(
260    config: &SamlConfig,
261    base_url: &str,
262    provider_id: &str,
263    opts: &SpBuildOptions,
264    response_id: &str,
265    in_response_to: &str,
266    relay_state: Option<&str>,
267    binding: Binding,
268) -> Result<BindingContext, OpenSamlError> {
269    let sp = create_service_provider(config, base_url, provider_id, opts)?;
270    let idp = create_identity_provider(config)?;
271    create_logout_response_with_id(
272        &sp.setting,
273        &sp.metadata,
274        &idp.metadata,
275        binding,
276        Some(in_response_to),
277        relay_state,
278        sp_has_signing_key(config),
279        Some(response_id),
280    )
281}
282
283/// Parse an inbound IdP-originated [`LogoutRequest`] at this SP.
284#[cfg(feature = "saml-signed")]
285pub fn parse_inbound_logout_request(
286    config: &SamlConfig,
287    base_url: &str,
288    provider_id: &str,
289    opts: &SpBuildOptions,
290    binding: Binding,
291    request: &HttpRequest,
292) -> Result<FlowResult, OpenSamlError> {
293    let sp = create_service_provider(config, base_url, provider_id, opts)?;
294    let idp = create_identity_provider(config)?;
295    parse_logout_request(&sp.setting, &idp.metadata, binding, request)
296}
297
298/// Parse an inbound IdP-originated [`LogoutResponse`] at this SP.
299#[cfg(feature = "saml-signed")]
300pub fn parse_inbound_logout_response(
301    config: &SamlConfig,
302    base_url: &str,
303    provider_id: &str,
304    opts: &SpBuildOptions,
305    binding: Binding,
306    request: &HttpRequest,
307) -> Result<FlowResult, OpenSamlError> {
308    let sp = create_service_provider(config, base_url, provider_id, opts)?;
309    let idp = create_identity_provider(config)?;
310    parse_logout_response(&sp.setting, &idp.metadata, binding, request)
311}
312
313#[cfg(feature = "saml-signed")]
314pub fn map_flow_to_conditions(extract: &Value) -> Option<SamlConditions> {
315    let conditions = extract.get("conditions")?;
316    Some(SamlConditions {
317        not_before: conditions.get_str("notBefore").map(str::to_owned),
318        not_on_or_after: conditions.get_str("notOnOrAfter").map(str::to_owned),
319    })
320}
321
322#[cfg(feature = "saml-signed")]
323pub fn map_flow_attributes(extract: &Value) -> std::collections::BTreeMap<String, String> {
324    let mut attributes = std::collections::BTreeMap::new();
325    let Some(Value::Object(entries)) = extract.get("attributes") else {
326        return attributes;
327    };
328    for (key, value) in entries {
329        let mapped = match value {
330            Value::Str(text) => text.clone(),
331            Value::Array(items) => items
332                .iter()
333                .filter_map(Value::as_str)
334                .collect::<Vec<_>>()
335                .join(","),
336            _ => continue,
337        };
338        attributes.insert(key.clone(), mapped);
339    }
340    attributes
341}
342
343#[cfg(feature = "saml-signed")]
344pub fn assertion_id_from_saml_content(xml: &str) -> Option<String> {
345    let field = opensaml::xml::ExtractorField::new("id", &["Response", "Assertion"]).attrs(&["ID"]);
346    opensaml::xml::extract(xml, std::slice::from_ref(&field))
347        .ok()
348        .and_then(|value| value.get_str("id").map(str::to_owned))
349}
350
351fn sp_entity_setting(config: &SamlConfig, opts: &SpBuildOptions) -> EntitySetting {
352    let skew_ms = opts.clock_skew.as_millis().min(i64::MAX as u128) as i64;
353    let mut setting = EntitySetting {
354        entity_id: config
355            .sp_metadata
356            .entity_id
357            .clone()
358            .or_else(|| Some(config.issuer.clone())),
359        request_signature_algorithm: resolve_signature_algorithm(config),
360        authn_requests_signed: config.authn_requests_signed,
361        want_assertions_signed: config.want_assertions_signed,
362        want_message_signed: config.want_assertions_signed,
363        want_logout_request_signed: opts.want_logout_request_signed,
364        want_logout_response_signed: opts.want_logout_response_signed,
365        is_assertion_encrypted: config.sp_metadata.is_assertion_encrypted.unwrap_or(false),
366        private_key: secret_to_string(config.private_key.as_ref())
367            .or_else(|| secret_to_string(config.sp_metadata.private_key.as_ref())),
368        private_key_pass: secret_to_string(config.sp_metadata.private_key_pass.as_ref()),
369        signing_cert: sp_signing_certs(config).into_iter().next(),
370        enc_private_key: secret_to_string(config.decryption_pvk.as_ref())
371            .or_else(|| secret_to_string(config.sp_metadata.enc_private_key.as_ref())),
372        enc_private_key_pass: secret_to_string(config.sp_metadata.enc_private_key_pass.as_ref()),
373        clock_drifts: (-skew_ms, skew_ms),
374        relay_state: opts.relay_state.clone().unwrap_or_default(),
375        ..EntitySetting::default()
376    };
377    if let Some(format) = &config.identifier_format {
378        setting.name_id_format = vec![format.clone()];
379    }
380    setting.logout_request_template = Some(
381        concat!(
382            r#"<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" "#,
383            r#"xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" "#,
384            r#"IssueInstant="{IssueInstant}" Destination="{Destination}">"#,
385            r#"<saml:Issuer>{Issuer}</saml:Issuer><saml:NameID>{NameID}</saml:NameID>"#,
386            r#"<samlp:SessionIndex>{SessionIndex}</samlp:SessionIndex></samlp:LogoutRequest>"#
387        )
388        .to_owned(),
389    );
390    setting
391}
392
393fn idp_entity_setting(config: &SamlConfig) -> EntitySetting {
394    EntitySetting {
395        entity_id: config
396            .idp_metadata
397            .as_ref()
398            .and_then(|idp| idp.entity_id.clone())
399            .or_else(|| Some(config.issuer.clone())),
400        want_authn_requests_signed: config.authn_requests_signed,
401        ..EntitySetting::default()
402    }
403}
404
405fn sp_signing_certs(config: &SamlConfig) -> Vec<String> {
406    if config.cert.is_empty() {
407        Vec::new()
408    } else {
409        vec![config.cert.clone()]
410    }
411}
412
413fn idp_signing_certs(config: &SamlConfig) -> Vec<String> {
414    if let Some(cert) = config
415        .idp_metadata
416        .as_ref()
417        .and_then(|idp| idp.cert.as_deref())
418        .filter(|cert| !cert.is_empty())
419    {
420        return vec![cert.to_owned()];
421    }
422    if !config.cert.is_empty() {
423        return vec![config.cert.clone()];
424    }
425    Vec::new()
426}
427
428fn idp_logout_endpoints(config: &SamlConfig) -> Vec<Endpoint> {
429    let Some(services) = config
430        .idp_metadata
431        .as_ref()
432        .and_then(|idp| idp.single_logout_service.as_ref())
433    else {
434        return Vec::new();
435    };
436    services
437        .iter()
438        .filter_map(|service| {
439            binding_from_urn(&service.binding)
440                .map(|binding| Endpoint::new(binding, service.location.clone()))
441        })
442        .collect()
443}
444
445fn binding_from_urn(urn: &str) -> Option<Binding> {
446    if urn.ends_with("HTTP-Redirect") {
447        Some(Binding::Redirect)
448    } else if urn.ends_with("HTTP-POST") {
449        Some(Binding::Post)
450    } else {
451        None
452    }
453}
454
455fn idp_entity_id(config: &SamlConfig) -> String {
456    config
457        .idp_metadata
458        .as_ref()
459        .and_then(|idp| idp.entity_id.clone())
460        .or_else(|| idp_entity_id_from_entry_point(&config.entry_point))
461        .unwrap_or_else(|| config.issuer.clone())
462}
463
464fn idp_entity_id_from_entry_point(entry_point: &str) -> Option<String> {
465    let url = url::Url::parse(entry_point).ok()?;
466    let host = url.host_str()?;
467    Some(format!("{}://{}", url.scheme(), host))
468}
469
470fn resolve_signature_algorithm(config: &SamlConfig) -> String {
471    match config.signature_algorithm.as_deref() {
472        Some(value) if value.contains("://") => value.to_owned(),
473        Some("sha256") | Some("SHA256") | Some("rsa-sha256") => {
474            signature_algorithm::RSA_SHA256.to_owned()
475        }
476        Some("sha1") | Some("SHA1") | Some("rsa-sha1") => signature_algorithm::RSA_SHA1.to_owned(),
477        Some("sha512") | Some("SHA512") | Some("rsa-sha512") => {
478            signature_algorithm::RSA_SHA512.to_owned()
479        }
480        Some(other) => other.to_owned(),
481        None => signature_algorithm::RSA_SHA256.to_owned(),
482    }
483}
484
485fn secret_to_string(secret: Option<&rustauth_core::secret::SecretString>) -> Option<String> {
486    secret.map(|value| value.expose_secret().to_owned())
487}
488
489#[cfg(test)]
490mod tests {
491    #![allow(clippy::expect_used, clippy::panic)]
492
493    use super::*;
494
495    #[test]
496    fn authn_request_for_post_slo_provider_config() {
497        use crate::options::{SamlIdpMetadata, SamlService, SamlSpMetadata};
498        use crate::saml_impl::authn_request::build_authn_request_redirect;
499
500        let config = SamlConfig {
501            issuer: "https://app.example.com/sso/saml2/sp/metadata".to_owned(),
502            entry_point: "https://idp.example.com/saml/sso".to_owned(),
503            cert: "CERTIFICATE".to_owned(),
504            callback_url: "https://app.example.com/sso/saml2/sp/acs/saml-okta".to_owned(),
505            acs_url: None,
506            audience: None,
507            idp_metadata: Some(SamlIdpMetadata {
508                single_logout_service: Some(vec![SamlService {
509                    binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST".to_owned(),
510                    location: "https://idp.example.com/saml/slo-post?tenant=acme".to_owned(),
511                }]),
512                ..Default::default()
513            }),
514            sp_metadata: SamlSpMetadata {
515                entity_id: Some("https://app.example.com/saml/sp".to_owned()),
516                ..Default::default()
517            },
518            mapping: None,
519            want_assertions_signed: false,
520            authn_requests_signed: false,
521            signature_algorithm: None,
522            digest_algorithm: None,
523            identifier_format: None,
524            private_key: None,
525            decryption_pvk: None,
526            additional_params: None,
527        };
528        let result = build_authn_request_redirect(
529            "saml-okta",
530            "https://app.example.com",
531            &config,
532            "id-test".to_owned(),
533            "id-test".to_owned(),
534        );
535        assert!(result.is_ok(), "{result:?}");
536    }
537
538    #[test]
539    fn create_sp_from_minimal_config() {
540        use crate::options::SamlSpMetadata;
541
542        let config = SamlConfig {
543            issuer: "https://sp.example.com".to_owned(),
544            entry_point: "https://idp.example.com/sso".to_owned(),
545            cert: "CERT".to_owned(),
546            callback_url: "https://sp.example.com/acs".to_owned(),
547            acs_url: None,
548            audience: None,
549            idp_metadata: None,
550            sp_metadata: SamlSpMetadata {
551                entity_id: Some("https://sp.example.com".to_owned()),
552                ..Default::default()
553            },
554            mapping: None,
555            want_assertions_signed: false,
556            authn_requests_signed: false,
557            signature_algorithm: None,
558            digest_algorithm: None,
559            identifier_format: None,
560            private_key: None,
561            decryption_pvk: None,
562            additional_params: None,
563        };
564        let sp = create_service_provider(
565            &config,
566            "https://app.example.com",
567            "provider-1",
568            &SpBuildOptions::default(),
569        )
570        .expect("sp");
571        assert_eq!(sp.metadata.get_entity_id(), Some("https://sp.example.com"));
572    }
573
574    #[cfg(feature = "saml-signed")]
575    #[test]
576    fn build_logout_request_includes_session_index() {
577        use std::io::Read;
578
579        use crate::options::SamlSpMetadata;
580        use base64::Engine;
581
582        let config = SamlConfig {
583            issuer: "https://app.example.com/sso/saml2/sp/metadata".to_owned(),
584            entry_point: "https://idp.example.com/saml/sso".to_owned(),
585            cert: "CERTIFICATE".to_owned(),
586            callback_url: "https://app.example.com/sso/saml2/sp/acs/saml-okta".to_owned(),
587            acs_url: None,
588            audience: None,
589            idp_metadata: None,
590            sp_metadata: SamlSpMetadata {
591                entity_id: Some("https://app.example.com/saml/sp".to_owned()),
592                ..Default::default()
593            },
594            mapping: None,
595            want_assertions_signed: false,
596            authn_requests_signed: false,
597            signature_algorithm: None,
598            digest_algorithm: None,
599            identifier_format: None,
600            private_key: None,
601            decryption_pvk: None,
602            additional_params: None,
603        };
604        let ctx = build_sp_logout_request(
605            &config,
606            "https://app.example.com",
607            "saml-okta",
608            &SpBuildOptions::default(),
609            "logout-req-test-id",
610            "user@example.com",
611            Some("session-1"),
612            Some("/done"),
613            Binding::Redirect,
614        )
615        .expect("logout request");
616        let url = url::Url::parse(&ctx.context).expect("redirect url");
617        let encoded = url
618            .query_pairs()
619            .find(|(key, _)| key == "SAMLRequest")
620            .map(|(_, value)| value.into_owned())
621            .expect("SAMLRequest");
622        let bytes = base64::engine::general_purpose::STANDARD
623            .decode(encoded)
624            .expect("base64 decode");
625        let mut xml = String::new();
626        flate2::read::DeflateDecoder::new(bytes.as_slice())
627            .read_to_string(&mut xml)
628            .expect("deflate");
629        assert!(xml.contains("<samlp:SessionIndex>session-1</samlp:SessionIndex>"));
630        assert!(xml.contains("user@example.com"));
631        assert!(xml.contains(r#"ID="logout-req-test-id""#));
632        assert_eq!(ctx.id, "logout-req-test-id");
633    }
634}