Skip to main content

faucet_auth/
oauth2.rs

1//! OAuth2 providers: `client_credentials` and `refresh_token` (with rotation).
2//!
3//! Both hold a single [`Mutex`]-guarded cache and perform the token-endpoint
4//! call **with the lock held**, so concurrent callers during a refresh await the
5//! one in-flight fetch (single-flight). The refresh provider captures a rotated
6//! `refresh_token` from each response in place, so a single active access token
7//! plus a rotating refresh token can be shared across many connectors without
8//! racing.
9
10use 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
49/// OAuth2 `client_credentials` grant provider.
50pub 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
60// Hand-written so `{:?}` (the trait requires `AuthProvider: Debug`, and providers
61// are shared as `Arc<dyn AuthProvider>`) never prints the `client_secret` or the
62// cached access token in `state`.
63impl 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    /// Build from a config object with `token_url`, `client_id`,
77    /// `client_secret`, optional `scopes` and `expiry_ratio`.
78    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        // CAS: only refresh if the cache still holds the stale token.
122        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    /// Whether the durable store has been consulted for a previously-rotated
144    /// refresh token. Read lazily on first use so a persisted token overrides
145    /// the config seed (a rotating provider's seed is stale after run 1).
146    loaded: bool,
147}
148
149/// OAuth2 `refresh_token` grant provider with refresh-token rotation capture.
150///
151/// When a durable [`StateStore`] is attached (via `persist:` config, #499), the
152/// rotated `refresh_token` is written back after every refresh and re-read on
153/// startup — so a *second* scheduled run authenticates with the current token
154/// instead of the now-invalidated config seed.
155pub struct OAuth2RefreshProvider {
156    http: Client,
157    token_url: String,
158    client_id: String,
159    client_secret: String,
160    expiry_ratio: f64,
161    /// Optional `scope` sent on the refresh grant. Some IdPs (Microsoft, Rippling)
162    /// require it on refresh — e.g. `https://graph.microsoft.com/.default
163    /// offline_access`. `None` omits the parameter entirely (RFC 6749 §6 allows
164    /// omitting `scope` on refresh to keep the original grant's scope).
165    scope: Option<String>,
166    /// Durable store for the rotated refresh token (`None` = in-memory only).
167    store: Option<Arc<dyn StateStore>>,
168    /// Key the rotated refresh token is stored under; stable across runs.
169    store_key: String,
170    state: Mutex<RefreshState>,
171}
172
173// Hand-written so `{:?}` never prints the `client_secret` or the `refresh_token`
174// / cached access token held in `state`.
175impl 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    /// Build from a config object with `token_url`, `client_id`,
189    /// `client_secret`, `refresh_token`, and optional `scope` / `expiry_ratio` /
190    /// `persist`.
191    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    /// Attach a durable store for the rotated refresh token (used by tests and
213    /// library callers that supply their own [`StateStore`]). `key` must be
214    /// stable across runs for the same logical provider.
215    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    /// Read the persisted refresh token (if any) into `state`, once. A store
222    /// read failure is logged and ignored — the config seed is the fallback, so
223    /// a missing/unreadable store degrades to the pre-persistence behavior
224    /// rather than failing the run.
225    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    /// Persist the current refresh token. A write failure is logged, not
249    /// propagated: the token still works for *this* run, and failing an
250    /// otherwise-successful run over a state-store hiccup is the worse outcome.
251    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    /// Refresh using the *current* refresh token and capture rotation in place.
260    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        // Only send `scope` when configured — RFC 6749 §6 lets a refresh omit it
269        // to inherit the original grant's scope, and some IdPs reject an empty one.
270        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; // capture rotation centrally
279            self.persist(state).await;
280        }
281        Ok(body.access_token)
282    }
283}
284
285/// Parse the optional `persist:` block. Returns `(store, key)`. When absent, the
286/// provider keeps rotation in memory only (`store = None`). When present, `path`
287/// is the state-store root directory (file-backed via [`FileStateStore`]) and
288/// the key defaults to a stable hash of `token_url + client_id` so several
289/// providers may share one directory without colliding.
290fn 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
322/// FNV-1a 64-bit — a tiny, dependency-free, cross-version-stable hash for the
323/// default persist key (unlike `DefaultHasher`, whose value is not contractually
324/// stable across std releases).
325fn 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        // CAS: another connector may have already refreshed; if the cached token
350        // no longer equals the stale one, hand back the fresh token.
351        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
373/// Read an optional non-empty string field; `None` when absent, null, or empty.
374fn 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        // Invalidate the token we hold → one more fetch, rotated refresh token used.
481        let second = provider.invalidate(&first).await.unwrap();
482        assert_eq!(second, Credential::Bearer("A2".into()));
483        assert_eq!(hits.load(Ordering::SeqCst), 2);
484        // Invalidating a *stale* token that no longer matches → no fetch.
485        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        // `AuthProvider: Debug`, and providers are held as `Arc<dyn AuthProvider>`,
493        // so a stray `{:?}` must never print the client secret / refresh token.
494        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        // Run 1 presents the seed rt0 and the server rotates it to rt1.
551        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        // Run 2 must present the *persisted* rt1 (not a stale seed) to succeed.
561        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        // Run 1: seed rt0 → A1, rotation rt1 persisted.
580        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        // Run 2: a *fresh* provider with a now-stale seed must read the persisted
593        // rt1 and authenticate — proving cross-run rotation survival.
594        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        // A brand-new provider (same path) reads the rotated token off disk.
650        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        // A store that fails every read and write must not fail the run: the
660        // provider warns and falls back to the config seed for the fetch.
661        #[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        // Read fails (warned) → falls back to seed; refresh succeeds; write fails
694        // (warned) → still returns a valid credential.
695        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        // The mock only matches when the POST body carries the configured scope,
728        // so a passing assertion proves `scope=` was sent on the refresh grant.
729        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        // A request carrying any `scope=` param must NOT match; only the
759        // scope-free mock does, proving the parameter is omitted by default.
760        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        // Verify no request body contained a scope parameter.
780        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        // An empty `scope: ""` should be normalized to `None` so we never send an
792        // empty `scope=` (which some IdPs reject).
793        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}