1use async_trait::async_trait;
11use faucet_core::{AuthProvider, Credential, FaucetError, FileStateStore, StateStore};
12use reqwest::Client;
13use serde::Deserialize;
14use serde_json::Value;
15use std::sync::Arc;
16use tokio::sync::Mutex;
17use tokio::time::Instant;
18
19use crate::expiry_instant;
20
21#[derive(Deserialize)]
22struct TokenResponse {
23 access_token: String,
24 #[serde(default)]
25 expires_in: Option<u64>,
26 #[serde(default)]
27 refresh_token: Option<String>,
28 #[allow(dead_code)]
29 #[serde(default)]
30 token_type: Option<String>,
31}
32
33#[derive(Default)]
34struct CachedToken {
35 access_token: Option<String>,
36 expires_at: Option<Instant>,
37}
38
39impl CachedToken {
40 fn valid(&self) -> Option<&str> {
41 match (&self.access_token, self.expires_at) {
42 (Some(tok), Some(exp)) if Instant::now() < exp => Some(tok),
43 (Some(tok), None) => Some(tok),
44 _ => None,
45 }
46 }
47}
48
49pub struct OAuth2ClientCredentialsProvider {
51 http: Client,
52 token_url: String,
53 client_id: String,
54 client_secret: String,
55 scopes: Vec<String>,
56 expiry_ratio: f64,
57 state: Mutex<CachedToken>,
58}
59
60impl std::fmt::Debug for OAuth2ClientCredentialsProvider {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("OAuth2ClientCredentialsProvider")
66 .field("token_url", &self.token_url)
67 .field("client_id", &self.client_id)
68 .field("client_secret", &"***")
69 .field("scopes", &self.scopes)
70 .field("expiry_ratio", &self.expiry_ratio)
71 .finish_non_exhaustive()
72 }
73}
74
75impl OAuth2ClientCredentialsProvider {
76 pub fn from_config(config: &Value) -> Result<Self, FaucetError> {
79 Ok(Self {
80 http: crate::auth_http_client(),
81 token_url: required_str(config, "token_url")?,
82 client_id: required_str(config, "client_id")?,
83 client_secret: required_str(config, "client_secret")?,
84 scopes: string_array(config, "scopes"),
85 expiry_ratio: crate::parse_expiry_ratio(config)?,
86 state: Mutex::new(CachedToken::default()),
87 })
88 }
89
90 async fn fetch(&self) -> Result<TokenResponse, FaucetError> {
91 let resp = self
92 .http
93 .post(&self.token_url)
94 .form(&[
95 ("grant_type", "client_credentials"),
96 ("client_id", &self.client_id),
97 ("client_secret", &self.client_secret),
98 ("scope", &self.scopes.join(" ")),
99 ])
100 .send()
101 .await?;
102 parse_token_response(resp).await
103 }
104}
105
106#[async_trait]
107impl AuthProvider for OAuth2ClientCredentialsProvider {
108 async fn credential(&self) -> Result<Credential, FaucetError> {
109 let mut state = self.state.lock().await;
110 if let Some(tok) = state.valid() {
111 return Ok(Credential::Bearer(tok.to_string()));
112 }
113 let body = self.fetch().await?;
114 state.access_token = Some(body.access_token.clone());
115 state.expires_at = expiry_instant(body.expires_in, self.expiry_ratio);
116 Ok(Credential::Bearer(body.access_token))
117 }
118
119 async fn invalidate(&self, stale: &Credential) -> Result<Credential, FaucetError> {
120 let mut state = self.state.lock().await;
121 if let (Some(cur), Credential::Bearer(stale_tok)) = (state.valid(), stale)
123 && cur != stale_tok
124 {
125 return Ok(Credential::Bearer(cur.to_string()));
126 }
127 let body = self.fetch().await?;
128 state.access_token = Some(body.access_token.clone());
129 state.expires_at = expiry_instant(body.expires_in, self.expiry_ratio);
130 Ok(Credential::Bearer(body.access_token))
131 }
132
133 fn provider_name(&self) -> &'static str {
134 "oauth2"
135 }
136}
137
138#[derive(Default)]
139struct RefreshState {
140 access_token: Option<String>,
141 expires_at: Option<Instant>,
142 refresh_token: String,
143 loaded: bool,
147}
148
149pub struct OAuth2RefreshProvider {
156 http: Client,
157 token_url: String,
158 client_id: String,
159 client_secret: String,
160 expiry_ratio: f64,
161 scope: Option<String>,
166 store: Option<Arc<dyn StateStore>>,
168 store_key: String,
170 state: Mutex<RefreshState>,
171}
172
173impl std::fmt::Debug for OAuth2RefreshProvider {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 f.debug_struct("OAuth2RefreshProvider")
178 .field("token_url", &self.token_url)
179 .field("client_id", &self.client_id)
180 .field("client_secret", &"***")
181 .field("scope", &self.scope)
182 .field("expiry_ratio", &self.expiry_ratio)
183 .finish_non_exhaustive()
184 }
185}
186
187impl OAuth2RefreshProvider {
188 pub fn from_config(config: &Value) -> Result<Self, FaucetError> {
192 let refresh_token = required_str(config, "refresh_token")?;
193 let token_url = required_str(config, "token_url")?;
194 let client_id = required_str(config, "client_id")?;
195 let (store, store_key) = parse_persist(config, &token_url, &client_id)?;
196 Ok(Self {
197 http: crate::auth_http_client(),
198 token_url,
199 client_id,
200 client_secret: required_str(config, "client_secret")?,
201 expiry_ratio: crate::parse_expiry_ratio(config)?,
202 scope: optional_str(config, "scope"),
203 store,
204 store_key,
205 state: Mutex::new(RefreshState {
206 refresh_token,
207 ..Default::default()
208 }),
209 })
210 }
211
212 pub fn with_store(mut self, store: Arc<dyn StateStore>, key: impl Into<String>) -> Self {
216 self.store = Some(store);
217 self.store_key = key.into();
218 self
219 }
220
221 async fn ensure_loaded(&self, state: &mut RefreshState) {
226 if state.loaded {
227 return;
228 }
229 state.loaded = true;
230 let Some(store) = &self.store else { return };
231 match store.get(&self.store_key).await {
232 Ok(Some(v)) => {
233 if let Some(tok) = v.get("refresh_token").and_then(Value::as_str)
234 && !tok.is_empty()
235 {
236 state.refresh_token = tok.to_string();
237 tracing::debug!("oauth2_refresh: loaded persisted refresh token");
238 }
239 }
240 Ok(None) => {}
241 Err(e) => tracing::warn!(
242 error = %e,
243 "oauth2_refresh: could not read persisted refresh token; using the config seed"
244 ),
245 }
246 }
247
248 async fn persist(&self, state: &RefreshState) {
252 let Some(store) = &self.store else { return };
253 let value = serde_json::json!({ "refresh_token": state.refresh_token });
254 if let Err(e) = store.put(&self.store_key, &value).await {
255 tracing::warn!(error = %e, "oauth2_refresh: could not persist rotated refresh token");
256 }
257 }
258
259 async fn refresh(&self, state: &mut RefreshState) -> Result<String, FaucetError> {
261 self.ensure_loaded(state).await;
262 let mut form: Vec<(&str, &str)> = vec![
263 ("grant_type", "refresh_token"),
264 ("refresh_token", &state.refresh_token),
265 ("client_id", &self.client_id),
266 ("client_secret", &self.client_secret),
267 ];
268 if let Some(scope) = &self.scope {
271 form.push(("scope", scope));
272 }
273 let resp = self.http.post(&self.token_url).form(&form).send().await?;
274 let body = parse_token_response(resp).await?;
275 state.access_token = Some(body.access_token.clone());
276 state.expires_at = expiry_instant(body.expires_in, self.expiry_ratio);
277 if let Some(rotated) = body.refresh_token {
278 state.refresh_token = rotated; self.persist(state).await;
280 }
281 Ok(body.access_token)
282 }
283}
284
285fn parse_persist(
291 config: &Value,
292 token_url: &str,
293 client_id: &str,
294) -> Result<(Option<Arc<dyn StateStore>>, String), FaucetError> {
295 let default_key = format!(
296 "oauth2_refresh_{:016x}",
297 fnv1a_64(&format!("{token_url}\u{0}{client_id}"))
298 );
299 let Some(persist) = config.get("persist").filter(|v| !v.is_null()) else {
300 return Ok((None, default_key));
301 };
302 let path = persist
303 .get("path")
304 .and_then(Value::as_str)
305 .filter(|s| !s.is_empty())
306 .ok_or_else(|| {
307 FaucetError::Config(
308 "oauth2_refresh: `persist` requires a non-empty `path` (the state-store directory)"
309 .into(),
310 )
311 })?;
312 let key = persist
313 .get("key")
314 .and_then(Value::as_str)
315 .filter(|s| !s.is_empty())
316 .map(str::to_string)
317 .unwrap_or(default_key);
318 let store: Arc<dyn StateStore> = Arc::new(FileStateStore::new(path));
319 Ok((Some(store), key))
320}
321
322fn fnv1a_64(s: &str) -> u64 {
326 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
327 for b in s.as_bytes() {
328 hash ^= *b as u64;
329 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
330 }
331 hash
332}
333
334#[async_trait]
335impl AuthProvider for OAuth2RefreshProvider {
336 async fn credential(&self) -> Result<Credential, FaucetError> {
337 let mut state = self.state.lock().await;
338 if let (Some(tok), Some(exp)) = (&state.access_token, state.expires_at)
339 && Instant::now() < exp
340 {
341 return Ok(Credential::Bearer(tok.clone()));
342 }
343 let token = self.refresh(&mut state).await?;
344 Ok(Credential::Bearer(token))
345 }
346
347 async fn invalidate(&self, stale: &Credential) -> Result<Credential, FaucetError> {
348 let mut state = self.state.lock().await;
349 if let (Some(cur), Credential::Bearer(stale_tok)) = (&state.access_token, stale)
352 && cur != stale_tok
353 {
354 return Ok(Credential::Bearer(cur.clone()));
355 }
356 let token = self.refresh(&mut state).await?;
357 Ok(Credential::Bearer(token))
358 }
359
360 fn provider_name(&self) -> &'static str {
361 "oauth2_refresh"
362 }
363}
364
365fn required_str(config: &Value, key: &str) -> Result<String, FaucetError> {
366 config
367 .get(key)
368 .and_then(Value::as_str)
369 .map(str::to_string)
370 .ok_or_else(|| FaucetError::Config(format!("oauth2 auth provider: missing `{key}`")))
371}
372
373fn optional_str(config: &Value, key: &str) -> Option<String> {
375 config
376 .get(key)
377 .and_then(Value::as_str)
378 .filter(|s| !s.is_empty())
379 .map(str::to_string)
380}
381
382fn string_array(config: &Value, key: &str) -> Vec<String> {
383 config
384 .get(key)
385 .and_then(Value::as_array)
386 .map(|a| {
387 a.iter()
388 .filter_map(|v| v.as_str().map(str::to_string))
389 .collect()
390 })
391 .unwrap_or_default()
392}
393
394async fn parse_token_response(resp: reqwest::Response) -> Result<TokenResponse, FaucetError> {
395 if !resp.status().is_success() {
396 let status = resp.status().as_u16();
397 let body = resp.text().await.unwrap_or_default();
398 return Err(FaucetError::Auth(format!(
399 "OAuth2 token request failed (HTTP {status}): {body}"
400 )));
401 }
402 resp.json::<TokenResponse>().await.map_err(Into::into)
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use std::sync::Arc;
409 use std::sync::atomic::{AtomicUsize, Ordering};
410 use wiremock::matchers::method;
411 use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
412
413 struct CountingToken {
414 hits: Arc<AtomicUsize>,
415 token_prefix: &'static str,
416 }
417 impl Respond for CountingToken {
418 fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
419 let n = self.hits.fetch_add(1, Ordering::SeqCst) + 1;
420 ResponseTemplate::new(200).set_body_json(serde_json::json!({
421 "access_token": format!("{}{n}", self.token_prefix),
422 "expires_in": 3600,
423 "refresh_token": format!("rt{n}"),
424 }))
425 }
426 }
427
428 #[tokio::test]
429 async fn refresh_provider_single_flight_one_fetch_for_concurrent_calls() {
430 let server = MockServer::start().await;
431 let hits = Arc::new(AtomicUsize::new(0));
432 Mock::given(method("POST"))
433 .respond_with(CountingToken {
434 hits: hits.clone(),
435 token_prefix: "A",
436 })
437 .mount(&server)
438 .await;
439
440 let provider = OAuth2RefreshProvider::from_config(&serde_json::json!({
441 "token_url": server.uri(),
442 "client_id": "id",
443 "client_secret": "secret",
444 "refresh_token": "rt0",
445 }))
446 .unwrap();
447
448 let results = futures::future::join_all((0..4).map(|_| provider.credential())).await;
449 for r in &results {
450 assert_eq!(r.as_ref().unwrap(), &Credential::Bearer("A1".into()));
451 }
452 assert_eq!(
453 hits.load(Ordering::SeqCst),
454 1,
455 "expected exactly one token fetch"
456 );
457 }
458
459 #[tokio::test]
460 async fn refresh_provider_invalidate_cas_refetches_once() {
461 let server = MockServer::start().await;
462 let hits = Arc::new(AtomicUsize::new(0));
463 Mock::given(method("POST"))
464 .respond_with(CountingToken {
465 hits: hits.clone(),
466 token_prefix: "A",
467 })
468 .mount(&server)
469 .await;
470 let provider = OAuth2RefreshProvider::from_config(&serde_json::json!({
471 "token_url": server.uri(),
472 "client_id": "id",
473 "client_secret": "secret",
474 "refresh_token": "rt0",
475 }))
476 .unwrap();
477
478 let first = provider.credential().await.unwrap();
479 assert_eq!(first, Credential::Bearer("A1".into()));
480 let second = provider.invalidate(&first).await.unwrap();
482 assert_eq!(second, Credential::Bearer("A2".into()));
483 assert_eq!(hits.load(Ordering::SeqCst), 2);
484 let again = provider.invalidate(&first).await.unwrap();
486 assert_eq!(again, Credential::Bearer("A2".into()));
487 assert_eq!(hits.load(Ordering::SeqCst), 2, "stale CAS must not refetch");
488 }
489
490 #[test]
491 fn provider_debug_does_not_leak_secrets() {
492 let cc = OAuth2ClientCredentialsProvider::from_config(&serde_json::json!({
495 "token_url": "https://idp.example/token",
496 "client_id": "id",
497 "client_secret": "topsecretclient",
498 }))
499 .unwrap();
500 let s = format!("{cc:?}");
501 assert!(!s.contains("topsecretclient"), "client_secret leaked: {s}");
502 assert!(
503 s.contains("client_id"),
504 "non-secret fields should remain: {s}"
505 );
506
507 let rf = OAuth2RefreshProvider::from_config(&serde_json::json!({
508 "token_url": "https://idp.example/token",
509 "client_id": "id",
510 "client_secret": "topsecretclient",
511 "refresh_token": "topsecretrefresh",
512 }))
513 .unwrap();
514 let s = format!("{rf:?}");
515 assert!(!s.contains("topsecretclient"), "client_secret leaked: {s}");
516 assert!(!s.contains("topsecretrefresh"), "refresh_token leaked: {s}");
517 }
518
519 #[tokio::test]
520 async fn client_credentials_single_flight() {
521 let server = MockServer::start().await;
522 let hits = Arc::new(AtomicUsize::new(0));
523 Mock::given(method("POST"))
524 .respond_with(CountingToken {
525 hits: hits.clone(),
526 token_prefix: "C",
527 })
528 .mount(&server)
529 .await;
530 let provider = OAuth2ClientCredentialsProvider::from_config(&serde_json::json!({
531 "token_url": server.uri(),
532 "client_id": "id",
533 "client_secret": "secret",
534 "scopes": ["read"],
535 }))
536 .unwrap();
537 let results = futures::future::join_all((0..4).map(|_| provider.credential())).await;
538 for r in &results {
539 assert_eq!(r.as_ref().unwrap(), &Credential::Bearer("C1".into()));
540 }
541 assert_eq!(hits.load(Ordering::SeqCst), 1);
542 }
543
544 #[tokio::test]
545 async fn persists_rotated_refresh_token_across_providers() {
546 use faucet_core::MemoryStateStore;
547 use wiremock::matchers::body_string_contains;
548
549 let server = MockServer::start().await;
550 Mock::given(method("POST"))
552 .and(body_string_contains("refresh_token=rt0"))
553 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
554 "access_token": "A1",
555 "expires_in": 3600,
556 "refresh_token": "rt1",
557 })))
558 .mount(&server)
559 .await;
560 Mock::given(method("POST"))
562 .and(body_string_contains("refresh_token=rt1"))
563 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
564 "access_token": "B1",
565 "expires_in": 3600,
566 "refresh_token": "rt2",
567 })))
568 .mount(&server)
569 .await;
570
571 let store: Arc<dyn StateStore> = Arc::new(MemoryStateStore::new());
572 let cfg = serde_json::json!({
573 "token_url": server.uri(),
574 "client_id": "id",
575 "client_secret": "secret",
576 "refresh_token": "rt0",
577 });
578
579 let p1 = OAuth2RefreshProvider::from_config(&cfg)
581 .unwrap()
582 .with_store(store.clone(), "k");
583 assert_eq!(
584 p1.credential().await.unwrap(),
585 Credential::Bearer("A1".into())
586 );
587 assert_eq!(
588 store.get("k").await.unwrap().unwrap()["refresh_token"],
589 "rt1"
590 );
591
592 let stale_seed = serde_json::json!({
595 "token_url": server.uri(),
596 "client_id": "id",
597 "client_secret": "secret",
598 "refresh_token": "STALE_SEED",
599 });
600 let p2 = OAuth2RefreshProvider::from_config(&stale_seed)
601 .unwrap()
602 .with_store(store.clone(), "k");
603 assert_eq!(
604 p2.credential().await.unwrap(),
605 Credential::Bearer("B1".into())
606 );
607 assert_eq!(
608 store.get("k").await.unwrap().unwrap()["refresh_token"],
609 "rt2"
610 );
611 }
612
613 #[tokio::test]
614 async fn persists_to_a_file_backed_store_from_config_path() {
615 use wiremock::matchers::body_string_contains;
616 let dir = tempfile::tempdir().unwrap();
617 let server = MockServer::start().await;
618 Mock::given(method("POST"))
619 .and(body_string_contains("refresh_token=seed"))
620 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
621 "access_token": "A1",
622 "expires_in": 3600,
623 "refresh_token": "rotated",
624 })))
625 .mount(&server)
626 .await;
627 Mock::given(method("POST"))
628 .and(body_string_contains("refresh_token=rotated"))
629 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
630 "access_token": "A2",
631 "expires_in": 3600,
632 })))
633 .mount(&server)
634 .await;
635
636 let cfg = serde_json::json!({
637 "token_url": server.uri(),
638 "client_id": "id",
639 "client_secret": "secret",
640 "refresh_token": "seed",
641 "persist": { "path": dir.path().to_str().unwrap() },
642 });
643 let p1 = OAuth2RefreshProvider::from_config(&cfg).unwrap();
644 assert_eq!(
645 p1.credential().await.unwrap(),
646 Credential::Bearer("A1".into())
647 );
648
649 let p2 = OAuth2RefreshProvider::from_config(&cfg).unwrap();
651 assert_eq!(
652 p2.credential().await.unwrap(),
653 Credential::Bearer("A2".into())
654 );
655 }
656
657 #[tokio::test]
658 async fn persist_store_errors_are_non_fatal() {
659 #[derive(Debug)]
662 struct FailingStore;
663 #[async_trait]
664 impl StateStore for FailingStore {
665 async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
666 Err(FaucetError::State("boom-read".into()))
667 }
668 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
669 Err(FaucetError::State("boom-write".into()))
670 }
671 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
672 Ok(())
673 }
674 }
675
676 let server = MockServer::start().await;
677 Mock::given(method("POST"))
678 .respond_with(CountingToken {
679 hits: Arc::new(AtomicUsize::new(0)),
680 token_prefix: "A",
681 })
682 .mount(&server)
683 .await;
684 let store: Arc<dyn StateStore> = Arc::new(FailingStore);
685 let p = OAuth2RefreshProvider::from_config(&serde_json::json!({
686 "token_url": server.uri(),
687 "client_id": "id",
688 "client_secret": "secret",
689 "refresh_token": "rt0",
690 }))
691 .unwrap()
692 .with_store(store, "k");
693 assert_eq!(
696 p.credential().await.unwrap(),
697 Credential::Bearer("A1".into())
698 );
699 }
700
701 #[test]
702 fn persist_requires_a_path() {
703 assert!(
704 OAuth2RefreshProvider::from_config(&serde_json::json!({
705 "token_url": "http://x", "client_id": "i", "client_secret": "s",
706 "refresh_token": "rt", "persist": {}
707 }))
708 .is_err()
709 );
710 }
711
712 #[test]
713 fn default_persist_key_is_stable_and_identity_scoped() {
714 let (_none, k1) = parse_persist(&serde_json::json!({}), "https://a/token", "id1").unwrap();
715 let (_none2, k1b) =
716 parse_persist(&serde_json::json!({}), "https://a/token", "id1").unwrap();
717 let (_none3, k2) = parse_persist(&serde_json::json!({}), "https://a/token", "id2").unwrap();
718 assert_eq!(k1, k1b, "same identity → same key across calls");
719 assert_ne!(k1, k2, "different client_id → different key");
720 assert!(_none.is_none(), "no persist block → no store");
721 }
722
723 #[tokio::test]
724 async fn refresh_grant_includes_scope_when_configured() {
725 use wiremock::matchers::body_string_contains;
726 let server = MockServer::start().await;
727 Mock::given(method("POST"))
730 .and(body_string_contains("grant_type=refresh_token"))
731 .and(body_string_contains(
732 "scope=https%3A%2F%2Fgraph.microsoft.com%2F.default+offline_access",
733 ))
734 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
735 "access_token": "A1",
736 "expires_in": 3600,
737 })))
738 .mount(&server)
739 .await;
740 let provider = OAuth2RefreshProvider::from_config(&serde_json::json!({
741 "token_url": server.uri(),
742 "client_id": "id",
743 "client_secret": "secret",
744 "refresh_token": "rt0",
745 "scope": "https://graph.microsoft.com/.default offline_access",
746 }))
747 .unwrap();
748 assert_eq!(
749 provider.credential().await.unwrap(),
750 Credential::Bearer("A1".into())
751 );
752 }
753
754 #[tokio::test]
755 async fn refresh_grant_omits_scope_when_not_configured() {
756 use wiremock::matchers::body_string_contains;
757 let server = MockServer::start().await;
758 Mock::given(method("POST"))
761 .and(body_string_contains("grant_type=refresh_token"))
762 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
763 "access_token": "A1",
764 "expires_in": 3600,
765 })))
766 .mount(&server)
767 .await;
768 let provider = OAuth2RefreshProvider::from_config(&serde_json::json!({
769 "token_url": server.uri(),
770 "client_id": "id",
771 "client_secret": "secret",
772 "refresh_token": "rt0",
773 }))
774 .unwrap();
775 assert_eq!(
776 provider.credential().await.unwrap(),
777 Credential::Bearer("A1".into())
778 );
779 let requests = server.received_requests().await.unwrap();
781 assert_eq!(requests.len(), 1);
782 let body = String::from_utf8_lossy(&requests[0].body);
783 assert!(
784 !body.contains("scope="),
785 "scope must be omitted when not configured: {body}"
786 );
787 }
788
789 #[test]
790 fn empty_scope_string_is_treated_as_absent() {
791 let p = OAuth2RefreshProvider::from_config(&serde_json::json!({
794 "token_url": "http://x",
795 "client_id": "id",
796 "client_secret": "secret",
797 "refresh_token": "rt0",
798 "scope": "",
799 }))
800 .unwrap();
801 assert!(p.scope.is_none());
802 let s = format!("{p:?}");
803 assert!(
804 s.contains("scope"),
805 "debug should surface the scope field: {s}"
806 );
807 }
808
809 #[tokio::test]
810 async fn token_endpoint_failure_surfaces_auth_error() {
811 let server = MockServer::start().await;
812 Mock::given(method("POST"))
813 .respond_with(ResponseTemplate::new(401).set_body_string("nope"))
814 .mount(&server)
815 .await;
816 let provider = OAuth2RefreshProvider::from_config(&serde_json::json!({
817 "token_url": server.uri(),
818 "client_id": "id",
819 "client_secret": "secret",
820 "refresh_token": "rt0",
821 }))
822 .unwrap();
823 assert!(matches!(
824 provider.credential().await,
825 Err(FaucetError::Auth(_))
826 ));
827 }
828}