tracedb_sdk/core/oauth_token_provider.rs
1use std::future::Future;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4use tokio::sync::Mutex as AsyncMutex;
5
6/// Buffer time in seconds subtracted from token expiration to ensure
7/// we refresh the token before it actually expires.
8const EXPIRATION_BUFFER_SECONDS: u64 = 120; // 2 minutes
9
10/// Default expiry time in seconds used when the OAuth response doesn't include an expires_in value.
11const DEFAULT_EXPIRY_SECONDS: u64 = 3600; // 1 hour fallback
12
13/// Manages OAuth access tokens, including caching and automatic refresh.
14///
15/// This provider implements thread-safe token management with automatic expiration
16/// handling. It uses a double-checked locking pattern to minimize lock contention
17/// while ensuring only one thread fetches a new token at a time.
18///
19/// # Example
20///
21/// ```rust,ignore
22/// use crate::OAuthTokenProvider;
23///
24/// let provider = OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string());
25///
26/// // Get or fetch a token (sync)
27/// let token = provider.get_or_fetch(|| {
28/// // Your token fetching logic here
29/// // Returns (access_token, expires_in_seconds)
30/// Ok(("token".to_string(), Some(3600)))
31/// })?;
32///
33/// // Get or fetch a token (async)
34/// let token = provider.get_or_fetch_async(|| async {
35/// // Your async token fetching logic here
36/// Ok(("token".to_string(), Some(3600)))
37/// }).await?;
38/// ```
39pub struct OAuthTokenProvider {
40 client_id: String,
41 client_secret: String,
42 inner: Mutex<OAuthTokenProviderInner>,
43 /// Separate mutex to ensure only one thread fetches a new token at a time (sync)
44 fetch_lock: Mutex<()>,
45 /// Async mutex for async token fetching
46 async_fetch_lock: AsyncMutex<()>,
47}
48
49struct OAuthTokenProviderInner {
50 access_token: Option<String>,
51 expires_at: Option<Instant>,
52}
53
54impl OAuthTokenProvider {
55 /// Creates a new OAuthTokenProvider with the given credentials.
56 pub fn new(client_id: String, client_secret: String) -> Self {
57 Self {
58 client_id,
59 client_secret,
60 inner: Mutex::new(OAuthTokenProviderInner {
61 access_token: None,
62 expires_at: None,
63 }),
64 fetch_lock: Mutex::new(()),
65 async_fetch_lock: AsyncMutex::new(()),
66 }
67 }
68
69 /// Returns the client ID.
70 pub fn client_id(&self) -> &str {
71 &self.client_id
72 }
73
74 /// Returns the client secret.
75 pub fn client_secret(&self) -> &str {
76 &self.client_secret
77 }
78
79 /// Sets the cached access token and its expiration time.
80 ///
81 /// The `expires_in` parameter is the number of seconds until the token expires.
82 /// A buffer is applied to refresh before actual expiration.
83 pub fn set_token(&self, access_token: String, expires_in: u64) {
84 let mut inner = self.inner.lock().unwrap();
85 inner.access_token = Some(access_token);
86
87 if expires_in > 0 {
88 // Apply buffer to refresh before actual expiration
89 let effective_expires_in = expires_in.saturating_sub(EXPIRATION_BUFFER_SECONDS);
90 inner.expires_at = Some(Instant::now() + Duration::from_secs(effective_expires_in));
91 } else {
92 // No expiration info, token won't auto-refresh based on time
93 inner.expires_at = None;
94 }
95 }
96
97 /// Returns the cached access token if it's still valid.
98 ///
99 /// Returns `None` if the token is expired or not set.
100 pub fn get_token(&self) -> Option<String> {
101 let inner = self.inner.lock().unwrap();
102
103 if let Some(ref token) = inner.access_token {
104 // Check if token is still valid
105 if let Some(expires_at) = inner.expires_at {
106 if Instant::now() < expires_at {
107 return Some(token.clone());
108 }
109 } else {
110 // No expiration set, token is always valid
111 return Some(token.clone());
112 }
113 }
114
115 None
116 }
117
118 /// Returns a valid token, fetching a new one if necessary (synchronous version).
119 ///
120 /// The `fetch_func` is called at most once even if multiple threads call `get_or_fetch`
121 /// concurrently when the token is expired. It should return `(access_token, expires_in_seconds)`.
122 ///
123 /// # Arguments
124 ///
125 /// * `fetch_func` - A function that fetches a new token. Returns `Result<(String, u64), E>`
126 /// where the tuple contains (access_token, expires_in_seconds).
127 ///
128 /// # Example
129 ///
130 /// ```rust,ignore
131 /// let token = provider.get_or_fetch(|| {
132 /// // Call your OAuth endpoint here (sync)
133 /// let response = auth_client.get_token(&provider.client_id(), &provider.client_secret())?;
134 /// Ok((response.access_token, response.expires_in.unwrap_or(3600)))
135 /// })?;
136 /// ```
137 pub fn get_or_fetch<F, E>(&self, fetch_func: F) -> Result<String, E>
138 where
139 F: FnOnce() -> Result<(String, u64), E>,
140 {
141 // Fast path: check if we have a valid token
142 if let Some(token) = self.get_token() {
143 return Ok(token);
144 }
145
146 // Slow path: acquire fetch lock to ensure only one thread fetches
147 let _fetch_guard = self.fetch_lock.lock().unwrap();
148
149 // Double-check after acquiring lock (another thread may have fetched)
150 if let Some(token) = self.get_token() {
151 return Ok(token);
152 }
153
154 // Fetch new token
155 let (access_token, expires_in) = fetch_func()?;
156
157 // Use default expiry if not provided
158 let effective_expires_in = if expires_in > 0 {
159 expires_in
160 } else {
161 DEFAULT_EXPIRY_SECONDS
162 };
163
164 self.set_token(access_token.clone(), effective_expires_in);
165 Ok(access_token)
166 }
167
168 /// Returns a valid token, fetching a new one if necessary (async version).
169 ///
170 /// This is the async version of `get_or_fetch` for use with async token fetching.
171 /// The `fetch_func` is called at most once even if multiple tasks call `get_or_fetch_async`
172 /// concurrently when the token is expired.
173 ///
174 /// # Arguments
175 ///
176 /// * `fetch_func` - An async function that fetches a new token. Returns `Result<(String, u64), E>`
177 /// where the tuple contains (access_token, expires_in_seconds).
178 ///
179 /// # Example
180 ///
181 /// ```rust,ignore
182 /// let token = provider.get_or_fetch_async(|| async {
183 /// // Call your OAuth endpoint here (async)
184 /// let response = auth_client.get_token(&provider.client_id(), &provider.client_secret()).await?;
185 /// Ok((response.access_token, response.expires_in.unwrap_or(3600)))
186 /// }).await?;
187 /// ```
188 pub async fn get_or_fetch_async<F, Fut, E>(&self, fetch_func: F) -> Result<String, E>
189 where
190 F: FnOnce() -> Fut,
191 Fut: Future<Output = Result<(String, u64), E>>,
192 {
193 // Fast path: check if we have a valid token
194 if let Some(token) = self.get_token() {
195 return Ok(token);
196 }
197
198 // Slow path: acquire async fetch lock to ensure only one task fetches
199 let _fetch_guard = self.async_fetch_lock.lock().await;
200
201 // Double-check after acquiring lock (another task may have fetched)
202 if let Some(token) = self.get_token() {
203 return Ok(token);
204 }
205
206 // Fetch new token
207 let (access_token, expires_in) = fetch_func().await?;
208
209 // Use default expiry if not provided
210 let effective_expires_in = if expires_in > 0 {
211 expires_in
212 } else {
213 DEFAULT_EXPIRY_SECONDS
214 };
215
216 self.set_token(access_token.clone(), effective_expires_in);
217 Ok(access_token)
218 }
219
220 /// Returns `true` if the token needs to be refreshed.
221 ///
222 /// This is useful for proactively refreshing tokens before they expire.
223 pub fn needs_refresh(&self) -> bool {
224 let inner = self.inner.lock().unwrap();
225
226 if inner.access_token.is_none() {
227 return true;
228 }
229
230 if let Some(expires_at) = inner.expires_at {
231 if Instant::now() >= expires_at {
232 return true;
233 }
234 }
235
236 false
237 }
238
239 /// Clears the cached token.
240 ///
241 /// This can be used to force a token refresh on the next request.
242 pub fn reset(&self) {
243 let mut inner = self.inner.lock().unwrap();
244 inner.access_token = None;
245 inner.expires_at = None;
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use std::sync::atomic::{AtomicUsize, Ordering};
253 use std::sync::Arc;
254 use std::thread;
255
256 #[test]
257 fn test_new_provider() {
258 let provider =
259 OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string());
260 assert_eq!(provider.client_id(), "client_id");
261 assert_eq!(provider.client_secret(), "client_secret");
262 assert!(provider.get_token().is_none());
263 assert!(provider.needs_refresh());
264 }
265
266 #[test]
267 fn test_set_and_get_token() {
268 let provider =
269 OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string());
270
271 provider.set_token("test_token".to_string(), 3600);
272
273 let token = provider.get_token();
274 assert!(token.is_some());
275 assert_eq!(token.unwrap(), "test_token");
276 assert!(!provider.needs_refresh());
277 }
278
279 #[test]
280 fn test_expired_token() {
281 let provider =
282 OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string());
283
284 // Set token with 0 expiry (will be expired immediately due to buffer)
285 provider.set_token("test_token".to_string(), 1);
286
287 // Token should be expired (1 second - 120 second buffer = expired)
288 assert!(provider.get_token().is_none());
289 assert!(provider.needs_refresh());
290 }
291
292 #[test]
293 fn test_get_or_fetch() {
294 let provider =
295 OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string());
296
297 let result: Result<String, &str> =
298 provider.get_or_fetch(|| Ok(("fetched_token".to_string(), 3600)));
299
300 assert!(result.is_ok());
301 assert_eq!(result.unwrap(), "fetched_token");
302
303 // Second call should return cached token
304 let result2: Result<String, &str> = provider.get_or_fetch(|| {
305 panic!("Should not be called - token is cached");
306 });
307
308 assert!(result2.is_ok());
309 assert_eq!(result2.unwrap(), "fetched_token");
310 }
311
312 #[test]
313 fn test_reset() {
314 let provider =
315 OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string());
316
317 provider.set_token("test_token".to_string(), 3600);
318 assert!(provider.get_token().is_some());
319
320 provider.reset();
321 assert!(provider.get_token().is_none());
322 assert!(provider.needs_refresh());
323 }
324
325 #[test]
326 fn test_concurrent_access() {
327 let provider = Arc::new(OAuthTokenProvider::new(
328 "client_id".to_string(),
329 "client_secret".to_string(),
330 ));
331 let fetch_count = Arc::new(AtomicUsize::new(0));
332
333 let mut handles = vec![];
334
335 for _ in 0..10 {
336 let provider_clone = Arc::clone(&provider);
337 let fetch_count_clone = Arc::clone(&fetch_count);
338
339 let handle = thread::spawn(move || {
340 let result: Result<String, &str> = provider_clone.get_or_fetch(|| {
341 fetch_count_clone.fetch_add(1, Ordering::SeqCst);
342 // Simulate some work
343 thread::sleep(Duration::from_millis(10));
344 Ok(("concurrent_token".to_string(), 3600))
345 });
346
347 assert!(result.is_ok());
348 assert_eq!(result.unwrap(), "concurrent_token");
349 });
350
351 handles.push(handle);
352 }
353
354 for handle in handles {
355 handle.join().unwrap();
356 }
357
358 // Due to double-checked locking, fetch should only be called once
359 // (or at most a few times if threads race before the first fetch completes)
360 let count = fetch_count.load(Ordering::SeqCst);
361 assert!(count >= 1 && count <= 3, "Fetch was called {} times", count);
362 }
363}