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/// Bullhorn `restUrl`, Zoho region 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` is the request's query parameters (the connector's, before the
264 /// HTTP client appends them), which OAuth1 folds into its signature base
265 /// string. Object-safe: no generics, all args are borrowed primitives.
266 async fn sign_request(
267 &self,
268 _method: &str,
269 _url: &str,
270 _query: &std::collections::BTreeMap<String, String>,
271 ) -> Result<Option<Credential>, FaucetError> {
272 Ok(None)
273 }
274
275 /// Richer per-request auth for multi-step flows (#511): credential
276 /// placements across header / query / cookie / body plus an optional
277 /// dynamic base-URL override.
278 ///
279 /// Most providers issue a single [`Credential`] and return an empty
280 /// [`RequestAuth`] here (the default); the connector then applies the plain
281 /// [`credential`](Self::credential) path. A provider that must place a
282 /// captured value somewhere other than a header, place several values at
283 /// once, or redirect the request to a captured base-URL overrides this. When
284 /// it returns a non-[`is_empty`](RequestAuth::is_empty) value, the connector
285 /// applies the placements **instead of** `credential()` for that request.
286 ///
287 /// `query` is the request's query parameters before the HTTP client appends
288 /// them (some flows fold them into a signature). Object-safe: no generics,
289 /// all args are borrowed primitives.
290 async fn request_auth(
291 &self,
292 _method: &str,
293 _url: &str,
294 _query: &std::collections::BTreeMap<String, String>,
295 ) -> Result<RequestAuth, FaucetError> {
296 Ok(RequestAuth::new())
297 }
298
299 /// HTTP status codes on which the connector should force a re-auth
300 /// (via [`invalidate`](Self::invalidate)) and retry the request once.
301 ///
302 /// The default is empty — connectors keep their built-in `401` handling.
303 /// A multi-step flow (#511) whose session cookie/token can expire mid-run
304 /// returns the statuses (e.g. `[401]`, `[401, 403]`) that mean "log in again".
305 fn reauth_statuses(&self) -> &[u16] {
306 &[]
307 }
308
309 /// Stable, non-empty name for diagnostics and metrics.
310 fn provider_name(&self) -> &'static str;
311}
312
313/// A shared [`AuthProvider`] handle. Cloning it shares the one live provider
314/// (and its single token cache) across connectors.
315pub type SharedAuthProvider = Arc<dyn AuthProvider>;
316
317/// A `{ ref: <name> }` pointer to a named provider in the top-level `auth:`
318/// catalog. The only permitted key is `ref`.
319#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
320#[serde(deny_unknown_fields)]
321pub struct AuthReference {
322 /// Name of the provider in the top-level `auth:` catalog.
323 #[serde(rename = "ref")]
324 pub name: String,
325}
326
327/// A connector's `auth:` field: **either** an inline auth definition `A`
328/// (the `{ type, config }` shape), **or** a `{ ref: <name> }` reference to a
329/// shared provider defined in the top-level `auth:` catalog.
330///
331/// `ref` is mutually exclusive with inline fields — supplying both is a
332/// deserialization error.
333#[derive(Debug, Clone, Serialize, JsonSchema)]
334#[serde(untagged)]
335pub enum AuthSpec<A> {
336 /// Inline auth, spelled out on the connector.
337 Inline(A),
338 /// A reference to a shared provider in the top-level `auth:` catalog.
339 Reference(AuthReference),
340}
341
342impl<A: Default> Default for AuthSpec<A> {
343 fn default() -> Self {
344 AuthSpec::Inline(A::default())
345 }
346}
347
348impl<A> AuthSpec<A> {
349 /// The inline auth, if this is not a reference.
350 pub fn inline(&self) -> Option<&A> {
351 match self {
352 AuthSpec::Inline(a) => Some(a),
353 AuthSpec::Reference(_) => None,
354 }
355 }
356
357 /// The referenced provider name, if this is a reference.
358 pub fn reference_name(&self) -> Option<&str> {
359 match self {
360 AuthSpec::Reference(r) => Some(&r.name),
361 AuthSpec::Inline(_) => None,
362 }
363 }
364}
365
366// Manual `Deserialize` enforces the `ref`-XOR-inline rule, which a plain
367// `#[serde(untagged)]` derive cannot (it would silently ignore extra keys).
368impl<'de, A> Deserialize<'de> for AuthSpec<A>
369where
370 A: serde::de::DeserializeOwned,
371{
372 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
373 where
374 D: Deserializer<'de>,
375 {
376 let value = serde_json::Value::deserialize(deserializer)?;
377 let has_ref = value.get("ref").is_some();
378 if has_ref {
379 let has_other = value
380 .as_object()
381 .map(|o| o.keys().any(|k| k != "ref"))
382 .unwrap_or(false);
383 if has_other {
384 return Err(serde::de::Error::custom(
385 "auth: `ref` cannot be combined with inline auth fields (type/config)",
386 ));
387 }
388 let r: AuthReference =
389 serde_json::from_value(value).map_err(serde::de::Error::custom)?;
390 return Ok(AuthSpec::Reference(r));
391 }
392 let inner: A = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
393 Ok(AuthSpec::Inline(inner))
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[derive(Debug, Deserialize, PartialEq)]
402 #[serde(tag = "type", content = "config", rename_all = "snake_case")]
403 enum StubAuth {
404 None,
405 Bearer { token: String },
406 }
407
408 #[derive(Debug)]
409 struct MinimalProvider;
410
411 #[async_trait]
412 impl AuthProvider for MinimalProvider {
413 async fn credential(&self) -> Result<Credential, FaucetError> {
414 Ok(Credential::Bearer("t".into()))
415 }
416 fn provider_name(&self) -> &'static str {
417 "minimal"
418 }
419 }
420
421 #[test]
422 fn credential_placement_debug_redacts_value_keeps_name() {
423 let cases = [
424 CredentialPlacement::Header {
425 name: "X-Tok".into(),
426 value: "secret".into(),
427 },
428 CredentialPlacement::Query {
429 name: "access_token".into(),
430 value: "secret".into(),
431 },
432 CredentialPlacement::Cookie {
433 name: "sid".into(),
434 value: "secret".into(),
435 },
436 CredentialPlacement::BodyField {
437 name: "auth".into(),
438 value: "secret".into(),
439 },
440 ];
441 for p in cases {
442 let s = format!("{p:?}");
443 assert!(s.contains("***"), "value must be redacted: {s}");
444 assert!(!s.contains("secret"), "secret leaked: {s}");
445 }
446 }
447
448 #[test]
449 fn request_auth_builders_and_is_empty() {
450 let empty = RequestAuth::new();
451 assert!(empty.is_empty());
452
453 let ra = RequestAuth::new()
454 .with_placement(CredentialPlacement::Query {
455 name: "t".into(),
456 value: "v".into(),
457 })
458 .with_base_url("https://host");
459 assert!(!ra.is_empty());
460 assert_eq!(ra.placements.len(), 1);
461 assert_eq!(ra.base_url.as_deref(), Some("https://host"));
462
463 // base-URL alone (no placements) is still non-empty.
464 assert!(!RequestAuth::new().with_base_url("https://h").is_empty());
465 assert!(RequestAuth::default().is_empty());
466
467 // Captured values alone (no placements / base-URL) also make it
468 // non-empty, and are exposed for `${name}` body substitution (#567).
469 let mut cap = std::collections::BTreeMap::new();
470 cap.insert("session_id".to_string(), "SID".to_string());
471 let ra = RequestAuth::new().with_captured(cap);
472 assert!(!ra.is_empty());
473 assert_eq!(
474 ra.captured.get("session_id").map(String::as_str),
475 Some("SID")
476 );
477 }
478
479 #[test]
480 fn request_auth_debug_redacts_placements_and_base_url() {
481 let ra = RequestAuth::new()
482 .with_placement(CredentialPlacement::Header {
483 name: "Authorization".into(),
484 value: "topsecret".into(),
485 })
486 .with_base_url("https://user:pw@host/path");
487 let s = format!("{ra:?}");
488 assert!(!s.contains("topsecret"), "placement value leaked: {s}");
489 assert!(!s.contains("pw"), "base-url userinfo leaked: {s}");
490 }
491
492 #[tokio::test]
493 async fn default_request_auth_is_empty_and_reauth_is_empty() {
494 let p = MinimalProvider;
495 assert!(matches!(
496 p.credential().await.unwrap(),
497 Credential::Bearer(_)
498 ));
499 assert_eq!(p.provider_name(), "minimal");
500 let ra = p
501 .request_auth("GET", "https://x", &std::collections::BTreeMap::new())
502 .await
503 .unwrap();
504 assert!(ra.is_empty());
505 assert!(p.reauth_statuses().is_empty());
506 // The default sign_request also contributes nothing.
507 assert!(
508 p.sign_request("GET", "https://x", &std::collections::BTreeMap::new())
509 .await
510 .unwrap()
511 .is_none()
512 );
513 }
514
515 #[test]
516 fn credential_authorization_value() {
517 assert_eq!(
518 Credential::Bearer("abc".into()).authorization_value(),
519 Some("Bearer abc".to_string())
520 );
521 assert_eq!(
522 Credential::Token("Custom xyz".into()).authorization_value(),
523 Some("Custom xyz".to_string())
524 );
525 assert_eq!(
526 Credential::Basic {
527 username: "u".into(),
528 password: "p".into()
529 }
530 .authorization_value(),
531 None
532 );
533 assert_eq!(
534 Credential::Header {
535 name: "X-Api-Key".into(),
536 value: "k".into()
537 }
538 .authorization_value(),
539 None
540 );
541 }
542
543 #[test]
544 fn authspec_parses_inline() {
545 let j = serde_json::json!({"type": "bearer", "config": {"token": "t"}});
546 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
547 match s {
548 AuthSpec::Inline(StubAuth::Bearer { token }) => assert_eq!(token, "t"),
549 other => panic!("expected inline bearer, got {other:?}"),
550 }
551 }
552
553 #[test]
554 fn authspec_parses_inline_unit_variant() {
555 let j = serde_json::json!({"type": "none"});
556 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
557 assert!(matches!(s, AuthSpec::Inline(StubAuth::None)));
558 }
559
560 #[test]
561 fn authspec_parses_ref() {
562 let j = serde_json::json!({"ref": "sf"});
563 let s: AuthSpec<StubAuth> = serde_json::from_value(j).unwrap();
564 assert_eq!(s.reference_name(), Some("sf"));
565 }
566
567 #[test]
568 fn authspec_rejects_ref_plus_inline() {
569 let j = serde_json::json!({"ref": "sf", "type": "bearer"});
570 let r: Result<AuthSpec<StubAuth>, _> = serde_json::from_value(j);
571 assert!(r.is_err(), "ref + inline must be rejected");
572 }
573
574 #[derive(Debug)]
575 struct Fixed(Credential);
576
577 #[async_trait]
578 impl AuthProvider for Fixed {
579 async fn credential(&self) -> Result<Credential, FaucetError> {
580 Ok(self.0.clone())
581 }
582 fn provider_name(&self) -> &'static str {
583 "fixed"
584 }
585 }
586
587 #[test]
588 fn credential_debug_redacts_secrets() {
589 // Bearer / Token fully redact the secret value.
590 let b = format!("{:?}", Credential::Bearer("supersecrettoken".into()));
591 assert!(!b.contains("supersecrettoken"), "bearer token leaked: {b}");
592 assert!(b.contains("***"), "bearer token not masked: {b}");
593
594 let t = format!("{:?}", Credential::Token("tok-supersecretxyz".into()));
595 assert!(!t.contains("tok-supersecretxyz"), "raw token leaked: {t}");
596 assert!(t.contains("***"), "raw token not masked: {t}");
597
598 // Basic redacts the password but keeps the (non-secret) username.
599 let basic = format!(
600 "{:?}",
601 Credential::Basic {
602 username: "alice".into(),
603 password: "hunter2secret".into(),
604 }
605 );
606 assert!(!basic.contains("hunter2secret"), "password leaked: {basic}");
607 assert!(
608 basic.contains("alice"),
609 "username should stay visible for diagnostics: {basic}"
610 );
611
612 // Header redacts the value but keeps the (non-secret) header name.
613 let header = format!(
614 "{:?}",
615 Credential::Header {
616 name: "X-Api-Key".into(),
617 value: "secretkeyvalue".into(),
618 }
619 );
620 assert!(
621 !header.contains("secretkeyvalue"),
622 "header value leaked: {header}"
623 );
624 assert!(
625 header.contains("X-Api-Key"),
626 "header name should stay visible for diagnostics: {header}"
627 );
628 }
629
630 #[tokio::test]
631 async fn auth_provider_default_invalidate_returns_current() {
632 let p = Fixed(Credential::Bearer("x".into()));
633 assert_eq!(
634 p.credential().await.unwrap(),
635 Credential::Bearer("x".into())
636 );
637 // Default invalidate just returns the current credential.
638 assert_eq!(
639 p.invalidate(&Credential::Bearer("old".into()))
640 .await
641 .unwrap(),
642 Credential::Bearer("x".into())
643 );
644 }
645}