Skip to main content

saml_rs/model/
validation.rs

1use super::{AssertionId, MessageId};
2use crate::error::SamlError;
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    /// Build clock skew from the raw SAML drift values, in milliseconds.
23    ///
24    /// The first argument applies to `NotBefore`; the second applies to
25    /// `NotOnOrAfter`.
26    pub fn from_millis(not_before_ms: i64, not_on_or_after_ms: i64) -> Self {
27        Self {
28            not_before_ms,
29            not_on_or_after_ms,
30        }
31    }
32
33    /// Return a copy with the `NotBefore` skew, in milliseconds.
34    pub fn with_not_before_millis(mut self, not_before_ms: i64) -> Self {
35        self.not_before_ms = not_before_ms;
36        self
37    }
38
39    /// Return a copy with the `NotOnOrAfter` skew, in milliseconds.
40    pub fn with_not_on_or_after_millis(mut self, not_on_or_after_ms: i64) -> Self {
41        self.not_on_or_after_ms = not_on_or_after_ms;
42        self
43    }
44
45    /// `NotBefore` skew, in milliseconds.
46    pub fn not_before_millis(self) -> i64 {
47        self.not_before_ms
48    }
49
50    /// `NotOnOrAfter` skew, in milliseconds.
51    pub fn not_on_or_after_millis(self) -> i64 {
52        self.not_on_or_after_ms
53    }
54
55    /// Return the raw `(NotBefore, NotOnOrAfter)` drift tuple.
56    pub fn as_millis(self) -> (i64, i64) {
57        (self.not_before_ms, self.not_on_or_after_ms)
58    }
59}
60
61impl Default for ClockSkew {
62    fn default() -> Self {
63        Self::strict()
64    }
65}
66
67/// Replay cache key derived from a validated SAML message.
68#[non_exhaustive]
69#[derive(Debug, Clone, PartialEq, Eq, Hash)]
70#[expect(
71    clippy::enum_variant_names,
72    reason = "variants name the exact SAML identifier family used in stable cache keys"
73)]
74pub enum ReplayKey {
75    /// SAML AuthnRequest ID.
76    AuthnRequestId(MessageId),
77    /// SAML LogoutRequest ID.
78    LogoutRequestId(MessageId),
79    /// SAML LogoutResponse ID.
80    LogoutResponseId(MessageId),
81    /// SAML protocol response ID.
82    ResponseId(MessageId),
83    /// SAML assertion ID.
84    AssertionId(AssertionId),
85}
86
87impl ReplayKey {
88    /// Stable key family.
89    pub fn kind(&self) -> &'static str {
90        match self {
91            Self::AuthnRequestId(_) => "authn_request_id",
92            Self::LogoutRequestId(_) => "logout_request_id",
93            Self::LogoutResponseId(_) => "logout_response_id",
94            Self::ResponseId(_) => "response_id",
95            Self::AssertionId(_) => "assertion_id",
96        }
97    }
98
99    /// Raw SAML identifier value.
100    pub fn value(&self) -> &str {
101        match self {
102            Self::AuthnRequestId(id) | Self::LogoutRequestId(id) | Self::LogoutResponseId(id) => {
103                id.as_str()
104            }
105            Self::ResponseId(id) => id.as_str(),
106            Self::AssertionId(id) => id.as_str(),
107        }
108    }
109
110    /// Namespaced key suitable for cache storage and replay error payloads.
111    pub fn cache_key(&self) -> String {
112        format!("{}:{}", self.kind(), self.value())
113    }
114}
115
116/// Caller-owned replay cache.
117///
118/// Implementations should atomically reject duplicate keys and return
119/// [`SamlError::ReplayDetected`] for duplicate SAML messages.
120///
121/// # Examples
122///
123/// This is a minimal in-memory example for a single process. Applications
124/// should use shared durable storage when multiple processes handle SAML
125/// responses.
126///
127/// ```
128/// use std::{collections::HashMap, time::{Duration, SystemTime}};
129///
130/// use saml_rs::{
131///     ReplayCache, ReplayKey, ReplayPolicy, SamlError, SamlValidationContext,
132/// };
133///
134/// #[derive(Default)]
135/// struct MinimalReplayCache {
136///     seen: HashMap<String, SystemTime>,
137/// }
138///
139/// impl ReplayCache for MinimalReplayCache {
140///     fn check_and_store(
141///         &mut self,
142///         key: ReplayKey,
143///         expires_at: SystemTime,
144///     ) -> Result<(), SamlError> {
145///         let cache_key = key.cache_key();
146///         if self.seen.contains_key(&cache_key) {
147///             return Err(SamlError::ReplayDetected { key: cache_key });
148///         }
149///         self.seen.insert(cache_key, expires_at);
150///         Ok(())
151///     }
152/// }
153///
154/// let now = SystemTime::now();
155/// let mut cache = MinimalReplayCache::default();
156/// let validation = SamlValidationContext::new(
157///     now,
158///     ReplayPolicy::RequireCache(&mut cache),
159/// )
160/// .with_replay_retention(Duration::from_secs(5 * 60));
161/// # let _ = validation;
162/// ```
163pub trait ReplayCache {
164    /// Check whether `key` has already been seen, then store it until
165    /// `expires_at` if it is new.
166    ///
167    /// # Errors
168    ///
169    /// Implementations should return [`SamlError::ReplayDetected`] for
170    /// duplicate keys. They may also return storage-specific failures mapped
171    /// to [`SamlError`] if cache access fails.
172    fn check_and_store(&mut self, key: ReplayKey, expires_at: SystemTime) -> Result<(), SamlError>;
173}
174
175/// Replay behavior for typed inbound browser flows.
176#[non_exhaustive]
177pub enum ReplayPolicy<'a> {
178    /// Skip replay checks for raw compatibility migrations.
179    DisabledForCompatibility,
180    /// Require the caller to provide replay storage.
181    RequireCache(&'a mut dyn ReplayCache),
182}
183
184/// Caller-owned validation context for typed inbound SAML messages.
185pub struct SamlValidationContext<'a> {
186    now: SystemTime,
187    clock_skew: ClockSkew,
188    replay: ReplayPolicy<'a>,
189    replay_retention: Option<Duration>,
190}
191
192impl<'a> SamlValidationContext<'a> {
193    /// Build a validation context with strict clock skew.
194    pub fn new(now: SystemTime, replay: ReplayPolicy<'a>) -> Self {
195        Self {
196            now,
197            clock_skew: ClockSkew::strict(),
198            replay,
199            replay_retention: None,
200        }
201    }
202
203    /// Set clock skew tolerance for SAML time windows.
204    pub fn with_clock_skew(mut self, clock_skew: ClockSkew) -> Self {
205        self.clock_skew = clock_skew;
206        self
207    }
208
209    /// Set explicit replay retention for protocol messages that do not carry a
210    /// SAML `NotOnOrAfter` value suitable for cache expiry.
211    pub fn with_replay_retention(mut self, retention: Duration) -> Self {
212        self.replay_retention = Some(retention);
213        self
214    }
215
216    /// Validation instant supplied by the caller.
217    pub fn now(&self) -> SystemTime {
218        self.now
219    }
220
221    /// Clock skew applied to time-window checks.
222    pub fn clock_skew(&self) -> ClockSkew {
223        self.clock_skew
224    }
225
226    /// Replay retention for protocol message IDs without SAML expiry.
227    pub fn replay_retention(&self) -> Option<Duration> {
228        self.replay_retention
229    }
230
231    pub(crate) fn now_offset(&self) -> Result<OffsetDateTime, SamlError> {
232        crate::validator::offset_datetime_from_system_time(self.now)
233    }
234
235    pub(crate) fn replay_policy(&mut self) -> &mut ReplayPolicy<'a> {
236        &mut self.replay
237    }
238
239    pub(crate) fn check_and_store_message_replay(
240        &mut self,
241        key: ReplayKey,
242    ) -> Result<(), SamlError> {
243        if matches!(&self.replay, ReplayPolicy::DisabledForCompatibility) {
244            return Ok(());
245        }
246        let expires_at = self.message_replay_expires_at()?;
247        match &mut self.replay {
248            ReplayPolicy::DisabledForCompatibility => Ok(()),
249            ReplayPolicy::RequireCache(cache) => cache.check_and_store(key, expires_at),
250        }
251    }
252
253    fn message_replay_expires_at(&self) -> Result<SystemTime, SamlError> {
254        let Some(retention) = self.replay_retention else {
255            return Err(SamlError::TimeWindowInvalid {
256                field: crate::error::TimeWindowField::ReplayExpiration,
257            });
258        };
259        if retention <= Duration::ZERO {
260            return Err(SamlError::TimeWindowInvalid {
261                field: crate::error::TimeWindowField::ReplayExpiration,
262            });
263        }
264        self.now
265            .checked_add(retention)
266            .ok_or(SamlError::TimeWindowInvalid {
267                field: crate::error::TimeWindowField::ReplayExpiration,
268            })
269    }
270}