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#[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
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum AuthnRequestAgePolicy {
207 Disabled,
209 Bounded {
211 max_age: Duration,
215 },
216}
217
218pub 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 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 pub fn with_clock_skew(mut self, clock_skew: ClockSkew) -> Self {
245 self.clock_skew = clock_skew;
246 self
247 }
248
249 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 pub fn with_replay_retention(mut self, retention: Duration) -> Self {
266 self.replay_retention = Some(retention);
267 self
268 }
269
270 pub fn now(&self) -> SystemTime {
272 self.now
273 }
274
275 pub fn clock_skew(&self) -> ClockSkew {
277 self.clock_skew
278 }
279
280 pub fn authn_request_age(&self) -> AuthnRequestAgePolicy {
282 self.authn_request_age
283 }
284
285 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}