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, 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    issuer: EntityId,
29    in_response_to: Option<MessageId>,
30    raw_flow: FlowResult,
31}
32
33impl SsoResponse {
34    /// Response ID.
35    pub fn response_id(&self) -> &MessageId {
36        &self.response_id
37    }
38
39    /// Assertion issuer used by the current validated flow result.
40    pub fn issuer(&self) -> &EntityId {
41        &self.issuer
42    }
43
44    /// InResponseTo, when present.
45    pub fn in_response_to(&self) -> Option<&MessageId> {
46        self.in_response_to.as_ref()
47    }
48
49    /// Raw validated flow result.
50    pub fn raw_flow(&self) -> &FlowResult {
51        &self.raw_flow
52    }
53}
54
55impl TryFrom<FlowResult> for SsoResponse {
56    type Error = SamlError;
57
58    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
59        let response_id = MessageId::try_new(required_str(&raw_flow.extract, "response.id")?)?;
60        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
61        let in_response_to = optional_request_id(&raw_flow.extract, "response.inResponseTo")?;
62        Ok(Self {
63            response_id,
64            issuer,
65            in_response_to,
66            raw_flow,
67        })
68    }
69}
70
71/// Assertion view extracted from an SSO session.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Assertion {
74    id: Option<AssertionId>,
75    issuer: EntityId,
76    subject: Subject,
77}
78
79impl Assertion {
80    /// Create an assertion view.
81    pub fn new(id: Option<AssertionId>, issuer: EntityId, subject: Subject) -> Self {
82        Self {
83            id,
84            issuer,
85            subject,
86        }
87    }
88
89    /// Assertion ID, when extracted.
90    pub fn id(&self) -> Option<&AssertionId> {
91        self.id.as_ref()
92    }
93
94    /// Assertion issuer.
95    pub fn issuer(&self) -> &EntityId {
96        &self.issuer
97    }
98
99    /// Assertion subject.
100    pub fn subject(&self) -> &Subject {
101        &self.subject
102    }
103}
104
105/// Parsed SSO login session.
106#[derive(Debug, Clone)]
107pub struct SsoSession {
108    response_id: MessageId,
109    assertion_id: AssertionId,
110    issuer: EntityId,
111    in_response_to: Option<MessageId>,
112    subject: Subject,
113    attributes: Attributes,
114    authn_sessions: Vec<AuthnSession>,
115    audience: Vec<EntityId>,
116    not_before: Option<SamlInstant>,
117    not_on_or_after: Option<SamlInstant>,
118    sig_alg: Option<String>,
119    raw_flow: FlowResult,
120}
121
122impl SsoSession {
123    /// Response ID.
124    pub fn response_id(&self) -> &MessageId {
125        &self.response_id
126    }
127
128    /// Assertion ID.
129    pub fn assertion_id(&self) -> &AssertionId {
130        &self.assertion_id
131    }
132
133    /// Assertion issuer.
134    pub fn issuer(&self) -> &EntityId {
135        &self.issuer
136    }
137
138    /// InResponseTo, when present.
139    pub fn in_response_to(&self) -> Option<&MessageId> {
140        self.in_response_to.as_ref()
141    }
142
143    /// Subject.
144    pub fn subject(&self) -> &Subject {
145        &self.subject
146    }
147
148    /// Subject NameID.
149    pub fn name_id(&self) -> &NameId {
150        self.subject.name_id()
151    }
152
153    /// Attributes.
154    pub fn attributes(&self) -> &Attributes {
155        &self.attributes
156    }
157
158    /// Legacy singular view of `AuthnStatement` session data.
159    ///
160    /// This compatibility accessor returns the first statement in document
161    /// order. For assertions containing multiple statements, use
162    /// [`Self::authn_sessions`]. When no statement is present, it returns an
163    /// immutable empty [`AuthnSession`].
164    pub fn authn_session(&self) -> &AuthnSession {
165        self.authn_sessions.first().unwrap_or(&EMPTY_AUTHN_SESSION)
166    }
167
168    /// Every `AuthnStatement` session tuple in XML document order.
169    pub fn authn_sessions(&self) -> &[AuthnSession] {
170        &self.authn_sessions
171    }
172
173    /// Audience restrictions.
174    pub fn audience(&self) -> &[EntityId] {
175        &self.audience
176    }
177
178    /// Conditions NotBefore.
179    pub fn not_before(&self) -> Option<&SamlInstant> {
180        self.not_before.as_ref()
181    }
182
183    /// Conditions NotOnOrAfter.
184    pub fn not_on_or_after(&self) -> Option<&SamlInstant> {
185        self.not_on_or_after.as_ref()
186    }
187
188    /// Verified detached signature algorithm, when applicable.
189    pub fn sig_alg(&self) -> Option<&str> {
190        self.sig_alg.as_deref()
191    }
192
193    /// Assertion view.
194    pub fn assertion(&self) -> Assertion {
195        Assertion::new(
196            Some(self.assertion_id.clone()),
197            self.issuer.clone(),
198            self.subject.clone(),
199        )
200    }
201
202    /// Subject data suitable for issuing Single Logout.
203    ///
204    /// Every present `SessionIndex` is included in `AuthnStatement` document
205    /// order.
206    ///
207    /// # Examples
208    ///
209    /// ```no_run
210    /// use saml_rs::{IdpDescriptor, Saml, SamlError, SsoSession, StartSlo};
211    ///
212    /// # fn logout(
213    /// #     sp: &Saml<saml_rs::Sp>,
214    /// #     idp: &IdpDescriptor,
215    /// #     session: &SsoSession,
216    /// # ) -> Result<(), SamlError> {
217    /// let subject = session
218    ///     .logout_subject()
219    ///     .ok_or_else(|| SamlError::Invalid("missing logout subject".into()))?;
220    /// let started = sp.start_slo(idp, subject, StartSlo::redirect())?;
221    ///
222    /// let redirect_url = started.outbound.redirect_url()?;
223    /// # let _ = redirect_url;
224    /// # Ok(()) }
225    /// ```
226    pub fn logout_subject(&self) -> Option<LogoutSubject> {
227        if self.name_id().value().trim().is_empty() {
228            return None;
229        }
230        let session_indexes = self
231            .authn_sessions
232            .iter()
233            .filter_map(AuthnSession::session_index)
234            .cloned()
235            .collect();
236        Some(LogoutSubject::new(self.name_id().clone(), session_indexes))
237    }
238
239    /// Replay keys available from this validated SSO session.
240    pub fn replay_keys(&self) -> Vec<ReplayKey> {
241        vec![
242            ReplayKey::ResponseId(self.response_id.clone()),
243            ReplayKey::AssertionId(self.assertion_id.clone()),
244        ]
245    }
246
247    /// Check and store this session's replay keys using the caller cache.
248    ///
249    /// This method is intended for typed inbound SSO facades. It should be
250    /// called only after signature, issuer, audience, destination, recipient,
251    /// `InResponseTo`, and time validation have already passed.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`SamlError::TimeWindowInvalid`] when no valid replay
256    /// expiration can be derived or the session is already expired. Replay
257    /// expiration uses the earliest upper bound across Conditions, bearer
258    /// SubjectConfirmation data, and every `AuthnStatement`. Returns
259    /// [`SamlError::ReplayDetected`] when any session replay key has already
260    /// been seen. Cache implementations may also return storage-specific
261    /// failures mapped to [`SamlError`].
262    pub fn check_and_store_replay(
263        &self,
264        validation: &mut SamlValidationContext<'_>,
265    ) -> Result<(), SamlError> {
266        let validation_now = validation.now_offset()?;
267        let not_on_or_after_skew_ms = validation.clock_skew().not_on_or_after_millis();
268        match validation.replay_policy() {
269            ReplayPolicy::DisabledForCompatibility => Ok(()),
270            ReplayPolicy::RequireCache(cache) => {
271                let expires_at = self.replay_expires_at(validation_now, not_on_or_after_skew_ms)?;
272                let since_epoch = expires_at - OffsetDateTime::UNIX_EPOCH;
273                let expires_at = if since_epoch.is_negative() {
274                    SystemTime::UNIX_EPOCH.checked_sub(since_epoch.unsigned_abs())
275                } else {
276                    SystemTime::UNIX_EPOCH.checked_add(since_epoch.unsigned_abs())
277                }
278                .ok_or(SamlError::TimeWindowInvalid {
279                    field: REPLAY_EXPIRATION_FIELD,
280                })?;
281                let keys = self.replay_keys();
282                for key in keys {
283                    cache.check_and_store(key, expires_at)?;
284                }
285                Ok(())
286            }
287        }
288    }
289
290    /// Raw validated flow result.
291    pub fn raw_flow(&self) -> &FlowResult {
292        &self.raw_flow
293    }
294
295    fn replay_expires_at(
296        &self,
297        validation_now: OffsetDateTime,
298        not_on_or_after_skew_ms: i64,
299    ) -> Result<OffsetDateTime, SamlError> {
300        let mut candidates = Vec::with_capacity(3);
301        if let Some(instant) = self.not_on_or_after() {
302            candidates.push(parse_replay_expiration(instant.as_str())?);
303        }
304        if let Some(instant) = earliest_authn_session_expiration(
305            self.authn_sessions
306                .iter()
307                .filter_map(AuthnSession::not_on_or_after)
308                .map(SamlInstant::as_str),
309            REPLAY_EXPIRATION_FIELD,
310        )? {
311            candidates.push(instant);
312        }
313        if let Some(instant) = self.bearer_subject_confirmation_expires_at()? {
314            candidates.push(instant);
315        }
316
317        let raw_expires_at = candidates
318            .into_iter()
319            .min()
320            .ok_or(SamlError::TimeWindowInvalid {
321                field: REPLAY_EXPIRATION_FIELD,
322            })?;
323        let expires_at = raw_expires_at
324            .checked_add(Duration::milliseconds(not_on_or_after_skew_ms))
325            .ok_or(SamlError::TimeWindowInvalid {
326                field: REPLAY_EXPIRATION_FIELD,
327            })?;
328        if validation_now >= expires_at {
329            return Err(SamlError::TimeWindowInvalid {
330                field: REPLAY_EXPIRATION_FIELD,
331            });
332        }
333        Ok(expires_at)
334    }
335
336    fn bearer_subject_confirmation_expires_at(&self) -> Result<Option<OffsetDateTime>, SamlError> {
337        let fields = [
338            ExtractorField::new("subjectConfirmation", &["SubjectConfirmation"]).attrs(&["Method"]),
339            ExtractorField::new(
340                "subjectConfirmationData",
341                &["SubjectConfirmation", "SubjectConfirmationData"],
342            )
343            .attrs(&["NotOnOrAfter"]),
344        ];
345        let mut expires_at = None;
346        for confirmation in self.subject.confirmations() {
347            let extracted =
348                extract_with_limits(confirmation.raw_xml(), &fields, XmlLimits::default())?;
349            if extracted.get_str("subjectConfirmation") != Some(BEARER_SUBJECT_CONFIRMATION_METHOD)
350            {
351                continue;
352            }
353            let Some(not_on_or_after) = extracted.get_str("subjectConfirmationData") else {
354                continue;
355            };
356            let candidate = parse_replay_expiration(not_on_or_after)?;
357            match expires_at {
358                Some(current) if current >= candidate => {}
359                Some(_) | None => expires_at = Some(candidate),
360            }
361        }
362        Ok(expires_at)
363    }
364}
365
366fn parse_replay_expiration(value: &str) -> Result<OffsetDateTime, SamlError> {
367    OffsetDateTime::parse(value, &Rfc3339).map_err(|_| SamlError::TimeWindowInvalid {
368        field: REPLAY_EXPIRATION_FIELD,
369    })
370}
371
372impl TryFrom<FlowResult> for SsoSession {
373    type Error = SamlError;
374
375    fn try_from(raw_flow: FlowResult) -> Result<Self, Self::Error> {
376        let response_id = MessageId::try_new(required_str(&raw_flow.extract, "response.id")?)?;
377        let assertion_id = AssertionId::try_new(required_str(&raw_flow.extract, "assertion.id")?)?;
378        let issuer = EntityId::try_new(required_str(&raw_flow.extract, "issuer")?)?;
379        let in_response_to = optional_request_id(&raw_flow.extract, "response.inResponseTo")?;
380        let name_id_format = raw_flow
381            .extract
382            .get_str("nameIDFormat")
383            .map(name_id_format_from_uri);
384        let name_id = NameId::new(required_str(&raw_flow.extract, "nameID")?, name_id_format);
385        let subject = Subject::new(
386            name_id,
387            subject_confirmations_from_extract(&raw_flow.extract),
388        );
389        let attributes = attributes_from_extract(&raw_flow.extract);
390        let authn_sessions = authn_sessions_from_extract(&raw_flow.extract)?;
391        let audience = entity_ids_from_value(raw_flow.extract.get("audience"))?;
392        let (not_before, not_on_or_after) = conditions_instants(&raw_flow.extract)?;
393        let sig_alg = raw_flow.sig_alg.clone();
394        Ok(Self {
395            response_id,
396            assertion_id,
397            issuer,
398            in_response_to,
399            subject,
400            attributes,
401            authn_sessions,
402            audience,
403            not_before,
404            not_on_or_after,
405            sig_alg,
406            raw_flow,
407        })
408    }
409}