Skip to main content

olai_http/
token.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use 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/// A temporary authentication token with an associated expiry
27#[derive(Debug, Clone)]
28pub struct TemporaryToken<T> {
29    /// The temporary credential
30    pub token: T,
31    /// The instant at which this credential is no longer valid
32    /// None means the credential does not expire
33    pub expiry: Option<Instant>,
34}
35
36/// The instant `expires` falls at, or now if it has already passed.
37pub(crate) fn expiry_from(expires: DateTime<Utc>) -> Instant {
38    Instant::now() + (expires - Utc::now()).to_std().unwrap_or_default()
39}
40
41/// The `exp` claim of `token` in seconds since the Unix epoch, if `token` is a JWT
42/// with one.
43///
44/// The signature is not verified: the expiry only decides when to refresh.
45pub(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/// Thread-safe cache for a [`TemporaryToken`] that proactively refreshes before
60/// the token expires.
61///
62/// ## Min-TTL strategy
63///
64/// A cached token is considered *still valid* only when its remaining lifetime
65/// exceeds `min_ttl` (default **5 minutes**).  Once the remaining lifetime
66/// drops below that threshold the next call to [`get_or_insert_with`] will
67/// trigger a synchronous re-fetch — while the lock is held — before returning
68/// the new token.  This ensures callers always receive a token with a
69/// comfortable margin before it expires, avoiding races where a token is used
70/// just as it becomes invalid.
71///
72/// An additional `fetch_backoff` guard (default **100 ms**) prevents a
73/// thundering-herd of re-fetch attempts in the window where the token is below
74/// `min_ttl` but has not yet expired: if the previous fetch happened within
75/// `fetch_backoff` and the token has not actually expired yet, the stale-but-
76/// not-yet-expired token is returned immediately.
77///
78/// [`get_or_insert_with`]: TokenCache::get_or_insert_with
79#[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            // How long to wait before re-attempting a token fetch after receiving one that
92            // is still within the min-ttl
93            fetch_backoff: Duration::from_millis(100),
94        }
95    }
96}
97
98impl<T: Clone + Send> TokenCache<T> {
99    /// Override the minimum remaining TTL for a cached token to be used
100    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                        // if we've recently attempted to fetch this token and it's not actually
118                        // expired, we'll wait to re-fetch it and return the cached one
119                        (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    // Helper function to create a token with a specific expiry duration from now
144    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        // Should fetch initial token
162        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        // Token is expired, so should fetch again
168        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        // Initial fetch
188        let _ = cache.get_or_insert_with(get_token).await.unwrap();
189        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
190
191        // Should not fetch again since not expired and within fetch_backoff
192        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        // Should fetch, since we've passed fetch_backoff
198        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        // Default min_ttl is 5 minutes; a token valid for an hour is comfortably
205        // within its window and must be served from cache, never re-fetched.
206        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        // First call performs the fetch.
215        let _ = cache.get_or_insert_with(get_token).await.unwrap();
216        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
217
218        // Several subsequent calls all hit the cache.
219        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        // A token with `expiry == None` never expires and must always be served
228        // from cache after the initial fetch.
229        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        // `get_or_insert_with` holds the cache mutex across the fetch future, so
245        // concurrent callers serialize: the first performs the (slow) fetch and
246        // stores a token comfortably within `min_ttl`, and the rest observe the
247        // freshly-cached token and return it without re-fetching.
248        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            // Simulate a slow network fetch so callers genuinely overlap.
254            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        // Exactly one fetch despite ten concurrent callers.
270        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
271    }
272}