Skip to main content

faucet_auth/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2//! Shared, single-flight authentication providers for faucet-stream.
3//!
4//! These implement [`faucet_core::AuthProvider`] — a live entity that owns a
5//! token cache and refresh lifecycle. One instance, wrapped in an [`Arc`], is
6//! shared across every connector that references it (via the CLI `auth:` catalog
7//! and `auth: { ref }`, or by a library caller cloning the `Arc`), so N
8//! connectors hitting one identity provider share a single token with
9//! single-flight refresh instead of racing.
10//!
11//! Providers:
12//! - [`StaticProvider`] — a fixed, pre-minted credential.
13//! - [`OAuth2ClientCredentialsProvider`] — OAuth2 `client_credentials` grant.
14//! - [`OAuth2RefreshProvider`] — OAuth2 `refresh_token` grant with rotation
15//!   capture (the headline: a single active access token + rotating refresh
16//!   token, shared safely).
17//! - [`TokenEndpointProvider`] — fetch a token from an arbitrary HTTP endpoint
18//!   and extract it via JSONPath.
19//!
20//! [`build_provider`] constructs one from a `{ type, config }` spec (the shape
21//! used by the CLI's top-level `auth:` block).
22//!
23//! [`Arc`]: std::sync::Arc
24
25mod flow;
26#[cfg(feature = "oauth1")]
27mod oauth1;
28mod oauth2;
29mod static_provider;
30mod token_endpoint;
31
32use std::sync::Arc;
33use std::time::Duration;
34
35use faucet_core::{FaucetError, SharedAuthProvider};
36use serde_json::Value;
37
38/// Build the HTTP client the auth providers use, with a bounded request timeout.
39///
40/// Providers hold a single-flight mutex across the token-fetch network call, so
41/// a hung or unreachable IdP with no timeout would wedge that mutex — and thus
42/// every connector sharing the provider — indefinitely. A bounded timeout lets
43/// the fetch fail and release the lock so callers can retry (audit #146 H11).
44pub(crate) fn auth_http_client() -> reqwest::Client {
45    const AUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
46    reqwest::Client::builder()
47        .timeout(AUTH_HTTP_TIMEOUT)
48        .build()
49        .unwrap_or_else(|_| reqwest::Client::new())
50}
51
52pub use flow::FlowProvider;
53#[cfg(feature = "oauth1")]
54pub use oauth1::OAuth1Provider;
55pub use oauth2::{OAuth2ClientCredentialsProvider, OAuth2RefreshProvider};
56pub use static_provider::StaticProvider;
57pub use token_endpoint::TokenEndpointProvider;
58
59/// Default fraction of `expires_in` after which a token is proactively
60/// refreshed. A token with `expires_in = 3600` is refreshed after 3240 s.
61pub const DEFAULT_EXPIRY_RATIO: f64 = 0.9;
62
63/// Build a shared [`AuthProvider`](faucet_core::AuthProvider) from a
64/// `{ type, config }` spec — the shape used by the CLI's top-level `auth:`
65/// catalog.
66///
67/// Supported `type` values: `flow` (composable multi-step, #511), `static`,
68/// `oauth2` (client-credentials), `oauth2_refresh`, `token_endpoint`, `oauth1`.
69pub fn build_provider(spec: &Value) -> Result<SharedAuthProvider, FaucetError> {
70    let kind = spec
71        .get("type")
72        .and_then(Value::as_str)
73        .ok_or_else(|| FaucetError::Config("auth provider: missing `type`".into()))?;
74    let config = spec.get("config").cloned().unwrap_or(Value::Null);
75
76    match kind {
77        "flow" => Ok(Arc::new(FlowProvider::from_config(&config)?)),
78        "static" => Ok(Arc::new(StaticProvider::from_config(&config)?)),
79        "oauth2" => Ok(Arc::new(OAuth2ClientCredentialsProvider::from_config(
80            &config,
81        )?)),
82        "oauth2_refresh" => Ok(Arc::new(OAuth2RefreshProvider::from_config(&config)?)),
83        "token_endpoint" => Ok(Arc::new(TokenEndpointProvider::from_config(&config)?)),
84        "oauth1" => {
85            #[cfg(feature = "oauth1")]
86            {
87                Ok(Arc::new(OAuth1Provider::from_config(&config)?))
88            }
89            #[cfg(not(feature = "oauth1"))]
90            {
91                Err(FaucetError::Config(
92                    "auth provider: `oauth1` requires the `oauth1` feature — rebuild with \
93                     `--features oauth1` (e.g. `cargo install faucet-cli --features oauth1`)"
94                        .into(),
95                ))
96            }
97        }
98        other => Err(FaucetError::Config(format!(
99            "auth provider: unknown type `{other}` (expected one of: flow, static, oauth2, oauth2_refresh, token_endpoint, oauth1)"
100        ))),
101    }
102}
103
104/// Compute the instant at which a token fetched now (with the given
105/// server-reported `expires_in`, in seconds) should be treated as expired,
106/// applying `expiry_ratio`. Returns `None` when the server gave no expiry.
107pub(crate) fn expiry_instant(
108    expires_in: Option<u64>,
109    expiry_ratio: f64,
110) -> Option<tokio::time::Instant> {
111    expires_in.map(|secs| {
112        let effective = (secs as f64 * expiry_ratio) as u64;
113        tokio::time::Instant::now() + std::time::Duration::from_secs(effective)
114    })
115}
116
117/// Parse and validate the optional `expiry_ratio` config field, shared by every
118/// provider that caches a token. Must be a finite number in `(0, 1]`; defaults
119/// to [`DEFAULT_EXPIRY_RATIO`] when absent or null.
120///
121/// Out-of-range values silently break token caching (#146 M16): `≤ 0` or `NaN`
122/// makes the effective expiry `0`, so every call refetches (defeating the cache
123/// and single-flight refresh); `> 1` treats the token as valid past its real
124/// expiry, causing 401s mid-use. Rejecting at construction surfaces the mistake
125/// at config-load time instead.
126pub(crate) fn parse_expiry_ratio(config: &Value) -> Result<f64, FaucetError> {
127    match config.get("expiry_ratio") {
128        None | Some(Value::Null) => Ok(DEFAULT_EXPIRY_RATIO),
129        Some(v) => {
130            let r = v.as_f64().ok_or_else(|| {
131                FaucetError::Config(format!(
132                    "auth provider: `expiry_ratio` must be a number in (0, 1], got {v}"
133                ))
134            })?;
135            if !r.is_finite() || r <= 0.0 || r > 1.0 {
136                return Err(FaucetError::Config(format!(
137                    "auth provider: `expiry_ratio` must be a finite number in (0, 1], got {r}"
138                )));
139            }
140            Ok(r)
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn build_provider_static() {
151        let spec = serde_json::json!({
152            "type": "static",
153            "config": { "token": "abc" }
154        });
155        let p = build_provider(&spec).unwrap();
156        assert_eq!(p.provider_name(), "static");
157    }
158
159    #[test]
160    fn build_provider_unknown_type_errors() {
161        let spec = serde_json::json!({ "type": "magic", "config": {} });
162        let err = build_provider(&spec).unwrap_err();
163        assert!(matches!(err, FaucetError::Config(_)));
164    }
165
166    #[test]
167    fn build_provider_missing_type_errors() {
168        let spec = serde_json::json!({ "config": {} });
169        assert!(build_provider(&spec).is_err());
170    }
171
172    #[test]
173    fn parse_expiry_ratio_validates_range() {
174        use serde_json::json;
175        // Absent / null → default.
176        assert_eq!(
177            parse_expiry_ratio(&json!({})).unwrap(),
178            DEFAULT_EXPIRY_RATIO
179        );
180        assert_eq!(
181            parse_expiry_ratio(&json!({ "expiry_ratio": null })).unwrap(),
182            DEFAULT_EXPIRY_RATIO
183        );
184        // In-range values pass.
185        assert_eq!(
186            parse_expiry_ratio(&json!({ "expiry_ratio": 0.5 })).unwrap(),
187            0.5
188        );
189        assert_eq!(
190            parse_expiry_ratio(&json!({ "expiry_ratio": 1.0 })).unwrap(),
191            1.0
192        );
193        // Out-of-range / non-numeric are rejected (#146 M16).
194        assert!(parse_expiry_ratio(&json!({ "expiry_ratio": 0 })).is_err());
195        assert!(parse_expiry_ratio(&json!({ "expiry_ratio": -0.5 })).is_err());
196        assert!(parse_expiry_ratio(&json!({ "expiry_ratio": 1.5 })).is_err());
197        assert!(parse_expiry_ratio(&json!({ "expiry_ratio": "0.5" })).is_err());
198    }
199
200    #[test]
201    fn build_provider_rejects_out_of_range_expiry_ratio() {
202        let spec = serde_json::json!({
203            "type": "oauth2",
204            "config": {
205                "token_url": "http://x", "client_id": "id",
206                "client_secret": "sec", "expiry_ratio": 2.0
207            }
208        });
209        assert!(build_provider(&spec).is_err());
210    }
211}