use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use ppoppo_clock::ArcClock;
use ppoppo_clock::native::WallClock;
use tokio::sync::Mutex;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TokenCacheError {
#[error("token fetch failed: {0}")]
Fetch(String),
#[error("token response is malformed: {0}")]
Malformed(String),
#[error("{0}")]
Source(#[source] Box<dyn std::error::Error + Send + Sync>),
}
#[async_trait]
pub trait TokenSource: Send + Sync {
async fn fetch_token(&self) -> Result<(String, Duration), TokenCacheError>;
}
#[derive(Debug, Clone)]
pub struct TokenCacheConfig {
pub refresh_skew: Duration,
}
impl Default for TokenCacheConfig {
fn default() -> Self {
#[allow(clippy::cast_sign_loss)] Self { refresh_skew: Duration::from_secs(jwt_expiry::EXPIRY_SKEW_SECS as u64) }
}
}
struct CacheInner {
cached: Option<(String, i64)>,
}
pub struct TokenCache {
data: Mutex<CacheInner>,
refresh: Mutex<()>,
config: TokenCacheConfig,
source: Box<dyn TokenSource>,
clock: ArcClock,
}
impl TokenCache {
pub fn new(source: Box<dyn TokenSource>, config: TokenCacheConfig) -> Self {
Self {
data: Mutex::new(CacheInner { cached: None }),
refresh: Mutex::new(()),
config,
source,
clock: Arc::new(WallClock),
}
}
#[must_use]
pub fn with_clock(mut self, clock: ArcClock) -> Self {
self.clock = clock;
self
}
#[must_use]
pub fn with_initial_token(mut self, token: impl Into<String>) -> Self {
let token = token.into();
if let Some(exp) = jwt_expiry::parse_expiry(&token) {
let skew_ms = i64::try_from(self.config.refresh_skew.as_millis()).unwrap_or(i64::MAX);
let exp_ms = exp.as_millisecond().saturating_sub(skew_ms);
self.data.get_mut().cached = Some((token, exp_ms));
}
self
}
pub async fn get(&self) -> Result<String, TokenCacheError> {
{
let inner = self.data.lock().await;
if let Some((token, exp)) = &inner.cached
&& self.clock.now_unix_millis() < *exp {
return Ok(token.clone());
}
}
let _refresh_guard = self.refresh.lock().await;
{
let inner = self.data.lock().await;
if let Some((token, exp)) = &inner.cached
&& self.clock.now_unix_millis() < *exp {
return Ok(token.clone());
}
}
let (token, ttl) = self.source.fetch_token().await?;
let skewed_ttl = ttl.saturating_sub(self.config.refresh_skew);
let exp = self.clock.now_unix_millis() + skewed_ttl.as_millis() as i64;
self.data.lock().await.cached = Some((token.clone(), exp));
Ok(token)
}
pub async fn invalidate(&self) {
self.data.lock().await.cached = None;
}
}
#[cfg(feature = "client-credentials")]
mod credentials;
mod jwt_expiry;
#[cfg(feature = "client-credentials")]
pub use credentials::ClientCredentialsSource;
pub use jwt_expiry::{EXPIRY_SKEW_SECS, is_expired, parse_expiry};
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct CountingSource {
count: Arc<AtomicUsize>,
ttl: Duration,
token: &'static str,
}
#[async_trait]
impl TokenSource for CountingSource {
async fn fetch_token(&self) -> Result<(String, Duration), TokenCacheError> {
self.count.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(10)).await;
Ok((self.token.to_string(), self.ttl))
}
}
#[tokio::test]
async fn token_cache_returns_cached_until_near_expiry() {
let count = Arc::new(AtomicUsize::new(0));
let source = CountingSource {
count: Arc::clone(&count),
ttl: Duration::from_secs(3600),
token: "tok-abc",
};
let cache = TokenCache::new(Box::new(source), TokenCacheConfig::default());
let t1 = cache.get().await.unwrap();
let t2 = cache.get().await.unwrap();
assert_eq!(t1, "tok-abc");
assert_eq!(t2, "tok-abc");
assert_eq!(count.load(Ordering::SeqCst), 1, "source called more than once for valid cache");
}
#[tokio::test]
async fn invalidate_forces_refetch_despite_valid_ttl() {
let count = Arc::new(AtomicUsize::new(0));
let source = CountingSource {
count: Arc::clone(&count),
ttl: Duration::from_secs(3600), token: "tok-1",
};
let cache = TokenCache::new(Box::new(source), TokenCacheConfig::default());
let _ = cache.get().await.unwrap(); let _ = cache.get().await.unwrap(); assert_eq!(count.load(Ordering::SeqCst), 1, "second get should hit cache");
cache.invalidate().await;
let _ = cache.get().await.unwrap(); assert_eq!(
count.load(Ordering::SeqCst),
2,
"invalidate() must force the next get() to re-mint",
);
}
fn fake_jwt(exp: jiff::Timestamp) -> String {
jwt_expiry::tests::make_fake_token(exp)
}
#[tokio::test]
async fn with_initial_token_serves_seed_without_fetch() {
let count = Arc::new(AtomicUsize::new(0));
let source = CountingSource {
count: Arc::clone(&count),
ttl: Duration::from_secs(3600),
token: "tok-fresh",
};
let seed = fake_jwt(jiff::Timestamp::now() + jiff::SignedDuration::from_hours(1));
let cache = TokenCache::new(Box::new(source), TokenCacheConfig::default())
.with_initial_token(seed.clone());
assert_eq!(cache.get().await.unwrap(), seed);
assert_eq!(count.load(Ordering::SeqCst), 0, "a valid seed must not trigger a fetch");
cache.invalidate().await;
assert_eq!(cache.get().await.unwrap(), "tok-fresh");
assert_eq!(count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn with_initial_token_ignores_unparseable_and_stale_seeds() {
for seed in [
"not-a-jwt".to_string(),
fake_jwt(jiff::Timestamp::now() - jiff::SignedDuration::from_hours(1)),
] {
let count = Arc::new(AtomicUsize::new(0));
let source = CountingSource {
count: Arc::clone(&count),
ttl: Duration::from_secs(3600),
token: "tok-fresh",
};
let cache = TokenCache::new(Box::new(source), TokenCacheConfig::default())
.with_initial_token(seed);
assert_eq!(cache.get().await.unwrap(), "tok-fresh");
assert_eq!(count.load(Ordering::SeqCst), 1, "unusable seed must fall through to fetch");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn token_cache_single_flights_concurrent_refresh() {
let count = Arc::new(AtomicUsize::new(0));
let source = CountingSource {
count: Arc::clone(&count),
ttl: Duration::from_secs(3600),
token: "tok-xyz",
};
let cache = Arc::new(TokenCache::new(Box::new(source), TokenCacheConfig::default()));
let mut handles = Vec::new();
for _ in 0..8 {
let cache = Arc::clone(&cache);
handles.push(tokio::spawn(async move { cache.get().await }));
}
for h in handles {
assert_eq!(h.await.unwrap().unwrap(), "tok-xyz");
}
assert_eq!(count.load(Ordering::SeqCst), 1, "source called more than once (single-flight broken)");
}
}