Skip to main content

faucet_core/
auth.rs

1//! Shared, connector-agnostic authentication abstraction.
2//!
3//! Multiple connectors that authenticate against the **same** system (e.g. four
4//! matrix rows reading from one Snowflake account, or four endpoints of one REST
5//! API) can share a single [`AuthProvider`]. A provider is a live entity that
6//! owns the token cache and refresh lifecycle; connectors hold an [`Arc`] to it
7//! and ask for the current [`Credential`] per request, so N connectors share one
8//! token with single-flight refresh instead of racing to refresh it.
9//!
10//! - [`Credential`] — a resolved credential (bearer token, header, basic auth).
11//! - [`AuthProvider`] — an object-safe trait yielding credentials, with
12//!   single-flight refresh implemented by the provider.
13//! - [`AuthSpec`] — a connector config field that is **either** inline auth
14//!   `{ type, config }` **or** a `{ ref: <name> }` pointer to a shared provider.
15//!
16//! The HTTP-based provider implementations (OAuth2, token-endpoint) live in the
17//! separate `faucet-auth` crate so `faucet-core` stays free of an HTTP-client
18//! dependency.
19
20use crate::FaucetError;
21use async_trait::async_trait;
22use schemars::JsonSchema;
23use serde::{Deserialize, Deserializer, Serialize};
24use std::sync::Arc;
25
26/// A resolved credential produced by an [`AuthProvider`] or built from inline
27/// auth config. Connectors map this onto their wire protocol (HTTP header, gRPC
28/// metadata, …).
29///
30/// Intentionally **not** `#[non_exhaustive]`: connectors must map every variant,
31/// so adding one should be a compile error that forces correct handling rather
32/// than a silently-ignored fallback.
33#[derive(Clone, PartialEq, Eq)]
34pub enum Credential {
35    /// `Authorization: Bearer <token>`.
36    Bearer(String),
37    /// An explicit header name + value.
38    Header {
39        /// Header name (e.g. `Authorization`, `X-Api-Key`).
40        name: String,
41        /// Header value.
42        value: String,
43    },
44    /// HTTP Basic credentials.
45    Basic {
46        /// Username.
47        username: String,
48        /// Password.
49        password: String,
50    },
51    /// A raw token for connector-specific assembly (e.g. gRPC `authorization`
52    /// metadata, Snowflake's `Authorization` with a token-type header).
53    Token(String),
54}
55
56// `Debug` is hand-written (not derived) so a `{:?}` of a credential — or of any
57// struct that embeds one, e.g. a logged connector config or a `StaticProvider` —
58// never prints the secret in clear. The secret-bearing fields render as `"***"`;
59// the non-secret identifiers (header name, basic-auth username) stay visible so
60// the output is still useful for diagnostics. `Credential` is a 1.0-frozen public
61// type, so this redaction is part of its contract.
62impl std::fmt::Debug for Credential {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Credential::Bearer(_) => f.debug_tuple("Bearer").field(&"***").finish(),
66            Credential::Header { name, .. } => f
67                .debug_struct("Header")
68                .field("name", name)
69                .field("value", &"***")
70                .finish(),
71            Credential::Basic { username, .. } => f
72                .debug_struct("Basic")
73                .field("username", username)
74                .field("password", &"***")
75                .finish(),
76            Credential::Token(_) => f.debug_tuple("Token").field(&"***").finish(),
77        }
78    }
79}
80
81impl Credential {
82    /// The value to use for an `Authorization` header, when this credential maps
83    /// to one. Returns `None` for credentials that are applied differently
84    /// (e.g. [`Credential::Basic`], which connectors apply via basic-auth, or
85    /// [`Credential::Header`], which carries its own name).
86    pub fn authorization_value(&self) -> Option<String> {
87        match self {
88            Credential::Bearer(t) => Some(format!("Bearer {t}")),
89            Credential::Token(t) => Some(t.clone()),
90            Credential::Header { .. } | Credential::Basic { .. } => None,
91        }
92    }
93}
94
95/// One placement of a captured credential into an outgoing HTTP request,
96/// produced by [`AuthProvider::request_auth`]. Unlike [`Credential`] (which is
97/// header/bearer-shaped), a placement can target a query parameter, cookie, or
98/// JSON body field — the shapes multi-step auth flows (#511) need.
99///
100/// `#[non_exhaustive]` so new placement kinds can be added as a minor change.
101#[derive(Clone, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum CredentialPlacement {
104    /// An HTTP header `name: value`.
105    Header {
106        /// Header name.
107        name: String,
108        /// Header value.
109        value: String,
110    },
111    /// A query-string parameter `?name=value`.
112    Query {
113        /// Parameter name.
114        name: String,
115        /// Parameter value.
116        value: String,
117    },
118    /// A cookie `name=value` (sent via the `Cookie` header).
119    Cookie {
120        /// Cookie name.
121        name: String,
122        /// Cookie value.
123        value: String,
124    },
125    /// A top-level field of the JSON request body.
126    BodyField {
127        /// Field name.
128        name: String,
129        /// Field value (string).
130        value: String,
131    },
132}
133
134// Hand-written `Debug` redacts the secret-bearing `value` while keeping the
135// non-secret `name` visible — same contract as `Credential`.
136impl std::fmt::Debug for CredentialPlacement {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        let (kind, name) = match self {
139            CredentialPlacement::Header { name, .. } => ("Header", name),
140            CredentialPlacement::Query { name, .. } => ("Query", name),
141            CredentialPlacement::Cookie { name, .. } => ("Cookie", name),
142            CredentialPlacement::BodyField { name, .. } => ("BodyField", name),
143        };
144        f.debug_struct(kind)
145            .field("name", name)
146            .field("value", &"***")
147            .finish()
148    }
149}
150
151/// The per-request auth a provider contributes beyond a single [`Credential`]:
152/// zero or more [`CredentialPlacement`]s plus an optional dynamic base-URL that
153/// overrides the connector's configured one (captured from a login response —
154/// a per-session REST URL, a region-specific host, #511).
155///
156/// A connector that receives a non-[`is_empty`](RequestAuth::is_empty)
157/// `RequestAuth` uses it **instead of** the plain [`credential`](AuthProvider::credential)
158/// path for that request. `#[non_exhaustive]`: construct via [`RequestAuth::new`]
159/// and the builder methods so fields can be added as a minor change.
160#[derive(Clone, Default)]
161#[non_exhaustive]
162pub struct RequestAuth {
163    /// Credential placements to apply to the request.
164    pub placements: Vec<CredentialPlacement>,
165    /// Optional per-session base-URL override.
166    pub base_url: Option<String>,
167    /// Values captured by a multi-step login (name → value), exposed so a
168    /// connector can substitute `${name}` tokens into a **raw request body**
169    /// (or header) string — an XML/SOAP gateway that must carry a captured
170    /// `sessionid` inside its body, which no [`CredentialPlacement`] can express
171    /// (#567). These are the same captured values the flow's `apply` placements
172    /// draw from; secret-bearing, so a connector must treat them as sensitive.
173    pub captured: std::collections::BTreeMap<String, String>,
174}
175
176impl RequestAuth {
177    /// An empty `RequestAuth` (no placements, no base-URL override).
178    pub fn new() -> Self {
179        Self::default()
180    }
181
182    /// Add a credential placement (builder).
183    #[must_use]
184    pub fn with_placement(mut self, placement: CredentialPlacement) -> Self {
185        self.placements.push(placement);
186        self
187    }
188
189    /// Set the dynamic base-URL override (builder).
190    #[must_use]
191    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
192        self.base_url = Some(base_url.into());
193        self
194    }
195
196    /// Attach the captured login values (name → value) for `${name}` body/header
197    /// substitution (builder, #567).
198    #[must_use]
199    pub fn with_captured(mut self, captured: std::collections::BTreeMap<String, String>) -> Self {
200        self.captured = captured;
201        self
202    }
203
204    /// True when the provider contributed nothing (the connector then falls back
205    /// to the plain [`credential`](AuthProvider::credential) path).
206    pub fn is_empty(&self) -> bool {
207        self.placements.is_empty() && self.base_url.is_none() && self.captured.is_empty()
208    }
209}
210
211// Redacting `Debug`: placements redact their own values; the base-URL is passed
212// through `redact_uri_credentials` so any embedded userinfo is scrubbed.
213impl std::fmt::Debug for RequestAuth {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        f.debug_struct("RequestAuth")
216            .field("placements", &self.placements)
217            .field(
218                "base_url",
219                &self
220                    .base_url
221                    .as_deref()
222                    .map(crate::util::redact_uri_credentials),
223            )
224            .finish()
225    }
226}
227
228/// A live, shareable source of credentials.
229///
230/// One instance is shared (via [`Arc`]) across all connectors that reference it,
231/// giving single-flight refresh: concurrent callers during a refresh await the
232/// one in-flight fetch rather than each refreshing independently.
233///
234/// Object-safe — no generics or associated types, so it can be held as
235/// `Arc<dyn AuthProvider>` ([`SharedAuthProvider`]).
236#[async_trait]
237pub trait AuthProvider: Send + Sync + std::fmt::Debug {
238    /// Return a currently-valid credential, refreshing if needed.
239    async fn credential(&self) -> Result<Credential, FaucetError>;
240
241    /// Force a refresh **iff** the cached credential still equals `stale`
242    /// (compare-and-swap). Multiple connectors that hit a `401` with the same
243    /// token collapse into a single refresh; callers holding an already-rotated
244    /// token get the new one without triggering another fetch.
245    ///
246    /// The default delegates to [`AuthProvider::credential`]; providers that
247    /// support refresh override it.
248    async fn invalidate(&self, _stale: &Credential) -> Result<Credential, FaucetError> {
249        self.credential().await
250    }
251
252    /// Per-request signing hook (OAuth1 and similar, #496).
253    ///
254    /// Most providers issue a **reusable** credential via [`credential`](Self::credential);
255    /// they return `Ok(None)` here (the default) and the connector applies the
256    /// cached credential. A provider that must sign **each request individually**
257    /// (e.g. OAuth1, whose signature covers the HTTP method, URL, and query
258    /// parameters) overrides this to compute a fresh [`Credential`] — typically a
259    /// [`Credential::Header`] carrying the `Authorization` signature — from the
260    /// request. When it returns `Some`, the connector uses it **instead of**
261    /// `credential()` for that request.
262    ///
263    /// `query` holds query parameters not already in `url`; OAuth1 folds both
264    /// `query` and the query component of `url` into its signature base string,
265    /// so a connector can pass the exact URL it sends (with an empty `query`); a
266    /// key present in both is taken from `query` only.
267    /// Object-safe: no generics, all args are borrowed primitives.
268    async fn sign_request(
269        &self,
270        _method: &str,
271        _url: &str,
272        _query: &std::collections::BTreeMap<String, String>,
273    ) -> Result<Option<Credential>, FaucetError> {
274        Ok(None)
275    }
276
277    /// Richer per-request auth for multi-step flows (#511): credential
278    /// placements across header / query / cookie / body plus an optional
279    /// dynamic base-URL override.
280    ///
281    /// Most providers issue a single [`Credential`] and return an empty
282    /// [`RequestAuth`] here (the default); the connector then applies the plain
283    /// [`credential`](Self::credential) path. A provider that must place a
284    /// captured value somewhere other than a header, place several values at
285    /// once, or redirect the request to a captured base-URL overrides this. When
286    /// it returns a non-[`is_empty`](RequestAuth::is_empty) value, the connector
287    /// applies the placements **instead of** `credential()` for that request.
288    ///
289    /// `query` is the request's query parameters before the HTTP client appends
290    /// them (some flows fold them into a signature). Object-safe: no generics,
291    /// all args are borrowed primitives.
292    async fn request_auth(
293        &self,
294        _method: &str,
295        _url: &str,
296        _query: &std::collections::BTreeMap<String, String>,
297    ) -> Result<RequestAuth, FaucetError> {
298        Ok(RequestAuth::new())
299    }
300
301    /// HTTP status codes on which the connector should force a re-auth
302    /// (via [`invalidate`](Self::invalidate)) and retry the request once.
303    ///
304    /// The default is empty — connectors keep their built-in `401` handling.
305    /// A multi-step flow (#511) whose session cookie/token can expire mid-run
306    /// returns the statuses (e.g. `[401]`, `[401, 403]`) that mean "log in again".
307    fn reauth_statuses(&self) -> &[u16] {
308        &[]
309    }
310
311    /// Stable, non-empty name for diagnostics and metrics.
312    fn provider_name(&self) -> &'static str;
313}
314
315/// A shared [`AuthProvider`] handle. Cloning it shares the one live provider
316/// (and its single token cache) across connectors.
317pub type SharedAuthProvider = Arc<dyn AuthProvider>;
318
319/// Whether `err` says the server rejected the credential a request carried: an
320/// HTTP `401`, or one of the provider's declared
321/// [`reauth_statuses`](AuthProvider::reauth_statuses).
322pub fn rejects_credential(err: &FaucetError, provider: &dyn AuthProvider) -> bool {
323    matches!(
324        err,
325        FaucetError::HttpStatus { status, .. }
326            if *status == 401 || provider.reauth_statuses().contains(status)
327    )
328}
329
330/// Send one authenticated request, re-authenticating once when the server
331/// rejects the credential.
332///
333/// With no `provider`, `send(None)` runs as-is. Otherwise the provider's
334/// current credential is passed to `send`; if that attempt fails with
335/// [`rejects_credential`], the provider is told the credential is stale
336/// ([`invalidate`](AuthProvider::invalidate), which refreshes it once even when
337/// several connectors hit the same `401`) and `send` runs again with the fresh
338/// one. A token the server expired or revoked before its client-side expiry
339/// therefore recovers on the next request instead of failing every run until a
340/// restart.
341pub async fn send_with_reauth<T, F, Fut>(
342    provider: Option<&SharedAuthProvider>,
343    mut send: F,
344) -> Result<T, FaucetError>
345where
346    F: FnMut(Option<Credential>) -> Fut,
347    Fut: std::future::Future<Output = Result<T, FaucetError>>,
348{
349    let Some(provider) = provider else {
350        return send(None).await;
351    };
352    let sent = provider.credential().await?;
353    match send(Some(sent.clone())).await {
354        Err(e) if rejects_credential(&e, provider.as_ref()) => {
355            let name = provider.provider_name();
356            tracing::warn!(
357                provider = name,
358                "the server rejected the shared credential; re-authenticating and retrying once"
359            );
360            let fresh = provider.invalidate(&sent).await?;
361            send(Some(fresh)).await
362        }
363        other => other,
364    }
365}
366
367/// A `{ ref: <name> }` pointer to a named provider in the top-level `auth:`
368/// catalog. The only permitted key is `ref`.
369#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
370#[serde(deny_unknown_fields)]
371pub struct AuthReference {
372    /// Name of the provider in the top-level `auth:` catalog.
373    #[serde(rename = "ref")]
374    pub name: String,
375}
376
377/// A connector's `auth:` field: **either** an inline auth definition `A`
378/// (the `{ type, config }` shape), **or** a `{ ref: <name> }` reference to a
379/// shared provider defined in the top-level `auth:` catalog.
380///
381/// `ref` is mutually exclusive with inline fields — supplying both is a
382/// deserialization error.
383#[derive(Debug, Clone, Serialize, JsonSchema)]
384#[serde(untagged)]
385pub enum AuthSpec<A> {
386    /// Inline auth, spelled out on the connector.
387    Inline(A),
388    /// A reference to a shared provider in the top-level `auth:` catalog.
389    Reference(AuthReference),
390}
391
392impl<A: Default> Default for AuthSpec<A> {
393    fn default() -> Self {
394        AuthSpec::Inline(A::default())
395    }
396}
397
398impl<A> AuthSpec<A> {
399    /// The inline auth, if this is not a reference.
400    pub fn inline(&self) -> Option<&A> {
401        match self {
402            AuthSpec::Inline(a) => Some(a),
403            AuthSpec::Reference(_) => None,
404        }
405    }
406
407    /// The referenced provider name, if this is a reference.
408    pub fn reference_name(&self) -> Option<&str> {
409        match self {
410            AuthSpec::Reference(r) => Some(&r.name),
411            AuthSpec::Inline(_) => None,
412        }
413    }
414}
415
416// Manual `Deserialize` enforces the `ref`-XOR-inline rule, which a plain
417// `#[serde(untagged)]` derive cannot (it would silently ignore extra keys).
418impl<'de, A> Deserialize<'de> for AuthSpec<A>
419where
420    A: serde::de::DeserializeOwned,
421{
422    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
423    where
424        D: Deserializer<'de>,
425    {
426        let value = serde_json::Value::deserialize(deserializer)?;
427        let has_ref = value.get("ref").is_some();
428        if has_ref {
429            let has_other = value
430                .as_object()
431                .map(|o| o.keys().any(|k| k != "ref"))
432                .unwrap_or(false);
433            if has_other {
434                return Err(serde::de::Error::custom(
435                    "auth: `ref` cannot be combined with inline auth fields (type/config)",
436                ));
437            }
438            let r: AuthReference =
439                serde_json::from_value(value).map_err(serde::de::Error::custom)?;
440            return Ok(AuthSpec::Reference(r));
441        }
442        let inner: A = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
443        Ok(AuthSpec::Inline(inner))
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[derive(Debug, Deserialize, PartialEq)]
452    #[serde(tag = "type", content = "config", rename_all = "snake_case")]
453    enum StubAuth {
454        None,
455        Bearer { token: String },
456    }
457
458    /// A provider whose tokens go stale on demand: `invalidate` hands out the
459    /// next token and counts the refreshes.
460    #[derive(Debug, Default)]
461    struct RotatingProvider {
462        token: std::sync::Mutex<u32>,
463        refreshes: std::sync::atomic::AtomicU32,
464    }
465
466    #[async_trait]
467    impl AuthProvider for RotatingProvider {
468        async fn credential(&self) -> Result<Credential, FaucetError> {
469            Ok(Credential::Bearer(format!(
470                "t{}",
471                self.token.lock().unwrap()
472            )))
473        }
474        async fn invalidate(&self, stale: &Credential) -> Result<Credential, FaucetError> {
475            let mut t = self.token.lock().unwrap();
476            if *stale == Credential::Bearer(format!("t{t}")) {
477                *t += 1;
478                self.refreshes
479                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
480            }
481            Ok(Credential::Bearer(format!("t{t}")))
482        }
483        fn reauth_statuses(&self) -> &[u16] {
484            &[419]
485        }
486        fn provider_name(&self) -> &'static str {
487            "rotating"
488        }
489    }
490
491    fn rejected(status: u16) -> FaucetError {
492        FaucetError::HttpStatus {
493            status,
494            url: "https://api".into(),
495            body: String::new(),
496        }
497    }
498
499    #[tokio::test]
500    async fn a_rejected_credential_is_refreshed_and_the_request_retried_once() {
501        let provider: SharedAuthProvider = Arc::new(RotatingProvider::default());
502        let mut seen = Vec::new();
503        let out = send_with_reauth(Some(&provider), |cred| {
504            seen.push(cred.clone());
505            let ok = cred == Some(Credential::Bearer("t1".into()));
506            async move { if ok { Ok("data") } else { Err(rejected(401)) } }
507        })
508        .await
509        .unwrap();
510        assert_eq!(out, "data");
511        assert_eq!(
512            seen,
513            vec![
514                Some(Credential::Bearer("t0".into())),
515                Some(Credential::Bearer("t1".into()))
516            ]
517        );
518    }
519
520    #[tokio::test]
521    async fn a_declared_reauth_status_also_refreshes_but_only_once() {
522        let provider: SharedAuthProvider = Arc::new(RotatingProvider::default());
523        let mut attempts = 0;
524        let err = send_with_reauth(Some(&provider), |_| {
525            attempts += 1;
526            async { Err::<(), _>(rejected(419)) }
527        })
528        .await
529        .unwrap_err();
530        assert!(matches!(err, FaucetError::HttpStatus { status: 419, .. }));
531        assert_eq!(attempts, 2, "one retry, never a loop");
532    }
533
534    #[tokio::test]
535    async fn other_failures_and_providerless_requests_are_not_retried() {
536        let provider: SharedAuthProvider = Arc::new(RotatingProvider::default());
537        let mut attempts = 0;
538        let err = send_with_reauth(Some(&provider), |_| {
539            attempts += 1;
540            async { Err::<(), _>(rejected(500)) }
541        })
542        .await
543        .unwrap_err();
544        assert!(matches!(err, FaucetError::HttpStatus { status: 500, .. }));
545        assert_eq!(attempts, 1);
546
547        let mut got = None;
548        send_with_reauth(None, |cred| {
549            got = Some(cred);
550            async { Ok::<_, FaucetError>(()) }
551        })
552        .await
553        .unwrap();
554        assert_eq!(
555            got,
556            Some(None),
557            "no provider → the request gets no credential"
558        );
559    }
560
561    #[derive(Debug)]
562    struct MinimalProvider;
563
564    #[async_trait]
565    impl AuthProvider for MinimalProvider {
566        async fn credential(&self) -> Result<Credential, FaucetError> {
567            Ok(Credential::Bearer("t".into()))
568        }
569        fn provider_name(&self) -> &'static str {
570            "minimal"
571        }
572    }
573
574    #[test]
575    fn credential_placement_debug_redacts_value_keeps_name() {
576        let cases = [
577            CredentialPlacement::Header {
578                name: "X-Tok".into(),
579                value: "secret".into(),
580            },
581            CredentialPlacement::Query {
582                name: "access_token".into(),
583                value: "secret".into(),
584            },
585            CredentialPlacement::Cookie {
586                name: "sid".into(),
587                value: "secret".into(),
588            },
589            CredentialPlacement::BodyField {
590                name: "auth".into(),
591                value: "secret".into(),
592            },
593        ];
594        for p in cases {
595            let s = format!("{p:?}");
596            assert!(s.contains("***"), "value must be redacted: {s}");
597            assert!(!s.contains("secret"), "secret leaked: {s}");
598        }
599    }
600
601    #[test]
602    fn request_auth_builders_and_is_empty() {
603        let empty = RequestAuth::new();
604        assert!(empty.is_empty());
605
606        let ra = RequestAuth::new()
607            .with_placement(CredentialPlacement::Query {
608                name: "t".into(),
609                value: "v".into(),
610            })
611            .with_base_url("https://host");
612        assert!(!ra.is_empty());
613        assert_eq!(ra.placements.len(), 1);
614        assert_eq!(ra.base_url.as_deref(), Some("https://host"));
615
616        // base-URL alone (no placements) is still non-empty.
617        assert!(!RequestAuth::new().with_base_url("https://h").is_empty());
618        assert!(RequestAuth::default().is_empty());
619
620        // Captured values alone (no placements / base-URL) also make it
621        // non-empty, and are exposed for `${name}` body substitution (#567).
622        let mut cap = std::collections::BTreeMap::new();
623        cap.insert("session_id".to_string(), "SID".to_string());
624        let ra = RequestAuth::new().with_captured(cap);
625        assert!(!ra.is_empty());
626        assert_eq!(
627            ra.captured.get("session_id").map(String::as_str),
628            Some("SID")
629        );
630    }
631
632    #[test]
633    fn request_auth_debug_redacts_placements_and_base_url() {
634        let ra = RequestAuth::new()
635            .with_placement(CredentialPlacement::Header {
636                name: "Authorization".into(),
637                value: "topsecret".into(),
638            })
639            .with_base_url("https://user:pw@host/path");
640        let s = format!("{ra:?}");
641        assert!(!s.contains("topsecret"), "placement value leaked: {s}");
642        assert!(!s.contains("pw"), "base-url userinfo leaked: {s}");
643    }
644
645    #[tokio::test]
646    async fn default_request_auth_is_empty_and_reauth_is_empty() {
647        let p = MinimalProvider;
648        assert!(matches!(
649            p.credential().await.unwrap(),
650            Credential::Bearer(_)
651        ));
652        assert_eq!(p.provider_name(), "minimal");
653        let ra = p
654            .request_auth("GET", "https://x", &std::collections::BTreeMap::new())
655            .await
656            .unwrap();
657        assert!(ra.is_empty());
658        assert!(p.reauth_statuses().is_empty());
659        // The default sign_request also contributes nothing.
660        assert!(
661            p.sign_request("GET", "https://x", &std::collections::BTreeMap::new())
662                .await
663                .unwrap()
664                .is_none()
665        );
666    }
667
668    #[test]
669    fn credential_authorization_value() {
670        assert_eq!(
671            Credential::Bearer("abc".into()).authorization_value(),
672            Some("Bearer abc".to_string())
673        );
674        assert_eq!(
675            Credential::Token("Custom xyz".into()).authorization_value(),
676            Some("Custom xyz".to_string())
677        );
678        assert_eq!(
679            Credential::Basic {
680                username: "u".into(),
681                password: "p".into()
682            }
683            .authorization_value(),
684            None
685        );
686        assert_eq!(
687            Credential::Header {
688                name: "X-Api-Key".into(),
689                value: "k".into()
690            }
691            .authorization_value(),
692            None
693        );
694    }
695
696    #[test]
697    fn authspec_parses_inline() {
698        let j = serde_json::json!({"type": "bearer", "config": {"token": "t"}});
699        let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
700        match s {
701            AuthSpec::Inline(StubAuth::Bearer { token }) => assert_eq!(token, "t"),
702            other => panic!("expected inline bearer, got {other:?}"),
703        }
704    }
705
706    #[test]
707    fn authspec_parses_inline_unit_variant() {
708        let j = serde_json::json!({"type": "none"});
709        let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
710        assert!(matches!(s, AuthSpec::Inline(StubAuth::None)));
711    }
712
713    #[test]
714    fn authspec_parses_ref() {
715        let j = serde_json::json!({"ref": "sf"});
716        let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
717        assert_eq!(s.reference_name(), Some("sf"));
718    }
719
720    #[test]
721    fn authspec_rejects_ref_plus_inline() {
722        let j = serde_json::json!({"ref": "sf", "type": "bearer"});
723        let r: Result<AuthSpec<StubAuth>, _> = serde_json::from_value(j);
724        assert!(r.is_err(), "ref + inline must be rejected");
725    }
726
727    #[derive(Debug)]
728    struct Fixed(Credential);
729
730    #[async_trait]
731    impl AuthProvider for Fixed {
732        async fn credential(&self) -> Result<Credential, FaucetError> {
733            Ok(self.0.clone())
734        }
735        fn provider_name(&self) -> &'static str {
736            "fixed"
737        }
738    }
739
740    #[test]
741    fn credential_debug_redacts_secrets() {
742        // Bearer / Token fully redact the secret value.
743        let b = format!("{:?}", Credential::Bearer("supersecrettoken".into()));
744        assert!(!b.contains("supersecrettoken"), "bearer token leaked: {b}");
745        assert!(b.contains("***"), "bearer token not masked: {b}");
746
747        let t = format!("{:?}", Credential::Token("tok-supersecretxyz".into()));
748        assert!(!t.contains("tok-supersecretxyz"), "raw token leaked: {t}");
749        assert!(t.contains("***"), "raw token not masked: {t}");
750
751        // Basic redacts the password but keeps the (non-secret) username.
752        let basic = format!(
753            "{:?}",
754            Credential::Basic {
755                username: "alice".into(),
756                password: "hunter2secret".into(),
757            }
758        );
759        assert!(!basic.contains("hunter2secret"), "password leaked: {basic}");
760        assert!(
761            basic.contains("alice"),
762            "username should stay visible for diagnostics: {basic}"
763        );
764
765        // Header redacts the value but keeps the (non-secret) header name.
766        let header = format!(
767            "{:?}",
768            Credential::Header {
769                name: "X-Api-Key".into(),
770                value: "secretkeyvalue".into(),
771            }
772        );
773        assert!(
774            !header.contains("secretkeyvalue"),
775            "header value leaked: {header}"
776        );
777        assert!(
778            header.contains("X-Api-Key"),
779            "header name should stay visible for diagnostics: {header}"
780        );
781    }
782
783    #[tokio::test]
784    async fn auth_provider_default_invalidate_returns_current() {
785        let p = Fixed(Credential::Bearer("x".into()));
786        assert_eq!(
787            p.credential().await.unwrap(),
788            Credential::Bearer("x".into())
789        );
790        // Default invalidate just returns the current credential.
791        assert_eq!(
792            p.invalidate(&Credential::Bearer("old".into()))
793                .await
794                .unwrap(),
795            Credential::Bearer("x".into())
796        );
797    }
798}