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