1use crate::errors::{AuthError, Result};
6use chrono::{DateTime, Duration, Utc};
7use serde::{Deserialize, Serialize};
8
9fn xml_escape(s: &str) -> String {
11 let mut out = String::with_capacity(s.len());
12 for c in s.chars() {
13 match c {
14 '&' => out.push_str("&"),
15 '<' => out.push_str("<"),
16 '>' => out.push_str(">"),
17 '"' => out.push_str("""),
18 '\'' => out.push_str("'"),
19 _ => out.push(c),
20 }
21 }
22 out
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SamlAssertion {
28 pub id: String,
30
31 pub issuer: String,
33
34 pub issue_instant: DateTime<Utc>,
36
37 pub version: String,
39
40 pub subject: Option<SamlSubject>,
42
43 pub conditions: Option<SamlConditions>,
45
46 pub attribute_statements: Vec<SamlAttributeStatement>,
48
49 pub authn_statements: Vec<SamlAuthnStatement>,
51
52 pub authz_decision_statements: Vec<SamlAuthzDecisionStatement>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SamlSubject {
59 pub name_id: Option<SamlNameId>,
61
62 pub subject_confirmations: Vec<SamlSubjectConfirmation>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SamlNameId {
69 pub value: String,
71
72 pub format: Option<String>,
74
75 pub name_qualifier: Option<String>,
77
78 pub sp_name_qualifier: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SamlSubjectConfirmation {
85 pub method: String,
87
88 pub subject_confirmation_data: Option<SamlSubjectConfirmationData>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct SamlSubjectConfirmationData {
95 pub not_before: Option<DateTime<Utc>>,
97
98 pub not_on_or_after: Option<DateTime<Utc>>,
100
101 pub recipient: Option<String>,
103
104 pub in_response_to: Option<String>,
106
107 pub address: Option<String>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct SamlConditions {
114 pub not_before: Option<DateTime<Utc>>,
116
117 pub not_on_or_after: Option<DateTime<Utc>>,
119
120 pub audience_restrictions: Vec<SamlAudienceRestriction>,
122
123 pub one_time_use: bool,
125
126 pub proxy_restriction: Option<SamlProxyRestriction>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct SamlAudienceRestriction {
133 pub audiences: Vec<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SamlProxyRestriction {
140 pub count: Option<u32>,
142
143 pub audiences: Vec<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SamlAttributeStatement {
150 pub attributes: Vec<SamlAttribute>,
152
153 pub encrypted_attributes: Vec<String>, }
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct SamlAttribute {
160 pub name: String,
162
163 pub name_format: Option<String>,
165
166 pub friendly_name: Option<String>,
168
169 pub values: Vec<SamlAttributeValue>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SamlAttributeValue {
176 pub value: String,
178
179 pub type_info: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct SamlAuthnStatement {
186 pub authn_instant: DateTime<Utc>,
188
189 pub session_index: Option<String>,
191
192 pub session_not_on_or_after: Option<DateTime<Utc>>,
194
195 pub authn_context: SamlAuthnContext,
197
198 pub subject_locality: Option<SamlSubjectLocality>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct SamlAuthnContext {
205 pub authn_context_class_ref: Option<String>,
207
208 pub authn_context_decl: Option<String>,
210
211 pub authn_context_decl_ref: Option<String>,
213
214 pub authenticating_authorities: Vec<String>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct SamlSubjectLocality {
221 pub address: Option<String>,
223
224 pub dns_name: Option<String>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct SamlAuthzDecisionStatement {
231 pub resource: String,
233
234 pub decision: SamlDecision,
236
237 pub actions: Vec<SamlAction>,
239
240 pub evidence: Option<SamlEvidence>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
246pub enum SamlDecision {
247 Permit,
249
250 Deny,
252
253 Indeterminate,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct SamlAction {
260 pub value: String,
262
263 pub namespace: Option<String>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct SamlEvidence {
270 pub assertions: Vec<String>, pub assertion_id_refs: Vec<String>,
275
276 pub assertion_uri_refs: Vec<String>,
278}
279
280pub struct SamlAssertionBuilder {
282 assertion: SamlAssertion,
284}
285
286pub struct SamlAssertionValidator {
288 clock_skew: Duration,
290
291 trusted_issuers: Vec<String>,
293
294 expected_audiences: Vec<String>,
296}
297
298impl SamlAssertionBuilder {
299 pub fn new(issuer: &str) -> Self {
301 let assertion = SamlAssertion {
302 id: format!("_{}", uuid::Uuid::new_v4()),
303 issuer: issuer.to_string(),
304 issue_instant: Utc::now(),
305 version: "2.0".to_string(),
306 subject: None,
307 conditions: None,
308 attribute_statements: Vec::new(),
309 authn_statements: Vec::new(),
310 authz_decision_statements: Vec::new(),
311 };
312
313 Self { assertion }
314 }
315
316 pub fn with_subject(mut self, subject: SamlSubject) -> Self {
318 self.assertion.subject = Some(subject);
319 self
320 }
321
322 pub fn with_conditions(mut self, conditions: SamlConditions) -> Self {
324 self.assertion.conditions = Some(conditions);
325 self
326 }
327
328 pub fn with_attribute_statement(mut self, statement: SamlAttributeStatement) -> Self {
330 self.assertion.attribute_statements.push(statement);
331 self
332 }
333
334 pub fn with_authn_statement(mut self, statement: SamlAuthnStatement) -> Self {
336 self.assertion.authn_statements.push(statement);
337 self
338 }
339
340 pub fn with_authz_decision_statement(mut self, statement: SamlAuthzDecisionStatement) -> Self {
342 self.assertion.authz_decision_statements.push(statement);
343 self
344 }
345
346 pub fn with_attribute(mut self, name: &str, value: &str) -> Self {
348 let attribute = SamlAttribute {
349 name: name.to_string(),
350 name_format: Some("urn:oasis:names:tc:SAML:2.0:attrname-format:basic".to_string()),
351 friendly_name: None,
352 values: vec![SamlAttributeValue {
353 value: value.to_string(),
354 type_info: None,
355 }],
356 };
357
358 if self.assertion.attribute_statements.is_empty() {
360 self.assertion
361 .attribute_statements
362 .push(SamlAttributeStatement {
363 attributes: vec![attribute],
364 encrypted_attributes: Vec::new(),
365 });
366 } else {
367 self.assertion.attribute_statements[0]
368 .attributes
369 .push(attribute);
370 }
371
372 self
373 }
374
375 pub fn with_validity_period(
377 mut self,
378 not_before: DateTime<Utc>,
379 not_on_or_after: DateTime<Utc>,
380 ) -> Self {
381 if let Some(ref mut conditions) = self.assertion.conditions {
382 conditions.not_before = Some(not_before);
384 conditions.not_on_or_after = Some(not_on_or_after);
385 } else {
386 let conditions = SamlConditions {
388 not_before: Some(not_before),
389 not_on_or_after: Some(not_on_or_after),
390 audience_restrictions: Vec::new(),
391 one_time_use: false,
392 proxy_restriction: None,
393 };
394 self.assertion.conditions = Some(conditions);
395 }
396 self
397 }
398
399 pub fn with_audience(mut self, audience: &str) -> Self {
401 if let Some(ref mut conditions) = self.assertion.conditions {
402 if conditions.audience_restrictions.is_empty() {
403 conditions
404 .audience_restrictions
405 .push(SamlAudienceRestriction {
406 audiences: vec![audience.to_string()],
407 });
408 } else {
409 conditions.audience_restrictions[0]
410 .audiences
411 .push(audience.to_string());
412 }
413 } else {
414 let conditions = SamlConditions {
415 not_before: None,
416 not_on_or_after: None,
417 audience_restrictions: vec![SamlAudienceRestriction {
418 audiences: vec![audience.to_string()],
419 }],
420 one_time_use: false,
421 proxy_restriction: None,
422 };
423 self.assertion.conditions = Some(conditions);
424 }
425
426 self
427 }
428
429 pub fn build(self) -> SamlAssertion {
431 self.assertion
432 }
433
434 pub fn build_xml(self) -> Result<String> {
436 let assertion = self.assertion;
437 assertion.to_xml()
438 }
439}
440
441impl SamlAssertionValidator {
442 pub fn new() -> Self {
444 Self {
445 clock_skew: Duration::minutes(5),
446 trusted_issuers: Vec::new(),
447 expected_audiences: Vec::new(),
448 }
449 }
450
451 pub fn with_clock_skew(mut self, skew: Duration) -> Self {
453 self.clock_skew = skew;
454 self
455 }
456
457 pub fn with_trusted_issuer(mut self, issuer: &str) -> Self {
459 self.trusted_issuers.push(issuer.to_string());
460 self
461 }
462
463 pub fn with_expected_audience(mut self, audience: &str) -> Self {
465 self.expected_audiences.push(audience.to_string());
466 self
467 }
468
469 pub fn validate(&self, assertion: &SamlAssertion) -> Result<()> {
471 if !self.trusted_issuers.is_empty() && !self.trusted_issuers.contains(&assertion.issuer) {
473 return Err(AuthError::auth_method("saml", "Untrusted issuer"));
474 }
475
476 self.validate_timing(assertion)?;
478
479 self.validate_audience(assertion)?;
481
482 if let Some(ref subject) = assertion.subject {
484 self.validate_subject_confirmation(subject)?;
485 }
486
487 Ok(())
488 }
489
490 fn validate_timing(&self, assertion: &SamlAssertion) -> Result<()> {
492 let now = Utc::now();
493
494 if assertion.issue_instant > now + self.clock_skew {
496 return Err(AuthError::auth_method(
497 "saml",
498 "Assertion issued in the future",
499 ));
500 }
501
502 if let Some(ref conditions) = assertion.conditions {
504 if let Some(not_before) = conditions.not_before
505 && now < not_before - self.clock_skew
506 {
507 return Err(AuthError::auth_method("saml", "Assertion not yet valid"));
508 }
509
510 if let Some(not_on_or_after) = conditions.not_on_or_after
511 && now >= not_on_or_after + self.clock_skew
512 {
513 return Err(AuthError::auth_method("saml", "Assertion has expired"));
514 }
515 }
516
517 Ok(())
518 }
519
520 fn validate_audience(&self, assertion: &SamlAssertion) -> Result<()> {
522 if self.expected_audiences.is_empty() {
523 return Ok(());
524 }
525
526 if let Some(ref conditions) = assertion.conditions {
527 for restriction in &conditions.audience_restrictions {
528 for audience in &restriction.audiences {
529 if self.expected_audiences.contains(audience) {
530 return Ok(());
531 }
532 }
533 }
534
535 if !conditions.audience_restrictions.is_empty() {
536 return Err(AuthError::auth_method("saml", "No matching audience found"));
537 }
538 }
539
540 Ok(())
541 }
542
543 fn validate_subject_confirmation(&self, subject: &SamlSubject) -> Result<()> {
550 const ALLOWED_METHODS: &[&str] = &[
551 "urn:oasis:names:tc:SAML:2.0:cm:bearer",
552 "urn:oasis:names:tc:SAML:2.0:cm:holder-of-key",
553 "urn:oasis:names:tc:SAML:2.0:cm:sender-vouches",
554 ];
555
556 for confirmation in &subject.subject_confirmations {
557 if !ALLOWED_METHODS.contains(&confirmation.method.as_str()) {
559 return Err(AuthError::auth_method(
560 "saml",
561 &format!(
562 "Unsupported subject confirmation method: {}",
563 confirmation.method
564 ),
565 ));
566 }
567
568 if let Some(ref data) = confirmation.subject_confirmation_data {
570 let now = Utc::now();
571
572 if let Some(not_on_or_after) = data.not_on_or_after {
574 if now >= not_on_or_after + self.clock_skew {
575 return Err(AuthError::auth_method(
576 "saml",
577 "Subject confirmation has expired (NotOnOrAfter)",
578 ));
579 }
580 }
581
582 if let Some(not_before) = data.not_before {
584 if now < not_before - self.clock_skew {
585 return Err(AuthError::auth_method(
586 "saml",
587 "Subject confirmation is not yet valid (NotBefore)",
588 ));
589 }
590 }
591
592 if let Some(ref recipient) = data.recipient {
594 if !self.expected_audiences.is_empty()
595 && !self.expected_audiences.contains(recipient)
596 {
597 return Err(AuthError::auth_method(
598 "saml",
599 "Subject confirmation recipient does not match expected audience",
600 ));
601 }
602 }
603 }
604 }
605
606 Ok(())
607 }
608}
609
610impl SamlAssertion {
611 pub fn to_xml(&self) -> Result<String> {
613 let mut xml = String::new();
614
615 xml.push_str(&format!(
616 r#"<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{}" IssueInstant="{}" Version="{}">"#,
617 xml_escape(&self.id),
618 self.issue_instant.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
619 xml_escape(&self.version)
620 ));
621
622 xml.push_str(&format!("<saml:Issuer>{}</saml:Issuer>", xml_escape(&self.issuer)));
624
625 if let Some(ref subject) = self.subject {
627 xml.push_str(&subject.to_xml()?);
628 }
629
630 if let Some(ref conditions) = self.conditions {
632 xml.push_str(&conditions.to_xml()?);
633 }
634
635 for statement in &self.attribute_statements {
637 xml.push_str(&statement.to_xml()?);
638 }
639
640 for statement in &self.authn_statements {
642 xml.push_str(&statement.to_xml()?);
643 }
644
645 for statement in &self.authz_decision_statements {
647 xml.push_str(&statement.to_xml()?);
648 }
649
650 xml.push_str("</saml:Assertion>");
651
652 Ok(xml)
653 }
654}
655
656impl SamlSubject {
657 pub fn to_xml(&self) -> Result<String> {
659 let mut xml = String::new();
660
661 xml.push_str("<saml:Subject>");
662
663 if let Some(ref name_id) = self.name_id {
664 xml.push_str(&name_id.to_xml()?);
665 }
666
667 for confirmation in &self.subject_confirmations {
668 xml.push_str(&confirmation.to_xml()?);
669 }
670
671 xml.push_str("</saml:Subject>");
672
673 Ok(xml)
674 }
675}
676
677impl SamlNameId {
678 pub fn to_xml(&self) -> Result<String> {
680 let mut xml = String::new();
681
682 xml.push_str("<saml:NameID");
683
684 if let Some(ref format) = self.format {
685 xml.push_str(&format!(" Format=\"{}\"", xml_escape(format)));
686 }
687
688 if let Some(ref name_qualifier) = self.name_qualifier {
689 xml.push_str(&format!(" NameQualifier=\"{}\"", xml_escape(name_qualifier)));
690 }
691
692 if let Some(ref sp_name_qualifier) = self.sp_name_qualifier {
693 xml.push_str(&format!(" SPNameQualifier=\"{}\"", xml_escape(sp_name_qualifier)));
694 }
695
696 xml.push_str(&format!(">{}</saml:NameID>", xml_escape(&self.value)));
697
698 Ok(xml)
699 }
700}
701
702impl SamlSubjectConfirmation {
703 pub fn to_xml(&self) -> Result<String> {
705 let mut xml = String::new();
706
707 xml.push_str(&format!(
708 "<saml:SubjectConfirmation Method=\"{}\">",
709 xml_escape(&self.method)
710 ));
711
712 if let Some(ref data) = self.subject_confirmation_data {
713 xml.push_str(&data.to_xml()?);
714 }
715
716 xml.push_str("</saml:SubjectConfirmation>");
717
718 Ok(xml)
719 }
720}
721
722impl SamlSubjectConfirmationData {
723 pub fn to_xml(&self) -> Result<String> {
725 let mut xml = String::new();
726
727 xml.push_str("<saml:SubjectConfirmationData");
728
729 if let Some(not_before) = self.not_before {
730 xml.push_str(&format!(
731 " NotBefore=\"{}\"",
732 not_before.format("%Y-%m-%dT%H:%M:%S%.3fZ")
733 ));
734 }
735
736 if let Some(not_on_or_after) = self.not_on_or_after {
737 xml.push_str(&format!(
738 " NotOnOrAfter=\"{}\"",
739 not_on_or_after.format("%Y-%m-%dT%H:%M:%S%.3fZ")
740 ));
741 }
742
743 if let Some(ref recipient) = self.recipient {
744 xml.push_str(&format!(" Recipient=\"{}\"", xml_escape(recipient)));
745 }
746
747 if let Some(ref in_response_to) = self.in_response_to {
748 xml.push_str(&format!(" InResponseTo=\"{}\"", xml_escape(in_response_to)));
749 }
750
751 if let Some(ref address) = self.address {
752 xml.push_str(&format!(" Address=\"{}\"", xml_escape(address)));
753 }
754
755 xml.push_str("/>");
756
757 Ok(xml)
758 }
759}
760
761impl SamlConditions {
762 pub fn to_xml(&self) -> Result<String> {
764 let mut xml = String::new();
765
766 xml.push_str("<saml:Conditions");
767
768 if let Some(not_before) = self.not_before {
769 xml.push_str(&format!(
770 " NotBefore=\"{}\"",
771 not_before.format("%Y-%m-%dT%H:%M:%S%.3fZ")
772 ));
773 }
774
775 if let Some(not_on_or_after) = self.not_on_or_after {
776 xml.push_str(&format!(
777 " NotOnOrAfter=\"{}\"",
778 not_on_or_after.format("%Y-%m-%dT%H:%M:%S%.3fZ")
779 ));
780 }
781
782 xml.push('>');
783
784 for restriction in &self.audience_restrictions {
785 xml.push_str(&restriction.to_xml()?);
786 }
787
788 if self.one_time_use {
789 xml.push_str("<saml:OneTimeUse/>");
790 }
791
792 if let Some(ref proxy_restriction) = self.proxy_restriction {
793 xml.push_str(&proxy_restriction.to_xml()?);
794 }
795
796 xml.push_str("</saml:Conditions>");
797
798 Ok(xml)
799 }
800}
801
802impl SamlAudienceRestriction {
803 pub fn to_xml(&self) -> Result<String> {
805 let mut xml = String::new();
806
807 xml.push_str("<saml:AudienceRestriction>");
808
809 for audience in &self.audiences {
810 xml.push_str(&format!("<saml:Audience>{}</saml:Audience>", xml_escape(audience)));
811 }
812
813 xml.push_str("</saml:AudienceRestriction>");
814
815 Ok(xml)
816 }
817}
818
819impl SamlProxyRestriction {
820 pub fn to_xml(&self) -> Result<String> {
822 let mut xml = String::new();
823
824 xml.push_str("<saml:ProxyRestriction");
825
826 if let Some(count) = self.count {
827 xml.push_str(&format!(" Count=\"{}\"", count));
828 }
829
830 xml.push('>');
831
832 for audience in &self.audiences {
833 xml.push_str(&format!("<saml:Audience>{}</saml:Audience>", xml_escape(audience)));
834 }
835
836 xml.push_str("</saml:ProxyRestriction>");
837
838 Ok(xml)
839 }
840}
841
842impl SamlAttributeStatement {
843 pub fn to_xml(&self) -> Result<String> {
845 let mut xml = String::new();
846
847 xml.push_str("<saml:AttributeStatement>");
848
849 for attribute in &self.attributes {
850 xml.push_str(&attribute.to_xml()?);
851 }
852
853 for encrypted_attr in &self.encrypted_attributes {
855 xml.push_str(encrypted_attr);
856 }
857
858 xml.push_str("</saml:AttributeStatement>");
859
860 Ok(xml)
861 }
862}
863
864impl SamlAttribute {
865 pub fn to_xml(&self) -> Result<String> {
867 let mut xml = String::new();
868
869 xml.push_str(&format!("<saml:Attribute Name=\"{}\">", xml_escape(&self.name)));
870
871 if let Some(ref name_format) = self.name_format {
872 xml = xml.replace(">", &format!(" NameFormat=\"{}\">", xml_escape(name_format)));
873 }
874
875 if let Some(ref friendly_name) = self.friendly_name {
876 xml = xml.replace(">", &format!(" FriendlyName=\"{}\">", xml_escape(friendly_name)));
877 }
878
879 for value in &self.values {
880 xml.push_str(&value.to_xml()?);
881 }
882
883 xml.push_str("</saml:Attribute>");
884
885 Ok(xml)
886 }
887}
888
889impl SamlAttributeValue {
890 pub fn to_xml(&self) -> Result<String> {
892 let mut xml = String::new();
893
894 xml.push_str("<saml:AttributeValue");
895
896 if let Some(ref type_info) = self.type_info {
897 xml.push_str(&format!(" xsi:type=\"{}\"", xml_escape(type_info)));
898 }
899
900 xml.push_str(&format!(">{}</saml:AttributeValue>", xml_escape(&self.value)));
901
902 Ok(xml)
903 }
904}
905
906impl SamlAuthnStatement {
907 pub fn to_xml(&self) -> Result<String> {
909 let mut xml = String::new();
910
911 xml.push_str(&format!(
912 "<saml:AuthnStatement AuthnInstant=\"{}\"",
913 self.authn_instant.format("%Y-%m-%dT%H:%M:%S%.3fZ")
914 ));
915
916 if let Some(ref session_index) = self.session_index {
917 xml.push_str(&format!(" SessionIndex=\"{}\"", xml_escape(session_index)));
918 }
919
920 if let Some(session_not_on_or_after) = self.session_not_on_or_after {
921 xml.push_str(&format!(
922 " SessionNotOnOrAfter=\"{}\"",
923 session_not_on_or_after.format("%Y-%m-%dT%H:%M:%S%.3fZ")
924 ));
925 }
926
927 xml.push('>');
928
929 if let Some(ref locality) = self.subject_locality {
930 xml.push_str(&locality.to_xml()?);
931 }
932
933 xml.push_str(&self.authn_context.to_xml()?);
934
935 xml.push_str("</saml:AuthnStatement>");
936
937 Ok(xml)
938 }
939}
940
941impl SamlAuthnContext {
942 pub fn to_xml(&self) -> Result<String> {
944 let mut xml = String::new();
945
946 xml.push_str("<saml:AuthnContext>");
947
948 if let Some(ref class_ref) = self.authn_context_class_ref {
949 xml.push_str(&format!(
950 "<saml:AuthnContextClassRef>{}</saml:AuthnContextClassRef>",
951 xml_escape(class_ref)
952 ));
953 }
954
955 if let Some(ref decl) = self.authn_context_decl {
956 xml.push_str(&format!(
957 "<saml:AuthnContextDecl>{}</saml:AuthnContextDecl>",
958 xml_escape(decl)
959 ));
960 }
961
962 if let Some(ref decl_ref) = self.authn_context_decl_ref {
963 xml.push_str(&format!(
964 "<saml:AuthnContextDeclRef>{}</saml:AuthnContextDeclRef>",
965 xml_escape(decl_ref)
966 ));
967 }
968
969 for authority in &self.authenticating_authorities {
970 xml.push_str(&format!(
971 "<saml:AuthenticatingAuthority>{}</saml:AuthenticatingAuthority>",
972 xml_escape(authority)
973 ));
974 }
975
976 xml.push_str("</saml:AuthnContext>");
977
978 Ok(xml)
979 }
980}
981
982impl SamlSubjectLocality {
983 pub fn to_xml(&self) -> Result<String> {
985 let mut xml = String::new();
986
987 xml.push_str("<saml:SubjectLocality");
988
989 if let Some(ref address) = self.address {
990 xml.push_str(&format!(" Address=\"{}\"", xml_escape(address)));
991 }
992
993 if let Some(ref dns_name) = self.dns_name {
994 xml.push_str(&format!(" DNSName=\"{}\"", xml_escape(dns_name)));
995 }
996
997 xml.push_str("/>");
998
999 Ok(xml)
1000 }
1001}
1002
1003impl SamlAuthzDecisionStatement {
1004 pub fn to_xml(&self) -> Result<String> {
1006 let mut xml = String::new();
1007
1008 let decision_str = match self.decision {
1009 SamlDecision::Permit => "Permit",
1010 SamlDecision::Deny => "Deny",
1011 SamlDecision::Indeterminate => "Indeterminate",
1012 };
1013
1014 xml.push_str(&format!(
1015 "<saml:AuthzDecisionStatement Decision=\"{}\" Resource=\"{}\">",
1016 decision_str, xml_escape(&self.resource)
1017 ));
1018
1019 for action in &self.actions {
1020 xml.push_str(&action.to_xml()?);
1021 }
1022
1023 if let Some(ref evidence) = self.evidence {
1024 xml.push_str(&evidence.to_xml()?);
1025 }
1026
1027 xml.push_str("</saml:AuthzDecisionStatement>");
1028
1029 Ok(xml)
1030 }
1031}
1032
1033impl SamlAction {
1034 pub fn to_xml(&self) -> Result<String> {
1036 let mut xml = String::new();
1037
1038 xml.push_str("<saml:Action");
1039
1040 if let Some(ref namespace) = self.namespace {
1041 xml.push_str(&format!(" Namespace=\"{}\"", xml_escape(namespace)));
1042 }
1043
1044 xml.push_str(&format!(">{}</saml:Action>", xml_escape(&self.value)));
1045
1046 Ok(xml)
1047 }
1048}
1049
1050impl SamlEvidence {
1051 pub fn to_xml(&self) -> Result<String> {
1053 let mut xml = String::new();
1054
1055 xml.push_str("<saml:Evidence>");
1056
1057 for assertion in &self.assertions {
1058 xml.push_str(assertion);
1059 }
1060
1061 for id_ref in &self.assertion_id_refs {
1062 xml.push_str(&format!(
1063 "<saml:AssertionIDRef>{}</saml:AssertionIDRef>",
1064 xml_escape(id_ref)
1065 ));
1066 }
1067
1068 for uri_ref in &self.assertion_uri_refs {
1069 xml.push_str(&format!(
1070 "<saml:AssertionURIRef>{}</saml:AssertionURIRef>",
1071 xml_escape(uri_ref)
1072 ));
1073 }
1074
1075 xml.push_str("</saml:Evidence>");
1076
1077 Ok(xml)
1078 }
1079}
1080
1081impl Default for SamlAssertionValidator {
1082 fn default() -> Self {
1083 Self::new()
1084 }
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089 use super::*;
1090
1091 #[test]
1092 fn test_saml_assertion_builder() {
1093 let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1094 .with_attribute("username", "testuser")
1095 .with_attribute("email", "test@example.com")
1096 .with_audience("https://sp.example.com")
1097 .build();
1098
1099 assert_eq!(assertion.issuer, "https://idp.example.com");
1100 assert_eq!(assertion.version, "2.0");
1101 assert!(!assertion.attribute_statements.is_empty());
1102 assert!(assertion.conditions.is_some());
1103 }
1104
1105 #[test]
1106 fn test_saml_assertion_xml() {
1107 let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1108 .with_attribute("username", "testuser")
1109 .build();
1110
1111 let xml = assertion.to_xml().unwrap();
1112 assert!(xml.contains("<saml:Assertion"));
1113 assert!(xml.contains("https://idp.example.com"));
1114 assert!(xml.contains("testuser"));
1115 assert!(xml.contains("</saml:Assertion>"));
1116 }
1117
1118 #[test]
1119 fn test_saml_assertion_validation() {
1120 let validator = SamlAssertionValidator::new()
1121 .with_trusted_issuer("https://idp.example.com")
1122 .with_expected_audience("https://sp.example.com");
1123
1124 let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1125 .with_audience("https://sp.example.com")
1126 .with_validity_period(
1127 Utc::now() - Duration::minutes(1),
1128 Utc::now() + Duration::hours(1),
1129 )
1130 .build();
1131
1132 assert!(validator.validate(&assertion).is_ok());
1133 }
1134
1135 #[test]
1136 fn test_expired_assertion_validation() {
1137 let validator = SamlAssertionValidator::new();
1138
1139 let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1140 .with_validity_period(
1141 Utc::now() - Duration::hours(2),
1142 Utc::now() - Duration::hours(1),
1143 )
1144 .build();
1145
1146 assert!(validator.validate(&assertion).is_err());
1147 }
1148
1149 #[test]
1150 fn test_untrusted_issuer_rejected() {
1151 let validator = SamlAssertionValidator::new()
1152 .with_trusted_issuer("https://trusted-idp.example.com");
1153
1154 let assertion = SamlAssertionBuilder::new("https://evil-idp.example.com")
1155 .with_validity_period(
1156 Utc::now() - Duration::minutes(1),
1157 Utc::now() + Duration::hours(1),
1158 )
1159 .build();
1160
1161 let err = validator.validate(&assertion).unwrap_err();
1162 let msg = format!("{err}");
1163 assert!(msg.contains("Untrusted issuer"), "got: {msg}");
1164 }
1165
1166 #[test]
1167 fn test_wrong_audience_rejected() {
1168 let validator = SamlAssertionValidator::new()
1169 .with_trusted_issuer("https://idp.example.com")
1170 .with_expected_audience("https://sp-a.example.com");
1171
1172 let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1173 .with_validity_period(
1174 Utc::now() - Duration::minutes(1),
1175 Utc::now() + Duration::hours(1),
1176 )
1177 .with_audience("https://sp-b.example.com") .build();
1179
1180 let err = validator.validate(&assertion).unwrap_err();
1181 let msg = format!("{err}");
1182 assert!(msg.contains("No matching audience found"), "got: {msg}");
1183 }
1184
1185 #[test]
1186 fn test_assertion_no_attributes() {
1187 let assertion = SamlAssertionBuilder::new("https://idp.example.com").build();
1188 assert!(assertion.attribute_statements.is_empty());
1189
1190 let xml = assertion.to_xml().unwrap();
1191 assert!(xml.contains("<saml:Assertion"));
1192 assert!(xml.contains("https://idp.example.com"));
1193 assert!(!xml.contains("<saml:AttributeStatement"));
1195 }
1196
1197 #[test]
1198 fn test_not_yet_valid_assertion_rejected() {
1199 let validator = SamlAssertionValidator::new()
1200 .with_clock_skew(Duration::seconds(0)); let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1203 .with_validity_period(
1204 Utc::now() + Duration::hours(1), Utc::now() + Duration::hours(2),
1206 )
1207 .build();
1208
1209 let err = validator.validate(&assertion).unwrap_err();
1210 let msg = format!("{err}");
1211 assert!(msg.contains("not yet valid"), "got: {msg}");
1212 }
1213
1214 #[test]
1215 fn test_xml_special_characters_in_attributes() {
1216 let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1217 .with_attribute("org", "A&B <Corp> \"Quoted\" 'Apos'")
1218 .build();
1219
1220 let xml = assertion.to_xml().unwrap();
1221 assert!(xml.contains("A&B"), "& not escaped: {xml}");
1223 assert!(xml.contains("<Corp>"), "< > not escaped: {xml}");
1224 assert!(xml.contains(""Quoted""), "\" not escaped: {xml}");
1225 assert!(xml.contains("'Apos'"), "' not escaped: {xml}");
1226 }
1227
1228 #[test]
1229 fn test_multiple_audience_restrictions() {
1230 let validator = SamlAssertionValidator::new()
1231 .with_expected_audience("https://sp-b.example.com");
1232
1233 let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1234 .with_validity_period(
1235 Utc::now() - Duration::minutes(1),
1236 Utc::now() + Duration::hours(1),
1237 )
1238 .with_audience("https://sp-a.example.com")
1239 .with_audience("https://sp-b.example.com") .build();
1241
1242 assert!(validator.validate(&assertion).is_ok());
1244 }
1245
1246 #[test]
1247 fn test_validator_no_constraints_accepts_any() {
1248 let validator = SamlAssertionValidator::new();
1249
1250 let assertion = SamlAssertionBuilder::new("https://random-idp.example.com")
1251 .with_validity_period(
1252 Utc::now() - Duration::minutes(1),
1253 Utc::now() + Duration::hours(1),
1254 )
1255 .build();
1256
1257 assert!(validator.validate(&assertion).is_ok());
1259 }
1260
1261 #[test]
1262 fn test_assertion_with_subject_confirmation() {
1263 let validator = SamlAssertionValidator::new()
1264 .with_trusted_issuer("https://idp.example.com")
1265 .with_expected_audience("https://sp.example.com");
1266
1267 let mut assertion = SamlAssertionBuilder::new("https://idp.example.com")
1268 .with_validity_period(
1269 Utc::now() - Duration::minutes(1),
1270 Utc::now() + Duration::hours(1),
1271 )
1272 .with_audience("https://sp.example.com")
1273 .build();
1274
1275 assertion.subject = Some(SamlSubject {
1276 name_id: Some(SamlNameId {
1277 value: "user@example.com".to_string(),
1278 format: Some("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress".into()),
1279 name_qualifier: None,
1280 sp_name_qualifier: None,
1281 }),
1282 subject_confirmations: vec![SamlSubjectConfirmation {
1283 method: "urn:oasis:names:tc:SAML:2.0:cm:bearer".to_string(),
1284 subject_confirmation_data: Some(SamlSubjectConfirmationData {
1285 not_before: None,
1286 not_on_or_after: Some(Utc::now() + Duration::hours(1)),
1287 recipient: Some("https://sp.example.com".into()),
1288 in_response_to: Some("req-123".into()),
1289 address: None,
1290 }),
1291 }],
1292 });
1293
1294 assert!(validator.validate(&assertion).is_ok());
1295
1296 let xml = assertion.to_xml().unwrap();
1298 assert!(xml.contains("<saml:Subject>"));
1299 assert!(xml.contains("user@example.com"));
1300 assert!(xml.contains("urn:oasis:names:tc:SAML:2.0:cm:bearer"));
1301 }
1302
1303 #[test]
1304 fn test_unsupported_subject_confirmation_method_rejected() {
1305 let validator = SamlAssertionValidator::new();
1306
1307 let mut assertion = SamlAssertionBuilder::new("https://idp.example.com")
1308 .with_validity_period(
1309 Utc::now() - Duration::minutes(1),
1310 Utc::now() + Duration::hours(1),
1311 )
1312 .build();
1313
1314 assertion.subject = Some(SamlSubject {
1315 name_id: None,
1316 subject_confirmations: vec![SamlSubjectConfirmation {
1317 method: "urn:oasis:names:tc:SAML:2.0:cm:INVALID".to_string(),
1318 subject_confirmation_data: None,
1319 }],
1320 });
1321
1322 let err = validator.validate(&assertion).unwrap_err();
1323 let msg = format!("{err}");
1324 assert!(
1325 msg.contains("Unsupported subject confirmation method"),
1326 "got: {msg}"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_expired_subject_confirmation_rejected() {
1332 let validator = SamlAssertionValidator::new()
1333 .with_clock_skew(Duration::seconds(0));
1334
1335 let mut assertion = SamlAssertionBuilder::new("https://idp.example.com")
1336 .with_validity_period(
1337 Utc::now() - Duration::hours(2),
1338 Utc::now() + Duration::hours(1), )
1340 .build();
1341
1342 assertion.subject = Some(SamlSubject {
1343 name_id: None,
1344 subject_confirmations: vec![SamlSubjectConfirmation {
1345 method: "urn:oasis:names:tc:SAML:2.0:cm:bearer".to_string(),
1346 subject_confirmation_data: Some(SamlSubjectConfirmationData {
1347 not_before: None,
1348 not_on_or_after: Some(Utc::now() - Duration::hours(1)), recipient: None,
1350 in_response_to: None,
1351 address: None,
1352 }),
1353 }],
1354 });
1355
1356 let err = validator.validate(&assertion).unwrap_err();
1357 let msg = format!("{err}");
1358 assert!(
1359 msg.contains("Subject confirmation has expired"),
1360 "got: {msg}"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_recipient_mismatch_rejected() {
1366 let validator = SamlAssertionValidator::new()
1367 .with_expected_audience("https://sp.example.com");
1368
1369 let mut assertion = SamlAssertionBuilder::new("https://idp.example.com")
1370 .with_validity_period(
1371 Utc::now() - Duration::minutes(1),
1372 Utc::now() + Duration::hours(1),
1373 )
1374 .with_audience("https://sp.example.com")
1375 .build();
1376
1377 assertion.subject = Some(SamlSubject {
1378 name_id: None,
1379 subject_confirmations: vec![SamlSubjectConfirmation {
1380 method: "urn:oasis:names:tc:SAML:2.0:cm:bearer".to_string(),
1381 subject_confirmation_data: Some(SamlSubjectConfirmationData {
1382 not_before: None,
1383 not_on_or_after: Some(Utc::now() + Duration::hours(1)),
1384 recipient: Some("https://wrong-sp.example.com".into()), in_response_to: None,
1386 address: None,
1387 }),
1388 }],
1389 });
1390
1391 let err = validator.validate(&assertion).unwrap_err();
1392 let msg = format!("{err}");
1393 assert!(
1394 msg.contains("recipient does not match"),
1395 "got: {msg}"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_clock_skew_tolerance() {
1401 let validator = SamlAssertionValidator::new()
1404 .with_clock_skew(Duration::minutes(5));
1405
1406 let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1407 .with_validity_period(
1408 Utc::now() - Duration::hours(1),
1409 Utc::now() - Duration::minutes(3), )
1411 .build();
1412
1413 assert!(
1414 validator.validate(&assertion).is_ok(),
1415 "should tolerate expiration within clock skew"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_authz_decision_statement_xml() {
1421 let mut assertion = SamlAssertionBuilder::new("https://idp.example.com").build();
1422
1423 assertion.authz_decision_statements.push(SamlAuthzDecisionStatement {
1424 resource: "https://api.example.com/data".to_string(),
1425 decision: SamlDecision::Permit,
1426 actions: vec![SamlAction {
1427 value: "GET".to_string(),
1428 namespace: Some("urn:oasis:names:tc:SAML:1.0:action:rwedc".to_string()),
1429 }],
1430 evidence: None,
1431 });
1432
1433 let xml = assertion.to_xml().unwrap();
1434 assert!(xml.contains("AuthzDecisionStatement"));
1435 assert!(xml.contains("Permit"));
1436 assert!(xml.contains("GET"));
1437 }
1438}