use super::{AssertionId, MessageId, SamlInstant};
use crate::error::{SamlError, TimeWindowField};
use std::time::{Duration, SystemTime};
use time::{format_description::well_known::Rfc3339, 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 five_minutes() -> Self {
const FIVE_MINUTES_MS: i64 = 5 * 60 * 1_000;
Self::from_millis(-FIVE_MINUTES_MS, FIVE_MINUTES_MS)
}
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),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthnRequestAgePolicy {
Disabled,
Bounded {
max_age: Duration,
},
}
pub struct SamlValidationContext<'a> {
now: SystemTime,
clock_skew: ClockSkew,
authn_request_age: AuthnRequestAgePolicy,
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::five_minutes(),
authn_request_age: AuthnRequestAgePolicy::Disabled,
replay,
replay_retention: None,
}
}
pub fn with_clock_skew(mut self, clock_skew: ClockSkew) -> Self {
self.clock_skew = clock_skew;
self
}
pub fn with_authn_request_age(mut self, authn_request_age: AuthnRequestAgePolicy) -> Self {
self.authn_request_age = authn_request_age;
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 authn_request_age(&self) -> AuthnRequestAgePolicy {
self.authn_request_age
}
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_authn_request_issue_instant(
&self,
issue_instant: &SamlInstant,
) -> Result<(), SamlError> {
let AuthnRequestAgePolicy::Bounded { max_age } = self.authn_request_age else {
return Ok(());
};
let past_tolerance = positive_millis(self.clock_skew.not_on_or_after_millis());
let future_tolerance = negative_millis_magnitude(self.clock_skew.not_before_millis());
let past_allowance = max_age
.checked_add(past_tolerance)
.ok_or_else(authn_request_issue_instant_window)?;
let now = self
.now_offset()
.map_err(|_| authn_request_issue_instant_window())?;
let earliest = offset_checked_sub(now, past_allowance)?;
let latest = offset_checked_add(now, future_tolerance)?;
let issued = OffsetDateTime::parse(issue_instant.as_str(), &Rfc3339)
.map_err(|_| authn_request_issue_instant_window())?;
if issued < earliest || issued > latest {
return Err(authn_request_issue_instant_window());
}
Ok(())
}
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),
}
}
pub(crate) fn check_and_store_message_replay_until(
&mut self,
key: ReplayKey,
derived_deadline: Option<OffsetDateTime>,
) -> Result<(), SamlError> {
if matches!(&self.replay, ReplayPolicy::DisabledForCompatibility) {
return Ok(());
}
let expires_at = derived_deadline
.map(system_time_from_offset_datetime)
.transpose()?
.map_or_else(|| self.message_replay_expires_at(), Ok)?;
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,
})
}
}
fn positive_millis(millis: i64) -> Duration {
u64::try_from(millis)
.ok()
.filter(|millis| *millis > 0)
.map_or(Duration::ZERO, Duration::from_millis)
}
fn negative_millis_magnitude(millis: i64) -> Duration {
if millis < 0 {
Duration::from_millis(millis.unsigned_abs())
} else {
Duration::ZERO
}
}
fn authn_request_issue_instant_window() -> SamlError {
SamlError::TimeWindowInvalid {
field: TimeWindowField::AuthnRequestIssueInstant,
}
}
fn offset_checked_add(
instant: OffsetDateTime,
duration: Duration,
) -> Result<OffsetDateTime, SamlError> {
let duration =
time::Duration::try_from(duration).map_err(|_| authn_request_issue_instant_window())?;
instant
.checked_add(duration)
.ok_or_else(authn_request_issue_instant_window)
}
fn offset_checked_sub(
instant: OffsetDateTime,
duration: Duration,
) -> Result<OffsetDateTime, SamlError> {
let duration =
time::Duration::try_from(duration).map_err(|_| authn_request_issue_instant_window())?;
instant
.checked_sub(duration)
.ok_or_else(authn_request_issue_instant_window)
}
fn system_time_from_offset_datetime(deadline: OffsetDateTime) -> Result<SystemTime, SamlError> {
let since_epoch = deadline - OffsetDateTime::UNIX_EPOCH;
let converted = if since_epoch.is_negative() {
SystemTime::UNIX_EPOCH.checked_sub(since_epoch.unsigned_abs())
} else {
SystemTime::UNIX_EPOCH.checked_add(since_epoch.unsigned_abs())
};
converted.ok_or(SamlError::TimeWindowInvalid {
field: TimeWindowField::ReplayExpiration,
})
}