1use faucet_core::FaucetError;
7use jsonpath_rust::JsonPath;
8use reqwest::Client;
9use reqwest::header::HeaderMap;
10use serde_json::Value;
11use std::fmt;
12use std::sync::Arc;
13use tokio::sync::Mutex;
14
15#[derive(Clone)]
34pub struct ResponseValidator(Arc<dyn Fn(u16) -> bool + Send + Sync>);
35
36impl ResponseValidator {
37 pub fn new(f: impl Fn(u16) -> bool + Send + Sync + 'static) -> Self {
42 Self(Arc::new(f))
43 }
44
45 pub(crate) fn is_success(&self, status: u16) -> bool {
47 (self.0)(status)
48 }
49}
50
51impl fmt::Debug for ResponseValidator {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 write!(f, "ResponseValidator(<fn>)")
54 }
55}
56
57pub const DEFAULT_TOKEN_ENDPOINT_EXPIRY_RATIO: f64 = 0.9;
59
60#[derive(Debug, Clone)]
62struct CachedToken {
63 token: String,
64 expires_at: Option<tokio::time::Instant>,
65}
66
67impl CachedToken {
68 fn is_valid(&self) -> bool {
69 match self.expires_at {
70 Some(exp) => tokio::time::Instant::now() < exp,
71 None => true,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct TokenEndpointCache(Arc<Mutex<Option<CachedToken>>>);
79
80impl TokenEndpointCache {
81 pub fn new() -> Self {
82 Self(Arc::new(Mutex::new(None)))
83 }
84
85 pub async fn invalidate(&self) {
90 *self.0.lock().await = None;
91 }
92
93 #[allow(clippy::too_many_arguments)]
95 pub async fn get_or_refresh(
96 &self,
97 client: &Client,
98 url: &str,
99 method: &reqwest::Method,
100 headers: &HeaderMap,
101 body: Option<&Value>,
102 token_path: &str,
103 expiry_path: Option<&str>,
104 expiry_ratio: f64,
105 response_validator: Option<&ResponseValidator>,
106 ) -> Result<String, FaucetError> {
107 let mut guard = self.0.lock().await;
108 if let Some(cached) = guard.as_ref() {
109 if cached.is_valid() {
110 return Ok(cached.token.clone());
111 }
112 tracing::debug!("TokenEndpoint token expired; refreshing");
113 }
114
115 let (token, expires_in) = fetch_token(
116 client,
117 url,
118 method,
119 headers,
120 body,
121 token_path,
122 expiry_path,
123 response_validator,
124 )
125 .await?;
126
127 let expires_at = expires_in.map(|secs| {
128 let effective = (secs as f64 * expiry_ratio) as u64;
129 tokio::time::Instant::now() + std::time::Duration::from_secs(effective)
130 });
131
132 *guard = Some(CachedToken {
133 token: token.clone(),
134 expires_at,
135 });
136
137 Ok(token)
138 }
139}
140
141pub async fn fetch_token_from_endpoint(
146 url: &str,
147 method: &reqwest::Method,
148 headers: &HeaderMap,
149 body: Option<&Value>,
150 token_path: &str,
151 response_validator: Option<&ResponseValidator>,
152) -> Result<String, FaucetError> {
153 let client = Client::new();
154 let (token, _) = fetch_token(
155 &client,
156 url,
157 method,
158 headers,
159 body,
160 token_path,
161 None,
162 response_validator,
163 )
164 .await?;
165 Ok(token)
166}
167
168#[allow(clippy::too_many_arguments)]
169async fn fetch_token(
170 client: &Client,
171 url: &str,
172 method: &reqwest::Method,
173 headers: &HeaderMap,
174 body: Option<&Value>,
175 token_path: &str,
176 expiry_path: Option<&str>,
177 response_validator: Option<&ResponseValidator>,
178) -> Result<(String, Option<u64>), FaucetError> {
179 let mut req = client.request(method.clone(), url).headers(headers.clone());
180 if let Some(b) = body {
181 req = req.json(b);
182 }
183
184 let resp = req.send().await?;
185
186 let status = resp.status();
187 let is_success = match response_validator {
188 Some(v) => v.is_success(status.as_u16()),
189 None => status.is_success(),
190 };
191 if !is_success {
192 let status_code = status.as_u16();
193 let body_text = resp.text().await.unwrap_or_default();
194 return Err(FaucetError::Auth(format!(
195 "token endpoint request failed (HTTP {status_code}): {body_text}"
196 )));
197 }
198
199 let resp_body: Value = resp.json().await?;
200
201 let token = extract_string(&resp_body, token_path).ok_or_else(|| {
202 FaucetError::Auth(format!(
203 "token_path '{token_path}' did not match a string value in the response"
204 ))
205 })?;
206
207 let expires_in = expiry_path.and_then(|ep| extract_u64(&resp_body, ep));
208
209 Ok((token, expires_in))
210}
211
212fn extract_string(body: &Value, path: &str) -> Option<String> {
214 let results = body.query(path).ok()?;
215 match results.first()? {
216 Value::String(s) => Some(s.clone()),
217 Value::Number(n) => Some(n.to_string()),
219 _ => None,
220 }
221}
222
223fn extract_u64(body: &Value, path: &str) -> Option<u64> {
225 let results = body.query(path).ok()?;
226 results.first()?.as_u64()
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use serde_json::json;
233
234 #[test]
235 fn extract_string_from_nested_json() {
236 let body = json!({"auth": {"token": "abc123"}});
237 assert_eq!(extract_string(&body, "$.auth.token"), Some("abc123".into()));
238 }
239
240 #[test]
241 fn extract_string_returns_none_for_missing_path() {
242 let body = json!({"auth": {}});
243 assert_eq!(extract_string(&body, "$.auth.token"), None);
244 }
245
246 #[test]
247 fn extract_string_converts_number_to_string() {
248 let body = json!({"token": 12345});
249 assert_eq!(extract_string(&body, "$.token"), Some("12345".into()));
250 }
251
252 #[test]
253 fn extract_u64_from_json() {
254 let body = json!({"expires_in": 3600});
255 assert_eq!(extract_u64(&body, "$.expires_in"), Some(3600));
256 }
257
258 #[test]
259 fn extract_u64_returns_none_for_string() {
260 let body = json!({"expires_in": "not a number"});
261 assert_eq!(extract_u64(&body, "$.expires_in"), None);
262 }
263
264 #[test]
265 fn extract_u64_returns_none_for_missing() {
266 let body = json!({});
267 assert_eq!(extract_u64(&body, "$.expires_in"), None);
268 }
269
270 #[test]
273 fn response_validator_accepts_matching_status() {
274 let v = ResponseValidator::new(|s| s == 200);
275 assert!(v.is_success(200));
276 assert!(!v.is_success(201));
277 }
278
279 #[test]
280 fn response_validator_range_check() {
281 let v = ResponseValidator::new(|s| s < 400);
282 assert!(v.is_success(200));
283 assert!(v.is_success(301));
284 assert!(v.is_success(399));
285 assert!(!v.is_success(400));
286 assert!(!v.is_success(500));
287 }
288
289 #[test]
290 fn response_validator_debug_format() {
291 let v = ResponseValidator::new(|_| true);
292 assert_eq!(format!("{v:?}"), "ResponseValidator(<fn>)");
293 }
294
295 #[test]
296 fn response_validator_clone() {
297 let v = ResponseValidator::new(|s| s == 200);
298 let cloned = v.clone();
299 assert!(cloned.is_success(200));
300 assert!(!cloned.is_success(404));
301 }
302
303 #[test]
306 fn cached_token_without_expiry_is_always_valid() {
307 let token = CachedToken {
308 token: "abc".into(),
309 expires_at: None,
310 };
311 assert!(token.is_valid());
312 }
313
314 #[test]
315 fn cached_token_with_future_expiry_is_valid() {
316 let token = CachedToken {
317 token: "abc".into(),
318 expires_at: Some(tokio::time::Instant::now() + std::time::Duration::from_secs(3600)),
319 };
320 assert!(token.is_valid());
321 }
322
323 #[test]
326 fn extract_string_from_array_path() {
327 let body = json!({"tokens": ["first", "second"]});
328 assert_eq!(extract_string(&body, "$.tokens[0]"), Some("first".into()));
329 }
330
331 #[test]
332 fn extract_string_returns_none_for_object() {
333 let body = json!({"token": {"nested": "value"}});
334 assert_eq!(extract_string(&body, "$.token"), None);
335 }
336
337 #[test]
338 fn extract_string_returns_none_for_null() {
339 let body = json!({"token": null});
340 assert_eq!(extract_string(&body, "$.token"), None);
341 }
342
343 #[test]
344 fn extract_u64_returns_none_for_negative() {
345 let body = json!({"expires_in": -1});
346 assert_eq!(extract_u64(&body, "$.expires_in"), None);
347 }
348
349 #[test]
350 fn extract_u64_returns_none_for_float() {
351 let body = json!({"expires_in": 3600.5});
352 assert_eq!(extract_u64(&body, "$.expires_in"), None);
353 }
354}