saml_rs/model/
validation.rs1use super::{AssertionId, MessageId};
2use crate::error::{SamlError, TimeWindowField};
3use std::time::{Duration, SystemTime};
4use time::OffsetDateTime;
5
6#[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 pub fn strict() -> Self {
16 Self {
17 not_before_ms: 0,
18 not_on_or_after_ms: 0,
19 }
20 }
21
22 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 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 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 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 pub fn not_before_millis(self) -> i64 {
56 self.not_before_ms
57 }
58
59 pub fn not_on_or_after_millis(self) -> i64 {
61 self.not_on_or_after_ms
62 }
63
64 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 fn default() -> Self {
73 Self::strict()
74 }
75}
76
77#[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 AuthnRequestId(MessageId),
87 LogoutRequestId(MessageId),
89 LogoutResponseId(MessageId),
91 ResponseId(MessageId),
93 AssertionId(AssertionId),
95}
96
97impl ReplayKey {
98 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 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 pub fn cache_key(&self) -> String {
122 format!("{}:{}", self.kind(), self.value())
123 }
124}
125
126pub trait ReplayCache {
174 fn check_and_store(&mut self, key: ReplayKey, expires_at: SystemTime) -> Result<(), SamlError>;
183}
184
185#[non_exhaustive]
192pub enum ReplayPolicy<'a> {
193 DisabledForCompatibility,
195 RequireCache(&'a mut dyn ReplayCache),
197}
198
199pub 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 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 pub fn with_clock_skew(mut self, clock_skew: ClockSkew) -> Self {
224 self.clock_skew = clock_skew;
225 self
226 }
227
228 pub fn with_replay_retention(mut self, retention: Duration) -> Self {
231 self.replay_retention = Some(retention);
232 self
233 }
234
235 pub fn now(&self) -> SystemTime {
237 self.now
238 }
239
240 pub fn clock_skew(&self) -> ClockSkew {
242 self.clock_skew
243 }
244
245 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}