#![allow(unused_assignments)]
use std::fmt;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct OAuth2Token {
access_token: String,
#[zeroize(skip)]
expires_at: Option<Instant>,
refresh_token: Option<String>,
}
impl OAuth2Token {
pub fn new(access_token: impl Into<String>) -> Self {
Self {
access_token: access_token.into(),
expires_at: None,
refresh_token: None,
}
}
pub fn with_expiry(access_token: impl Into<String>, expires_in: Duration) -> Self {
Self {
access_token: access_token.into(),
expires_at: Some(Instant::now() + expires_in),
refresh_token: None,
}
}
#[must_use]
pub fn with_refresh_token(mut self, refresh_token: impl Into<String>) -> Self {
self.refresh_token = Some(refresh_token.into());
self
}
pub fn access_token(&self) -> &str {
&self.access_token
}
pub fn refresh_token(&self) -> Option<&str> {
self.refresh_token.as_deref()
}
pub fn is_expired(&self) -> bool {
self.expires_at.is_some_and(|exp| Instant::now() >= exp)
}
pub fn should_refresh(&self, threshold: Duration) -> bool {
self.expires_at
.is_some_and(|exp| Instant::now() + threshold >= exp)
}
pub fn time_until_expiry(&self) -> Option<Duration> {
self.expires_at.and_then(|exp| {
let now = Instant::now();
if now >= exp { None } else { Some(exp - now) }
})
}
}
impl fmt::Debug for OAuth2Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuth2Token")
.field("access_token", &"[REDACTED]")
.field("expires_at", &self.expires_at)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
#[derive(Debug, Clone, Default)]
pub struct TokenCache {
inner: Arc<RwLock<Option<OAuth2Token>>>,
}
impl TokenCache {
pub fn new() -> Self {
Self::default()
}
pub fn with_token(token: OAuth2Token) -> Self {
Self {
inner: Arc::new(RwLock::new(Some(token))),
}
}
pub async fn get(&self) -> Option<OAuth2Token> {
let guard = self.inner.read().await;
guard.as_ref().filter(|t| !t.is_expired()).cloned()
}
pub async fn should_refresh(&self, threshold: Duration) -> bool {
let guard = self.inner.read().await;
match guard.as_ref() {
None => true,
Some(token) => token.should_refresh(threshold),
}
}
pub async fn set(&self, token: OAuth2Token) {
let mut guard = self.inner.write().await;
*guard = Some(token);
}
#[cfg_attr(not(test), allow(dead_code))]
pub async fn clear(&self) {
let mut guard = self.inner.write().await;
*guard = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_create_token() {
let token = OAuth2Token::new("access-token-123");
assert_eq!(token.access_token(), "access-token-123");
assert!(token.refresh_token().is_none());
assert!(!token.is_expired());
}
#[test]
fn should_create_token_with_expiry() {
let token = OAuth2Token::with_expiry("token", Duration::from_secs(3600));
assert!(!token.is_expired());
assert!(token.time_until_expiry().is_some());
}
#[test]
fn should_detect_expired_token() {
let token = OAuth2Token::with_expiry("token", Duration::ZERO);
assert!(token.is_expired());
}
#[test]
fn should_detect_refresh_needed() {
let token = OAuth2Token::with_expiry("token", Duration::from_secs(30));
assert!(token.should_refresh(Duration::from_secs(60)));
assert!(!token.should_refresh(Duration::from_secs(10)));
}
#[test]
fn should_add_refresh_token() {
let token = OAuth2Token::new("access").with_refresh_token("refresh");
assert_eq!(token.refresh_token(), Some("refresh"));
}
#[test]
fn should_redact_debug_output() {
let token = OAuth2Token::new("secret-token").with_refresh_token("secret-refresh");
let debug_str = format!("{token:?}");
assert!(debug_str.contains("[REDACTED]"));
assert!(!debug_str.contains("secret-token"));
assert!(!debug_str.contains("secret-refresh"));
}
#[tokio::test]
async fn should_cache_token() {
let cache = TokenCache::new();
assert!(cache.get().await.is_none());
let token = OAuth2Token::new("cached-token");
cache.set(token).await;
let cached = cache.get().await.expect("Token should be cached");
assert_eq!(cached.access_token(), "cached-token");
}
#[tokio::test]
async fn should_not_return_expired_token() {
let cache = TokenCache::new();
let token = OAuth2Token::with_expiry("expired", Duration::ZERO);
cache.set(token).await;
assert!(cache.get().await.is_none());
}
#[tokio::test]
async fn should_clear_cache() {
let cache = TokenCache::new();
cache.set(OAuth2Token::new("token")).await;
assert!(cache.get().await.is_some());
cache.clear().await;
assert!(cache.get().await.is_none());
}
#[tokio::test]
async fn should_detect_refresh_needed_in_cache() {
let cache = TokenCache::new();
assert!(cache.should_refresh(Duration::from_secs(60)).await);
let token = OAuth2Token::with_expiry("token", Duration::from_secs(30));
cache.set(token).await;
assert!(cache.should_refresh(Duration::from_secs(60)).await);
let token = OAuth2Token::with_expiry("token", Duration::from_secs(3600));
cache.set(token).await;
assert!(!cache.should_refresh(Duration::from_secs(60)).await);
}
}