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 oauth2;
26mod static_provider;
27mod token_endpoint;
28
29use std::sync::Arc;
30use std::time::Duration;
31
32use faucet_core::{FaucetError, SharedAuthProvider};
33use serde_json::Value;
34
35/// Build the HTTP client the auth providers use, with a bounded request timeout.
36///
37/// Providers hold a single-flight mutex across the token-fetch network call, so
38/// a hung or unreachable IdP with no timeout would wedge that mutex — and thus
39/// every connector sharing the provider — indefinitely. A bounded timeout lets
40/// the fetch fail and release the lock so callers can retry (audit #146 H11).
41pub(crate) fn auth_http_client() -> reqwest::Client {
42    const AUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
43    reqwest::Client::builder()
44        .timeout(AUTH_HTTP_TIMEOUT)
45        .build()
46        .unwrap_or_else(|_| reqwest::Client::new())
47}
48
49pub use oauth2::{OAuth2ClientCredentialsProvider, OAuth2RefreshProvider};
50pub use static_provider::StaticProvider;
51pub use token_endpoint::TokenEndpointProvider;
52
53/// Default fraction of `expires_in` after which a token is proactively
54/// refreshed. A token with `expires_in = 3600` is refreshed after 3240 s.
55pub const DEFAULT_EXPIRY_RATIO: f64 = 0.9;
56
57/// Build a shared [`AuthProvider`](faucet_core::AuthProvider) from a
58/// `{ type, config }` spec — the shape used by the CLI's top-level `auth:`
59/// catalog.
60///
61/// Supported `type` values: `static`, `oauth2` (client-credentials),
62/// `oauth2_refresh`, `token_endpoint`.
63pub fn build_provider(spec: &Value) -> Result<SharedAuthProvider, FaucetError> {
64    let kind = spec
65        .get("type")
66        .and_then(Value::as_str)
67        .ok_or_else(|| FaucetError::Config("auth provider: missing `type`".into()))?;
68    let config = spec.get("config").cloned().unwrap_or(Value::Null);
69
70    match kind {
71        "static" => Ok(Arc::new(StaticProvider::from_config(&config)?)),
72        "oauth2" => Ok(Arc::new(OAuth2ClientCredentialsProvider::from_config(
73            &config,
74        )?)),
75        "oauth2_refresh" => Ok(Arc::new(OAuth2RefreshProvider::from_config(&config)?)),
76        "token_endpoint" => Ok(Arc::new(TokenEndpointProvider::from_config(&config)?)),
77        other => Err(FaucetError::Config(format!(
78            "auth provider: unknown type `{other}` (expected one of: static, oauth2, oauth2_refresh, token_endpoint)"
79        ))),
80    }
81}
82
83/// Compute the instant at which a token fetched now (with the given
84/// server-reported `expires_in`, in seconds) should be treated as expired,
85/// applying `expiry_ratio`. Returns `None` when the server gave no expiry.
86pub(crate) fn expiry_instant(
87    expires_in: Option<u64>,
88    expiry_ratio: f64,
89) -> Option<tokio::time::Instant> {
90    expires_in.map(|secs| {
91        let effective = (secs as f64 * expiry_ratio) as u64;
92        tokio::time::Instant::now() + std::time::Duration::from_secs(effective)
93    })
94}
95
96/// Parse and validate the optional `expiry_ratio` config field, shared by every
97/// provider that caches a token. Must be a finite number in `(0, 1]`; defaults
98/// to [`DEFAULT_EXPIRY_RATIO`] when absent or null.
99///
100/// Out-of-range values silently break token caching (#146 M16): `≤ 0` or `NaN`
101/// makes the effective expiry `0`, so every call refetches (defeating the cache
102/// and single-flight refresh); `> 1` treats the token as valid past its real
103/// expiry, causing 401s mid-use. Rejecting at construction surfaces the mistake
104/// at config-load time instead.
105pub(crate) fn parse_expiry_ratio(config: &Value) -> Result<f64, FaucetError> {
106    match config.get("expiry_ratio") {
107        None | Some(Value::Null) => Ok(DEFAULT_EXPIRY_RATIO),
108        Some(v) => {
109            let r = v.as_f64().ok_or_else(|| {
110                FaucetError::Config(format!(
111                    "auth provider: `expiry_ratio` must be a number in (0, 1], got {v}"
112                ))
113            })?;
114            if !r.is_finite() || r <= 0.0 || r > 1.0 {
115                return Err(FaucetError::Config(format!(
116                    "auth provider: `expiry_ratio` must be a finite number in (0, 1], got {r}"
117                )));
118            }
119            Ok(r)
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn build_provider_static() {
130        let spec = serde_json::json!({
131            "type": "static",
132            "config": { "token": "abc" }
133        });
134        let p = build_provider(&spec).unwrap();
135        assert_eq!(p.provider_name(), "static");
136    }
137
138    #[test]
139    fn build_provider_unknown_type_errors() {
140        let spec = serde_json::json!({ "type": "magic", "config": {} });
141        let err = build_provider(&spec).unwrap_err();
142        assert!(matches!(err, FaucetError::Config(_)));
143    }
144
145    #[test]
146    fn build_provider_missing_type_errors() {
147        let spec = serde_json::json!({ "config": {} });
148        assert!(build_provider(&spec).is_err());
149    }
150
151    #[test]
152    fn parse_expiry_ratio_validates_range() {
153        use serde_json::json;
154        // Absent / null → default.
155        assert_eq!(
156            parse_expiry_ratio(&json!({})).unwrap(),
157            DEFAULT_EXPIRY_RATIO
158        );
159        assert_eq!(
160            parse_expiry_ratio(&json!({ "expiry_ratio": null })).unwrap(),
161            DEFAULT_EXPIRY_RATIO
162        );
163        // In-range values pass.
164        assert_eq!(
165            parse_expiry_ratio(&json!({ "expiry_ratio": 0.5 })).unwrap(),
166            0.5
167        );
168        assert_eq!(
169            parse_expiry_ratio(&json!({ "expiry_ratio": 1.0 })).unwrap(),
170            1.0
171        );
172        // Out-of-range / non-numeric are rejected (#146 M16).
173        assert!(parse_expiry_ratio(&json!({ "expiry_ratio": 0 })).is_err());
174        assert!(parse_expiry_ratio(&json!({ "expiry_ratio": -0.5 })).is_err());
175        assert!(parse_expiry_ratio(&json!({ "expiry_ratio": 1.5 })).is_err());
176        assert!(parse_expiry_ratio(&json!({ "expiry_ratio": "0.5" })).is_err());
177    }
178
179    #[test]
180    fn build_provider_rejects_out_of_range_expiry_ratio() {
181        let spec = serde_json::json!({
182            "type": "oauth2",
183            "config": {
184                "token_url": "http://x", "client_id": "id",
185                "client_secret": "sec", "expiry_ratio": 2.0
186            }
187        });
188        assert!(build_provider(&spec).is_err());
189    }
190}