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/// A live, shareable source of credentials.
96///
97/// One instance is shared (via [`Arc`]) across all connectors that reference it,
98/// giving single-flight refresh: concurrent callers during a refresh await the
99/// one in-flight fetch rather than each refreshing independently.
100///
101/// Object-safe — no generics or associated types, so it can be held as
102/// `Arc<dyn AuthProvider>` ([`SharedAuthProvider`]).
103#[async_trait]
104pub trait AuthProvider: Send + Sync + std::fmt::Debug {
105    /// Return a currently-valid credential, refreshing if needed.
106    async fn credential(&self) -> Result<Credential, FaucetError>;
107
108    /// Force a refresh **iff** the cached credential still equals `stale`
109    /// (compare-and-swap). Multiple connectors that hit a `401` with the same
110    /// token collapse into a single refresh; callers holding an already-rotated
111    /// token get the new one without triggering another fetch.
112    ///
113    /// The default delegates to [`AuthProvider::credential`]; providers that
114    /// support refresh override it.
115    async fn invalidate(&self, _stale: &Credential) -> Result<Credential, FaucetError> {
116        self.credential().await
117    }
118
119    /// Stable, non-empty name for diagnostics and metrics.
120    fn provider_name(&self) -> &'static str;
121}
122
123/// A shared [`AuthProvider`] handle. Cloning it shares the one live provider
124/// (and its single token cache) across connectors.
125pub type SharedAuthProvider = Arc<dyn AuthProvider>;
126
127/// A `{ ref: <name> }` pointer to a named provider in the top-level `auth:`
128/// catalog. The only permitted key is `ref`.
129#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
130#[serde(deny_unknown_fields)]
131pub struct AuthReference {
132    /// Name of the provider in the top-level `auth:` catalog.
133    #[serde(rename = "ref")]
134    pub name: String,
135}
136
137/// A connector's `auth:` field: **either** an inline auth definition `A`
138/// (the `{ type, config }` shape), **or** a `{ ref: <name> }` reference to a
139/// shared provider defined in the top-level `auth:` catalog.
140///
141/// `ref` is mutually exclusive with inline fields — supplying both is a
142/// deserialization error.
143#[derive(Debug, Clone, Serialize, JsonSchema)]
144#[serde(untagged)]
145pub enum AuthSpec<A> {
146    /// Inline auth, spelled out on the connector.
147    Inline(A),
148    /// A reference to a shared provider in the top-level `auth:` catalog.
149    Reference(AuthReference),
150}
151
152impl<A: Default> Default for AuthSpec<A> {
153    fn default() -> Self {
154        AuthSpec::Inline(A::default())
155    }
156}
157
158impl<A> AuthSpec<A> {
159    /// The inline auth, if this is not a reference.
160    pub fn inline(&self) -> Option<&A> {
161        match self {
162            AuthSpec::Inline(a) => Some(a),
163            AuthSpec::Reference(_) => None,
164        }
165    }
166
167    /// The referenced provider name, if this is a reference.
168    pub fn reference_name(&self) -> Option<&str> {
169        match self {
170            AuthSpec::Reference(r) => Some(&r.name),
171            AuthSpec::Inline(_) => None,
172        }
173    }
174}
175
176// Manual `Deserialize` enforces the `ref`-XOR-inline rule, which a plain
177// `#[serde(untagged)]` derive cannot (it would silently ignore extra keys).
178impl<'de, A> Deserialize<'de> for AuthSpec<A>
179where
180    A: serde::de::DeserializeOwned,
181{
182    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
183    where
184        D: Deserializer<'de>,
185    {
186        let value = serde_json::Value::deserialize(deserializer)?;
187        let has_ref = value.get("ref").is_some();
188        if has_ref {
189            let has_other = value
190                .as_object()
191                .map(|o| o.keys().any(|k| k != "ref"))
192                .unwrap_or(false);
193            if has_other {
194                return Err(serde::de::Error::custom(
195                    "auth: `ref` cannot be combined with inline auth fields (type/config)",
196                ));
197            }
198            let r: AuthReference =
199                serde_json::from_value(value).map_err(serde::de::Error::custom)?;
200            return Ok(AuthSpec::Reference(r));
201        }
202        let inner: A = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
203        Ok(AuthSpec::Inline(inner))
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[derive(Debug, Deserialize, PartialEq)]
212    #[serde(tag = "type", content = "config", rename_all = "snake_case")]
213    enum StubAuth {
214        None,
215        Bearer { token: String },
216    }
217
218    #[test]
219    fn credential_authorization_value() {
220        assert_eq!(
221            Credential::Bearer("abc".into()).authorization_value(),
222            Some("Bearer abc".to_string())
223        );
224        assert_eq!(
225            Credential::Token("Custom xyz".into()).authorization_value(),
226            Some("Custom xyz".to_string())
227        );
228        assert_eq!(
229            Credential::Basic {
230                username: "u".into(),
231                password: "p".into()
232            }
233            .authorization_value(),
234            None
235        );
236        assert_eq!(
237            Credential::Header {
238                name: "X-Api-Key".into(),
239                value: "k".into()
240            }
241            .authorization_value(),
242            None
243        );
244    }
245
246    #[test]
247    fn authspec_parses_inline() {
248        let j = serde_json::json!({"type": "bearer", "config": {"token": "t"}});
249        let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
250        match s {
251            AuthSpec::Inline(StubAuth::Bearer { token }) => assert_eq!(token, "t"),
252            other => panic!("expected inline bearer, got {other:?}"),
253        }
254    }
255
256    #[test]
257    fn authspec_parses_inline_unit_variant() {
258        let j = serde_json::json!({"type": "none"});
259        let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
260        assert!(matches!(s, AuthSpec::Inline(StubAuth::None)));
261    }
262
263    #[test]
264    fn authspec_parses_ref() {
265        let j = serde_json::json!({"ref": "sf"});
266        let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
267        assert_eq!(s.reference_name(), Some("sf"));
268    }
269
270    #[test]
271    fn authspec_rejects_ref_plus_inline() {
272        let j = serde_json::json!({"ref": "sf", "type": "bearer"});
273        let r: Result<AuthSpec<StubAuth>, _> = serde_json::from_value(j);
274        assert!(r.is_err(), "ref + inline must be rejected");
275    }
276
277    #[derive(Debug)]
278    struct Fixed(Credential);
279
280    #[async_trait]
281    impl AuthProvider for Fixed {
282        async fn credential(&self) -> Result<Credential, FaucetError> {
283            Ok(self.0.clone())
284        }
285        fn provider_name(&self) -> &'static str {
286            "fixed"
287        }
288    }
289
290    #[test]
291    fn credential_debug_redacts_secrets() {
292        // Bearer / Token fully redact the secret value.
293        let b = format!("{:?}", Credential::Bearer("supersecrettoken".into()));
294        assert!(!b.contains("supersecrettoken"), "bearer token leaked: {b}");
295        assert!(b.contains("***"), "bearer token not masked: {b}");
296
297        let t = format!("{:?}", Credential::Token("tok-supersecretxyz".into()));
298        assert!(!t.contains("tok-supersecretxyz"), "raw token leaked: {t}");
299        assert!(t.contains("***"), "raw token not masked: {t}");
300
301        // Basic redacts the password but keeps the (non-secret) username.
302        let basic = format!(
303            "{:?}",
304            Credential::Basic {
305                username: "alice".into(),
306                password: "hunter2secret".into(),
307            }
308        );
309        assert!(!basic.contains("hunter2secret"), "password leaked: {basic}");
310        assert!(
311            basic.contains("alice"),
312            "username should stay visible for diagnostics: {basic}"
313        );
314
315        // Header redacts the value but keeps the (non-secret) header name.
316        let header = format!(
317            "{:?}",
318            Credential::Header {
319                name: "X-Api-Key".into(),
320                value: "secretkeyvalue".into(),
321            }
322        );
323        assert!(
324            !header.contains("secretkeyvalue"),
325            "header value leaked: {header}"
326        );
327        assert!(
328            header.contains("X-Api-Key"),
329            "header name should stay visible for diagnostics: {header}"
330        );
331    }
332
333    #[tokio::test]
334    async fn auth_provider_default_invalidate_returns_current() {
335        let p = Fixed(Credential::Bearer("x".into()));
336        assert_eq!(
337            p.credential().await.unwrap(),
338            Credential::Bearer("x".into())
339        );
340        // Default invalidate just returns the current credential.
341        assert_eq!(
342            p.invalidate(&Credential::Bearer("old".into()))
343                .await
344                .unwrap(),
345            Credential::Bearer("x".into())
346        );
347    }
348}