use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Clone, Default)]
pub struct TokenBlacklist {
revoked: Arc<RwLock<HashMap<String, Option<u64>>>>,
}
impl TokenBlacklist {
pub fn new() -> Self {
Self {
revoked: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn revoke(&self, jti: String, expires_at: Option<u64>) {
if let Ok(mut lock) = self.revoked.write() {
lock.insert(jti, expires_at);
}
}
pub fn is_revoked(&self, jti: &str) -> bool {
let Ok(lock) = self.revoked.read() else {
return true; };
match lock.get(jti) {
None => false,
Some(None) => true, Some(Some(exp)) => now_secs() <= *exp, }
}
pub fn cleanup(&self) {
let now = now_secs();
if let Ok(mut lock) = self.revoked.write() {
lock.retain(|_, exp| match exp {
None => true,
Some(exp) => now <= *exp,
});
}
}
pub fn start_cleanup_task(&self, interval: std::time::Duration) {
let bl = self.clone();
std::thread::Builder::new()
.name("chopin-auth-blacklist-cleanup".into())
.spawn(move || {
loop {
std::thread::sleep(interval);
bl.cleanup();
}
})
.expect("failed to spawn blacklist cleanup thread");
}
pub fn len(&self) -> usize {
self.revoked.read().map(|l| l.len()).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
const FAR_FUTURE: u64 = 9_999_999_999;
#[test]
fn test_new_is_not_revoked() {
let bl = TokenBlacklist::new();
assert!(!bl.is_revoked("any-jti"));
}
#[test]
fn test_default_equals_new_behavior() {
let bl: TokenBlacklist = Default::default();
assert!(!bl.is_revoked("x"));
}
#[test]
fn test_revoke_then_is_revoked() {
let bl = TokenBlacklist::new();
bl.revoke("jti-abc".to_string(), Some(FAR_FUTURE));
assert!(bl.is_revoked("jti-abc"));
}
#[test]
fn test_revoke_indefinitely() {
let bl = TokenBlacklist::new();
bl.revoke("jti-perm".to_string(), None);
assert!(bl.is_revoked("jti-perm"));
}
#[test]
fn test_expired_entry_not_revoked() {
let bl = TokenBlacklist::new();
bl.revoke("jti-old".to_string(), Some(1));
assert!(!bl.is_revoked("jti-old"));
}
#[test]
fn test_not_revoked_unknown_jti() {
let bl = TokenBlacklist::new();
bl.revoke("jti-1".to_string(), None);
assert!(!bl.is_revoked("jti-2"));
}
#[test]
fn test_revoke_idempotent() {
let bl = TokenBlacklist::new();
bl.revoke("same-jti".to_string(), Some(FAR_FUTURE));
bl.revoke("same-jti".to_string(), Some(FAR_FUTURE));
assert!(bl.is_revoked("same-jti"));
}
#[test]
fn test_clone_shares_arc_state() {
let bl1 = TokenBlacklist::new();
let bl2 = bl1.clone();
bl1.revoke("shared-jti".to_string(), None);
assert!(bl2.is_revoked("shared-jti"));
}
#[test]
fn test_revoke_multiple_unique_jtis() {
let bl = TokenBlacklist::new();
for i in 0..10 {
bl.revoke(format!("jti-{i}"), None);
}
for i in 0..10 {
assert!(bl.is_revoked(&format!("jti-{i}")));
}
assert!(!bl.is_revoked("jti-99"));
}
#[test]
fn test_scale_100_jtis() {
let bl = TokenBlacklist::new();
for i in 0..100 {
bl.revoke(format!("scale-jti-{i}"), Some(FAR_FUTURE));
}
for i in 0..100 {
assert!(bl.is_revoked(&format!("scale-jti-{i}")));
}
assert!(!bl.is_revoked("scale-jti-100"));
}
#[test]
fn test_cleanup_removes_expired_entries() {
let bl = TokenBlacklist::new();
bl.revoke("expired".to_string(), Some(1)); bl.revoke("live".to_string(), Some(FAR_FUTURE));
bl.revoke("perm".to_string(), None);
assert_eq!(bl.len(), 3);
bl.cleanup();
assert_eq!(bl.len(), 2);
assert!(!bl.is_revoked("expired"));
assert!(bl.is_revoked("live"));
assert!(bl.is_revoked("perm"));
}
#[test]
fn test_len_and_is_empty() {
let bl = TokenBlacklist::new();
assert!(bl.is_empty());
bl.revoke("j".to_string(), None);
assert_eq!(bl.len(), 1);
assert!(!bl.is_empty());
}
}