Skip to main content

faucet_source_rest/auth/
token_endpoint.rs

1//! Generic token-endpoint authentication with caching.
2//!
3//! Fetches a token from an arbitrary HTTP endpoint, extracts it from the
4//! response via JSONPath, and caches it with optional expiry tracking.
5
6use 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/// Optional callback to decide whether the token endpoint response is
16/// successful.
17///
18/// Receives the HTTP status code and returns `true` if the response should
19/// be treated as successful.  When not provided, the default check is
20/// `status.is_success()` (i.e. 2xx).
21///
22/// # Example
23///
24/// ```
25/// use faucet_source_rest::ResponseValidator;
26///
27/// // Accept 200 and 201 only:
28/// let validator = ResponseValidator::new(|status| status == 200 || status == 201);
29///
30/// // Accept anything below 400:
31/// let validator = ResponseValidator::new(|status| status < 400);
32/// ```
33#[derive(Clone)]
34pub struct ResponseValidator(Arc<dyn Fn(u16) -> bool + Send + Sync>);
35
36impl ResponseValidator {
37    /// Create a new response validator from a closure.
38    ///
39    /// The closure receives the HTTP status code as a `u16` and must
40    /// return `true` if the response should be considered successful.
41    pub fn new(f: impl Fn(u16) -> bool + Send + Sync + 'static) -> Self {
42        Self(Arc::new(f))
43    }
44
45    /// Evaluate the validator against a status code.
46    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
57/// Default fraction of `expires_in` after which the token is refreshed.
58pub const DEFAULT_TOKEN_ENDPOINT_EXPIRY_RATIO: f64 = 0.9;
59
60/// Cached token with expiration tracking.
61#[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/// Thread-safe token cache for `Auth::TokenEndpoint`.
77#[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    /// Drop any cached token so the next [`get_or_refresh`](Self::get_or_refresh)
86    /// fetches a fresh one. Called when the API rejects the current token with a
87    /// 401 — a server-side expiry the time-based `is_valid` check cannot detect
88    /// (F57).
89    pub async fn invalidate(&self) {
90        *self.0.lock().await = None;
91    }
92
93    /// Return a valid cached token or fetch a new one from the endpoint.
94    #[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
141/// Fetch a token from the given endpoint and extract it using JSONPath.
142///
143/// This is the public one-shot variant for callers who want to fetch a token
144/// without caching (e.g. for use with `Auth::Bearer`).
145pub 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
212/// Extract a single string value from a JSON body using a JSONPath expression.
213fn 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        // Accept numbers/bools as tokens by converting to string.
218        Value::Number(n) => Some(n.to_string()),
219        _ => None,
220    }
221}
222
223/// Extract a single u64 value from a JSON body using a JSONPath expression.
224fn 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    // ── ResponseValidator tests ──────────────────────────────────────────────
271
272    #[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    // ── CachedToken tests ────────────────────────────────────────────────────
304
305    #[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    // ── extract edge cases ───────────────────────────────────────────────────
324
325    #[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}