1use 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#[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
39pub 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#[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#[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#[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#[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}