Skip to main content

faucet_source_rest/auth/
mod.rs

1//! Authentication strategies for REST APIs.
2
3pub mod api_key;
4pub mod basic;
5pub mod bearer;
6pub mod custom;
7pub mod oauth2;
8pub mod token_endpoint;
9
10use faucet_core::FaucetError;
11use reqwest::header::HeaderMap;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14pub use token_endpoint::ResponseValidator;
15
16/// Supported authentication methods.
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(tag = "type")]
19pub enum Auth {
20    None,
21    Bearer(String),
22    Basic {
23        username: String,
24        password: String,
25    },
26    /// API key sent in a request header.
27    ApiKey {
28        header: String,
29        value: String,
30    },
31    /// API key sent as a query parameter (e.g. `?api_key=secret`).
32    ///
33    /// Some APIs require the key in the URL rather than a header. The `param`
34    /// field is the query parameter name, and `value` is the key itself.
35    ApiKeyQuery {
36        param: String,
37        value: String,
38    },
39    OAuth2 {
40        token_url: String,
41        client_id: String,
42        client_secret: String,
43        scopes: Vec<String>,
44        /// Fraction of `expires_in` after which the cached token is considered
45        /// expired and a new one is fetched. Must be in `(0.0, 1.0]`.
46        /// Defaults to `0.9` (refresh after 90 % of the token lifetime).
47        expiry_ratio: f64,
48    },
49    /// Fetch a token from an arbitrary HTTP endpoint.
50    ///
51    /// The endpoint is called, the token is extracted from the JSON response
52    /// using `token_path` (a JSONPath expression), and then used as a Bearer
53    /// token (or in a custom header if `header_name` is set).
54    ///
55    /// Tokens are cached and refreshed automatically when `expiry_path`
56    /// is provided and the server returns an expiry value.
57    TokenEndpoint {
58        /// URL of the token endpoint.
59        url: String,
60        /// HTTP method for the token request (e.g. GET, POST).
61        #[serde(with = "crate::serde_helpers::http_method")]
62        #[schemars(with = "String")]
63        method: reqwest::Method,
64        /// Headers to send with the token request (e.g. API keys, content type).
65        #[serde(skip, default)]
66        headers: HeaderMap,
67        /// Optional JSON body for the token request.
68        body: Option<serde_json::Value>,
69        /// JSONPath expression to extract the token string from the response.
70        token_path: String,
71        /// Optional JSONPath expression to extract the expiry (in seconds)
72        /// from the response. When absent, the token is cached indefinitely.
73        expiry_path: Option<String>,
74        /// Fraction of the expiry after which the token is proactively refreshed.
75        /// Must be in `(0.0, 1.0]`. Defaults to `0.9`.
76        expiry_ratio: f64,
77        /// Optional callback to decide whether the token endpoint response is
78        /// successful. Receives the HTTP status code. When `None`, defaults to
79        /// `status.is_success()` (2xx).
80        #[serde(skip, default)]
81        response_validator: Option<ResponseValidator>,
82    },
83    #[serde(skip)]
84    Custom(HeaderMap),
85}
86
87impl Auth {
88    /// Apply header-based auth to the request headers.
89    ///
90    /// `ApiKeyQuery` is a no-op here — it is applied as a query parameter by
91    /// `RestStream::execute_request` instead.
92    pub fn apply(&self, headers: &mut HeaderMap) -> Result<(), FaucetError> {
93        match self {
94            Auth::None | Auth::ApiKeyQuery { .. } => Ok(()),
95            Auth::Bearer(token) => bearer::apply(headers, token),
96            Auth::Basic { username, password } => basic::apply(headers, username, password),
97            Auth::ApiKey { header, value } => api_key::apply(headers, header, value),
98            // OAuth2 is resolved to Auth::Bearer by RestStream before apply() is called.
99            // If apply() is reached with an OAuth2 variant, it means the caller bypassed
100            // RestStream — return a clear error rather than silently sending no auth.
101            Auth::OAuth2 { .. } => Err(FaucetError::Auth(
102                "OAuth2 auth must be resolved to a bearer token before applying; \
103                 use RestStream (which resolves it automatically) or call \
104                 fetch_oauth2_token() and use Auth::Bearer"
105                    .into(),
106            )),
107            // TokenEndpoint is resolved to Auth::Bearer by RestStream before apply().
108            Auth::TokenEndpoint { .. } => Err(FaucetError::Auth(
109                "TokenEndpoint auth must be resolved to a bearer token before applying; \
110                 use RestStream (which resolves it automatically) or call \
111                 fetch_token_from_endpoint() and use Auth::Bearer"
112                    .into(),
113            )),
114            Auth::Custom(h) => {
115                custom::apply(headers, h);
116                Ok(())
117            }
118        }
119    }
120}
121
122pub use oauth2::fetch_oauth2_token;
123pub use token_endpoint::fetch_token_from_endpoint;
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn auth_none_is_noop() {
131        let mut headers = HeaderMap::new();
132        Auth::None.apply(&mut headers).unwrap();
133        assert!(headers.is_empty());
134    }
135
136    #[test]
137    fn auth_bearer_sets_authorization_header() {
138        let mut headers = HeaderMap::new();
139        Auth::Bearer("my-token".into()).apply(&mut headers).unwrap();
140        assert_eq!(headers.get("authorization").unwrap(), "Bearer my-token");
141    }
142
143    #[test]
144    fn auth_basic_sets_authorization_header() {
145        let mut headers = HeaderMap::new();
146        Auth::Basic {
147            username: "user".into(),
148            password: "pass".into(),
149        }
150        .apply(&mut headers)
151        .unwrap();
152        let value = headers.get("authorization").unwrap().to_str().unwrap();
153        assert!(value.starts_with("Basic "));
154    }
155
156    #[test]
157    fn auth_api_key_sets_custom_header() {
158        let mut headers = HeaderMap::new();
159        Auth::ApiKey {
160            header: "X-Api-Key".into(),
161            value: "secret".into(),
162        }
163        .apply(&mut headers)
164        .unwrap();
165        assert_eq!(headers.get("x-api-key").unwrap(), "secret");
166    }
167
168    #[test]
169    fn auth_api_key_query_is_noop_on_apply() {
170        let mut headers = HeaderMap::new();
171        Auth::ApiKeyQuery {
172            param: "api_key".into(),
173            value: "secret".into(),
174        }
175        .apply(&mut headers)
176        .unwrap();
177        assert!(headers.is_empty());
178    }
179
180    #[test]
181    fn auth_oauth2_errors_on_direct_apply() {
182        let mut headers = HeaderMap::new();
183        let result = Auth::OAuth2 {
184            token_url: "https://auth.example.com/token".into(),
185            client_id: "id".into(),
186            client_secret: "secret".into(),
187            scopes: vec![],
188            expiry_ratio: 0.9,
189        }
190        .apply(&mut headers);
191        assert!(result.is_err());
192        assert!(matches!(result, Err(FaucetError::Auth(_))));
193    }
194
195    #[test]
196    fn auth_token_endpoint_errors_on_direct_apply() {
197        let mut headers = HeaderMap::new();
198        let result = Auth::TokenEndpoint {
199            url: "https://auth.example.com/token".into(),
200            method: reqwest::Method::POST,
201            headers: HeaderMap::new(),
202            body: None,
203            token_path: "$.token".into(),
204            expiry_path: None,
205            expiry_ratio: 0.9,
206            response_validator: None,
207        }
208        .apply(&mut headers);
209        assert!(result.is_err());
210        assert!(matches!(result, Err(FaucetError::Auth(_))));
211    }
212
213    #[test]
214    fn auth_custom_headers() {
215        let mut custom = HeaderMap::new();
216        custom.insert(
217            reqwest::header::HeaderName::from_static("x-custom"),
218            reqwest::header::HeaderValue::from_static("value"),
219        );
220        let mut headers = HeaderMap::new();
221        Auth::Custom(custom).apply(&mut headers).unwrap();
222        assert_eq!(headers.get("x-custom").unwrap(), "value");
223    }
224
225    #[test]
226    fn auth_debug_format() {
227        let auth = Auth::None;
228        assert_eq!(format!("{auth:?}"), "None");
229
230        let auth = Auth::Bearer("tok".into());
231        let debug = format!("{auth:?}");
232        assert!(debug.contains("Bearer"));
233    }
234
235    #[test]
236    fn auth_clone() {
237        let auth = Auth::Bearer("token".into());
238        let cloned = auth.clone();
239        let mut h = HeaderMap::new();
240        cloned.apply(&mut h).unwrap();
241        assert_eq!(h.get("authorization").unwrap(), "Bearer token");
242    }
243}