Skip to main content

auth_framework/protocols/
saml_assertions.rs

1//! SAML 2.0 Assertion Support for WS-Security
2//!
3//! This module provides SAML 2.0 assertion generation and validation for WS-Security scenarios.
4
5use crate::errors::{AuthError, Result};
6use chrono::{DateTime, Duration, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Escape user-provided values for safe inclusion in XML text and attributes.
10fn 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("&lt;"),
16            '>' => out.push_str("&gt;"),
17            '"' => out.push_str("&quot;"),
18            '\'' => out.push_str("&apos;"),
19            _ => out.push(c),
20        }
21    }
22    out
23}
24
25/// SAML 2.0 Assertion
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SamlAssertion {
28    /// Assertion ID
29    pub id: String,
30
31    /// Issuer of the assertion
32    pub issuer: String,
33
34    /// Issue instant
35    pub issue_instant: DateTime<Utc>,
36
37    /// Version (always "2.0" for SAML 2.0)
38    pub version: String,
39
40    /// Subject information
41    pub subject: Option<SamlSubject>,
42
43    /// Conditions (validity constraints)
44    pub conditions: Option<SamlConditions>,
45
46    /// Attribute statements
47    pub attribute_statements: Vec<SamlAttributeStatement>,
48
49    /// Authentication statements
50    pub authn_statements: Vec<SamlAuthnStatement>,
51
52    /// Authorization decision statements
53    pub authz_decision_statements: Vec<SamlAuthzDecisionStatement>,
54}
55
56/// SAML Subject
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SamlSubject {
59    /// Name identifier
60    pub name_id: Option<SamlNameId>,
61
62    /// Subject confirmations
63    pub subject_confirmations: Vec<SamlSubjectConfirmation>,
64}
65
66/// SAML Name Identifier
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SamlNameId {
69    /// Value of the name identifier
70    pub value: String,
71
72    /// Format of the name identifier
73    pub format: Option<String>,
74
75    /// Name qualifier
76    pub name_qualifier: Option<String>,
77
78    /// SP name qualifier
79    pub sp_name_qualifier: Option<String>,
80}
81
82/// SAML Subject Confirmation
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SamlSubjectConfirmation {
85    /// Method (bearer, holder-of-key, etc.)
86    pub method: String,
87
88    /// Subject confirmation data
89    pub subject_confirmation_data: Option<SamlSubjectConfirmationData>,
90}
91
92/// SAML Subject Confirmation Data
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct SamlSubjectConfirmationData {
95    /// Not before timestamp
96    pub not_before: Option<DateTime<Utc>>,
97
98    /// Not on or after timestamp
99    pub not_on_or_after: Option<DateTime<Utc>>,
100
101    /// Recipient URL
102    pub recipient: Option<String>,
103
104    /// In response to (for response assertions)
105    pub in_response_to: Option<String>,
106
107    /// Address restriction
108    pub address: Option<String>,
109}
110
111/// SAML Conditions
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct SamlConditions {
114    /// Not before timestamp
115    pub not_before: Option<DateTime<Utc>>,
116
117    /// Not on or after timestamp
118    pub not_on_or_after: Option<DateTime<Utc>>,
119
120    /// Audience restrictions
121    pub audience_restrictions: Vec<SamlAudienceRestriction>,
122
123    /// One time use
124    pub one_time_use: bool,
125
126    /// Proxy restrictions
127    pub proxy_restriction: Option<SamlProxyRestriction>,
128}
129
130/// SAML Audience Restriction
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct SamlAudienceRestriction {
133    /// Audience URIs
134    pub audiences: Vec<String>,
135}
136
137/// SAML Proxy Restriction
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SamlProxyRestriction {
140    /// Count limit
141    pub count: Option<u32>,
142
143    /// Allowed audiences
144    pub audiences: Vec<String>,
145}
146
147/// SAML Attribute Statement
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SamlAttributeStatement {
150    /// Attributes
151    pub attributes: Vec<SamlAttribute>,
152
153    /// Encrypted attributes
154    pub encrypted_attributes: Vec<String>, // Would be proper encrypted elements in production
155}
156
157/// SAML Attribute
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct SamlAttribute {
160    /// Attribute name
161    pub name: String,
162
163    /// Name format
164    pub name_format: Option<String>,
165
166    /// Friendly name
167    pub friendly_name: Option<String>,
168
169    /// Attribute values
170    pub values: Vec<SamlAttributeValue>,
171}
172
173/// SAML Attribute Value
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SamlAttributeValue {
176    /// Value content
177    pub value: String,
178
179    /// Type information
180    pub type_info: Option<String>,
181}
182
183/// SAML Authentication Statement
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct SamlAuthnStatement {
186    /// Authentication instant
187    pub authn_instant: DateTime<Utc>,
188
189    /// Session index
190    pub session_index: Option<String>,
191
192    /// Session not on or after
193    pub session_not_on_or_after: Option<DateTime<Utc>>,
194
195    /// Authentication context
196    pub authn_context: SamlAuthnContext,
197
198    /// Subject locality
199    pub subject_locality: Option<SamlSubjectLocality>,
200}
201
202/// SAML Authentication Context
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct SamlAuthnContext {
205    /// Authentication context class reference
206    pub authn_context_class_ref: Option<String>,
207
208    /// Authentication context declaration
209    pub authn_context_decl: Option<String>,
210
211    /// Authentication context declaration reference
212    pub authn_context_decl_ref: Option<String>,
213
214    /// Authenticating authorities
215    pub authenticating_authorities: Vec<String>,
216}
217
218/// SAML Subject Locality
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct SamlSubjectLocality {
221    /// IP address
222    pub address: Option<String>,
223
224    /// DNS name
225    pub dns_name: Option<String>,
226}
227
228/// SAML Authorization Decision Statement
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct SamlAuthzDecisionStatement {
231    /// Resource being accessed
232    pub resource: String,
233
234    /// Decision (Permit, Deny, Indeterminate)
235    pub decision: SamlDecision,
236
237    /// Actions being performed
238    pub actions: Vec<SamlAction>,
239
240    /// Evidence supporting the decision
241    pub evidence: Option<SamlEvidence>,
242}
243
244/// SAML Decision
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub enum SamlDecision {
247    /// Access permitted
248    Permit,
249
250    /// Access denied
251    Deny,
252
253    /// Decision cannot be made
254    Indeterminate,
255}
256
257/// SAML Action
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct SamlAction {
260    /// Action value
261    pub value: String,
262
263    /// Action namespace
264    pub namespace: Option<String>,
265}
266
267/// SAML Evidence
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct SamlEvidence {
270    /// Supporting assertions
271    pub assertions: Vec<String>, // References to other assertions
272
273    /// Assertion ID references
274    pub assertion_id_refs: Vec<String>,
275
276    /// Assertion URI references
277    pub assertion_uri_refs: Vec<String>,
278}
279
280/// SAML Assertion Builder
281pub struct SamlAssertionBuilder {
282    /// Current assertion being built
283    assertion: SamlAssertion,
284}
285
286/// SAML Assertion Validator
287pub struct SamlAssertionValidator {
288    /// Allowed clock skew
289    clock_skew: Duration,
290
291    /// Trusted issuers
292    trusted_issuers: Vec<String>,
293
294    /// Expected audiences
295    expected_audiences: Vec<String>,
296}
297
298impl SamlAssertionBuilder {
299    /// Create a new SAML assertion builder
300    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    /// Set the subject
317    pub fn with_subject(mut self, subject: SamlSubject) -> Self {
318        self.assertion.subject = Some(subject);
319        self
320    }
321
322    /// Set conditions
323    pub fn with_conditions(mut self, conditions: SamlConditions) -> Self {
324        self.assertion.conditions = Some(conditions);
325        self
326    }
327
328    /// Add an attribute statement
329    pub fn with_attribute_statement(mut self, statement: SamlAttributeStatement) -> Self {
330        self.assertion.attribute_statements.push(statement);
331        self
332    }
333
334    /// Add an authentication statement
335    pub fn with_authn_statement(mut self, statement: SamlAuthnStatement) -> Self {
336        self.assertion.authn_statements.push(statement);
337        self
338    }
339
340    /// Add an authorization decision statement
341    pub fn with_authz_decision_statement(mut self, statement: SamlAuthzDecisionStatement) -> Self {
342        self.assertion.authz_decision_statements.push(statement);
343        self
344    }
345
346    /// Add a simple attribute
347    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        // Find or create attribute statement
359        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    /// Set validity period
376    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            // Update existing conditions
383            conditions.not_before = Some(not_before);
384            conditions.not_on_or_after = Some(not_on_or_after);
385        } else {
386            // Create new conditions
387            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    /// Add audience restriction
400    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    /// Build the assertion
430    pub fn build(self) -> SamlAssertion {
431        self.assertion
432    }
433
434    /// Build and convert to XML
435    pub fn build_xml(self) -> Result<String> {
436        let assertion = self.assertion;
437        assertion.to_xml()
438    }
439}
440
441impl SamlAssertionValidator {
442    /// Create a new SAML assertion validator
443    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    /// Set clock skew tolerance
452    pub fn with_clock_skew(mut self, skew: Duration) -> Self {
453        self.clock_skew = skew;
454        self
455    }
456
457    /// Add trusted issuer
458    pub fn with_trusted_issuer(mut self, issuer: &str) -> Self {
459        self.trusted_issuers.push(issuer.to_string());
460        self
461    }
462
463    /// Add expected audience
464    pub fn with_expected_audience(mut self, audience: &str) -> Self {
465        self.expected_audiences.push(audience.to_string());
466        self
467    }
468
469    /// Validate a SAML assertion
470    pub fn validate(&self, assertion: &SamlAssertion) -> Result<()> {
471        // Check issuer
472        if !self.trusted_issuers.is_empty() && !self.trusted_issuers.contains(&assertion.issuer) {
473            return Err(AuthError::auth_method("saml", "Untrusted issuer"));
474        }
475
476        // Check time validity
477        self.validate_timing(assertion)?;
478
479        // Check audience restrictions
480        self.validate_audience(assertion)?;
481
482        // Check subject confirmation (if present)
483        if let Some(ref subject) = assertion.subject {
484            self.validate_subject_confirmation(subject)?;
485        }
486
487        Ok(())
488    }
489
490    /// Validate timing constraints
491    fn validate_timing(&self, assertion: &SamlAssertion) -> Result<()> {
492        let now = Utc::now();
493
494        // Check issue instant (shouldn't be too far in the future)
495        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        // Check conditions timing
503        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    /// Validate audience restrictions
521    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    /// Validate subject confirmation.
544    ///
545    /// Verifies each `SubjectConfirmation` element:
546    /// - The Method is a recognised SAML 2.0 confirmation method.
547    /// - If `NotOnOrAfter` is present it has not passed (accounting for clock skew).
548    /// - If `Recipient` is present it matches one of the expected audiences.
549    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            // 1. Method must be recognised.
558            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            // 2. Check SubjectConfirmationData constraints (if present).
569            if let Some(ref data) = confirmation.subject_confirmation_data {
570                let now = Utc::now();
571
572                // NotOnOrAfter must not have passed.
573                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                // NotBefore must have passed.
583                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                // Recipient must match one of our expected audiences (if we have any).
593                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    /// Convert assertion to XML
612    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        // Issuer
623        xml.push_str(&format!("<saml:Issuer>{}</saml:Issuer>", xml_escape(&self.issuer)));
624
625        // Subject
626        if let Some(ref subject) = self.subject {
627            xml.push_str(&subject.to_xml()?);
628        }
629
630        // Conditions
631        if let Some(ref conditions) = self.conditions {
632            xml.push_str(&conditions.to_xml()?);
633        }
634
635        // Attribute statements
636        for statement in &self.attribute_statements {
637            xml.push_str(&statement.to_xml()?);
638        }
639
640        // Authentication statements
641        for statement in &self.authn_statements {
642            xml.push_str(&statement.to_xml()?);
643        }
644
645        // Authorization decision statements
646        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    /// Convert subject to XML
658    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    /// Convert name ID to XML
679    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    /// Convert subject confirmation to XML
704    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    /// Convert subject confirmation data to XML
724    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    /// Convert conditions to XML
763    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    /// Convert audience restriction to XML
804    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    /// Convert proxy restriction to XML
821    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    /// Convert attribute statement to XML
844    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        // Encrypted attributes would be handled here
854        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    /// Convert attribute to XML
866    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    /// Convert attribute value to XML
891    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    /// Convert authentication statement to XML
908    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    /// Convert authentication context to XML
943    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    /// Convert subject locality to XML
984    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    /// Convert authorization decision statement to XML
1005    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    /// Convert action to XML
1035    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    /// Convert evidence to XML
1052    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") // wrong audience
1178            .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        // No AttributeStatement element should be present
1194        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)); // strict, no skew
1201
1202        let assertion = SamlAssertionBuilder::new("https://idp.example.com")
1203            .with_validity_period(
1204                Utc::now() + Duration::hours(1), // far in the future
1205                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        // Special chars must be escaped
1222        assert!(xml.contains("A&amp;B"), "& not escaped: {xml}");
1223        assert!(xml.contains("&lt;Corp&gt;"), "< > not escaped: {xml}");
1224        assert!(xml.contains("&quot;Quoted&quot;"), "\" not escaped: {xml}");
1225        assert!(xml.contains("&apos;Apos&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") // second audience matches
1240            .build();
1241
1242        // Should succeed because the expected audience is in the list
1243        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        // No trusted issuers and no expected audiences → accept anything within time
1258        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        // Check XML output includes subject
1297        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), // assertion itself is still valid
1339            )
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)), // expired
1349                    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()), // wrong
1385                    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        // With default 5-minute skew, an assertion that expired 3 minutes ago
1402        // should still be accepted
1403        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), // expired 3 min ago, within 5-min skew
1410            )
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}