use super::{AssertionId, MessageId};
use crate::error::SamlError;
use std::time::{Duration, SystemTime};
use time::OffsetDateTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClockSkew {
not_before_ms: i64,
not_on_or_after_ms: i64,
}
impl ClockSkew {
pub fn strict() -> Self {
Self {
not_before_ms: 0,
not_on_or_after_ms: 0,
}
}
pub fn from_millis(not_before_ms: i64, not_on_or_after_ms: i64) -> Self {
Self {
not_before_ms,
not_on_or_after_ms,
}
}
pub fn with_not_before_millis(mut self, not_before_ms: i64) -> Self {
self.not_before_ms = not_before_ms;
self
}
pub fn with_not_on_or_after_millis(mut self, not_on_or_after_ms: i64) -> Self {
self.not_on_or_after_ms = not_on_or_after_ms;
self
}
pub fn not_before_millis(self) -> i64 {
self.not_before_ms
}
pub fn not_on_or_after_millis(self) -> i64 {
self.not_on_or_after_ms
}
pub fn as_millis(self) -> (i64, i64) {
(self.not_before_ms, self.not_on_or_after_ms)
}
}
impl Default for ClockSkew {
fn default() -> Self {
Self::strict()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[expect(
clippy::enum_variant_names,
reason = "variants name the exact SAML identifier family used in stable cache keys"
)]
pub enum ReplayKey {
AuthnRequestId(MessageId),
LogoutRequestId(MessageId),
LogoutResponseId(MessageId),
ResponseId(MessageId),
AssertionId(AssertionId),
}
impl ReplayKey {
pub fn kind(&self) -> &'static str {
match self {
Self::AuthnRequestId(_) => "authn_request_id",
Self::LogoutRequestId(_) => "logout_request_id",
Self::LogoutResponseId(_) => "logout_response_id",
Self::ResponseId(_) => "response_id",
Self::AssertionId(_) => "assertion_id",
}
}
pub fn value(&self) -> &str {
match self {
Self::AuthnRequestId(id) | Self::LogoutRequestId(id) | Self::LogoutResponseId(id) => {
id.as_str()
}
Self::ResponseId(id) => id.as_str(),
Self::AssertionId(id) => id.as_str(),
}
}
pub fn cache_key(&self) -> String {
format!("{}:{}", self.kind(), self.value())
}
}
pub trait ReplayCache {
fn check_and_store(&mut self, key: ReplayKey, expires_at: SystemTime) -> Result<(), SamlError>;
}
#[non_exhaustive]
pub enum ReplayPolicy<'a> {
DisabledForCompatibility,
RequireCache(&'a mut dyn ReplayCache),
}
pub struct SamlValidationContext<'a> {
now: SystemTime,
clock_skew: ClockSkew,
replay: ReplayPolicy<'a>,
replay_retention: Option<Duration>,
}
impl<'a> SamlValidationContext<'a> {
pub fn new(now: SystemTime, replay: ReplayPolicy<'a>) -> Self {
Self {
now,
clock_skew: ClockSkew::strict(),
replay,
replay_retention: None,
}
}
pub fn with_clock_skew(mut self, clock_skew: ClockSkew) -> Self {
self.clock_skew = clock_skew;
self
}
pub fn with_replay_retention(mut self, retention: Duration) -> Self {
self.replay_retention = Some(retention);
self
}
pub fn now(&self) -> SystemTime {
self.now
}
pub fn clock_skew(&self) -> ClockSkew {
self.clock_skew
}
pub fn replay_retention(&self) -> Option<Duration> {
self.replay_retention
}
pub(crate) fn now_offset(&self) -> Result<OffsetDateTime, SamlError> {
crate::validator::offset_datetime_from_system_time(self.now)
}
pub(crate) fn replay_policy(&mut self) -> &mut ReplayPolicy<'a> {
&mut self.replay
}
pub(crate) fn check_and_store_message_replay(
&mut self,
key: ReplayKey,
) -> Result<(), SamlError> {
if matches!(&self.replay, ReplayPolicy::DisabledForCompatibility) {
return Ok(());
}
let expires_at = self.message_replay_expires_at()?;
match &mut self.replay {
ReplayPolicy::DisabledForCompatibility => Ok(()),
ReplayPolicy::RequireCache(cache) => cache.check_and_store(key, expires_at),
}
}
fn message_replay_expires_at(&self) -> Result<SystemTime, SamlError> {
let Some(retention) = self.replay_retention else {
return Err(SamlError::TimeWindowInvalid {
field: crate::error::TimeWindowField::ReplayExpiration,
});
};
if retention <= Duration::ZERO {
return Err(SamlError::TimeWindowInvalid {
field: crate::error::TimeWindowField::ReplayExpiration,
});
}
self.now
.checked_add(retention)
.ok_or(SamlError::TimeWindowInvalid {
field: crate::error::TimeWindowField::ReplayExpiration,
})
}
}