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/// Parsed SSO login session.
115#[derive(Debug, Clone)]
116pub struct SsoSession {
117    response_id: MessageId,
118    response_issue_instant: SamlInstant,
119    assertion_id: AssertionId,
120    assertion_issue_instant: SamlInstant,
121    issuer: EntityId,
122    in_response_to: Option<MessageId>,
123    subject: Subject,
124    attributes: Attributes,
125    authn_sessions: Vec<AuthnSession>,
126    audience: Vec<EntityId>,
127    not_before: Option<SamlInstant>,
128    not_on_or_after: Option<SamlInstant>,
129    sig_alg: Option<String>,
130    raw_flow: FlowResult,
131}
132
133impl SsoSession {
134    /// Response ID.
135    pub fn response_id(&self) -> &MessageId {
136        &self.response_id
137    }
138
139    /// Response `IssueInstant`, normalized according to XML Schema whitespace rules.
140    pub fn response_issue_instant(&self) -> &SamlInstant {
141        &self.response_issue_instant
142    }
143
144    /// Assertion ID.
145    pub fn assertion_id(&self) -> &AssertionId {
146        &self.assertion_id
147    }
148
149    /// Selected assertion `IssueInstant`, normalized according to XML Schema whitespace rules.
150    pub fn assertion_issue_instant(&self) -> &SamlInstant {
151        &self.assertion_issue_instant
152    }
153
154    /// Assertion issuer.
155    pub fn issuer(&self) -> &EntityId {
156        &self.issuer
157    }
158
159    /// InResponseTo, when present.
160    pub fn in_response_to(&self) -> Option<&MessageId> {
161        self.in_response_to.as_ref()
162    }
163
164    /// Subject.
165    pub fn subject(&self) -> &Subject {
166        &self.subject
167    }
168
169    /// Subject NameID.
170    pub fn name_id(&self) -> &NameId {
171        self.subject.name_id()
172    }
173
174    /// Attributes.
175    pub fn attributes(&self) -> &Attributes {
176        &self.attributes
177    }
178
179    /// Legacy singular view of `AuthnStatement` session data.
180    ///
181    /// This compatibility accessor returns the first statement in document
182    /// order. For assertions containing multiple statements, use
183    /// [`Self::authn_sessions`]. When no statement is present, it returns an
184    /// immutable empty [`AuthnSession`].
185    pub fn authn_session(&self) -> &AuthnSession {
186        self.authn_sessions.first().unwrap_or(&EMPTY_AUTHN_SESSION)
187    }
188
189    /// Every `AuthnStatement` session tuple in XML document order.
190    pub fn authn_sessions(&self) -> &[AuthnSession] {
191        &self.authn_sessions
192    }
193
194    /// Audience restrictions.
195    pub fn audience(&self) -> &[EntityId] {
196        &self.audience
197    }
198
199    /// Conditions NotBefore.
200    pub fn not_before(&self) -> Option<&SamlInstant> {
201        self.not_before.as_ref()
202    }
203
204    /// Conditions NotOnOrAfter.
205    pub fn not_on_or_after(&self) -> Option<&SamlInstant> {
206        self.not_on_or_after.as_ref()
207    }
208
209    /// Verified detached signature algorithm, when applicable.
210    pub fn sig_alg(&self) -> Option<&str> {
211        self.sig_alg.as_deref()
212    }
213
214    /// Assertion view.
215    pub fn assertion(&self) -> Assertion {
216        Assertion::new(
217            Some(self.assertion_id.clone()),
218            self.issuer.clone(),
219            self.subject.clone(),
220        )
221    }
222
223    /// Subject data suitable for issuing Single Logout.
224    ///
225    /// Every present `SessionIndex` is included in `AuthnStatement` document
226    /// order.
227    ///
228    /// # Examples
229    ///
230    /// ```no_run
231    /// use saml_rs::{IdpDescriptor, Saml, SamlError, SsoSession, StartSlo};
232    ///
233    /// # fn logout(
234    /// #     sp: &Saml<saml_rs::Sp>,
235    /// #     idp: &IdpDescriptor,
236    /// #     session: &SsoSession,
237    /// # ) -> Result<(), SamlError> {
238    /// let subject = session
239    ///     .logout_subject()
240    ///     .ok_or_else(|| SamlError::Invalid("missing logout subject".into()))?;
241    /// let started = sp.start_slo(idp, subject, StartSlo::redirect())?;
242    ///
243    /// let redirect_url = started.outbound.redirect_url()?;
244    /// # let _ = redirect_url;
245    /// # Ok(()) }
246    /// ```
247    pub fn logout_subject(&self) -> Option<LogoutSubject> {
248        if self.name_id().value().trim().is_empty() {
249            return None;
250        }
251        let session_indexes = self
252            .authn_sessions
253            .iter()
254            .filter_map(AuthnSession::session_index)
255            .cloned()
256            .collect();
257        Some(LogoutSubject::new(self.name_id().clone(), session_indexes))
258    }
259
260    /// Replay keys available from this validated SSO session.
261    pub fn replay_keys(&self) -> Vec<ReplayKey> {
262        vec![
263            ReplayKey::ResponseId(self.response_id.clone()),
264            ReplayKey::AssertionId(self.assertion_id.clone()),
265        ]
266    }
267
268    /// Check and store this session's replay keys using the caller cache.
269    ///
270    /// This method is intended for typed inbound SSO facades. It should be
271    /// called only after signature, issuer, audience, destination, recipient,
272    /// `InResponseTo`, and time validation have already passed.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`SamlError::TimeWindowInvalid`] when no valid replay
277    /// expiration can be derived or the session is already expired. Replay
278    /// expiration uses the earliest upper bound across Conditions, bearer
279    /// SubjectConfirmation data, and every `AuthnStatement`. Returns
280    /// [`SamlError::ReplayDetected`] when any session replay key has already
281    /// been seen. Cache implementations may also return storage-specific
282    /// failures mapped to [`SamlError`].
283    pub fn check_and_store_replay(
284        &self,
285        validation: &mut SamlValidationContext<'_>,
286    ) -> Result<(), SamlError> {
287        let validation_now = validation.now_offset()?;
288        let not_on_or_after_skew_ms = validation.clock_skew().not_on_or_after_millis();
289        match validation.replay_policy() {
290            ReplayPolicy::DisabledForCompatibility => Ok(()),
291            ReplayPolicy::RequireCache(cache) => {
292                let expires_at = self.replay_expires_at(validation_now, not_on_or_after_skew_ms)?;
293                let since_epoch = expires_at - OffsetDateTime::UNIX_EPOCH;
294                let expires_at = if since_epoch.is_negative() {
295                    SystemTime::UNIX_EPOCH.checked_sub(since_epoch.unsigned_abs())
296                } else {
297                    SystemTime::UNIX_EPOCH.checked_add(since_epoch.unsigned_abs())
298                }
299                .ok_or(SamlError::TimeWindowInvalid {
300                    field: REPLAY_EXPIRATION_FIELD,
301                })?;
302                let keys = self.replay_keys();
303                for key in keys {
304                    cache.check_and_store(key, expires_at)?;
305                }
306                Ok(())
307            }
308        }
309    }
310
311    /// Raw validated flow result.
312    pub fn raw_flow(&self) -> &FlowResult {
313        &self.raw_flow
314    }
315
316    fn replay_expires_at(
317        &self,
318        validation_now: OffsetDateTime,
319        not_on_or_after_skew_ms: i64,
320    ) -> Result<OffsetDateTime, SamlError> {
321        let mut candidates = Vec::with_capacity(3);
322        if let Some(instant) = self.not_on_or_after() {
323            candidates.push(parse_replay_expiration(instant.as_str())?);
324        }
325        if let Some(instant) = earliest_authn_session_expiration(
326            self.authn_sessions
327                .iter()
328                .filter_map(AuthnSession::not_on_or_after)
329                .map(SamlInstant::as_str),
330            REPLAY_EXPIRATION_FIELD,
331        )? {
332            candidates.push(instant);
333        }
334        if let Some(instant) = self.bearer_subject_confirmation_expires_at()? {
335            candidates.push(instant);
336        }
337
338        let raw_expires_at = candidates
339            .into_iter()
340            .min()
341            .ok_or(SamlError::TimeWindowInvalid {
342                field: REPLAY_EXPIRATION_FIELD,
343            })?;
344        let expires_at = raw_expires_at
345            .checked_add(Duration::milliseconds(not_on_or_after_skew_ms))
346            .ok_or(SamlError::TimeWindowInvalid {
347                field: REPLAY_EXPIRATION_FIELD,
348            })?;
349        if validation_now >= expires_at {
350            return Err(SamlError::TimeWindowInvalid {
351                field: REPLAY_EXPIRATION_FIELD,
352            });
353        }
354        Ok(expires_at)
355    }
356
357    fn bearer_subject_confirmation_expires_at(&self) -> Result<Option<OffsetDateTime>, SamlError> {
358        let fields = [
359            ExtractorField::new("subjectConfirmation", &["SubjectConfirmation"]).attrs(&["Method"]),
360            ExtractorField::new(
361                "subjectConfirmationData",
362                &["SubjectConfirmation", "SubjectConfirmationData"],
363            )
364            .attrs(&["NotOnOrAfter"]),
365        ];
366        let mut expires_at = None;
367        for confirmation in self.subject.confirmations() {
368            let extracted =
369                extract_with_limits(confirmation.raw_xml(), &fields, XmlLimits::default())?;
370            if extracted.get_str("subjectConfirmation") != Some(BEARER_SUBJECT_CONFIRMATION_METHOD)
371            {
372                continue;
373            }
374            let Some(not_on_or_after) = extracted.get_str("subjectConfirmationData") else {
375                continue;
376            };
377            let candidate = parse_replay_expiration(not_on_or_after)?;
378            match expires_at {
379                Some(current) if current >= candidate => {}
380                Some(_) | None => expires_at = Some(candidate),
381            }
382        }
383        Ok(expires_at)
384    }
385}
386
387fn parse_replay_expiration(value: &str) -> Result<OffsetDateTime, SamlError> {
388    OffsetDateTime::parse(value, &Rfc3339).map_err(|_| SamlError::TimeWindowInvalid {
389        field: REPLAY_EXPIRATION_FIELD,
390    })
391}
392
393impl TryFrom<FlowResult> for SsoSession {
394    type Error = SamlError;
395
396    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
397        let response_id = MessageId::try_new(required_str(&raw_flow.extract, "response.id")?)?;
398        let response_issue_instant =
399            issue_instant_from_extract(&raw_flow.extract, "response.issueInstant", "Response")?;
400        let assertion_id = AssertionId::try_new(required_str(&raw_flow.extract, "assertion.id")?)?;
401        let assertion_issue_instant =
402            issue_instant_from_extract(&raw_flow.extract, "assertion.issueInstant", "Assertion")?;
403        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
404        let in_response_to = optional_request_id(&raw_flow.extract, "response.inResponseTo")?;
405        let name_id_format = raw_flow
406            .extract
407            .get_str("nameIDFormat")
408            .map(name_id_format_from_uri);
409        let name_id = NameId::new(required_str(&raw_flow.extract, "nameID")?, name_id_format);
410        let subject = Subject::new(
411            name_id,
412            subject_confirmations_from_extract(&raw_flow.extract),
413        );
414        let attributes = attributes_from_extract(&raw_flow.extract);
415        let authn_sessions = authn_sessions_from_extract(&raw_flow.extract)?;
416        let audience = entity_ids_from_value(raw_flow.extract.get("audience"))?;
417        let (not_before, not_on_or_after) = conditions_instants(&raw_flow.extract)?;
418        let sig_alg = raw_flow.sig_alg.clone();
419        Ok(Self {
420            response_id,
421            response_issue_instant,
422            assertion_id,
423            assertion_issue_instant,
424            issuer,
425            in_response_to,
426            subject,
427            attributes,
428            authn_sessions,
429            audience,
430            not_before,
431            not_on_or_after,
432            sig_alg,
433            raw_flow,
434        })
435    }
436}
437
438fn issue_instant_from_extract(
439    extract: &crate::util::Value,
440    path: &str,
441    element: &str,
442) -> Result<SamlInstant, SamlError> {
443    let issue_instant = extract.get_str(path).ok_or_else(|| {
444        SamlError::ProtocolProfile(format!(
445            "{element} is missing required unqualified attribute IssueInstant"
446        ))
447    })?;
448    let issue_instant = parse_saml_utc_date_time(issue_instant).ok_or_else(|| {
449        SamlError::ProtocolProfile(format!(
450            "{element} IssueInstant must use the SAML-conformant UTC xs:dateTime form ending in Z"
451        ))
452    })?;
453    SamlInstant::try_new(issue_instant)
454}