Skip to main content

faucet_source_rest/auth/
oauth2.rs

1//! OAuth2 client credentials flow with token caching.
2
3use faucet_core::FaucetError;
4use reqwest::Client;
5use serde::Deserialize;
6use std::sync::Arc;
7use tokio::sync::Mutex;
8
9#[derive(Debug, Deserialize)]
10struct TokenResponse {
11    access_token: String,
12    #[serde(default)]
13    expires_in: Option<u64>,
14    #[allow(dead_code)]
15    #[serde(default)]
16    token_type: Option<String>,
17}
18
19/// Default fraction of `expires_in` after which the token is refreshed.
20pub const DEFAULT_EXPIRY_RATIO: f64 = 0.9;
21
22/// Cached OAuth2 token with expiration tracking.
23#[derive(Debug, Clone)]
24struct CachedToken {
25    access_token: String,
26    /// Instant at which the token should be considered expired. Computed as
27    /// `now + expires_in * expiry_ratio` at fetch time.  `None` means no
28    /// expiry info was provided by the server — the token is treated as valid
29    /// indefinitely (until a 401 forces a refresh).
30    expires_at: Option<tokio::time::Instant>,
31}
32
33impl CachedToken {
34    fn is_valid(&self) -> bool {
35        match self.expires_at {
36            Some(exp) => tokio::time::Instant::now() < exp,
37            None => true,
38        }
39    }
40}
41
42/// Thread-safe token cache shared across requests within a single `RestStream`.
43#[derive(Debug, Clone, Default)]
44pub struct TokenCache(Arc<Mutex<Option<CachedToken>>>);
45
46impl TokenCache {
47    pub fn new() -> Self {
48        Self(Arc::new(Mutex::new(None)))
49    }
50
51    /// Drop any cached token so the next [`get_or_refresh`](Self::get_or_refresh)
52    /// fetches a fresh one. Called when the API rejects the current token with a
53    /// 401 — a server-side expiry (or a token with no `expires_in`, cached as
54    /// valid forever) that the time-based `is_valid` check cannot detect (F57).
55    pub async fn invalidate(&self) {
56        *self.0.lock().await = None;
57    }
58
59    /// Return a valid cached token or fetch a new one.
60    ///
61    /// `expiry_ratio` is the fraction of the server-reported `expires_in`
62    /// lifetime after which the token is proactively refreshed. For example,
63    /// `0.9` means a token with `expires_in = 3600` is refreshed after 3240 s.
64    pub async fn get_or_refresh(
65        &self,
66        client: &Client,
67        token_url: &str,
68        client_id: &str,
69        client_secret: &str,
70        scopes: &[String],
71        expiry_ratio: f64,
72    ) -> Result<String, FaucetError> {
73        let mut guard = self.0.lock().await;
74        if let Some(cached) = guard.as_ref() {
75            if cached.is_valid() {
76                return Ok(cached.access_token.clone());
77            }
78            tracing::debug!("OAuth2 token expired; refreshing");
79        }
80
81        let (token, expires_in) = fetch_oauth2_token_inner_with_client(
82            client,
83            token_url,
84            client_id,
85            client_secret,
86            scopes,
87        )
88        .await?;
89
90        let expires_at = expires_in.map(|secs| {
91            let effective = (secs as f64 * expiry_ratio) as u64;
92            tokio::time::Instant::now() + std::time::Duration::from_secs(effective)
93        });
94
95        *guard = Some(CachedToken {
96            access_token: token.clone(),
97            expires_at,
98        });
99
100        Ok(token)
101    }
102}
103
104/// Fetch an OAuth2 token using the client credentials grant.
105///
106/// Prefer using [`TokenCache::get_or_refresh`] to avoid fetching a new token
107/// on every request.
108pub async fn fetch_oauth2_token(
109    token_url: &str,
110    client_id: &str,
111    client_secret: &str,
112    scopes: &[String],
113) -> Result<String, FaucetError> {
114    let (token, _) = fetch_oauth2_token_inner(token_url, client_id, client_secret, scopes).await?;
115    Ok(token)
116}
117
118async fn fetch_oauth2_token_inner(
119    token_url: &str,
120    client_id: &str,
121    client_secret: &str,
122    scopes: &[String],
123) -> Result<(String, Option<u64>), FaucetError> {
124    let client = Client::new();
125    fetch_oauth2_token_inner_with_client(&client, token_url, client_id, client_secret, scopes).await
126}
127
128async fn fetch_oauth2_token_inner_with_client(
129    client: &Client,
130    token_url: &str,
131    client_id: &str,
132    client_secret: &str,
133    scopes: &[String],
134) -> Result<(String, Option<u64>), FaucetError> {
135    let resp = client
136        .post(token_url)
137        .form(&[
138            ("grant_type", "client_credentials"),
139            ("client_id", client_id),
140            ("client_secret", client_secret),
141            ("scope", &scopes.join(" ")),
142        ])
143        .send()
144        .await?;
145
146    if !resp.status().is_success() {
147        let status = resp.status().as_u16();
148        let body = resp.text().await.unwrap_or_default();
149        return Err(FaucetError::Auth(format!(
150            "OAuth2 token request failed (HTTP {status}): {body}"
151        )));
152    }
153
154    let token_resp: TokenResponse = resp.json().await?;
155    Ok((token_resp.access_token, token_resp.expires_in))
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use wiremock::matchers::{body_string_contains, method, path};
162    use wiremock::{Mock, MockServer, ResponseTemplate};
163
164    #[tokio::test]
165    async fn fetch_oauth2_token_returns_access_token_on_success() {
166        let server = MockServer::start().await;
167        Mock::given(method("POST"))
168            .and(path("/token"))
169            // The client-credentials grant must POST the form fields.
170            .and(body_string_contains("grant_type=client_credentials"))
171            .and(body_string_contains("scope=read+write"))
172            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
173                "access_token": "abc123",
174                "expires_in": 3600,
175                "token_type": "Bearer"
176            })))
177            .expect(1)
178            .mount(&server)
179            .await;
180        let url = format!("{}/token", server.uri());
181        let token = fetch_oauth2_token(&url, "id", "secret", &["read".into(), "write".into()])
182            .await
183            .expect("token fetch should succeed");
184        assert_eq!(token, "abc123");
185    }
186
187    #[tokio::test]
188    async fn fetch_oauth2_token_maps_error_status_to_auth_error_with_body() {
189        let server = MockServer::start().await;
190        Mock::given(method("POST"))
191            .respond_with(ResponseTemplate::new(401).set_body_string("invalid_client"))
192            .mount(&server)
193            .await;
194        let url = format!("{}/token", server.uri());
195        let err = fetch_oauth2_token(&url, "id", "bad", &[])
196            .await
197            .expect_err("401 must surface as an error");
198        match err {
199            FaucetError::Auth(msg) => {
200                assert!(msg.contains("HTTP 401"), "status must be in message: {msg}");
201                assert!(
202                    msg.contains("invalid_client"),
203                    "error body must be included: {msg}"
204                );
205            }
206            other => panic!("expected FaucetError::Auth, got {other:?}"),
207        }
208    }
209
210    #[tokio::test]
211    async fn get_or_refresh_caches_token_across_calls() {
212        let server = MockServer::start().await;
213        // `.expect(1)` asserts the network is hit exactly once even though
214        // get_or_refresh is called twice — proving the cache-hit branch.
215        Mock::given(method("POST"))
216            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
217                "access_token": "cached-token",
218                "expires_in": 3600
219            })))
220            .expect(1)
221            .mount(&server)
222            .await;
223        let url = format!("{}/token", server.uri());
224        let client = Client::new();
225        let cache = TokenCache::new();
226        let t1 = cache
227            .get_or_refresh(&client, &url, "id", "secret", &[], DEFAULT_EXPIRY_RATIO)
228            .await
229            .unwrap();
230        let t2 = cache
231            .get_or_refresh(&client, &url, "id", "secret", &[], DEFAULT_EXPIRY_RATIO)
232            .await
233            .unwrap();
234        assert_eq!(t1, "cached-token");
235        assert_eq!(t2, "cached-token");
236    }
237
238    #[tokio::test(start_paused = true)]
239    async fn get_or_refresh_refetches_after_proactive_expiry() {
240        let server = MockServer::start().await;
241        // expires_in=100, ratio=0.9 → token considered expired after 90s.
242        // Two fetches expected: initial, then after the clock advances past 90s.
243        Mock::given(method("POST"))
244            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
245                "access_token": "rotating",
246                "expires_in": 100
247            })))
248            .expect(2)
249            .mount(&server)
250            .await;
251        let url = format!("{}/token", server.uri());
252        let client = Client::new();
253        let cache = TokenCache::new();
254        cache
255            .get_or_refresh(&client, &url, "id", "s", &[], 0.9)
256            .await
257            .unwrap();
258        tokio::time::advance(std::time::Duration::from_secs(91)).await;
259        cache
260            .get_or_refresh(&client, &url, "id", "s", &[], 0.9)
261            .await
262            .unwrap();
263    }
264
265    #[tokio::test]
266    async fn get_or_refresh_treats_missing_expiry_as_valid_indefinitely() {
267        let server = MockServer::start().await;
268        // No `expires_in` → expires_at = None → is_valid()'s None arm keeps the
269        // token forever, so the second call must not re-hit the network.
270        Mock::given(method("POST"))
271            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
272                "access_token": "no-expiry"
273            })))
274            .expect(1)
275            .mount(&server)
276            .await;
277        let url = format!("{}/token", server.uri());
278        let client = Client::new();
279        let cache = TokenCache::new();
280        let t1 = cache
281            .get_or_refresh(&client, &url, "i", "s", &[], 0.9)
282            .await
283            .unwrap();
284        let t2 = cache
285            .get_or_refresh(&client, &url, "i", "s", &[], 0.9)
286            .await
287            .unwrap();
288        assert_eq!(t1, "no-expiry");
289        assert_eq!(t2, "no-expiry");
290    }
291
292    #[tokio::test]
293    async fn get_or_refresh_propagates_server_error() {
294        let server = MockServer::start().await;
295        Mock::given(method("POST"))
296            .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
297            .mount(&server)
298            .await;
299        let url = format!("{}/token", server.uri());
300        let client = Client::new();
301        let cache = TokenCache::new();
302        let err = cache
303            .get_or_refresh(&client, &url, "i", "s", &[], 0.9)
304            .await
305            .expect_err("5xx must propagate");
306        assert!(matches!(err, FaucetError::Auth(_)));
307    }
308
309    #[test]
310    fn cached_token_without_expiry_is_always_valid() {
311        let token = CachedToken {
312            access_token: "x".into(),
313            expires_at: None,
314        };
315        assert!(token.is_valid());
316    }
317
318    #[tokio::test(start_paused = true)]
319    async fn cached_token_with_future_expiry_is_valid_until_it_passes() {
320        let token = CachedToken {
321            access_token: "x".into(),
322            expires_at: Some(tokio::time::Instant::now() + std::time::Duration::from_secs(60)),
323        };
324        assert!(token.is_valid(), "still inside the window");
325        tokio::time::advance(std::time::Duration::from_secs(61)).await;
326        assert!(
327            !token.is_valid(),
328            "expired once the clock passes expires_at"
329        );
330    }
331}