Skip to main content

saml_rs/model/
sso.rs

1use super::attributes::Attributes;
2use super::extract::{
3    attributes_from_extract, authn_sessions_from_extract, conditions_instants,
4    entity_ids_from_value, name_id_format_from_uri, optional_request_id, required_str,
5    subject_confirmations_from_extract,
6};
7use super::identifiers::{AssertionId, MessageId, SamlInstant};
8use super::session::{AuthnSession, EMPTY_AUTHN_SESSION};
9use super::subject::{NameId, Subject};
10use super::{
11    earliest_authn_session_expiration, LogoutSubject, ReplayKey, ReplayPolicy,
12    SamlValidationContext,
13};
14use crate::config::EntityId;
15use crate::error::{SamlError, TimeWindowField};
16use crate::raw::FlowResult;
17use crate::xml::{extract_with_limits, parse_saml_utc_date_time, ExtractorField, XmlLimits};
18use std::time::SystemTime;
19use time::{format_description::well_known::Rfc3339, Duration, OffsetDateTime};
20
21const BEARER_SUBJECT_CONFIRMATION_METHOD: &str = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
22const REPLAY_EXPIRATION_FIELD: TimeWindowField = TimeWindowField::ReplayExpiration;
23
24/// Parsed SSO response envelope.
25#[derive(Debug, Clone)]
26pub struct SsoResponse {
27    response_id: MessageId,
28    issue_instant: SamlInstant,
29    issuer: EntityId,
30    in_response_to: Option<MessageId>,
31    raw_flow: FlowResult,
32}
33
34impl SsoResponse {
35    /// Response ID.
36    pub fn response_id(&self) -> &MessageId {
37        &self.response_id
38    }
39
40    /// Response `IssueInstant`, normalized according to XML Schema whitespace rules.
41    pub fn issue_instant(&self) -> &SamlInstant {
42        &self.issue_instant
43    }
44
45    /// Assertion issuer used by the current validated flow result.
46    pub fn issuer(&self) -> &EntityId {
47        &self.issuer
48    }
49
50    /// InResponseTo, when present.
51    pub fn in_response_to(&self) -> Option<&MessageId> {
52        self.in_response_to.as_ref()
53    }
54
55    /// Raw validated flow result.
56    pub fn raw_flow(&self) -> &FlowResult {
57        &self.raw_flow
58    }
59}
60
61impl TryFrom<FlowResult> for SsoResponse {
62    type Error = SamlError;
63
64    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
65        let response_id = MessageId::try_new(required_str(&raw_flow.extract, "response.id")?)?;
66        let issue_instant =
67            issue_instant_from_extract(&raw_flow.extract, "response.issueInstant", "Response")?;
68        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
69        let in_response_to = optional_request_id(&raw_flow.extract, "response.inResponseTo")?;
70        Ok(Self {
71            response_id,
72            issue_instant,
73            issuer,
74            in_response_to,
75            raw_flow,
76        })
77    }
78}
79
80/// Assertion view extracted from an SSO session.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Assertion {
83    id: Option<AssertionId>,
84    issuer: EntityId,
85    subject: Subject,
86}
87
88impl Assertion {
89    /// Create an assertion view.
90    pub fn new(id: Option<AssertionId>, issuer: EntityId, subject: Subject) -> Self {
91        Self {
92            id,
93            issuer,
94            subject,
95        }
96    }
97
98    /// Assertion ID, when extracted.
99    pub fn id(&self) -> Option<&AssertionId> {
100        self.id.as_ref()
101    }
102
103    /// Assertion issuer.
104    pub fn issuer(&self) -> &EntityId {
105        &self.issuer
106    }
107
108    /// Assertion subject.
109    pub fn subject(&self) -> &Subject {
110        &self.subject
111    }
112}
113
114/// Authenticated SAML objects covered by one verified embedded XML signature.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum VerifiedXmlSignatureCoverage {
117    /// The signature covers the top-level SAML `Response` element.
118    ResponseRoot,
119    /// The signature directly covers the Assertion consumed for this session.
120    ConsumedAssertion,
121    /// The signature covers both the Response root and the consumed Assertion.
122    ResponseRootAndConsumedAssertion,
123}
124
125/// Evidence from one successfully verified embedded XML signature.
126///
127/// The algorithm URI comes from the exact `SignatureMethod` belonging to the
128/// signature node that the XML verifier reported as valid. This evidence does
129/// not contain raw XML, signature bytes, or certificate material, and exposing
130/// it does not apply an application algorithm allowlist.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct VerifiedXmlSignature {
133    algorithm_uri: String,
134    coverage: VerifiedXmlSignatureCoverage,
135}
136
137impl VerifiedXmlSignature {
138    pub(crate) fn from_verified_coverage(
139        algorithm_uri: String,
140        response_covered: bool,
141        assertion_directly_covered: bool,
142    ) -> Option<Self> {
143        let coverage = match (response_covered, assertion_directly_covered) {
144            (true, true) => VerifiedXmlSignatureCoverage::ResponseRootAndConsumedAssertion,
145            (true, false) => VerifiedXmlSignatureCoverage::ResponseRoot,
146            (false, true) => VerifiedXmlSignatureCoverage::ConsumedAssertion,
147            (false, false) => return None,
148        };
149        Some(Self {
150            algorithm_uri,
151            coverage,
152        })
153    }
154
155    /// Exact XML-DSig `SignatureMethod@Algorithm` URI for this verified signature.
156    pub fn algorithm_uri(&self) -> &str {
157        &self.algorithm_uri
158    }
159
160    /// Authenticated SAML object coverage established by this signature's
161    /// verified references.
162    pub fn coverage(&self) -> VerifiedXmlSignatureCoverage {
163        self.coverage
164    }
165}
166
167/// Parsed SSO login session.
168#[derive(Debug, Clone)]
169pub struct SsoSession {
170    response_id: MessageId,
171    response_issue_instant: SamlInstant,
172    assertion_id: AssertionId,
173    assertion_issue_instant: SamlInstant,
174    issuer: EntityId,
175    in_response_to: Option<MessageId>,
176    subject: Subject,
177    attributes: Attributes,
178    authn_sessions: Vec<AuthnSession>,
179    audience: Vec<EntityId>,
180    not_before: Option<SamlInstant>,
181    not_on_or_after: Option<SamlInstant>,
182    sig_alg: Option<String>,
183    verified_xml_signatures: Vec<VerifiedXmlSignature>,
184    raw_flow: FlowResult,
185}
186
187impl SsoSession {
188    /// Response ID.
189    pub fn response_id(&self) -> &MessageId {
190        &self.response_id
191    }
192
193    /// Response `IssueInstant`, normalized according to XML Schema whitespace rules.
194    pub fn response_issue_instant(&self) -> &SamlInstant {
195        &self.response_issue_instant
196    }
197
198    /// Assertion ID.
199    pub fn assertion_id(&self) -> &AssertionId {
200        &self.assertion_id
201    }
202
203    /// Selected assertion `IssueInstant`, normalized according to XML Schema whitespace rules.
204    pub fn assertion_issue_instant(&self) -> &SamlInstant {
205        &self.assertion_issue_instant
206    }
207
208    /// Assertion issuer.
209    pub fn issuer(&self) -> &EntityId {
210        &self.issuer
211    }
212
213    /// InResponseTo, when present.
214    pub fn in_response_to(&self) -> Option<&MessageId> {
215        self.in_response_to.as_ref()
216    }
217
218    /// Subject.
219    pub fn subject(&self) -> &Subject {
220        &self.subject
221    }
222
223    /// Subject NameID.
224    pub fn name_id(&self) -> &NameId {
225        self.subject.name_id()
226    }
227
228    /// Attributes.
229    pub fn attributes(&self) -> &Attributes {
230        &self.attributes
231    }
232
233    /// Legacy singular view of `AuthnStatement` session data.
234    ///
235    /// This compatibility accessor returns the first statement in document
236    /// order. For assertions containing multiple statements, use
237    /// [`Self::authn_sessions`]. When no statement is present, it returns an
238    /// immutable empty [`AuthnSession`].
239    pub fn authn_session(&self) -> &AuthnSession {
240        self.authn_sessions.first().unwrap_or(&EMPTY_AUTHN_SESSION)
241    }
242
243    /// Every `AuthnStatement` session tuple in XML document order.
244    pub fn authn_sessions(&self) -> &[AuthnSession] {
245        &self.authn_sessions
246    }
247
248    /// Audience restrictions.
249    pub fn audience(&self) -> &[EntityId] {
250        &self.audience
251    }
252
253    /// Conditions NotBefore.
254    pub fn not_before(&self) -> Option<&SamlInstant> {
255        self.not_before.as_ref()
256    }
257
258    /// Conditions NotOnOrAfter.
259    pub fn not_on_or_after(&self) -> Option<&SamlInstant> {
260        self.not_on_or_after.as_ref()
261    }
262
263    /// Verified detached Redirect or HTTP-POST-SimpleSign algorithm, when applicable.
264    ///
265    /// Embedded XML signatures are exposed separately through
266    /// [`Self::verified_xml_signatures`] because a message can contain multiple
267    /// verified signatures with different algorithms and coverage.
268    pub fn sig_alg(&self) -> Option<&str> {
269        self.sig_alg.as_deref()
270    }
271
272    /// Successfully verified embedded XML signatures that authenticate this
273    /// session's Response root or consumed Assertion.
274    ///
275    /// Each item is bound to one exact verifier-approved signature node and
276    /// its verified references. Invalid, untrusted, malformed, or unrelated
277    /// signatures are not included. The view preserves distinct signatures;
278    /// it does not select a signature by XML order. Applications may inspect
279    /// the reported algorithm URIs to enforce their own allowlist.
280    pub fn verified_xml_signatures(&self) -> &[VerifiedXmlSignature] {
281        &self.verified_xml_signatures
282    }
283
284    /// Assertion view.
285    pub fn assertion(&self) -> Assertion {
286        Assertion::new(
287            Some(self.assertion_id.clone()),
288            self.issuer.clone(),
289            self.subject.clone(),
290        )
291    }
292
293    /// Subject data suitable for issuing Single Logout.
294    ///
295    /// Every present `SessionIndex` is included in `AuthnStatement` document
296    /// order.
297    ///
298    /// # Examples
299    ///
300    /// ```no_run
301    /// use saml_rs::{IdpDescriptor, Saml, SamlError, SsoSession, StartSlo};
302    ///
303    /// # fn logout(
304    /// #     sp: &Saml<saml_rs::Sp>,
305    /// #     idp: &IdpDescriptor,
306    /// #     session: &SsoSession,
307    /// # ) -> Result<(), SamlError> {
308    /// let subject = session
309    ///     .logout_subject()
310    ///     .ok_or_else(|| SamlError::Invalid("missing logout subject".into()))?;
311    /// let started = sp.start_slo(idp, subject, StartSlo::redirect())?;
312    ///
313    /// let redirect_url = started.outbound.redirect_url()?;
314    /// # let _ = redirect_url;
315    /// # Ok(()) }
316    /// ```
317    pub fn logout_subject(&self) -> Option<LogoutSubject> {
318        if self.name_id().value().trim().is_empty() {
319            return None;
320        }
321        let session_indexes = self
322            .authn_sessions
323            .iter()
324            .filter_map(AuthnSession::session_index)
325            .cloned()
326            .collect();
327        Some(LogoutSubject::new(self.name_id().clone(), session_indexes))
328    }
329
330    /// Replay keys available from this validated SSO session.
331    pub fn replay_keys(&self) -> Vec<ReplayKey> {
332        let mut keys = vec![ReplayKey::ResponseId(self.response_id.clone())];
333        let mut ids = crate::assertion_acceptance::assertion_ids(&self.raw_flow.saml_content)
334            .unwrap_or_default();
335        if !ids.iter().any(|id| id == self.assertion_id.as_str()) {
336            ids.insert(0, self.assertion_id.as_str().to_string());
337        }
338        for id in ids {
339            if let Ok(id) = AssertionId::try_new(id) {
340                keys.push(ReplayKey::AssertionId(id));
341            }
342        }
343        keys
344    }
345
346    /// Check and store this session's replay keys using the caller cache.
347    ///
348    /// This method is intended for typed inbound SSO facades. It should be
349    /// called only after signature, issuer, audience, destination, recipient,
350    /// `InResponseTo`, and time validation have already passed.
351    ///
352    /// The bearer assertion identifier is kept until
353    /// `SubjectConfirmationData@NotOnOrAfter` plus skew. An earlier
354    /// `Conditions` or `AuthnStatement` instant does not shorten it. The
355    /// Response identifier still expires at the earliest bound. Every direct
356    /// assertion identifier is stored. Another assertion uses its own bearer
357    /// `NotOnOrAfter` when that attribute is present.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`SamlError::TimeWindowInvalid`] when no valid replay
362    /// expiration can be derived or a stored deadline has already passed.
363    /// Nothing is stored in that case. Returns [`SamlError::ReplayDetected`]
364    /// when any session replay key has already been seen. Cache
365    /// implementations may also return storage-specific failures mapped to
366    /// [`SamlError`].
367    pub fn check_and_store_replay(
368        &self,
369        validation: &mut SamlValidationContext<'_>,
370    ) -> Result<(), SamlError> {
371        let validation_now = validation.now_offset()?;
372        let not_on_or_after_skew_ms = validation.clock_skew().not_on_or_after_millis();
373        match validation.replay_policy() {
374            ReplayPolicy::DisabledForCompatibility => Ok(()),
375            ReplayPolicy::RequireCache(cache) => {
376                let replays =
377                    crate::assertion_acceptance::assertion_replays(&self.raw_flow.saml_content)?;
378                let response_expires = system_time_from_offset(replay_deadline(
379                    self.earliest_replay_instant()?,
380                    validation_now,
381                    not_on_or_after_skew_ms,
382                )?)?;
383                let assertion_expires = system_time_from_offset(replay_deadline(
384                    self.assertion_replay_instant()?,
385                    validation_now,
386                    not_on_or_after_skew_ms,
387                )?)?;
388                cache.check_and_store(
389                    ReplayKey::ResponseId(self.response_id.clone()),
390                    response_expires,
391                )?;
392                cache.check_and_store(
393                    ReplayKey::AssertionId(self.assertion_id.clone()),
394                    assertion_expires,
395                )?;
396                for replay in replays {
397                    if replay.id == self.assertion_id.as_str() {
398                        continue;
399                    }
400                    let expires_at = match replay.bearer_not_on_or_after {
401                        Some(instant) => system_time_from_offset(replay_deadline(
402                            parse_replay_expiration(&instant)?,
403                            validation_now,
404                            not_on_or_after_skew_ms,
405                        )?)?,
406                        None => assertion_expires,
407                    };
408                    cache.check_and_store(
409                        ReplayKey::AssertionId(AssertionId::try_new(replay.id)?),
410                        expires_at,
411                    )?;
412                }
413                Ok(())
414            }
415        }
416    }
417
418    /// Raw validated flow result.
419    pub fn raw_flow(&self) -> &FlowResult {
420        &self.raw_flow
421    }
422
423    fn earliest_replay_instant(&self) -> Result<OffsetDateTime, SamlError> {
424        let mut candidates = Vec::with_capacity(3);
425        if let Some(instant) = self.not_on_or_after() {
426            candidates.push(parse_replay_expiration(instant.as_str())?);
427        }
428        if let Some(instant) = earliest_authn_session_expiration(
429            self.authn_sessions
430                .iter()
431                .filter_map(AuthnSession::not_on_or_after)
432                .map(SamlInstant::as_str),
433            REPLAY_EXPIRATION_FIELD,
434        )? {
435            candidates.push(instant);
436        }
437        if let Some(instant) = self.bearer_subject_confirmation_expires_at()? {
438            candidates.push(instant);
439        }
440        candidates
441            .into_iter()
442            .min()
443            .ok_or(SamlError::TimeWindowInvalid {
444                field: REPLAY_EXPIRATION_FIELD,
445            })
446    }
447
448    fn assertion_replay_instant(&self) -> Result<OffsetDateTime, SamlError> {
449        if let Some(instant) = self.bearer_subject_confirmation_expires_at()? {
450            return Ok(instant);
451        }
452        self.earliest_replay_instant()
453    }
454
455    fn bearer_subject_confirmation_expires_at(&self) -> Result<Option<OffsetDateTime>, SamlError> {
456        let fields = [
457            ExtractorField::new("subjectConfirmation", &["SubjectConfirmation"]).attrs(&["Method"]),
458            ExtractorField::new(
459                "subjectConfirmationData",
460                &["SubjectConfirmation", "SubjectConfirmationData"],
461            )
462            .attrs(&["NotOnOrAfter"]),
463        ];
464        let mut expires_at = None;
465        for confirmation in self.subject.confirmations() {
466            let extracted =
467                extract_with_limits(confirmation.raw_xml(), &fields, XmlLimits::default())?;
468            if extracted.get_str("subjectConfirmation") != Some(BEARER_SUBJECT_CONFIRMATION_METHOD)
469            {
470                continue;
471            }
472            let Some(not_on_or_after) = extracted.get_str("subjectConfirmationData") else {
473                continue;
474            };
475            let candidate = parse_replay_expiration(not_on_or_after)?;
476            match expires_at {
477                Some(current) if current >= candidate => {}
478                Some(_) | None => expires_at = Some(candidate),
479            }
480        }
481        Ok(expires_at)
482    }
483}
484
485fn replay_deadline(
486    raw_expires_at: OffsetDateTime,
487    validation_now: OffsetDateTime,
488    not_on_or_after_skew_ms: i64,
489) -> Result<OffsetDateTime, SamlError> {
490    let expires_at = raw_expires_at
491        .checked_add(Duration::milliseconds(not_on_or_after_skew_ms))
492        .ok_or(SamlError::TimeWindowInvalid {
493            field: REPLAY_EXPIRATION_FIELD,
494        })?;
495    if validation_now >= expires_at {
496        return Err(SamlError::TimeWindowInvalid {
497            field: REPLAY_EXPIRATION_FIELD,
498        });
499    }
500    Ok(expires_at)
501}
502
503fn system_time_from_offset(expires_at: OffsetDateTime) -> Result<SystemTime, SamlError> {
504    let since_epoch = expires_at - OffsetDateTime::UNIX_EPOCH;
505    let converted = if since_epoch.is_negative() {
506        SystemTime::UNIX_EPOCH.checked_sub(since_epoch.unsigned_abs())
507    } else {
508        SystemTime::UNIX_EPOCH.checked_add(since_epoch.unsigned_abs())
509    };
510    converted.ok_or(SamlError::TimeWindowInvalid {
511        field: REPLAY_EXPIRATION_FIELD,
512    })
513}
514
515fn parse_replay_expiration(value: &str) -> Result<OffsetDateTime, SamlError> {
516    OffsetDateTime::parse(value, &Rfc3339).map_err(|_| SamlError::TimeWindowInvalid {
517        field: REPLAY_EXPIRATION_FIELD,
518    })
519}
520
521impl TryFrom<FlowResult> for SsoSession {
522    type Error = SamlError;
523
524    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
525        Self::try_from_with_verified_xml_signatures(raw_flow, Vec::new())
526    }
527}
528
529impl SsoSession {
530    pub(crate) fn try_from_with_verified_xml_signatures(
531        raw_flow: FlowResult,
532        verified_xml_signatures: Vec<VerifiedXmlSignature>,
533    ) -> Result<Self, SamlError> {
534        let response_id = MessageId::try_new(required_str(&raw_flow.extract, "response.id")?)?;
535        let response_issue_instant =
536            issue_instant_from_extract(&raw_flow.extract, "response.issueInstant", "Response")?;
537        let assertion_id = AssertionId::try_new(required_str(&raw_flow.extract, "assertion.id")?)?;
538        let assertion_issue_instant =
539            issue_instant_from_extract(&raw_flow.extract, "assertion.issueInstant", "Assertion")?;
540        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
541        let in_response_to = optional_request_id(&raw_flow.extract, "response.inResponseTo")?;
542        let name_id_format = raw_flow
543            .extract
544            .get_str("nameIDFormat")
545            .map(name_id_format_from_uri);
546        let name_id = NameId::new(required_str(&raw_flow.extract, "nameID")?, name_id_format);
547        let subject = Subject::new(
548            name_id,
549            subject_confirmations_from_extract(&raw_flow.extract),
550        );
551        let attributes = attributes_from_extract(&raw_flow.extract);
552        let authn_sessions = authn_sessions_from_extract(&raw_flow.extract)?;
553        let audience = entity_ids_from_value(raw_flow.extract.get("audience"))?;
554        let (not_before, not_on_or_after) = conditions_instants(&raw_flow.extract)?;
555        let sig_alg = raw_flow.sig_alg.clone();
556        Ok(Self {
557            response_id,
558            response_issue_instant,
559            assertion_id,
560            assertion_issue_instant,
561            issuer,
562            in_response_to,
563            subject,
564            attributes,
565            authn_sessions,
566            audience,
567            not_before,
568            not_on_or_after,
569            sig_alg,
570            verified_xml_signatures,
571            raw_flow,
572        })
573    }
574}
575
576fn issue_instant_from_extract(
577    extract: &crate::util::Value,
578    path: &str,
579    element: &str,
580) -> Result<SamlInstant, SamlError> {
581    let issue_instant = extract.get_str(path).ok_or_else(|| {
582        SamlError::ProtocolProfile(format!(
583            "{element} is missing required unqualified attribute IssueInstant"
584        ))
585    })?;
586    let issue_instant = parse_saml_utc_date_time(issue_instant).ok_or_else(|| {
587        SamlError::ProtocolProfile(format!(
588            "{element} IssueInstant must use the SAML-conformant UTC xs:dateTime form ending in Z"
589        ))
590    })?;
591    SamlInstant::try_new(issue_instant)
592}