saml_rs/model/
validation.rs1use super::{AssertionId, MessageId};
2use crate::error::SamlError;
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 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 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 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 pub fn not_before_millis(self) -> i64 {
47 self.not_before_ms
48 }
49
50 pub fn not_on_or_after_millis(self) -> i64 {
52 self.not_on_or_after_ms
53 }
54
55 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#[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 AuthnRequestId(MessageId),
77 LogoutRequestId(MessageId),
79 LogoutResponseId(MessageId),
81 ResponseId(MessageId),
83 AssertionId(AssertionId),
85}
86
87impl ReplayKey {
88 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 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 pub fn cache_key(&self) -> String {
112 format!("{}:{}", self.kind(), self.value())
113 }
114}
115
116pub trait ReplayCache {
164 fn check_and_store(&mut self, key: ReplayKey, expires_at: SystemTime) -> Result<(), SamlError>;
173}
174
175#[non_exhaustive]
177pub enum ReplayPolicy<'a> {
178 DisabledForCompatibility,
180 RequireCache(&'a mut dyn ReplayCache),
182}
183
184pub 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 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 pub fn with_clock_skew(mut self, clock_skew: ClockSkew) -> Self {
205 self.clock_skew = clock_skew;
206 self
207 }
208
209 pub fn with_replay_retention(mut self, retention: Duration) -> Self {
212 self.replay_retention = Some(retention);
213 self
214 }
215
216 pub fn now(&self) -> SystemTime {
218 self.now
219 }
220
221 pub fn clock_skew(&self) -> ClockSkew {
223 self.clock_skew
224 }
225
226 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}