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 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
96pub 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 .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 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 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 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}