use std::{
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
use async_trait::async_trait;
use tracing::warn;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ReplayCacheError {
#[error("JWT token has already been used (jti replay detected)")]
Replayed,
#[error("Replay cache backend error: {0}")]
Backend(String),
}
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub enum FailurePolicy {
#[default]
FailOpen,
FailClosed,
}
#[async_trait]
pub trait ReplayCacheBackend: Send + Sync {
async fn check_and_record(&self, jti: &str, ttl: Duration) -> Result<(), ReplayCacheError>;
}
static JWT_REPLAY_REJECTED_TOTAL: AtomicU64 = AtomicU64::new(0);
static JWT_REPLAY_CACHE_ERRORS_TOTAL: AtomicU64 = AtomicU64::new(0);
#[must_use]
pub fn jwt_replay_rejected_total() -> u64 {
JWT_REPLAY_REJECTED_TOTAL.load(Ordering::Relaxed)
}
#[must_use]
pub fn jwt_replay_cache_errors_total() -> u64 {
JWT_REPLAY_CACHE_ERRORS_TOTAL.load(Ordering::Relaxed)
}
pub struct ReplayCache {
backend: Box<dyn ReplayCacheBackend>,
policy: FailurePolicy,
}
impl ReplayCache {
#[must_use]
pub fn new(backend: impl ReplayCacheBackend + 'static) -> Self {
Self {
backend: Box::new(backend),
policy: FailurePolicy::FailOpen,
}
}
#[must_use]
pub const fn with_policy(mut self, policy: FailurePolicy) -> Self {
self.policy = policy;
self
}
pub async fn check_and_record(&self, jti: &str, ttl: Duration) -> Result<(), ReplayCacheError> {
match self.backend.check_and_record(jti, ttl).await {
Ok(()) => Ok(()),
Err(ReplayCacheError::Replayed) => {
JWT_REPLAY_REJECTED_TOTAL.fetch_add(1, Ordering::Relaxed);
Err(ReplayCacheError::Replayed)
},
Err(ReplayCacheError::Backend(msg)) => {
JWT_REPLAY_CACHE_ERRORS_TOTAL.fetch_add(1, Ordering::Relaxed);
match self.policy {
FailurePolicy::FailOpen => {
warn!(
error = %msg,
"JWT replay cache backend error — failing open (token accepted). \
Replay protection is degraded while the backend is unavailable."
);
Ok(())
},
FailurePolicy::FailClosed => Err(ReplayCacheError::Backend(msg)),
}
},
}
}
}
pub struct MemoryReplayCache {
store: dashmap::DashMap<String, std::time::Instant>,
}
impl MemoryReplayCache {
#[must_use]
pub fn new() -> Self {
Self {
store: dashmap::DashMap::new(),
}
}
}
impl Default for MemoryReplayCache {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ReplayCacheBackend for MemoryReplayCache {
async fn check_and_record(&self, jti: &str, ttl: Duration) -> Result<(), ReplayCacheError> {
let now = std::time::Instant::now();
let expiry = now + ttl;
if let Some(existing) = self.store.get(jti) {
if *existing > now {
return Err(ReplayCacheError::Replayed);
}
drop(existing);
}
self.store.insert(jti.to_string(), expiry);
Ok(())
}
}
#[cfg(feature = "jwt-replay")]
pub struct RedisReplayCache {
pool: redis::aio::ConnectionManager,
key_prefix: String,
}
#[cfg(feature = "jwt-replay")]
impl RedisReplayCache {
pub async fn new(redis_url: &str) -> Result<Self, ReplayCacheError> {
Self::with_prefix(redis_url, "fraiseql:jti:").await
}
pub async fn with_prefix(redis_url: &str, key_prefix: &str) -> Result<Self, ReplayCacheError> {
let client = redis::Client::open(redis_url)
.map_err(|e| ReplayCacheError::Backend(format!("invalid Redis URL: {e}")))?;
let pool = client
.get_connection_manager()
.await
.map_err(|e| ReplayCacheError::Backend(format!("Redis connection failed: {e}")))?;
Ok(Self {
pool,
key_prefix: key_prefix.to_string(),
})
}
fn key(&self, jti: &str) -> String {
format!("{}{}", self.key_prefix, jti)
}
}
#[cfg(feature = "jwt-replay")]
#[async_trait]
impl ReplayCacheBackend for RedisReplayCache {
async fn check_and_record(&self, jti: &str, ttl: Duration) -> Result<(), ReplayCacheError> {
use redis::AsyncCommands;
let key = self.key(jti);
let ttl_secs = ttl.as_secs().max(1);
let mut conn = self.pool.clone();
let was_set: bool = conn
.set_options(
&key,
1u8,
redis::SetOptions::default()
.conditional_set(redis::ExistenceCheck::NX)
.with_expiration(redis::SetExpiry::EX(ttl_secs)),
)
.await
.map_err(|e| ReplayCacheError::Backend(format!("Redis SET NX failed: {e}")))?;
if was_set {
Ok(())
} else {
Err(ReplayCacheError::Replayed)
}
}
}