clawspec_core/client/oauth2/
token.rs

1//! OAuth2 token types and caching.
2
3// Note: The `unused_assignments` allow is needed because the `Zeroize`/`ZeroizeOnDrop`
4// derive macros generate code that clippy incorrectly flags as unused assignment for the
5// `expires_at` field (which is marked with `#[zeroize(skip)]`). The field IS used in
6// `is_expired()`, `should_refresh()`, and `time_until_expiry()` methods.
7#![allow(unused_assignments)]
8
9use std::fmt;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use tokio::sync::RwLock;
14use zeroize::{Zeroize, ZeroizeOnDrop};
15
16/// An OAuth2 access token with expiration tracking.
17#[derive(Clone, Zeroize, ZeroizeOnDrop)]
18pub struct OAuth2Token {
19    /// The access token value.
20    access_token: String,
21    /// When the token expires (if known).
22    #[zeroize(skip)]
23    expires_at: Option<Instant>,
24    /// Optional refresh token for obtaining new access tokens.
25    refresh_token: Option<String>,
26}
27
28impl OAuth2Token {
29    /// Creates a new OAuth2 token.
30    pub fn new(access_token: impl Into<String>) -> Self {
31        Self {
32            access_token: access_token.into(),
33            expires_at: None,
34            refresh_token: None,
35        }
36    }
37
38    /// Creates a new OAuth2 token with an expiration time.
39    pub fn with_expiry(access_token: impl Into<String>, expires_in: Duration) -> Self {
40        Self {
41            access_token: access_token.into(),
42            expires_at: Some(Instant::now() + expires_in),
43            refresh_token: None,
44        }
45    }
46
47    /// Sets the refresh token.
48    #[must_use]
49    pub fn with_refresh_token(mut self, refresh_token: impl Into<String>) -> Self {
50        self.refresh_token = Some(refresh_token.into());
51        self
52    }
53
54    /// Returns the access token value.
55    pub fn access_token(&self) -> &str {
56        &self.access_token
57    }
58
59    /// Returns the refresh token if available.
60    pub fn refresh_token(&self) -> Option<&str> {
61        self.refresh_token.as_deref()
62    }
63
64    /// Checks if the token is expired.
65    ///
66    /// Returns `false` if the token has no expiration time.
67    pub fn is_expired(&self) -> bool {
68        self.expires_at.is_some_and(|exp| Instant::now() >= exp)
69    }
70
71    /// Checks if the token should be refreshed.
72    ///
73    /// Returns `true` if the token will expire within the given threshold.
74    pub fn should_refresh(&self, threshold: Duration) -> bool {
75        self.expires_at
76            .is_some_and(|exp| Instant::now() + threshold >= exp)
77    }
78
79    /// Returns the time until expiration, if known.
80    pub fn time_until_expiry(&self) -> Option<Duration> {
81        self.expires_at.and_then(|exp| {
82            let now = Instant::now();
83            if now >= exp { None } else { Some(exp - now) }
84        })
85    }
86}
87
88impl fmt::Debug for OAuth2Token {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.debug_struct("OAuth2Token")
91            .field("access_token", &"[REDACTED]")
92            .field("expires_at", &self.expires_at)
93            .field(
94                "refresh_token",
95                &self.refresh_token.as_ref().map(|_| "[REDACTED]"),
96            )
97            .finish()
98    }
99}
100
101/// Thread-safe cache for OAuth2 tokens.
102///
103/// This cache ensures that only one token refresh happens at a time,
104/// even when multiple requests are made concurrently.
105#[derive(Debug, Clone, Default)]
106pub struct TokenCache {
107    inner: Arc<RwLock<Option<OAuth2Token>>>,
108}
109
110impl TokenCache {
111    /// Creates a new empty token cache.
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Creates a token cache with an initial token.
117    pub fn with_token(token: OAuth2Token) -> Self {
118        Self {
119            inner: Arc::new(RwLock::new(Some(token))),
120        }
121    }
122
123    /// Returns the cached token if it exists and is not expired.
124    pub async fn get(&self) -> Option<OAuth2Token> {
125        let guard = self.inner.read().await;
126        guard.as_ref().filter(|t| !t.is_expired()).cloned()
127    }
128
129    /// Returns `true` if the token should be refreshed.
130    ///
131    /// A token should be refreshed if:
132    /// - No token is cached
133    /// - The token is expired
134    /// - The token will expire within the threshold
135    pub async fn should_refresh(&self, threshold: Duration) -> bool {
136        let guard = self.inner.read().await;
137        match guard.as_ref() {
138            None => true,
139            Some(token) => token.should_refresh(threshold),
140        }
141    }
142
143    /// Stores a new token in the cache.
144    pub async fn set(&self, token: OAuth2Token) {
145        let mut guard = self.inner.write().await;
146        *guard = Some(token);
147    }
148
149    /// Clears the cached token.
150    #[cfg_attr(not(test), allow(dead_code))]
151    pub async fn clear(&self) {
152        let mut guard = self.inner.write().await;
153        *guard = None;
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn should_create_token() {
163        let token = OAuth2Token::new("access-token-123");
164        assert_eq!(token.access_token(), "access-token-123");
165        assert!(token.refresh_token().is_none());
166        assert!(!token.is_expired());
167    }
168
169    #[test]
170    fn should_create_token_with_expiry() {
171        let token = OAuth2Token::with_expiry("token", Duration::from_secs(3600));
172        assert!(!token.is_expired());
173        assert!(token.time_until_expiry().is_some());
174    }
175
176    #[test]
177    fn should_detect_expired_token() {
178        let token = OAuth2Token::with_expiry("token", Duration::ZERO);
179        // Token created with zero duration is immediately expired
180        assert!(token.is_expired());
181    }
182
183    #[test]
184    fn should_detect_refresh_needed() {
185        // Token expires in 30 seconds
186        let token = OAuth2Token::with_expiry("token", Duration::from_secs(30));
187
188        // Should refresh if threshold is 60 seconds
189        assert!(token.should_refresh(Duration::from_secs(60)));
190
191        // Should not refresh if threshold is 10 seconds
192        assert!(!token.should_refresh(Duration::from_secs(10)));
193    }
194
195    #[test]
196    fn should_add_refresh_token() {
197        let token = OAuth2Token::new("access").with_refresh_token("refresh");
198        assert_eq!(token.refresh_token(), Some("refresh"));
199    }
200
201    #[test]
202    fn should_redact_debug_output() {
203        let token = OAuth2Token::new("secret-token").with_refresh_token("secret-refresh");
204        let debug_str = format!("{token:?}");
205        assert!(debug_str.contains("[REDACTED]"));
206        assert!(!debug_str.contains("secret-token"));
207        assert!(!debug_str.contains("secret-refresh"));
208    }
209
210    #[tokio::test]
211    async fn should_cache_token() {
212        let cache = TokenCache::new();
213        assert!(cache.get().await.is_none());
214
215        let token = OAuth2Token::new("cached-token");
216        cache.set(token).await;
217
218        let cached = cache.get().await.expect("Token should be cached");
219        assert_eq!(cached.access_token(), "cached-token");
220    }
221
222    #[tokio::test]
223    async fn should_not_return_expired_token() {
224        let cache = TokenCache::new();
225        let token = OAuth2Token::with_expiry("expired", Duration::ZERO);
226        cache.set(token).await;
227
228        // Should return None for expired token
229        assert!(cache.get().await.is_none());
230    }
231
232    #[tokio::test]
233    async fn should_clear_cache() {
234        let cache = TokenCache::new();
235        cache.set(OAuth2Token::new("token")).await;
236        assert!(cache.get().await.is_some());
237
238        cache.clear().await;
239        assert!(cache.get().await.is_none());
240    }
241
242    #[tokio::test]
243    async fn should_detect_refresh_needed_in_cache() {
244        let cache = TokenCache::new();
245
246        // Empty cache needs refresh
247        assert!(cache.should_refresh(Duration::from_secs(60)).await);
248
249        // Token expiring soon needs refresh
250        let token = OAuth2Token::with_expiry("token", Duration::from_secs(30));
251        cache.set(token).await;
252        assert!(cache.should_refresh(Duration::from_secs(60)).await);
253
254        // Token with plenty of time doesn't need refresh
255        let token = OAuth2Token::with_expiry("token", Duration::from_secs(3600));
256        cache.set(token).await;
257        assert!(!cache.should_refresh(Duration::from_secs(60)).await);
258    }
259}