use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
#[async_trait::async_trait]
pub trait TokenRevocationStore: Send + Sync {
async fn revoke(&self, jti: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
async fn is_revoked(&self, jti: &str)
-> Result<bool, Box<dyn std::error::Error + Send + Sync>>;
}
pub const DEFAULT_MAX_ENTRIES: usize = 100_000;
struct InMemoryInner {
entries: HashMap<String, Instant>,
order: VecDeque<String>,
}
impl InMemoryInner {
fn sweep_expired(&mut self, ttl: Option<Duration>) {
let Some(ttl) = ttl else { return };
let now = Instant::now();
self.entries
.retain(|_, inserted| now.duration_since(*inserted) < ttl);
self.order.retain(|jti| self.entries.contains_key(jti));
}
fn evict_oldest_to(&mut self, max_entries: usize) {
while self.entries.len() >= max_entries {
let Some(oldest) = self.order.pop_front() else {
break;
};
self.entries.remove(&oldest);
}
}
}
pub struct InMemoryRevocationStore {
inner: RwLock<InMemoryInner>,
max_entries: usize,
ttl: Option<Duration>,
}
impl InMemoryRevocationStore {
pub fn new() -> Self {
Self::with_limits(DEFAULT_MAX_ENTRIES, None)
}
pub fn with_limits(max_entries: usize, ttl: Option<Duration>) -> Self {
Self {
inner: RwLock::new(InMemoryInner {
entries: HashMap::new(),
order: VecDeque::new(),
}),
max_entries: max_entries.max(1),
ttl,
}
}
pub async fn len(&self) -> usize {
let mut inner = self.inner.write().await;
inner.sweep_expired(self.ttl);
inner.entries.len()
}
pub async fn is_empty(&self) -> bool {
self.len().await == 0
}
async fn make_room(&self) {
let mut inner = self.inner.write().await;
inner.sweep_expired(self.ttl);
inner.evict_oldest_to(self.max_entries);
}
}
impl Default for InMemoryRevocationStore {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl TokenRevocationStore for InMemoryRevocationStore {
async fn revoke(&self, jti: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.make_room().await;
let mut inner = self.inner.write().await;
inner.entries.insert(jti.to_string(), Instant::now());
inner.order.push_back(jti.to_string());
Ok(())
}
async fn is_revoked(
&self,
jti: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let inner = self.inner.read().await;
Ok(inner
.entries
.get(jti)
.is_some_and(|inserted| match self.ttl {
Some(ttl) => inserted.elapsed() < ttl,
None => true,
}))
}
}
#[cfg(feature = "revocation-redis")]
pub struct RedisRevocationStore {
client: redis::Client,
connection: RwLock<Option<redis::aio::MultiplexedConnection>>,
ttl: u64,
}
#[cfg(feature = "revocation-redis")]
impl RedisRevocationStore {
pub fn new(redis_url: &str, ttl: u64) -> Result<Self, redis::RedisError> {
let client = redis::Client::open(redis_url)?;
Ok(Self {
client,
connection: RwLock::new(None),
ttl,
})
}
async fn connection(&self) -> Result<redis::aio::MultiplexedConnection, redis::RedisError> {
if let Some(conn) = self.connection.read().await.clone() {
return Ok(conn);
}
let mut write = self.connection.write().await;
if let Some(conn) = write.clone() {
return Ok(conn);
}
let conn = self.client.get_multiplexed_async_connection().await?;
*write = Some(conn.clone());
Ok(conn)
}
async fn reset_connection(&self) {
*self.connection.write().await = None;
}
}
#[cfg(feature = "revocation-redis")]
#[async_trait::async_trait]
impl TokenRevocationStore for RedisRevocationStore {
async fn revoke(&self, jti: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut conn = self.connection().await?;
let mut cmd = redis::cmd("SETEX");
cmd.arg(jti).arg(self.ttl).arg("1");
let ack: Result<(), _> = cmd.query_async(&mut conn).await;
match ack {
Ok(()) => Ok(()),
Err(_) => {
self.reset_connection().await;
let mut conn = self.connection().await?;
let mut cmd = redis::cmd("SETEX");
cmd.arg(jti).arg(self.ttl).arg("1");
cmd.query_async::<redis::aio::MultiplexedConnection, ()>(&mut conn)
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
}
}
}
async fn is_revoked(
&self,
jti: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let mut conn = self.connection().await?;
let mut cmd = redis::cmd("EXISTS");
cmd.arg(jti);
let exists: Result<bool, _> = cmd.query_async(&mut conn).await;
match exists {
Ok(exists) => Ok(exists),
Err(_) => {
self.reset_connection().await;
let mut conn = self.connection().await?;
let mut cmd = redis::cmd("EXISTS");
cmd.arg(jti);
cmd.query_async::<redis::aio::MultiplexedConnection, bool>(&mut conn)
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
}
}
}
}
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
#[cfg(test)]
mod tests {
use super::*;
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Runtime::new().unwrap()
}
#[test]
fn store_never_exceeds_max_entries_and_evicts_oldest() {
let rt = runtime();
rt.block_on(async {
let store = InMemoryRevocationStore::with_limits(3, None);
assert!(store.is_empty().await);
for jti in ["jti-1", "jti-2", "jti-3"] {
store.revoke(jti).await.unwrap();
}
assert_eq!(store.len().await, 3);
store.revoke("jti-4").await.unwrap();
assert_eq!(store.len().await, 3, "store must stay bounded");
assert!(
!store.is_empty().await,
"a store holding live entries must not report empty"
);
assert!(!store.is_revoked("jti-1").await.unwrap(), "oldest evicted");
assert!(store.is_revoked("jti-2").await.unwrap());
assert!(store.is_revoked("jti-3").await.unwrap());
assert!(store.is_revoked("jti-4").await.unwrap());
});
}
#[test]
fn flood_of_revocations_stays_bounded() {
let rt = runtime();
rt.block_on(async {
let store = InMemoryRevocationStore::with_limits(50, None);
for i in 0..500 {
store.revoke(&format!("flood-{i}")).await.unwrap();
}
assert_eq!(store.len().await, 50);
assert!(store.is_revoked("flood-499").await.unwrap());
assert!(!store.is_revoked("flood-0").await.unwrap());
});
}
#[test]
fn ttl_expires_entries() {
let rt = runtime();
rt.block_on(async {
let store = InMemoryRevocationStore::with_limits(100, Some(Duration::from_millis(30)));
store.revoke("ephemeral").await.unwrap();
assert!(store.is_revoked("ephemeral").await.unwrap());
std::thread::sleep(Duration::from_millis(50));
assert!(!store.is_revoked("ephemeral").await.unwrap(), "TTL expired");
assert_eq!(store.len().await, 0, "sweep drops expired entries");
});
}
#[test]
fn sweep_on_insert_frees_capacity() {
let rt = runtime();
rt.block_on(async {
let store = InMemoryRevocationStore::with_limits(2, Some(Duration::from_millis(30)));
store.revoke("a").await.unwrap();
store.revoke("b").await.unwrap();
assert_eq!(store.len().await, 2);
std::thread::sleep(Duration::from_millis(50));
store.revoke("c").await.unwrap();
assert_eq!(store.len().await, 1);
assert!(store.is_revoked("c").await.unwrap());
assert!(!store.is_revoked("a").await.unwrap());
assert!(!store.is_revoked("b").await.unwrap());
});
}
#[test]
fn revoke_is_idempotent() {
let rt = runtime();
rt.block_on(async {
let store = InMemoryRevocationStore::new();
store.revoke("same").await.unwrap();
store.revoke("same").await.unwrap();
assert_eq!(store.len().await, 1);
assert!(store.is_revoked("same").await.unwrap());
});
}
#[test]
fn zero_capacity_is_clamped_to_one() {
let rt = runtime();
rt.block_on(async {
let store = InMemoryRevocationStore::with_limits(0, None);
store.revoke("first").await.unwrap();
store.revoke("second").await.unwrap();
assert_eq!(store.len().await, 1);
assert!(store.is_revoked("second").await.unwrap());
});
}
}