Skip to main content

saml_rs/model/
validation.rs

1use super::{AssertionId, MessageId};
2use crate::error::{SamlError, TimeWindowField};
3use std::time::{Duration, SystemTime};
4use time::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/// Caller-owned validation context for typed inbound SAML messages.
200pub struct SamlValidationContext<'a> {
201    now: SystemTime,
202    clock_skew: ClockSkew,
203    replay: ReplayPolicy<'a>,
204    replay_retention: Option<Duration>,
205}
206
207impl<'a> SamlValidationContext<'a> {
208    /// Build a validation context that allows five minutes of clock skew.
209    ///
210    /// The same skew covers assertion conditions, bearer confirmation,
211    /// `SessionNotOnOrAfter`, and `LogoutRequest@NotOnOrAfter`.
212    /// [`Self::with_clock_skew`] replaces it.
213    pub fn new(now: SystemTime, replay: ReplayPolicy<'a>) -> Self {
214        Self {
215            now,
216            clock_skew: ClockSkew::five_minutes(),
217            replay,
218            replay_retention: None,
219        }
220    }
221
222    /// Replace the context clock skew, including [`ClockSkew::strict`].
223    pub fn with_clock_skew(mut self, clock_skew: ClockSkew) -> Self {
224        self.clock_skew = clock_skew;
225        self
226    }
227
228    /// Set explicit replay retention for protocol messages that do not carry a
229    /// SAML `NotOnOrAfter` value suitable for cache expiry.
230    pub fn with_replay_retention(mut self, retention: Duration) -> Self {
231        self.replay_retention = Some(retention);
232        self
233    }
234
235    /// Validation instant supplied by the caller.
236    pub fn now(&self) -> SystemTime {
237        self.now
238    }
239
240    /// Clock skew applied to time-window checks.
241    pub fn clock_skew(&self) -> ClockSkew {
242        self.clock_skew
243    }
244
245    /// Replay retention for protocol message IDs without SAML expiry.
246    pub fn replay_retention(&self) -> Option<Duration> {
247        self.replay_retention
248    }
249
250    pub(crate) fn now_offset(&self) -> Result<OffsetDateTime, SamlError> {
251        crate::validator::offset_datetime_from_system_time(self.now)
252    }
253
254    pub(crate) fn replay_policy(&mut self) -> &mut ReplayPolicy<'a> {
255        &mut self.replay
256    }
257
258    pub(crate) fn check_and_store_message_replay(
259        &mut self,
260        key: ReplayKey,
261    ) -> Result<(), SamlError> {
262        if matches!(&self.replay, ReplayPolicy::DisabledForCompatibility) {
263            return Ok(());
264        }
265        let expires_at = self.message_replay_expires_at()?;
266        match &mut self.replay {
267            ReplayPolicy::DisabledForCompatibility => Ok(()),
268            ReplayPolicy::RequireCache(cache) => cache.check_and_store(key, expires_at),
269        }
270    }
271
272    pub(crate) fn check_and_store_message_replay_until(
273        &mut self,
274        key: ReplayKey,
275        derived_deadline: Option<OffsetDateTime>,
276    ) -> Result<(), SamlError> {
277        if matches!(&self.replay, ReplayPolicy::DisabledForCompatibility) {
278            return Ok(());
279        }
280        let expires_at = derived_deadline
281            .map(system_time_from_offset_datetime)
282            .transpose()?
283            .map_or_else(|| self.message_replay_expires_at(), Ok)?;
284        match &mut self.replay {
285            ReplayPolicy::DisabledForCompatibility => Ok(()),
286            ReplayPolicy::RequireCache(cache) => cache.check_and_store(key, expires_at),
287        }
288    }
289
290    fn message_replay_expires_at(&self) -> Result<SystemTime, SamlError> {
291        let Some(retention) = self.replay_retention else {
292            return Err(SamlError::TimeWindowInvalid {
293                field: crate::error::TimeWindowField::ReplayExpiration,
294            });
295        };
296        if retention <= Duration::ZERO {
297            return Err(SamlError::TimeWindowInvalid {
298                field: crate::error::TimeWindowField::ReplayExpiration,
299            });
300        }
301        self.now
302            .checked_add(retention)
303            .ok_or(SamlError::TimeWindowInvalid {
304                field: crate::error::TimeWindowField::ReplayExpiration,
305            })
306    }
307}
308
309fn system_time_from_offset_datetime(deadline: OffsetDateTime) -> Result<SystemTime, SamlError> {
310    let since_epoch = deadline - OffsetDateTime::UNIX_EPOCH;
311    let converted = if since_epoch.is_negative() {
312        SystemTime::UNIX_EPOCH.checked_sub(since_epoch.unsigned_abs())
313    } else {
314        SystemTime::UNIX_EPOCH.checked_add(since_epoch.unsigned_abs())
315    };
316    converted.ok_or(SamlError::TimeWindowInvalid {
317        field: TimeWindowField::ReplayExpiration,
318    })
319}