1use 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#[derive(Debug, Clone)]
26enum ApplyAs {
27 Bearer,
29 Header { name: String, template: String },
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum BodyEncoding {
36 Json,
38 Form,
41}
42
43pub 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
59impl 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 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 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 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 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) = ¤t_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
222fn 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 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
249fn 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 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 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 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 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 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 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 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 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 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 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}