1use 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
19pub const DEFAULT_EXPIRY_RATIO: f64 = 0.9;
21
22#[derive(Debug, Clone)]
24struct CachedToken {
25 access_token: String,
26 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#[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 pub async fn invalidate(&self) {
56 *self.0.lock().await = None;
57 }
58
59 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
104pub 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 .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 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 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 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}