Skip to main content

faucet_auth/
token_endpoint.rs

1//! Generic token-endpoint provider: fetch a token from an arbitrary HTTP
2//! endpoint and extract it from the JSON response via JSONPath.
3
4use async_trait::async_trait;
5use faucet_core::{AuthProvider, Credential, FaucetError};
6use jsonpath_rust::JsonPath;
7use reqwest::Client;
8use serde_json::Value;
9use tokio::sync::Mutex;
10use tokio::time::Instant;
11
12use crate::expiry_instant;
13
14#[derive(Default)]
15struct CachedToken {
16    token: Option<String>,
17    expires_at: Option<Instant>,
18}
19
20/// How the fetched token is applied to each request.
21///
22/// The default is `Authorization: Bearer <token>`. `apply_as` overrides this to
23/// place the token into an arbitrary header via a template (e.g. a session
24/// cookie), yielding a [`Credential::Header`] the REST source applies verbatim.
25#[derive(Debug, Clone)]
26enum ApplyAs {
27    /// Standard `Authorization: Bearer <token>`.
28    Bearer,
29    /// A custom header whose value is `template` with `{token}` substituted.
30    Header { name: String, template: String },
31}
32
33/// Content type of the token request body.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum BodyEncoding {
36    /// `Content-Type: application/json` (the default).
37    Json,
38    /// `application/x-www-form-urlencoded` — required by OAuth token endpoints
39    /// (e.g. Azure AD v1 `/oauth2/token` with a `resource=` param).
40    Form,
41}
42
43/// Fetches a token from an arbitrary endpoint, extracts it via `token_path`
44/// (JSONPath), and caches it with optional expiry tracking. Single-flight
45/// refresh via an internal [`Mutex`].
46pub struct TokenEndpointProvider {
47    http: Client,
48    url: String,
49    method: reqwest::Method,
50    body: Option<Value>,
51    encoding: BodyEncoding,
52    token_path: String,
53    expiry_path: Option<String>,
54    expiry_ratio: f64,
55    apply_as: ApplyAs,
56    state: Mutex<CachedToken>,
57}
58
59// Hand-written so `{:?}` never prints the cached token in `state` or the request
60// `body` (which can carry a `client_secret`). `finish_non_exhaustive` omits both.
61impl std::fmt::Debug for TokenEndpointProvider {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("TokenEndpointProvider")
64            .field("url", &self.url)
65            .field("method", &self.method)
66            .field("encoding", &self.encoding)
67            .field("token_path", &self.token_path)
68            .field("expiry_path", &self.expiry_path)
69            .field("expiry_ratio", &self.expiry_ratio)
70            .field("apply_as", &self.apply_as)
71            .finish_non_exhaustive()
72    }
73}
74
75impl TokenEndpointProvider {
76    /// Build from a config object with `url`, optional `method` (default `POST`),
77    /// optional `body`, `token_path` (JSONPath), optional `expiry_path`, and
78    /// optional `expiry_ratio`.
79    pub fn from_config(config: &Value) -> Result<Self, FaucetError> {
80        let url = config
81            .get("url")
82            .and_then(Value::as_str)
83            .ok_or_else(|| {
84                FaucetError::Config("token_endpoint auth provider: missing `url`".into())
85            })?
86            .to_string();
87        let method = config
88            .get("method")
89            .and_then(Value::as_str)
90            .unwrap_or("POST")
91            .parse::<reqwest::Method>()
92            .map_err(|e| FaucetError::Config(format!("token_endpoint: invalid method: {e}")))?;
93        let token_path = config
94            .get("token_path")
95            .and_then(Value::as_str)
96            .ok_or_else(|| {
97                FaucetError::Config("token_endpoint auth provider: missing `token_path`".into())
98            })?
99            .to_string();
100        let encoding = match config.get("encoding").and_then(Value::as_str) {
101            None | Some("json") => BodyEncoding::Json,
102            Some("form") => BodyEncoding::Form,
103            Some(other) => {
104                return Err(FaucetError::Config(format!(
105                    "token_endpoint: invalid `encoding` {other:?} (expected \"json\" or \"form\")"
106                )));
107            }
108        };
109        let apply_as = parse_apply_as(config)?;
110        Ok(Self {
111            http: crate::auth_http_client(),
112            url,
113            method,
114            body: config.get("body").cloned().filter(|v| !v.is_null()),
115            encoding,
116            token_path,
117            expiry_path: config
118                .get("expiry_path")
119                .and_then(Value::as_str)
120                .map(str::to_string),
121            expiry_ratio: crate::parse_expiry_ratio(config)?,
122            apply_as,
123            state: Mutex::new(CachedToken::default()),
124        })
125    }
126
127    /// Wrap a raw token string into the configured [`Credential`] shape.
128    fn make_credential(&self, token: String) -> Credential {
129        match &self.apply_as {
130            ApplyAs::Bearer => Credential::Bearer(token),
131            ApplyAs::Header { name, template } => Credential::Header {
132                name: name.clone(),
133                value: template.replace("{token}", &token),
134            },
135        }
136    }
137
138    async fn fetch(&self) -> Result<(String, Option<u64>), FaucetError> {
139        let mut req = self.http.request(self.method.clone(), &self.url);
140        if let Some(body) = &self.body {
141            req = match self.encoding {
142                BodyEncoding::Json => req.json(body),
143                // Form encoding requires a flat map of string→string pairs; the
144                // OAuth token endpoints that need `encoding: form` always send
145                // such a body (`grant_type`, `client_id`, `resource`, …).
146                BodyEncoding::Form => req.form(&form_pairs(body)?),
147            };
148        }
149        let resp = req.send().await?;
150        if !resp.status().is_success() {
151            let status = resp.status().as_u16();
152            let body = resp.text().await.unwrap_or_default();
153            return Err(FaucetError::Auth(format!(
154                "token endpoint request failed (HTTP {status}): {body}"
155            )));
156        }
157        let body: Value = resp.json().await?;
158        let token = extract_string(&body, &self.token_path).ok_or_else(|| {
159            FaucetError::Auth(format!(
160                "token_path '{}' did not match a string value in the response",
161                self.token_path
162            ))
163        })?;
164        let expires_in = self
165            .expiry_path
166            .as_deref()
167            .and_then(|p| extract_u64(&body, p));
168        Ok((token, expires_in))
169    }
170}
171
172#[async_trait]
173impl AuthProvider for TokenEndpointProvider {
174    async fn credential(&self) -> Result<Credential, FaucetError> {
175        let mut state = self.state.lock().await;
176        let still_valid = match (&state.token, state.expires_at) {
177            (Some(_), Some(exp)) => Instant::now() < exp,
178            (Some(_), None) => true,
179            _ => false,
180        };
181        if still_valid {
182            return Ok(self.make_credential(state.token.clone().unwrap()));
183        }
184        let (token, expires_in) = self.fetch().await?;
185        state.token = Some(token.clone());
186        state.expires_at = expiry_instant(expires_in, self.expiry_ratio);
187        Ok(self.make_credential(token))
188    }
189
190    async fn invalidate(&self, stale: &Credential) -> Result<Credential, FaucetError> {
191        let mut state = self.state.lock().await;
192        // CAS: if the cache already holds a *different* still-valid credential, a
193        // concurrent caller already refreshed after the same 401 — hand that
194        // back instead of fetching again (single-flight). Only refetch when the
195        // cached credential is the stale one (or itself expired). Without this
196        // override the default `invalidate` just returns `credential()`, which
197        // serves the still-cached stale credential straight back, so a connector
198        // that hit a 401 can never force a refresh (#146 M15). Comparing the
199        // *rendered* credential keeps this correct for both Bearer and Header
200        // (cookie) apply modes.
201        let current_valid = match (&state.token, state.expires_at) {
202            (Some(t), Some(exp)) if Instant::now() < exp => Some(self.make_credential(t.clone())),
203            (Some(t), None) => Some(self.make_credential(t.clone())),
204            _ => None,
205        };
206        if let Some(cur) = &current_valid
207            && cur != stale
208        {
209            return Ok(cur.clone());
210        }
211        let (token, expires_in) = self.fetch().await?;
212        state.token = Some(token.clone());
213        state.expires_at = expiry_instant(expires_in, self.expiry_ratio);
214        Ok(self.make_credential(token))
215    }
216
217    fn provider_name(&self) -> &'static str {
218        "token_endpoint"
219    }
220}
221
222/// Parse the optional `apply_as` block. Absent → `Bearer`. When present it must
223/// carry a non-empty `header` and a `template` (which should contain `{token}`).
224fn parse_apply_as(config: &Value) -> Result<ApplyAs, FaucetError> {
225    let Some(spec) = config.get("apply_as").filter(|v| !v.is_null()) else {
226        return Ok(ApplyAs::Bearer);
227    };
228    let header = spec
229        .get("header")
230        .and_then(Value::as_str)
231        .filter(|s| !s.is_empty())
232        .ok_or_else(|| {
233            FaucetError::Config("token_endpoint: `apply_as` requires a non-empty `header`".into())
234        })?
235        .to_string();
236    // Default template is the bare token, so `apply_as: { header: X }` puts the
237    // raw token into header X. A cookie/session flow supplies its own template.
238    let template = spec
239        .get("template")
240        .and_then(Value::as_str)
241        .unwrap_or("{token}")
242        .to_string();
243    Ok(ApplyAs::Header {
244        name: header,
245        template,
246    })
247}
248
249/// Flatten a JSON object body into form-encoded `(key, value)` pairs. Scalar
250/// values (string/number/bool) are stringified; nested objects/arrays and a
251/// non-object body are rejected — form encoding has no representation for them.
252fn form_pairs(body: &Value) -> Result<Vec<(String, String)>, FaucetError> {
253    let obj = body.as_object().ok_or_else(|| {
254        FaucetError::Config("token_endpoint: `encoding: form` requires a JSON object `body`".into())
255    })?;
256    obj.iter()
257        .map(|(k, v)| {
258            let s = match v {
259                Value::String(s) => s.clone(),
260                Value::Number(n) => n.to_string(),
261                Value::Bool(b) => b.to_string(),
262                _ => {
263                    return Err(FaucetError::Config(format!(
264                        "token_endpoint: `encoding: form` body field {k:?} must be a string, \
265                         number, or boolean"
266                    )));
267                }
268            };
269            Ok((k.clone(), s))
270        })
271        .collect()
272}
273
274fn extract_string(body: &Value, path: &str) -> Option<String> {
275    let results = body.query(path).ok()?;
276    match results.first()? {
277        Value::String(s) => Some(s.clone()),
278        Value::Number(n) => Some(n.to_string()),
279        _ => None,
280    }
281}
282
283fn extract_u64(body: &Value, path: &str) -> Option<u64> {
284    let results = body.query(path).ok()?;
285    results.first()?.as_u64()
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::sync::Arc;
292    use std::sync::atomic::{AtomicUsize, Ordering};
293    use wiremock::matchers::method;
294    use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
295
296    struct Counting(Arc<AtomicUsize>);
297    impl Respond for Counting {
298        fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
299            let n = self.0.fetch_add(1, Ordering::SeqCst) + 1;
300            ResponseTemplate::new(200).set_body_json(serde_json::json!({
301                "auth": { "access_token": format!("tok{n}") },
302                "ttl": 3600
303            }))
304        }
305    }
306
307    #[tokio::test]
308    async fn extracts_token_via_jsonpath_and_single_flights() {
309        let server = MockServer::start().await;
310        let hits = Arc::new(AtomicUsize::new(0));
311        Mock::given(method("POST"))
312            .respond_with(Counting(hits.clone()))
313            .mount(&server)
314            .await;
315        let p = TokenEndpointProvider::from_config(&serde_json::json!({
316            "url": server.uri(),
317            "token_path": "$.auth.access_token",
318            "expiry_path": "$.ttl",
319        }))
320        .unwrap();
321        let results = futures::future::join_all((0..3).map(|_| p.credential())).await;
322        for r in &results {
323            assert_eq!(r.as_ref().unwrap(), &Credential::Bearer("tok1".into()));
324        }
325        assert_eq!(hits.load(Ordering::SeqCst), 1);
326    }
327
328    #[test]
329    fn provider_debug_does_not_leak_body_secrets() {
330        // The request `body` may carry a client secret; a `{:?}` of the provider
331        // (held as `Arc<dyn AuthProvider>`) must not print it.
332        let p = TokenEndpointProvider::from_config(&serde_json::json!({
333            "url": "https://idp.example/token",
334            "token_path": "$.access_token",
335            "body": { "client_secret": "topsecretbody" },
336        }))
337        .unwrap();
338        let s = format!("{p:?}");
339        assert!(
340            !s.contains("topsecretbody"),
341            "request body secret leaked: {s}"
342        );
343        assert!(
344            s.contains("token_path"),
345            "non-secret fields should remain: {s}"
346        );
347    }
348
349    #[test]
350    fn missing_url_errors() {
351        assert!(
352            TokenEndpointProvider::from_config(&serde_json::json!({"token_path": "$.t"})).is_err()
353        );
354    }
355
356    #[tokio::test]
357    async fn invalidate_forces_a_refresh_of_the_stale_token() {
358        // M15 (#146): a connector that hit a 401 calls invalidate(stale) and
359        // must get a freshly-fetched token — not the cached stale one back.
360        let server = MockServer::start().await;
361        let hits = Arc::new(AtomicUsize::new(0));
362        Mock::given(method("POST"))
363            .respond_with(Counting(hits.clone()))
364            .mount(&server)
365            .await;
366        let p = TokenEndpointProvider::from_config(&serde_json::json!({
367            "url": server.uri(),
368            "token_path": "$.auth.access_token",
369            "expiry_path": "$.ttl",
370        }))
371        .unwrap();
372
373        assert_eq!(
374            p.credential().await.unwrap(),
375            Credential::Bearer("tok1".into())
376        );
377        assert_eq!(hits.load(Ordering::SeqCst), 1);
378
379        // invalidate(tok1) must refetch → tok2.
380        assert_eq!(
381            p.invalidate(&Credential::Bearer("tok1".into()))
382                .await
383                .unwrap(),
384            Credential::Bearer("tok2".into())
385        );
386        assert_eq!(hits.load(Ordering::SeqCst), 2);
387
388        // The refreshed token is now cached — no extra fetch.
389        assert_eq!(
390            p.credential().await.unwrap(),
391            Credential::Bearer("tok2".into())
392        );
393        assert_eq!(hits.load(Ordering::SeqCst), 2);
394    }
395
396    #[tokio::test]
397    async fn invalidate_short_circuits_when_token_already_rotated() {
398        // CAS: if the cache already holds a token different from the stale one,
399        // a concurrent caller already refreshed — return it without refetching.
400        let server = MockServer::start().await;
401        let hits = Arc::new(AtomicUsize::new(0));
402        Mock::given(method("POST"))
403            .respond_with(Counting(hits.clone()))
404            .mount(&server)
405            .await;
406        let p = TokenEndpointProvider::from_config(&serde_json::json!({
407            "url": server.uri(),
408            "token_path": "$.auth.access_token",
409            "expiry_path": "$.ttl",
410        }))
411        .unwrap();
412
413        assert_eq!(
414            p.credential().await.unwrap(),
415            Credential::Bearer("tok1".into())
416        );
417        assert_eq!(hits.load(Ordering::SeqCst), 1);
418        // Invalidating an already-superseded token returns cached tok1, no fetch.
419        assert_eq!(
420            p.invalidate(&Credential::Bearer("old-token".into()))
421                .await
422                .unwrap(),
423            Credential::Bearer("tok1".into())
424        );
425        assert_eq!(hits.load(Ordering::SeqCst), 1);
426    }
427
428    #[tokio::test]
429    async fn apply_as_header_returns_templated_cookie_credential() {
430        // SAP B1: the fetched SessionId is carried as a Cookie header, not a
431        // bearer token. `apply_as` renders it via the `{token}` template.
432        let server = MockServer::start().await;
433        Mock::given(method("POST"))
434            .respond_with(
435                ResponseTemplate::new(200)
436                    .set_body_json(serde_json::json!({ "SessionId": "abc123" })),
437            )
438            .mount(&server)
439            .await;
440        let p = TokenEndpointProvider::from_config(&serde_json::json!({
441            "url": server.uri(),
442            "token_path": "$.SessionId",
443            "apply_as": { "header": "Cookie", "template": "B1SESSION={token}; CompanyDB=DB" },
444        }))
445        .unwrap();
446        assert_eq!(
447            p.credential().await.unwrap(),
448            Credential::Header {
449                name: "Cookie".into(),
450                value: "B1SESSION=abc123; CompanyDB=DB".into(),
451            }
452        );
453    }
454
455    #[tokio::test]
456    async fn apply_as_header_defaults_template_to_bare_token() {
457        let server = MockServer::start().await;
458        Mock::given(method("POST"))
459            .respond_with(
460                ResponseTemplate::new(200).set_body_json(serde_json::json!({ "t": "raw" })),
461            )
462            .mount(&server)
463            .await;
464        let p = TokenEndpointProvider::from_config(&serde_json::json!({
465            "url": server.uri(),
466            "token_path": "$.t",
467            "apply_as": { "header": "X-Session" },
468        }))
469        .unwrap();
470        assert_eq!(
471            p.credential().await.unwrap(),
472            Credential::Header {
473                name: "X-Session".into(),
474                value: "raw".into(),
475            }
476        );
477    }
478
479    #[tokio::test]
480    async fn form_encoding_posts_urlencoded_body() {
481        use wiremock::matchers::{body_string_contains, header};
482        let server = MockServer::start().await;
483        // Only matches when the request is form-encoded and carries the params.
484        Mock::given(method("POST"))
485            .and(header("content-type", "application/x-www-form-urlencoded"))
486            .and(body_string_contains("grant_type=client_credentials"))
487            .and(body_string_contains("resource=https"))
488            .respond_with(
489                ResponseTemplate::new(200)
490                    .set_body_json(serde_json::json!({ "access_token": "ok" })),
491            )
492            .mount(&server)
493            .await;
494        let p = TokenEndpointProvider::from_config(&serde_json::json!({
495            "url": server.uri(),
496            "encoding": "form",
497            "token_path": "$.access_token",
498            "body": { "grant_type": "client_credentials", "resource": "https://x.example" },
499        }))
500        .unwrap();
501        assert_eq!(
502            p.credential().await.unwrap(),
503            Credential::Bearer("ok".into())
504        );
505    }
506
507    #[tokio::test]
508    async fn json_encoding_posts_json_body() {
509        use wiremock::matchers::{body_string_contains, header};
510        let server = MockServer::start().await;
511        Mock::given(method("POST"))
512            .and(header("content-type", "application/json"))
513            .and(body_string_contains("client_secret"))
514            .respond_with(
515                ResponseTemplate::new(200)
516                    .set_body_json(serde_json::json!({ "access_token": "ok" })),
517            )
518            .mount(&server)
519            .await;
520        let p = TokenEndpointProvider::from_config(&serde_json::json!({
521            "url": server.uri(),
522            "token_path": "$.access_token",
523            "body": { "client_id": "id", "client_secret": "sec" },
524        }))
525        .unwrap();
526        assert_eq!(
527            p.credential().await.unwrap(),
528            Credential::Bearer("ok".into())
529        );
530    }
531
532    #[test]
533    fn rejects_invalid_encoding() {
534        assert!(
535            TokenEndpointProvider::from_config(&serde_json::json!({
536                "url": "http://x", "token_path": "$.t", "encoding": "xml"
537            }))
538            .is_err()
539        );
540    }
541
542    #[test]
543    fn apply_as_requires_a_header() {
544        assert!(
545            TokenEndpointProvider::from_config(&serde_json::json!({
546                "url": "http://x", "token_path": "$.t", "apply_as": { "template": "{token}" }
547            }))
548            .is_err()
549        );
550    }
551
552    #[test]
553    fn form_pairs_rejects_non_object_and_nested() {
554        assert!(form_pairs(&serde_json::json!("scalar")).is_err());
555        assert!(form_pairs(&serde_json::json!({ "nested": { "a": 1 } })).is_err());
556        let pairs = form_pairs(&serde_json::json!({ "a": "1", "n": 2, "b": true })).unwrap();
557        assert!(pairs.contains(&("a".to_string(), "1".to_string())));
558        assert!(pairs.contains(&("n".to_string(), "2".to_string())));
559        assert!(pairs.contains(&("b".to_string(), "true".to_string())));
560    }
561
562    #[test]
563    fn provider_debug_does_not_leak_form_body_secrets() {
564        let p = TokenEndpointProvider::from_config(&serde_json::json!({
565            "url": "https://idp.example/token",
566            "encoding": "form",
567            "token_path": "$.access_token",
568            "body": { "client_secret": "topsecretform" },
569        }))
570        .unwrap();
571        assert!(!format!("{p:?}").contains("topsecretform"));
572    }
573
574    #[test]
575    fn rejects_out_of_range_expiry_ratio() {
576        // M16 (#146): an out-of-range expiry_ratio breaks caching — reject it.
577        assert!(
578            TokenEndpointProvider::from_config(&serde_json::json!({
579                "url": "http://x", "token_path": "$.t", "expiry_ratio": 0
580            }))
581            .is_err()
582        );
583        assert!(
584            TokenEndpointProvider::from_config(&serde_json::json!({
585                "url": "http://x", "token_path": "$.t", "expiry_ratio": 1.5
586            }))
587            .is_err()
588        );
589        // A valid ratio still constructs.
590        assert!(
591            TokenEndpointProvider::from_config(&serde_json::json!({
592                "url": "http://x", "token_path": "$.t", "expiry_ratio": 0.5
593            }))
594            .is_ok()
595        );
596    }
597}