Skip to main content

saml_rs/model/
validation.rs

1use super::{AssertionId, MessageId, SamlInstant};
2use crate::error::{SamlError, TimeWindowField};
3use std::time::{Duration, SystemTime};
4use time::{format_description::well_known::Rfc3339, OffsetDateTime};
5
6/// Clock skew applied to SAML time-window checks.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct ClockSkew {
9    not_before_ms: i64,
10    not_on_or_after_ms: i64,
11}
12
13impl ClockSkew {
14    /// No clock skew tolerance.
15    pub fn strict() -> Self {
16        Self {
17            not_before_ms: 0,
18            not_on_or_after_ms: 0,
19        }
20    }
21
22    /// Five minutes of skew on `NotBefore` and `NotOnOrAfter`.
23    ///
24    /// [`SamlValidationContext::new`] starts here. Approved Errata 05 E92
25    /// calls three to five minutes reasonable.
26    pub fn five_minutes() -> Self {
27        const FIVE_MINUTES_MS: i64 = 5 * 60 * 1_000;
28        Self::from_millis(-FIVE_MINUTES_MS, FIVE_MINUTES_MS)
29    }
30
31    /// Build clock skew from the raw SAML drift values, in milliseconds.
32    ///
33    /// The first argument applies to `NotBefore`; the second applies to
34    /// `NotOnOrAfter`.
35    pub fn from_millis(not_before_ms: i64, not_on_or_after_ms: i64) -> Self {
36        Self {
37            not_before_ms,
38            not_on_or_after_ms,
39        }
40    }
41
42    /// Return a copy with the `NotBefore` skew, in milliseconds.
43    pub fn with_not_before_millis(mut self, not_before_ms: i64) -> Self {
44        self.not_before_ms = not_before_ms;
45        self
46    }
47
48    /// Return a copy with the `NotOnOrAfter` skew, in milliseconds.
49    pub fn with_not_on_or_after_millis(mut self, not_on_or_after_ms: i64) -> Self {
50        self.not_on_or_after_ms = not_on_or_after_ms;
51        self
52    }
53
54    /// `NotBefore` skew, in milliseconds.
55    pub fn not_before_millis(self) -> i64 {
56        self.not_before_ms
57    }
58
59    /// `NotOnOrAfter` skew, in milliseconds.
60    pub fn not_on_or_after_millis(self) -> i64 {
61        self.not_on_or_after_ms
62    }
63
64    /// Return the raw `(NotBefore, NotOnOrAfter)` drift tuple.
65    pub fn as_millis(self) -> (i64, i64) {
66        (self.not_before_ms, self.not_on_or_after_ms)
67    }
68}
69
70impl Default for ClockSkew {
71    /// Zero skew. [`SamlValidationContext::new`] does not use this.
72    fn default() -> Self {
73        Self::strict()
74    }
75}
76
77/// Replay cache key derived from a validated SAML message.
78#[non_exhaustive]
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80#[expect(
81    clippy::enum_variant_names,
82    reason = "variants name the exact SAML identifier family used in stable cache keys"
83)]
84pub enum ReplayKey {
85    /// SAML AuthnRequest ID.
86    AuthnRequestId(MessageId),
87    /// SAML LogoutRequest ID.
88    LogoutRequestId(MessageId),
89    /// SAML LogoutResponse ID.
90    LogoutResponseId(MessageId),
91    /// SAML protocol response ID.
92    ResponseId(MessageId),
93    /// SAML assertion ID.
94    AssertionId(AssertionId),
95}
96
97impl ReplayKey {
98    /// Stable key family.
99    pub fn kind(&self) -> &'static str {
100        match self {
101            Self::AuthnRequestId(_) => "authn_request_id",
102            Self::LogoutRequestId(_) => "logout_request_id",
103            Self::LogoutResponseId(_) => "logout_response_id",
104            Self::ResponseId(_) => "response_id",
105            Self::AssertionId(_) => "assertion_id",
106        }
107    }
108
109    /// Raw SAML identifier value.
110    pub fn value(&self) -> &str {
111        match self {
112            Self::AuthnRequestId(id) | Self::LogoutRequestId(id) | Self::LogoutResponseId(id) => {
113                id.as_str()
114            }
115            Self::ResponseId(id) => id.as_str(),
116            Self::AssertionId(id) => id.as_str(),
117        }
118    }
119
120    /// Namespaced key suitable for cache storage and replay error payloads.
121    pub fn cache_key(&self) -> String {
122        format!("{}:{}", self.kind(), self.value())
123    }
124}
125
126/// Caller-owned replay cache.
127///
128/// Implementations should atomically reject duplicate keys and return
129/// [`SamlError::ReplayDetected`] for duplicate SAML messages.
130///
131/// # Examples
132///
133/// This is a minimal in-memory example for a single process. Applications
134/// should use shared durable storage when multiple processes handle SAML
135/// responses.
136///
137/// ```
138/// use std::{collections::HashMap, time::{Duration, SystemTime}};
139///
140/// use saml_rs::{
141///     ReplayCache, ReplayKey, ReplayPolicy, SamlError, SamlValidationContext,
142/// };
143///
144/// #[derive(Default)]
145/// struct MinimalReplayCache {
146///     seen: HashMap<String, SystemTime>,
147/// }
148///
149/// impl ReplayCache for MinimalReplayCache {
150///     fn check_and_store(
151///         &mut self,
152///         key: ReplayKey,
153///         expires_at: SystemTime,
154///     ) -> Result<(), SamlError> {
155///         let cache_key = key.cache_key();
156///         if self.seen.contains_key(&cache_key) {
157///             return Err(SamlError::ReplayDetected { key: cache_key });
158///         }
159///         self.seen.insert(cache_key, expires_at);
160///         Ok(())
161///     }
162/// }
163///
164/// let now = SystemTime::now();
165/// let mut cache = MinimalReplayCache::default();
166/// let validation = SamlValidationContext::new(
167///     now,
168///     ReplayPolicy::RequireCache(&mut cache),
169/// )
170/// .with_replay_retention(Duration::from_secs(5 * 60));
171/// # let _ = validation;
172/// ```
173pub trait ReplayCache {
174    /// Check whether `key` has already been seen, then store it until
175    /// `expires_at` if it is new.
176    ///
177    /// # Errors
178    ///
179    /// Implementations should return [`SamlError::ReplayDetected`] for
180    /// duplicate keys. They may also return storage-specific failures mapped
181    /// to [`SamlError`] if cache access fails.
182    fn check_and_store(&mut self, key: ReplayKey, expires_at: SystemTime) -> Result<(), SamlError>;
183}
184
185/// Replay behavior for typed inbound browser flows.
186///
187/// This is a caller argument on [`SamlValidationContext::new`], not a field of
188/// [`crate::SpValidationPolicy`] or [`crate::IdpValidationPolicy`]. There is
189/// no `recommended()` constructor. The classification is
190/// `docs/conformance/metadata-and-replay.md`.
191#[non_exhaustive]
192pub enum ReplayPolicy<'a> {
193    /// Skip replay checks. The raw API and the samlify port have no replay cache.
194    DisabledForCompatibility,
195    /// Require the caller to provide replay storage.
196    RequireCache(&'a mut dyn ReplayCache),
197}
198
199/// Maximum age for an inbound `AuthnRequest` `IssueInstant`.
200///
201/// [`Self::Disabled`] skips the comparison. [`Self::Bounded`] accepts an
202/// instant inside an inclusive window of `max_age`, widened by the context
203/// clock skew. The classification is
204/// `docs/conformance/web-browser-sso-acceptance.md`.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum AuthnRequestAgePolicy {
207    /// Do not compare `IssueInstant` with the validation clock.
208    Disabled,
209    /// Accept an `IssueInstant` inside an inclusive window of `max_age`.
210    Bounded {
211        /// Maximum age before clock-skew tolerance is applied.
212        ///
213        /// Zero leaves only the skew neighborhood.
214        max_age: Duration,
215    },
216}
217
218/// Caller-owned validation context for typed inbound SAML messages.
219pub struct SamlValidationContext<'a> {
220    now: SystemTime,
221    clock_skew: ClockSkew,
222    authn_request_age: AuthnRequestAgePolicy,
223    replay: ReplayPolicy<'a>,
224    replay_retention: Option<Duration>,
225}
226
227impl<'a> SamlValidationContext<'a> {
228    /// Build a validation context that allows five minutes of clock skew.
229    ///
230    /// The same skew covers assertion conditions, bearer confirmation,
231    /// `SessionNotOnOrAfter`, and `LogoutRequest@NotOnOrAfter`.
232    /// [`Self::with_clock_skew`] replaces it.
233    pub fn new(now: SystemTime, replay: ReplayPolicy<'a>) -> Self {
234        Self {
235            now,
236            clock_skew: ClockSkew::five_minutes(),
237            authn_request_age: AuthnRequestAgePolicy::Disabled,
238            replay,
239            replay_retention: None,
240        }
241    }
242
243    /// Replace the context clock skew, including [`ClockSkew::strict`].
244    pub fn with_clock_skew(mut self, clock_skew: ClockSkew) -> Self {
245        self.clock_skew = clock_skew;
246        self
247    }
248
249    /// Select the maximum age of an inbound `AuthnRequest` `IssueInstant`.
250    ///
251    /// [`AuthnRequestAgePolicy::Disabled`] skips the comparison.
252    /// [`AuthnRequestAgePolicy::Bounded`] accepts an instant at or after
253    /// `now - max_age - past_tolerance` and at or before
254    /// `now + future_tolerance`. Both edges are inclusive. Past tolerance is
255    /// the positive `NotOnOrAfter` skew. Future tolerance is the magnitude of
256    /// a negative `NotBefore` skew. Zero skew and inverted skew do not shrink
257    /// `max_age`.
258    pub fn with_authn_request_age(mut self, authn_request_age: AuthnRequestAgePolicy) -> Self {
259        self.authn_request_age = authn_request_age;
260        self
261    }
262
263    /// Set explicit replay retention for protocol messages that do not carry a
264    /// SAML `NotOnOrAfter` value suitable for cache expiry.
265    pub fn with_replay_retention(mut self, retention: Duration) -> Self {
266        self.replay_retention = Some(retention);
267        self
268    }
269
270    /// Validation instant supplied by the caller.
271    pub fn now(&self) -> SystemTime {
272        self.now
273    }
274
275    /// Clock skew applied to time-window checks.
276    pub fn clock_skew(&self) -> ClockSkew {
277        self.clock_skew
278    }
279
280    /// Maximum age policy for an inbound `AuthnRequest` `IssueInstant`.
281    pub fn authn_request_age(&self) -> AuthnRequestAgePolicy {
282        self.authn_request_age
283    }
284
285    /// Replay retention for protocol message IDs without SAML expiry.
286    pub fn replay_retention(&self) -> Option<Duration> {
287        self.replay_retention
288    }
289
290    pub(crate) fn now_offset(&self) -> Result<OffsetDateTime, SamlError> {
291        crate::validator::offset_datetime_from_system_time(self.now)
292    }
293
294    pub(crate) fn replay_policy(&mut self) -> &mut ReplayPolicy<'a> {
295        &mut self.replay
296    }
297
298    pub(crate) fn check_authn_request_issue_instant(
299        &self,
300        issue_instant: &SamlInstant,
301    ) -> Result<(), SamlError> {
302        let AuthnRequestAgePolicy::Bounded { max_age } = self.authn_request_age else {
303            return Ok(());
304        };
305        let past_tolerance = positive_millis(self.clock_skew.not_on_or_after_millis());
306        let future_tolerance = negative_millis_magnitude(self.clock_skew.not_before_millis());
307        let past_allowance = max_age
308            .checked_add(past_tolerance)
309            .ok_or_else(authn_request_issue_instant_window)?;
310        let now = self
311            .now_offset()
312            .map_err(|_| authn_request_issue_instant_window())?;
313        let earliest = offset_checked_sub(now, past_allowance)?;
314        let latest = offset_checked_add(now, future_tolerance)?;
315        let issued = OffsetDateTime::parse(issue_instant.as_str(), &Rfc3339)
316            .map_err(|_| authn_request_issue_instant_window())?;
317        if issued < earliest || issued > latest {
318            return Err(authn_request_issue_instant_window());
319        }
320        Ok(())
321    }
322
323    pub(crate) fn check_and_store_message_replay(
324        &mut self,
325        key: ReplayKey,
326    ) -> Result<(), SamlError> {
327        if matches!(&self.replay, ReplayPolicy::DisabledForCompatibility) {
328            return Ok(());
329        }
330        let expires_at = self.message_replay_expires_at()?;
331        match &mut self.replay {
332            ReplayPolicy::DisabledForCompatibility => Ok(()),
333            ReplayPolicy::RequireCache(cache) => cache.check_and_store(key, expires_at),
334        }
335    }
336
337    pub(crate) fn check_and_store_message_replay_until(
338        &mut self,
339        key: ReplayKey,
340        derived_deadline: Option<OffsetDateTime>,
341    ) -> Result<(), SamlError> {
342        if matches!(&self.replay, ReplayPolicy::DisabledForCompatibility) {
343            return Ok(());
344        }
345        let expires_at = derived_deadline
346            .map(system_time_from_offset_datetime)
347            .transpose()?
348            .map_or_else(|| self.message_replay_expires_at(), Ok)?;
349        match &mut self.replay {
350            ReplayPolicy::DisabledForCompatibility => Ok(()),
351            ReplayPolicy::RequireCache(cache) => cache.check_and_store(key, expires_at),
352        }
353    }
354
355    fn message_replay_expires_at(&self) -> Result<SystemTime, SamlError> {
356        let Some(retention) = self.replay_retention else {
357            return Err(SamlError::TimeWindowInvalid {
358                field: crate::error::TimeWindowField::ReplayExpiration,
359            });
360        };
361        if retention <= Duration::ZERO {
362            return Err(SamlError::TimeWindowInvalid {
363                field: crate::error::TimeWindowField::ReplayExpiration,
364            });
365        }
366        self.now
367            .checked_add(retention)
368            .ok_or(SamlError::TimeWindowInvalid {
369                field: crate::error::TimeWindowField::ReplayExpiration,
370            })
371    }
372}
373
374fn positive_millis(millis: i64) -> Duration {
375    u64::try_from(millis)
376        .ok()
377        .filter(|millis| *millis > 0)
378        .map_or(Duration::ZERO, Duration::from_millis)
379}
380
381fn negative_millis_magnitude(millis: i64) -> Duration {
382    if millis < 0 {
383        Duration::from_millis(millis.unsigned_abs())
384    } else {
385        Duration::ZERO
386    }
387}
388
389fn authn_request_issue_instant_window() -> SamlError {
390    SamlError::TimeWindowInvalid {
391        field: TimeWindowField::AuthnRequestIssueInstant,
392    }
393}
394
395fn offset_checked_add(
396    instant: OffsetDateTime,
397    duration: Duration,
398) -> Result<OffsetDateTime, SamlError> {
399    let duration =
400        time::Duration::try_from(duration).map_err(|_| authn_request_issue_instant_window())?;
401    instant
402        .checked_add(duration)
403        .ok_or_else(authn_request_issue_instant_window)
404}
405
406fn offset_checked_sub(
407    instant: OffsetDateTime,
408    duration: Duration,
409) -> Result<OffsetDateTime, SamlError> {
410    let duration =
411        time::Duration::try_from(duration).map_err(|_| authn_request_issue_instant_window())?;
412    instant
413        .checked_sub(duration)
414        .ok_or_else(authn_request_issue_instant_window)
415}
416
417fn system_time_from_offset_datetime(deadline: OffsetDateTime) -> Result<SystemTime, SamlError> {
418    let since_epoch = deadline - OffsetDateTime::UNIX_EPOCH;
419    let converted = if since_epoch.is_negative() {
420        SystemTime::UNIX_EPOCH.checked_sub(since_epoch.unsigned_abs())
421    } else {
422        SystemTime::UNIX_EPOCH.checked_add(since_epoch.unsigned_abs())
423    };
424    converted.ok_or(SamlError::TimeWindowInvalid {
425        field: TimeWindowField::ReplayExpiration,
426    })
427}