entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
//! SAML 2.0 assertion, subject, conditions, and attribute types.

use crate::util::log::{debug, trace};
use crate::xml::XmlElement;

use super::SAML_NS;
use super::response::SamlResponseError;

/// The bearer `SubjectConfirmation` method (SAML Web Browser SSO profile).
const CM_BEARER: &str = "urn:oasis:names:tc:SAML:2.0:cm:bearer";

/// A parsed SAML 2.0 Assertion.
#[doc(alias = "saml_assertion")]
#[derive(Debug, Clone)]
pub struct SamlAssertion {
    /// The entity that issued this assertion (`IdP` entity ID).
    issuer: String,
    /// The authenticated subject.
    subject: SamlSubject,
    /// Conditions under which the assertion is valid.
    conditions: SamlConditions,
    /// SAML attributes (e.g. email, groups, display name).
    attributes: Vec<SamlAttribute>,
}

impl SamlAssertion {
    /// Returns the issuer (`IdP` entity ID).
    #[must_use]
    #[inline]
    pub fn issuer(&self) -> &str {
        &self.issuer
    }

    /// Returns the authenticated subject.
    #[must_use]
    #[inline]
    pub fn subject(&self) -> &SamlSubject {
        &self.subject
    }

    /// Returns the conditions under which the assertion is valid.
    #[must_use]
    #[inline]
    pub fn conditions(&self) -> &SamlConditions {
        &self.conditions
    }

    /// Returns the SAML attributes (e.g. email, groups, display name).
    #[must_use]
    #[inline]
    pub fn attributes(&self) -> &[SamlAttribute] {
        &self.attributes
    }
}

/// The authenticated subject of a SAML assertion.
#[doc(alias = "saml_subject")]
#[derive(Debug, Clone)]
pub struct SamlSubject {
    /// The `NameID` value identifying the subject.
    name_id: String,
    /// The `NameID` format URI, if present.
    name_id_format: Option<String>,
    /// `SubjectConfirmationData/@Recipient` โ€” the ACS URL the assertion is
    /// addressed to (SAML 2.0 Web Browser SSO profile ยง4.1.4.3).
    recipient: Option<String>,
    /// `SubjectConfirmationData/@NotOnOrAfter` โ€” the (usually tighter)
    /// deadline by which the assertion must be delivered to the recipient.
    confirmation_not_on_or_after: Option<String>,
    /// `SubjectConfirmationData/@InResponseTo` โ€” the ID of the `AuthnRequest`
    /// this assertion answers (absent for IdP-initiated SSO). The caller
    /// MUST match it against the outstanding request it issued.
    in_response_to: Option<String>,
}

impl SamlSubject {
    /// Returns the `NameID` value identifying the subject.
    #[must_use]
    #[inline]
    pub fn name_id(&self) -> &str {
        &self.name_id
    }

    /// Returns the `NameID` format URI, if present.
    #[must_use]
    #[inline]
    pub fn name_id_format(&self) -> Option<&str> {
        self.name_id_format.as_deref()
    }

    /// Returns the `SubjectConfirmationData/@Recipient`, if present.
    ///
    /// The SAML Web Browser SSO profile requires this to equal the SP's ACS
    /// URL; `SamlResponse::validate_conditions_only` enforces that when present.
    #[must_use]
    #[inline]
    pub fn recipient(&self) -> Option<&str> {
        self.recipient.as_deref()
    }

    /// Returns the `SubjectConfirmationData/@NotOnOrAfter`, if present.
    #[must_use]
    #[inline]
    pub fn confirmation_not_on_or_after(&self) -> Option<&str> {
        self.confirmation_not_on_or_after.as_deref()
    }

    /// Returns the `SubjectConfirmationData/@InResponseTo`, if present.
    ///
    /// For an SP-initiated flow the caller MUST confirm this equals the ID
    /// of the `AuthnRequest` it sent (and that the request is still
    /// outstanding); the crate is storage-free and cannot do that itself.
    /// `None` indicates an unsolicited (IdP-initiated) assertion, which the
    /// caller should reject unless it explicitly supports IdP-initiated SSO.
    #[must_use]
    #[inline]
    pub fn in_response_to(&self) -> Option<&str> {
        self.in_response_to.as_deref()
    }
}

/// Time and audience conditions on a SAML assertion.
#[doc(alias = "saml_conditions")]
#[derive(Debug, Clone)]
pub struct SamlConditions {
    /// The earliest time the assertion is valid (`NotBefore`).
    not_before: Option<String>,
    /// The time at which the assertion expires (`NotOnOrAfter`).
    not_on_or_after: Option<String>,
    /// Audience restrictions, one inner `Vec` per `<AudienceRestriction>`
    /// element (each holding that restriction's `<Audience>` URIs). Per SAML
    /// Core ยง2.5.1.4 the assertion is valid for an SP only if it appears in
    /// **every** restriction (AND across restrictions, OR within one).
    audience_restrictions: Vec<Vec<String>>,
}

impl SamlConditions {
    /// Returns the earliest time the assertion is valid, if present.
    #[must_use]
    #[inline]
    pub fn not_before(&self) -> Option<&str> {
        self.not_before.as_deref()
    }

    /// Returns the time at which the assertion expires, if present.
    #[must_use]
    #[inline]
    pub fn not_on_or_after(&self) -> Option<&str> {
        self.not_on_or_after.as_deref()
    }

    /// Returns the audience restrictions, one slice element per
    /// `<AudienceRestriction>` element (each holding its `<Audience>` URIs).
    ///
    /// The assertion is valid for an SP only if its entity ID appears in
    /// **every** restriction (AND across, OR within) โ€” SAML Core ยง2.5.1.4.
    #[must_use]
    #[inline]
    pub fn audience_restrictions(&self) -> &[Vec<String>] {
        &self.audience_restrictions
    }
}

/// A single SAML attribute with one or more values.
#[doc(alias = "saml_attribute")]
#[derive(Debug, Clone)]
pub struct SamlAttribute {
    /// The attribute name.
    name: String,
    /// The attribute name format URI, if present.
    name_format: Option<String>,
    /// One or more attribute values.
    values: Vec<String>,
}

impl SamlAttribute {
    /// Returns the attribute name.
    #[must_use]
    #[inline]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Returns the attribute name format URI, if present.
    #[must_use]
    #[inline]
    pub fn name_format(&self) -> Option<&str> {
        self.name_format.as_deref()
    }

    /// Returns the attribute values.
    #[must_use]
    #[inline]
    pub fn values(&self) -> &[String] {
        &self.values
    }
}

/// Parse a `<saml:Assertion>` element into a [`SamlAssertion`].
///
/// # Errors
///
/// Returns [`SamlResponseError`] if required child elements (`Issuer`,
/// `Subject`, `NameID`, or `NameID` text content) are missing.
pub(crate) fn parse_assertion(el: &XmlElement) -> Result<SamlAssertion, SamlResponseError> {
    // SECURITY: `Version="2.0"` is mandatory on every assertion (SAML Core
    // ยง2.3.3). Reject a missing/mismatched version rather than processing an
    // assertion of an unknown SAML version.
    if el.attribute("Version") != Some("2.0") {
        return Err(SamlResponseError::invalid_version("Assertion"));
    }

    // SECURITY: reject duplicated single-occurrence elements. First-match
    // selection over a duplicate (e.g. a permissive `<Conditions>` placed
    // before the real one) is the element-level analogue of the duplicate
    // *attribute* XSW surface the XML parser already rejects.
    let ensure_single = |parent: &XmlElement, name: &str| -> Result<(), SamlResponseError> {
        if find_saml_children(parent, name).len() > 1 {
            return Err(SamlResponseError::duplicate_element(name));
        }
        Ok(())
    };
    ensure_single(el, "Issuer")?;
    ensure_single(el, "Subject")?;
    ensure_single(el, "Conditions")?;

    // Issuer
    trace!("saml: extracting assertion issuer");
    let issuer_el = find_saml_child(el, "Issuer")
        .ok_or_else(|| SamlResponseError::missing_element("Assertion/Issuer"))?;
    let issuer = issuer_el
        .text_content()
        .ok_or_else(|| SamlResponseError::missing_element("Assertion/Issuer text"))?
        .to_string();

    // Subject
    trace!("saml: extracting assertion subject");
    let subject_el = find_saml_child(el, "Subject")
        .ok_or_else(|| SamlResponseError::missing_element("Assertion/Subject"))?;
    ensure_single(subject_el, "NameID")?;
    let name_id_el = find_saml_child(subject_el, "NameID")
        .ok_or_else(|| SamlResponseError::missing_element("Assertion/Subject/NameID"))?;
    let name_id_text = name_id_el
        .text_content()
        .ok_or_else(|| SamlResponseError::missing_element("Assertion/Subject/NameID text"))?;
    // SubjectConfirmation / SubjectConfirmationData (SAML Web Browser SSO
    // profile ยง4.1.4.2). Recipient/NotOnOrAfter/InResponseTo bind the
    // assertion to this SP and this request; `validate` enforces the first
    // two and exposes InResponseTo for the caller's request matching.
    //
    // SECURITY: SAML allows MULTIPLE SubjectConfirmation elements. We must not
    // read only the first โ€” an attacker shaping the (pre-signature) XML could
    // place a SubjectConfirmationData with NO Recipient first to suppress the
    // recipient check, then the real one after. So among all confirmation-data
    // elements, prefer one that actually carries a Recipient; only if none
    // does do we fall back to the first (recipient stays None).
    // SECURITY: the Web Browser SSO profile ยง4.1.4.2 requires the bearer
    // confirmation method. Skip any *explicitly* non-bearer confirmation (e.g.
    // `holder-of-key`) so its `SubjectConfirmationData` is never mistaken for
    // the bearer binding whose Recipient/NotOnOrAfter we enforce. A missing
    // `Method` (schema-required, but some IdPs omit it) is tolerated.
    // SECURITY: REJECT more than one bearer confirmation rather than choosing
    // among them. Preferring the one carrying a `Recipient` and then sourcing
    // NotOnOrAfter/InResponseTo from that same element still let an attacker
    // supply a second confirmation whose fields a differently-behaving consumer
    // might read โ€” the classic signature-wrapping shape the `ensure_single`
    // guard above already closes for Issuer/Subject/Conditions. A Web-Browser
    // SSO assertion has exactly one.
    let bearer: Vec<&XmlElement> = find_saml_children(subject_el, "SubjectConfirmation")
        .into_iter()
        .filter(|sc| sc.attribute("Method").is_none_or(|m| m == CM_BEARER))
        .collect();
    if bearer.len() > 1 {
        return Err(SamlResponseError::duplicate_element("SubjectConfirmation"));
    }
    let scd = bearer
        .first()
        .and_then(|sc| find_saml_child(sc, "SubjectConfirmationData"));
    let subject = SamlSubject {
        name_id: name_id_text.to_string(),
        name_id_format: name_id_el.attribute("Format").map(String::from),
        recipient: scd.and_then(|d| d.attribute("Recipient")).map(String::from),
        confirmation_not_on_or_after: scd
            .and_then(|d| d.attribute("NotOnOrAfter"))
            .map(String::from),
        in_response_to: scd
            .and_then(|d| d.attribute("InResponseTo"))
            .map(String::from),
    };

    // Conditions
    trace!("saml: extracting assertion conditions");
    let conditions = parse_conditions(el);

    // Attributes
    trace!("saml: extracting assertion attributes");
    let mut attributes = Vec::new();
    // SAML Core ยง2.3.3 allows any number of statements; reading only the first
    // silently dropped every attribute in the rest โ€” including, for an IdP that
    // splits them, the group/role attributes an authorization decision uses.
    for attr_stmt in find_saml_children(el, "AttributeStatement") {
        for attr_el in find_saml_children(attr_stmt, "Attribute") {
            if let Some(name_val) = attr_el.attribute("Name") {
                let name = name_val.to_string();
                // SECURITY: Log attribute name only โ€” never log attribute values.
                trace!(name = %name, "saml: extracting attribute");
                let name_format = attr_el.attribute("NameFormat").map(String::from);
                let values = find_saml_children(attr_el, "AttributeValue")
                    .into_iter()
                    .filter_map(|v| v.text_content().map(String::from))
                    .collect();
                attributes.push(SamlAttribute {
                    name,
                    name_format,
                    values,
                });
            }
        }
    }

    // SECURITY: Log issuer only โ€” never log NameID or assertion content.
    debug!(issuer = %issuer, "saml: assertion parsed");

    Ok(SamlAssertion {
        issuer,
        subject,
        conditions,
        attributes,
    })
}

/// Parse the `<Conditions>` element (time window + audience restrictions).
///
/// A missing `<Conditions>` yields an empty (unconstrained) set; the caller's
/// `validate` enforces presence where required.
fn parse_conditions(el: &XmlElement) -> SamlConditions {
    let Some(cond_el) = find_saml_child(el, "Conditions") else {
        return SamlConditions {
            not_before: None,
            not_on_or_after: None,
            audience_restrictions: Vec::new(),
        };
    };
    // Group `<Audience>` URIs per `<AudienceRestriction>` (OR within a
    // restriction, AND across them per SAML Core ยง2.5.1.4). Each inner Vec
    // collects all audiences of one restriction โ€” reading only the first would
    // wrongly reject an SP listed as a non-first audience.
    let audience_restrictions: Vec<Vec<String>> =
        find_saml_children(cond_el, "AudienceRestriction")
            .into_iter()
            .map(|ar| {
                find_saml_children(ar, "Audience")
                    .into_iter()
                    .filter_map(|aud| aud.text_content().map(String::from))
                    .collect()
            })
            .collect();
    SamlConditions {
        not_before: cond_el.attribute("NotBefore").map(String::from),
        not_on_or_after: cond_el.attribute("NotOnOrAfter").map(String::from),
        audience_restrictions,
    }
}

/// Find the first child element in the SAML assertion namespace.
///
/// # Security
///
/// Falls back to local-name-only matching when namespace-qualified lookup
/// fails โ€” but only for *unnamespaced* elements, because some `IdP`s omit
/// namespace prefixes on child elements. An element in a *foreign* namespace
/// is **not** matched: matching any namespace would let an attacker shaping
/// the pre-signature XML inject an `<evil:Audience>`/`<x:Issuer>` decoy in an
/// unexpected namespace and confuse the parser versus an external XML-DSig
/// verifier (a signature-wrapping divergence). Accepting only the SAML
/// namespace or no namespace keeps prefix-less interop while closing that gap.
fn find_saml_child<'a>(parent: &'a XmlElement, local_name: &str) -> Option<&'a XmlElement> {
    parent
        .children()
        .iter()
        .find(|c| is_saml_named(c, local_name))
}

/// Find all SAML-namespaced (or unnamespaced) children with `local_name`.
/// A foreign namespace is never matched โ€” see [`find_saml_child`].
fn find_saml_children<'a>(parent: &'a XmlElement, local_name: &str) -> Vec<&'a XmlElement> {
    parent
        .children()
        .iter()
        .filter(|c| is_saml_named(c, local_name))
        .collect()
}

/// True if `el` has the given local name and is in the SAML assertion
/// namespace or carries no namespace at all (never a foreign namespace).
fn is_saml_named(el: &XmlElement, local_name: &str) -> bool {
    el.name() == local_name && el.namespace().is_none_or(|ns| ns == SAML_NS)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::xml::parse_xml;

    fn sample_assertion_xml() -> String {
        r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_abc123" IssueInstant="2025-01-15T12:00:00Z">
            <saml:Issuer>https://idp.example.com</saml:Issuer>
            <saml:Subject>
                <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">user@example.com</saml:NameID>
            </saml:Subject>
            <saml:Conditions NotBefore="2025-01-15T11:59:00Z" NotOnOrAfter="2025-01-15T12:05:00Z">
                <saml:AudienceRestriction>
                    <saml:Audience>https://sp.example.com</saml:Audience>
                </saml:AudienceRestriction>
            </saml:Conditions>
            <saml:AttributeStatement>
                <saml:Attribute Name="email">
                    <saml:AttributeValue>user@example.com</saml:AttributeValue>
                </saml:Attribute>
                <saml:Attribute Name="groups" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
                    <saml:AttributeValue>admins</saml:AttributeValue>
                    <saml:AttributeValue>users</saml:AttributeValue>
                </saml:Attribute>
            </saml:AttributeStatement>
        </saml:Assertion>"#.to_string()
    }

    #[test]
    fn parse_assertion_issuer() {
        let el = parse_xml(&sample_assertion_xml()).unwrap();
        let assertion = parse_assertion(&el).unwrap();
        assert_eq!(assertion.issuer(), "https://idp.example.com");
    }

    #[test]
    fn parse_assertion_subject() {
        let el = parse_xml(&sample_assertion_xml()).unwrap();
        let assertion = parse_assertion(&el).unwrap();
        assert_eq!(assertion.subject().name_id(), "user@example.com");
        assert_eq!(
            assertion.subject().name_id_format(),
            Some("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress")
        );
    }

    #[test]
    fn parse_assertion_conditions() {
        let el = parse_xml(&sample_assertion_xml()).unwrap();
        let assertion = parse_assertion(&el).unwrap();
        assert_eq!(
            assertion.conditions().not_before(),
            Some("2025-01-15T11:59:00Z")
        );
        assert_eq!(
            assertion.conditions().not_on_or_after(),
            Some("2025-01-15T12:05:00Z")
        );
        assert_eq!(
            assertion.conditions().audience_restrictions(),
            [vec!["https://sp.example.com".to_string()]]
        );
    }

    // -- Error paths: missing required elements ----------------------------

    #[test]
    fn parse_assertion_missing_issuer() {
        let xml = r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                          ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
            <saml:Subject>
                <saml:NameID>user@example.com</saml:NameID>
            </saml:Subject>
        </saml:Assertion>"#;
        let el = parse_xml(xml).unwrap();
        let err = parse_assertion(&el).unwrap_err();
        assert!(
            err.is_missing_element(),
            "expected MissingElement, got: {err}"
        );
    }

    #[test]
    fn parse_assertion_missing_subject() {
        let xml = r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                          ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
            <saml:Issuer>https://idp.example.com</saml:Issuer>
        </saml:Assertion>"#;
        let el = parse_xml(xml).unwrap();
        let err = parse_assertion(&el).unwrap_err();
        assert!(
            err.is_missing_element(),
            "expected MissingElement, got: {err}"
        );
    }

    #[test]
    fn parse_assertion_missing_name_id() {
        let xml = r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                          ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
            <saml:Issuer>https://idp.example.com</saml:Issuer>
            <saml:Subject/>
        </saml:Assertion>"#;
        let el = parse_xml(xml).unwrap();
        let err = parse_assertion(&el).unwrap_err();
        assert!(
            err.is_missing_element(),
            "expected MissingElement, got: {err}"
        );
    }

    #[test]
    fn parse_assertion_rejects_missing_or_wrong_version() {
        for version in ["", r#" Version="1.1""#, r#" Version="2.0.0""#] {
            let xml = format!(
                r#"<saml:Assertion{version} xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                              ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
                <saml:Issuer>https://idp.example.com</saml:Issuer>
                <saml:Subject><saml:NameID>u@example.com</saml:NameID></saml:Subject>
            </saml:Assertion>"#
            );
            let el = parse_xml(&xml).unwrap();
            let err = parse_assertion(&el).unwrap_err();
            assert!(err.is_invalid_version(), "version={version:?}: {err}");
        }
    }

    #[test]
    fn parse_assertion_rejects_duplicate_single_occurrence_elements() {
        // A permissive duplicate placed first must not be silently selected.
        let xml = r#"<saml:Assertion Version="2.0" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                          ID="_a1" IssueInstant="2025-01-15T12:00:00Z">
            <saml:Issuer>https://evil.example.com</saml:Issuer>
            <saml:Issuer>https://idp.example.com</saml:Issuer>
            <saml:Subject><saml:NameID>u@example.com</saml:NameID></saml:Subject>
        </saml:Assertion>"#;
        let el = parse_xml(xml).unwrap();
        let err = parse_assertion(&el).unwrap_err();
        assert!(
            err.is_duplicate_element(),
            "expected DuplicateElement: {err}"
        );
    }

    // -- Attributes -------------------------------------------------------

    #[test]
    fn parse_assertion_attributes() {
        let el = parse_xml(&sample_assertion_xml()).unwrap();
        let assertion = parse_assertion(&el).unwrap();
        assert_eq!(assertion.attributes().len(), 2);

        let email = &assertion.attributes()[0];
        assert_eq!(email.name(), "email");
        assert_eq!(email.values(), ["user@example.com"]);

        let groups = &assertion.attributes()[1];
        assert_eq!(groups.name(), "groups");
        assert_eq!(groups.values(), ["admins", "users"]);
        assert_eq!(
            groups.name_format(),
            Some("urn:oasis:names:tc:SAML:2.0:attrname-format:basic")
        );
    }
}