1use std::future::Future;
19use std::time::{Duration, Instant};
20
21use base64::Engine;
22use chrono::{DateTime, Utc};
23use serde::Deserialize;
24use tokio::sync::Mutex;
25
26#[derive(Debug, Clone)]
28pub struct TemporaryToken<T> {
29 pub token: T,
31 pub expiry: Option<Instant>,
34}
35
36pub(crate) fn expiry_from(expires: DateTime<Utc>) -> Instant {
38 Instant::now() + (expires - Utc::now()).to_std().unwrap_or_default()
39}
40
41pub(crate) fn jwt_exp_secs(token: &str) -> Option<u64> {
46 #[derive(Deserialize)]
47 struct Claims {
48 exp: u64,
49 }
50 let payload = token.split('.').nth(1)?;
51 let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD
52 .decode(payload.trim_end_matches('='))
53 .ok()?;
54 serde_json::from_slice::<Claims>(&claims)
55 .ok()
56 .map(|claims| claims.exp)
57}
58
59#[derive(Debug)]
80pub struct TokenCache<T> {
81 cache: Mutex<Option<(TemporaryToken<T>, Instant)>>,
82 min_ttl: Duration,
83 fetch_backoff: Duration,
84}
85
86impl<T> Default for TokenCache<T> {
87 fn default() -> Self {
88 Self {
89 cache: Default::default(),
90 min_ttl: Duration::from_secs(300),
91 fetch_backoff: Duration::from_millis(100),
94 }
95 }
96}
97
98impl<T: Clone + Send> TokenCache<T> {
99 pub(crate) fn with_min_ttl(self, min_ttl: Duration) -> Self {
101 Self { min_ttl, ..self }
102 }
103
104 pub async fn get_or_insert_with<F, Fut, E>(&self, f: F) -> Result<T, E>
105 where
106 F: FnOnce() -> Fut + Send,
107 Fut: Future<Output = Result<TemporaryToken<T>, E>> + Send,
108 {
109 let now = Instant::now();
110 let mut locked = self.cache.lock().await;
111
112 if let Some((cached, fetched_at)) = locked.as_ref() {
113 match cached.expiry {
114 Some(ttl) => {
115 let remaining = ttl.checked_duration_since(now);
116 if remaining.unwrap_or_default() > self.min_ttl ||
117 (fetched_at.elapsed() < self.fetch_backoff && remaining.is_some())
120 {
121 return Ok(cached.token.clone());
122 }
123 }
124 None => return Ok(cached.token.clone()),
125 }
126 }
127
128 let cached = f().await?;
129 let token = cached.token.clone();
130 *locked = Some((cached, Instant::now()));
131
132 Ok(token)
133 }
134}
135
136#[cfg(test)]
137mod test {
138 use super::*;
139 use std::sync::Arc;
140 use std::sync::atomic::{AtomicU32, Ordering};
141 use std::time::{Duration, Instant};
142
143 fn create_token(expiry_duration: Option<Duration>) -> TemporaryToken<String> {
145 TemporaryToken {
146 token: "test_token".to_string(),
147 expiry: expiry_duration.map(|d| Instant::now() + d),
148 }
149 }
150
151 #[tokio::test]
152 async fn test_expired_token_is_refreshed() {
153 let cache = TokenCache::default();
154 static COUNTER: AtomicU32 = AtomicU32::new(0);
155
156 async fn get_token() -> Result<TemporaryToken<String>, String> {
157 COUNTER.fetch_add(1, Ordering::SeqCst);
158 Ok::<_, String>(create_token(Some(Duration::from_secs(0))))
159 }
160
161 let _ = cache.get_or_insert_with(get_token).await.unwrap();
163 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
164
165 tokio::time::sleep(Duration::from_millis(2)).await;
166
167 let _ = cache.get_or_insert_with(get_token).await.unwrap();
169 assert_eq!(COUNTER.load(Ordering::SeqCst), 2);
170 }
171
172 #[tokio::test]
173 async fn test_min_ttl_causes_refresh() {
174 let cache = TokenCache {
175 cache: Default::default(),
176 min_ttl: Duration::from_secs(1),
177 fetch_backoff: Duration::from_millis(1),
178 };
179
180 static COUNTER: AtomicU32 = AtomicU32::new(0);
181
182 async fn get_token() -> Result<TemporaryToken<String>, String> {
183 COUNTER.fetch_add(1, Ordering::SeqCst);
184 Ok::<_, String>(create_token(Some(Duration::from_millis(100))))
185 }
186
187 let _ = cache.get_or_insert_with(get_token).await.unwrap();
189 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
190
191 let _ = cache.get_or_insert_with(get_token).await.unwrap();
193 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
194
195 tokio::time::sleep(Duration::from_millis(2)).await;
196
197 let _ = cache.get_or_insert_with(get_token).await.unwrap();
199 assert_eq!(COUNTER.load(Ordering::SeqCst), 2);
200 }
201
202 #[tokio::test]
203 async fn test_valid_token_within_ttl_is_reused() {
204 let cache = TokenCache::default();
207 static COUNTER: AtomicU32 = AtomicU32::new(0);
208
209 async fn get_token() -> Result<TemporaryToken<String>, String> {
210 COUNTER.fetch_add(1, Ordering::SeqCst);
211 Ok::<_, String>(create_token(Some(Duration::from_secs(3600))))
212 }
213
214 let _ = cache.get_or_insert_with(get_token).await.unwrap();
216 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
217
218 for _ in 0..5 {
220 let _ = cache.get_or_insert_with(get_token).await.unwrap();
221 }
222 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
223 }
224
225 #[tokio::test]
226 async fn test_non_expiring_token_is_reused() {
227 let cache = TokenCache::default();
230 static COUNTER: AtomicU32 = AtomicU32::new(0);
231
232 async fn get_token() -> Result<TemporaryToken<String>, String> {
233 COUNTER.fetch_add(1, Ordering::SeqCst);
234 Ok::<_, String>(create_token(None))
235 }
236
237 let _ = cache.get_or_insert_with(get_token).await.unwrap();
238 let _ = cache.get_or_insert_with(get_token).await.unwrap();
239 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
240 }
241
242 #[tokio::test]
243 async fn test_concurrent_callers_single_flight() {
244 let cache = Arc::new(TokenCache::default());
249 static COUNTER: AtomicU32 = AtomicU32::new(0);
250
251 async fn get_token() -> Result<TemporaryToken<String>, String> {
252 COUNTER.fetch_add(1, Ordering::SeqCst);
253 tokio::time::sleep(Duration::from_millis(20)).await;
255 Ok::<_, String>(create_token(Some(Duration::from_secs(3600))))
256 }
257
258 let mut handles = Vec::new();
259 for _ in 0..10 {
260 let cache = Arc::clone(&cache);
261 handles.push(tokio::spawn(async move {
262 cache.get_or_insert_with(get_token).await.unwrap()
263 }));
264 }
265 for h in handles {
266 h.await.unwrap();
267 }
268
269 assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
271 }
272}