use std::fmt;
use std::sync::Arc;
use std::time::{Duration, Instant};
use arc_swap::ArcSwap;
use rand::RngExt as _;
use toolkit_utils::SecretString;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenStatus {
Fresh,
Stale,
Expired,
}
#[derive(Clone)]
pub struct CachedToken {
access_token: SecretString,
received_at: Instant,
fresh_until: Duration,
expires_at: Duration,
}
impl fmt::Debug for CachedToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CachedToken")
.field("access_token", &"[REDACTED]")
.field("fresh_until", &self.fresh_until)
.field("expires_at", &self.expires_at)
.finish_non_exhaustive()
}
}
impl CachedToken {
pub(crate) fn new(
access_token: SecretString,
lifetime_secs: u64,
freshness_ratio: f64,
) -> Result<Self, super::error::TokenError> {
if lifetime_secs == 0 {
return Err(super::error::TokenError::InvalidTokenLifetime(
"lifetime_secs must be > 0".into(),
));
}
if !freshness_ratio.is_finite() || freshness_ratio <= 0.0 || freshness_ratio > 1.0 {
return Err(super::error::TokenError::InvalidTokenLifetime(format!(
"freshness_ratio must be finite and in (0.0, 1.0], got {freshness_ratio}"
)));
}
let lifetime = Duration::from_secs(lifetime_secs);
let fresh_until = lifetime.mul_f64(freshness_ratio);
if fresh_until.is_zero() {
return Err(super::error::TokenError::InvalidTokenLifetime(format!(
"freshness window rounds to zero: lifetime_secs={lifetime_secs}, \
freshness_ratio={freshness_ratio}"
)));
}
Ok(Self {
access_token,
received_at: Instant::now(),
fresh_until,
expires_at: lifetime,
})
}
pub(crate) fn access_token(&self) -> &str {
self.access_token.expose()
}
pub(crate) fn token_status(&self) -> TokenStatus {
let elapsed = self.received_at.elapsed();
if elapsed >= self.expires_at {
TokenStatus::Expired
} else if elapsed >= self.fresh_until {
TokenStatus::Stale
} else {
TokenStatus::Fresh
}
}
fn time_until_stale(&self) -> Duration {
self.fresh_until
.checked_sub(self.received_at.elapsed())
.unwrap_or(Duration::ZERO)
}
fn time_until_expired(&self) -> Duration {
self.expires_at
.checked_sub(self.received_at.elapsed())
.unwrap_or(Duration::ZERO)
}
}
#[derive(Debug, Clone)]
pub struct WatcherConfig {
jitter_max: Duration,
min_refresh_period: Duration,
backoff_multiplier: u32,
max_backoff: Duration,
}
impl WatcherConfig {
pub(crate) fn new(
jitter_max: Duration,
min_refresh_period: Duration,
) -> Result<Self, super::error::TokenError> {
if min_refresh_period.is_zero() {
return Err(super::error::TokenError::ConfigError(
"min_refresh_period must be > 0".into(),
));
}
Ok(Self {
jitter_max,
min_refresh_period,
backoff_multiplier: 2,
max_backoff: min_refresh_period.saturating_mul(30),
})
}
}
#[derive(Debug)]
pub struct FetchedToken {
pub access_token: SecretString,
pub lifetime_secs: u64,
pub freshness_ratio: f64,
}
pub struct TokenWatcher {
current: Arc<ArcSwap<CachedToken>>,
_shutdown: tokio::sync::oneshot::Sender<()>,
}
impl TokenWatcher {
pub(crate) async fn spawn(
mut source: super::source::OAuthTokenSource,
config: WatcherConfig,
) -> Result<Self, super::error::TokenError> {
let initial = source.request_token().await?;
let cached = CachedToken::new(
initial.access_token,
initial.lifetime_secs,
initial.freshness_ratio,
)?;
let current = Arc::new(ArcSwap::from_pointee(cached));
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let current_for_task = Arc::clone(¤t);
tokio::spawn(async move {
let mut consecutive_errors: u32 = 0;
loop {
let guard = current_for_task.load();
let base_delay = guard.time_until_stale();
let until_expired = guard.time_until_expired();
let jitter_cap = config.jitter_max.min(base_delay);
let jitter = random_jitter(jitter_cap);
let delay = base_delay.saturating_sub(jitter);
let sleep_dur = if consecutive_errors > 0 {
let backoff = compute_backoff(
config.min_refresh_period,
config.max_backoff,
config.backoff_multiplier,
consecutive_errors,
);
if until_expired.is_zero() {
tracing::warn!(
"OAuth2 token watcher: cached token has expired, refresh still failing"
);
backoff
} else {
backoff.min(until_expired)
}
} else {
delay
};
drop(guard);
tokio::select! {
() = tokio::time::sleep(sleep_dur) => {}
_ = &mut shutdown_rx => {
tracing::debug!("OAuth2 token watcher: shutdown signal received");
return;
}
}
let refresh_result = tokio::select! {
result = source.request_token() => result,
_ = &mut shutdown_rx => {
tracing::debug!("OAuth2 token watcher: shutdown signal received");
return;
}
};
match refresh_result {
Ok(fetched) => {
match CachedToken::new(
fetched.access_token,
fetched.lifetime_secs,
fetched.freshness_ratio,
) {
Ok(new_cached) => {
consecutive_errors = 0;
current_for_task.store(Arc::new(new_cached));
tracing::debug!(
"OAuth2 token watcher: refreshed token successfully"
);
}
Err(e) => {
consecutive_errors = consecutive_errors.saturating_add(1);
tracing::warn!(
error = %e,
consecutive_errors,
"OAuth2 token watcher: server returned invalid token"
);
}
}
}
Err(e) => {
consecutive_errors = consecutive_errors.saturating_add(1);
tracing::warn!(
error = %e,
consecutive_errors,
"OAuth2 token watcher: refresh failed"
);
}
}
}
});
Ok(Self {
current,
_shutdown: shutdown_tx,
})
}
pub(crate) fn valid_token(
&self,
) -> Result<arc_swap::Guard<Arc<CachedToken>>, super::error::TokenError> {
let guard = self.current.load();
if matches!(guard.token_status(), TokenStatus::Expired) {
return Err(super::error::TokenError::Unavailable(
"token expired, refresh pending".into(),
));
}
Ok(guard)
}
}
fn random_jitter(max: Duration) -> Duration {
let max_nanos = u64::try_from(max.as_nanos()).unwrap_or(u64::MAX);
if max_nanos == 0 {
return Duration::ZERO;
}
let nanos = rand::rng().random_range(0..max_nanos);
Duration::from_nanos(nanos)
}
fn compute_backoff(min: Duration, max: Duration, multiplier: u32, errors: u32) -> Duration {
let factor = u64::from(multiplier).saturating_pow(errors.saturating_sub(1));
let clamped = u32::try_from(factor).unwrap_or(u32::MAX);
let backoff = min.saturating_mul(clamped);
backoff.min(max)
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn cached_token_fresh_immediately() {
let ct = CachedToken::new(SecretString::new("tok"), 3600, 0.8).unwrap();
assert_eq!(ct.access_token(), "tok");
assert_eq!(ct.token_status(), TokenStatus::Fresh);
}
#[test]
fn cached_token_zero_lifetime_rejected() {
let err = CachedToken::new(SecretString::new("tok"), 0, 0.8).unwrap_err();
assert!(
err.to_string().contains("lifetime_secs"),
"expected lifetime error, got: {err}"
);
}
#[test]
fn cached_token_zero_freshness_rejected() {
let err = CachedToken::new(SecretString::new("tok"), 3600, 0.0).unwrap_err();
assert!(
err.to_string().contains("freshness_ratio"),
"expected freshness error, got: {err}"
);
}
#[test]
fn cached_token_negative_freshness_rejected() {
let err = CachedToken::new(SecretString::new("tok"), 3600, -0.1).unwrap_err();
assert!(
err.to_string().contains("freshness_ratio"),
"expected freshness error, got: {err}"
);
}
#[test]
fn cached_token_freshness_above_one_rejected() {
let err = CachedToken::new(SecretString::new("tok"), 3600, 1.1).unwrap_err();
assert!(
err.to_string().contains("freshness_ratio"),
"expected freshness error, got: {err}"
);
}
#[test]
fn cached_token_nan_freshness_rejected() {
let err = CachedToken::new(SecretString::new("tok"), 3600, f64::NAN).unwrap_err();
assert!(
err.to_string().contains("freshness_ratio"),
"expected freshness error, got: {err}"
);
}
#[test]
fn cached_token_inf_freshness_rejected() {
let err = CachedToken::new(SecretString::new("tok"), 3600, f64::INFINITY).unwrap_err();
assert!(
err.to_string().contains("freshness_ratio"),
"expected freshness error, got: {err}"
);
}
#[test]
fn cached_token_tiny_freshness_zero_window_rejected() {
let err = CachedToken::new(SecretString::new("tok"), 1, 0.000_000_000_01).unwrap_err();
assert!(
err.to_string().contains("freshness window rounds to zero"),
"expected zero-window error, got: {err}"
);
}
#[test]
fn cached_token_full_freshness() {
let ct = CachedToken::new(SecretString::new("tok"), 3600, 1.0).unwrap();
assert_eq!(ct.token_status(), TokenStatus::Fresh);
}
#[test]
fn time_until_stale_positive_when_fresh() {
let ct = CachedToken::new(SecretString::new("tok"), 3600, 0.8).unwrap();
assert!(ct.time_until_stale() > Duration::ZERO);
}
#[test]
fn time_until_expired_positive_when_fresh() {
let ct = CachedToken::new(SecretString::new("tok"), 3600, 0.8).unwrap();
assert!(ct.time_until_expired() > Duration::ZERO);
}
#[test]
fn watcher_config_valid() {
let cfg = WatcherConfig::new(Duration::from_secs(5), Duration::from_secs(1));
assert!(cfg.is_ok());
}
#[test]
fn watcher_config_zero_min_refresh_rejected() {
let err = WatcherConfig::new(Duration::from_secs(5), Duration::ZERO).unwrap_err();
assert!(
err.to_string().contains("min_refresh_period"),
"expected min_refresh_period error, got: {err}"
);
}
#[test]
fn jitter_zero_max_returns_zero() {
assert_eq!(random_jitter(Duration::ZERO), Duration::ZERO);
}
#[test]
fn jitter_within_bounds() {
let max = Duration::from_secs(10);
for _ in 0..100 {
let j = random_jitter(max);
assert!(j < max, "jitter {j:?} must be < {max:?}");
}
}
#[test]
fn jitter_sub_millisecond_does_not_panic() {
let max = Duration::from_nanos(500);
for _ in 0..100 {
let j = random_jitter(max);
assert!(j < max, "jitter {j:?} must be < {max:?}");
}
}
#[test]
fn backoff_first_error() {
let b = compute_backoff(Duration::from_secs(1), Duration::from_mins(1), 2, 1);
assert_eq!(b, Duration::from_secs(1));
}
#[test]
fn backoff_second_error() {
let b = compute_backoff(Duration::from_secs(1), Duration::from_mins(1), 2, 2);
assert_eq!(b, Duration::from_secs(2));
}
#[test]
fn backoff_capped_at_max() {
let b = compute_backoff(Duration::from_secs(1), Duration::from_secs(30), 2, 100);
assert_eq!(b, Duration::from_secs(30));
}
}