use std::sync::{Arc, OnceLock};
use arc_swap::ArcSwapOption;
use bon::Builder;
use rand::RngExt as _;
use crate::{
cache::{GrantTokenSource, RefreshTokenStore, TokenCache, TokenSource},
core::{
Error,
dpop::ResourceServerDPoP,
platform::{Duration, MaybeSendBoxFuture},
},
grant::core::{OAuth2ExchangeGrant, TokenResponse},
};
const JITTER_LIFETIME_FRACTION: f64 = 0.1;
const MARGIN_LIFETIME_FRACTION: f64 = 0.5;
#[derive(Builder)]
pub struct InMemoryTokenCache<Src: TokenSource> {
source: Src,
#[builder(default = Duration::from_secs(30))]
expires_margin: Duration,
#[builder(default = Duration::from_hours(1))]
default_expires_in: Duration,
refresh_ahead: Option<Duration>,
#[builder(required, default = Some(Duration::from_secs(30)))]
refresh_jitter: Option<Duration>,
#[builder(skip)]
jitter_cell: OnceLock<f64>,
#[builder(skip)]
cached: ArcSwapOption<TokenResponse>,
#[builder(skip)]
refresh_lock: tokio::sync::Mutex<()>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CacheState {
Active,
Restorable,
Unauthenticated,
}
impl<Src: TokenSource> core::fmt::Debug for InMemoryTokenCache<Src> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("InMemoryTokenCache")
.field("expires_margin", &self.expires_margin)
.field("default_expires_in", &self.default_expires_in)
.field("refresh_ahead", &self.refresh_ahead)
.field("refresh_jitter", &self.refresh_jitter)
.finish_non_exhaustive()
}
}
impl<Src: TokenSource> InMemoryTokenCache<Src> {
pub fn source(&self) -> &Src {
&self.source
}
pub async fn logout(&self) -> Result<(), Error> {
self.clear().await
}
fn jitter_fraction(&self) -> f64 {
*self
.jitter_cell
.get_or_init(|| rand::rng().random_range(0.0..1.0))
}
fn jitter_offset(&self, lifetime: Duration) -> Duration {
let Some(cap) = self.refresh_jitter else {
return Duration::ZERO;
};
let band = lifetime.mul_f64(JITTER_LIFETIME_FRACTION).min(cap);
band.mul_f64(self.jitter_fraction())
}
fn effective_margin(&self, lifetime: Duration) -> Duration {
self.expires_margin
.min(lifetime.mul_f64(MARGIN_LIFETIME_FRACTION))
}
fn get_valid_cached(&self) -> Option<Arc<TokenResponse>> {
self.cached.load_full().filter(|t| {
let access_token = t.access_token();
let margin =
self.effective_margin(access_token.effective_lifetime(self.default_expires_in));
!access_token.is_expired(self.default_expires_in, margin)
})
}
fn in_refresh_window(&self, token: &TokenResponse) -> bool {
let access_token = token.access_token();
let lifetime = access_token.effective_lifetime(self.default_expires_in);
let base = self
.refresh_ahead
.unwrap_or_else(|| self.effective_margin(lifetime));
let trigger = base + self.jitter_offset(lifetime);
access_token.is_expired(self.default_expires_in, trigger)
}
async fn refresh_ahead_now(&self, current: Arc<TokenResponse>) -> Arc<TokenResponse> {
let Ok(_refresh_lock) = self.refresh_lock.try_lock() else {
return current;
};
if !self.source.has_pending_token()
&& let Some(token) = self.get_valid_cached()
&& !self.in_refresh_window(&token)
{
return token;
}
match self.source.token().await {
Ok(token) => {
self.cached.store(Some(token.clone()));
token
}
Err(_) => current,
}
}
}
impl<Src: TokenSource> TokenSource for InMemoryTokenCache<Src> {
fn token(&self) -> MaybeSendBoxFuture<'_, Result<Arc<TokenResponse>, Error>> {
Box::pin(async move {
if !self.source.has_pending_token()
&& let Some(token) = self.get_valid_cached()
{
if self.in_refresh_window(&token) {
return Ok(self.refresh_ahead_now(token).await);
}
return Ok(token);
}
let _refresh_lock = self.refresh_lock.lock().await;
if !self.source.has_pending_token()
&& let Some(token) = self.get_valid_cached()
{
return Ok(token);
}
let token = self.source.token().await?;
self.cached.store(Some(token.clone()));
Ok(token)
})
}
fn resource_server_dpop(&self) -> &dyn ResourceServerDPoP {
self.source.resource_server_dpop()
}
fn invalidate(&self) {
self.cached.store(None);
}
fn clear(&self) -> MaybeSendBoxFuture<'_, Result<(), Error>> {
Box::pin(async move {
self.cached.store(None);
self.source.clear().await
})
}
}
impl<G: OAuth2ExchangeGrant, S: RefreshTokenStore> InMemoryTokenCache<GrantTokenSource<G, S>> {
pub async fn state(&self, max_staleness: Option<Duration>) -> Result<CacheState, Error> {
if self.source.has_pending_token() || self.get_valid_cached().is_some() {
return Ok(CacheState::Active);
}
if self.source.can_restore(max_staleness).await? {
return Ok(CacheState::Restorable);
}
Ok(CacheState::Unauthenticated)
}
}
impl<Src: TokenSource> TokenCache for InMemoryTokenCache<Src> {}
#[cfg(test)]
mod tests {
use std::sync::{
Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use super::*;
use crate::{
core::{platform::SystemTime, secrets::SecretString},
grant::core::token_response::RawTokenResponse,
};
struct FakeSource {
results: Mutex<std::collections::VecDeque<Result<TokenResponse, Error>>>,
calls: AtomicUsize,
cleared: AtomicBool,
pending: AtomicBool,
}
impl FakeSource {
fn new(results: impl IntoIterator<Item = Result<TokenResponse, Error>>) -> Self {
Self {
results: Mutex::new(results.into_iter().collect()),
calls: AtomicUsize::new(0),
cleared: AtomicBool::new(false),
pending: AtomicBool::new(false),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::Relaxed)
}
fn set_pending(&self, pending: bool) {
self.pending.store(pending, Ordering::Relaxed);
}
}
impl TokenSource for FakeSource {
fn token(&self) -> MaybeSendBoxFuture<'_, Result<Arc<TokenResponse>, Error>> {
self.calls.fetch_add(1, Ordering::Relaxed);
let result = self
.results
.lock()
.unwrap()
.pop_front()
.expect("unexpected extra token() call");
Box::pin(async move {
tokio::task::yield_now().await;
result.map(Arc::new)
})
}
fn has_pending_token(&self) -> bool {
self.pending.load(Ordering::Relaxed)
}
fn clear(&self) -> MaybeSendBoxFuture<'_, Result<(), Error>> {
self.cleared.store(true, Ordering::Relaxed);
Box::pin(async { Ok(()) })
}
}
fn token(access: &str, expires_in: u64) -> TokenResponse {
RawTokenResponse::builder()
.access_token(SecretString::new(access))
.token_type("bearer")
.expires_in(expires_in)
.build()
.into_token_response(None, SystemTime::now())
.expect("valid token response")
}
fn access_of(token: &TokenResponse) -> String {
token
.raw_token_response()
.access_token
.expose_secret()
.to_owned()
}
#[tokio::test]
async fn serves_cached_token_without_re_calling_source() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([Ok(token("t1", 3600))]))
.build();
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
assert_eq!(cache.source().calls(), 1);
}
#[tokio::test]
async fn pending_token_supersedes_valid_cached_token() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([
Ok(token("t1", 3600)),
Ok(token("t2", 3600)),
]))
.build();
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
cache.source().set_pending(true);
assert_eq!(access_of(&cache.token().await.unwrap()), "t2");
assert_eq!(cache.source().calls(), 2);
}
#[tokio::test]
async fn refetches_after_expiry() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([
Ok(token("expired", 0)),
Ok(token("fresh", 3600)),
]))
.build();
assert_eq!(access_of(&cache.token().await.unwrap()), "expired");
assert_eq!(access_of(&cache.token().await.unwrap()), "fresh");
assert_eq!(cache.source().calls(), 2);
}
#[tokio::test]
async fn short_lived_token_is_served_not_immediately_refetched() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([
Ok(token("short", 20)),
Ok(token("next", 20)),
]))
.refresh_jitter(None)
.build();
assert_eq!(access_of(&cache.token().await.unwrap()), "short");
assert_eq!(access_of(&cache.token().await.unwrap()), "short");
assert_eq!(cache.source().calls(), 1);
}
#[tokio::test]
async fn invalidate_forces_refetch() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([
Ok(token("t1", 3600)),
Ok(token("t2", 3600)),
]))
.build();
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
cache.invalidate();
assert_eq!(access_of(&cache.token().await.unwrap()), "t2");
assert_eq!(cache.source().calls(), 2);
}
#[tokio::test]
async fn logout_clears_cache_and_source() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([
Ok(token("t1", 3600)),
Ok(token("t2", 3600)),
]))
.build();
cache.token().await.unwrap();
cache.logout().await.unwrap();
assert!(cache.source().cleared.load(Ordering::Relaxed));
assert_eq!(access_of(&cache.token().await.unwrap()), "t2");
}
#[tokio::test]
async fn propagates_source_error() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([Err(Error::from(
crate::core::ErrorKind::ReauthRequired,
))]))
.build();
let err = cache.token().await.unwrap_err();
assert_eq!(err.kind(), crate::core::ErrorKind::ReauthRequired);
}
#[tokio::test]
async fn concurrent_callers_coalesce_onto_one_source_call() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([Ok(token("t1", 3600))]))
.build();
let (a, b) = tokio::join!(cache.token(), cache.token());
assert_eq!(access_of(&a.unwrap()), "t1");
assert_eq!(access_of(&b.unwrap()), "t1");
assert_eq!(cache.source().calls(), 1);
}
fn refresh_ahead_cache(
results: impl IntoIterator<Item = Result<TokenResponse, Error>>,
) -> InMemoryTokenCache<FakeSource> {
InMemoryTokenCache::builder()
.source(FakeSource::new(results))
.expires_margin(Duration::from_secs(30))
.refresh_ahead(Duration::from_mins(2))
.refresh_jitter(None)
.build()
}
#[tokio::test]
async fn refresh_ahead_refreshes_a_still_valid_token() {
let cache = refresh_ahead_cache([Ok(token("t1", 100)), Ok(token("t2", 100))]);
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
assert_eq!(cache.source().calls(), 1);
assert_eq!(access_of(&cache.token().await.unwrap()), "t2");
assert_eq!(cache.source().calls(), 2);
}
#[tokio::test]
async fn refresh_ahead_failure_serves_the_valid_token() {
let cache = refresh_ahead_cache([
Ok(token("t1", 100)),
Err(Error::from(crate::core::ErrorKind::ReauthRequired)),
]);
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
assert_eq!(cache.source().calls(), 2);
}
#[tokio::test]
async fn without_refresh_ahead_a_valid_token_is_not_refreshed() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([Ok(token("t1", 100))]))
.expires_margin(Duration::from_secs(30))
.refresh_jitter(None)
.build();
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
assert_eq!(cache.source().calls(), 1);
}
#[test]
fn jitter_offset_is_capped_and_stable() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([]))
.refresh_jitter(Some(Duration::from_secs(30)))
.build();
let long = Duration::from_hours(1);
let offset = cache.jitter_offset(long);
assert!(offset <= Duration::from_secs(30), "offset within the cap");
assert_eq!(
offset,
cache.jitter_offset(long),
"offset is stable across calls"
);
}
#[test]
fn jitter_offset_scales_with_short_lifetimes() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([]))
.refresh_jitter(Some(Duration::from_secs(30)))
.build();
let short = Duration::from_mins(1);
let offset = cache.jitter_offset(short);
assert!(
offset <= Duration::from_secs(6),
"offset scaled to the lifetime, not the cap"
);
}
#[test]
fn disabled_jitter_has_no_offset() {
let cache = InMemoryTokenCache::builder()
.source(FakeSource::new([]))
.refresh_jitter(None)
.build();
assert_eq!(cache.jitter_offset(Duration::from_hours(1)), Duration::ZERO);
}
#[tokio::test]
async fn refresh_ahead_does_not_block_concurrent_callers() {
let cache = refresh_ahead_cache([Ok(token("t1", 100)), Ok(token("t2", 100))]);
assert_eq!(access_of(&cache.token().await.unwrap()), "t1");
let (a, b) = tokio::join!(cache.token(), cache.token());
let served: std::collections::HashSet<_> =
[access_of(&a.unwrap()), access_of(&b.unwrap())].into();
assert_eq!(served, ["t1".to_owned(), "t2".to_owned()].into());
assert_eq!(cache.source().calls(), 2);
}
}