Skip to main content

sendra_core/request/
auth.rs

1//! [`Auth`]/[`BasicAuth`]/[`ApiKeyAuth`]: [`crate::Request::auth`]'s shape,
2//! resolved to a header or query parameter by [`crate::Request::resolve_auth`].
3//!
4//! Also reused verbatim as [`crate::environment::Environment::auth`] — an
5//! environment-level default applied to a request that sets no `auth:` of
6//! its own — so the validation this module owns (mutual exclusivity,
7//! header/query collision) is written here once and called from both
8//! [`crate::Request::validate`] and [`crate::environment`], never
9//! duplicated.
10
11use serde::{Deserialize, Serialize};
12
13/// [`crate::Request::auth`]: exactly one of `bearer`, `basic`, `api_key` or
14/// `oauth`, enforced by [`Auth::validate_exclusivity`].
15///
16/// This is also the shape a default `auth:` at the environment level reuses
17/// unchanged (see [`crate::environment::Environment::auth`]) — `api_key`
18/// and `oauth` join `bearer`/`basic` as mutually-exclusive cases in that same
19/// shape, so kept as its own type rather than inlined onto `Request`, the
20/// same way `MultipartPart` is its own type rather than an inline tuple.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[serde(deny_unknown_fields)]
24pub struct Auth {
25    /// Sets `Authorization: Bearer <bearer>`.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub bearer: Option<String>,
28    /// Sets `Authorization: Basic <base64(user:pass)>`.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub basic: Option<BasicAuth>,
31    /// Sets a named header or query parameter to a static value — see
32    /// [`ApiKeyAuth`].
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub api_key: Option<ApiKeyAuth>,
35    /// Acquires a bearer token from an OAuth token endpoint before the
36    /// request is sent — see [`OAuthAuth`].
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub oauth: Option<OAuthAuth>,
39}
40
41impl Auth {
42    /// Exactly one of `bearer`/`basic`/`api_key`/`oauth` must be set. Called
43    /// wherever an `Auth` value is parsed — a request's own `auth:` block
44    /// ([`crate::Request::validate`]) and an environment's default
45    /// ([`crate::environment::Environment::from_yaml_str`]/`from_path`) —
46    /// so the rule is written once and cannot drift between the two.
47    pub(crate) fn validate_exclusivity(&self) -> Result<(), String> {
48        let mut set = Vec::new();
49        if self.bearer.is_some() {
50            set.push("bearer");
51        }
52        if self.basic.is_some() {
53            set.push("basic");
54        }
55        if self.api_key.is_some() {
56            set.push("api_key");
57        }
58        if self.oauth.is_some() {
59            set.push("oauth");
60        }
61        if set.len() != 1 {
62            return Err(format!(
63                "exactly one of `auth.bearer`, `auth.basic`, `auth.api_key` or `auth.oauth` must \
64                 be set, but found: {}",
65                if set.is_empty() {
66                    "neither".to_string()
67                } else {
68                    set.join(", ")
69                }
70            ));
71        }
72        Ok(())
73    }
74
75    /// Whether sending this `auth` would collide with an explicit header or
76    /// query entry already on the request — the same "two things claiming
77    /// ownership of one header" rule for every case `auth` can set:
78    /// `bearer`/`basic` vs. an explicit `Authorization` header, and
79    /// `api_key` vs. an explicit `headers:`/`query:` entry of the same name.
80    ///
81    /// Reused for a request's own `auth:` (checked once, at parse time,
82    /// against the request's own `headers`/`query`) and for an
83    /// environment-level default (checked once substitution has produced
84    /// the final header/query names, since an environment's `auth:` cannot
85    /// know at parse time what a request that later picks it up will look
86    /// like) — see [`crate::Request::validate`] and
87    /// [`crate::environment::Environment::apply`].
88    pub(crate) fn collision_reason(
89        &self,
90        headers: &[(String, String)],
91        query: &[(String, String)],
92    ) -> Option<String> {
93        if (self.bearer.is_some() || self.basic.is_some() || self.oauth.is_some())
94            && headers
95                .iter()
96                .any(|(name, _)| name.eq_ignore_ascii_case("Authorization"))
97        {
98            return Some(
99                "`auth` and an explicit `Authorization` header cannot both be set on the same \
100                 request; remove one"
101                    .to_string(),
102            );
103        }
104
105        if let Some(api_key) = &self.api_key {
106            match api_key.r#in {
107                ApiKeyLocation::Header => {
108                    if headers
109                        .iter()
110                        .any(|(name, _)| name.eq_ignore_ascii_case(&api_key.name))
111                    {
112                        return Some(format!(
113                            "`auth.api_key` and an explicit `headers.{}` cannot both be set on \
114                             the same request; remove one",
115                            api_key.name
116                        ));
117                    }
118                }
119                ApiKeyLocation::Query => {
120                    if query.iter().any(|(name, _)| name == &api_key.name) {
121                        return Some(format!(
122                            "`auth.api_key` and an explicit `query.{}` cannot both be set on the \
123                             same request; remove one",
124                            api_key.name
125                        ));
126                    }
127                }
128            }
129        }
130
131        None
132    }
133}
134
135/// [`Auth::basic`]'s credentials, base64-encoded as `user:pass` by
136/// [`crate::Request::resolve_auth`].
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
139#[serde(deny_unknown_fields)]
140pub struct BasicAuth {
141    pub user: String,
142    pub pass: String,
143}
144
145/// [`Auth::api_key`]: a static value sent as either a header or a query
146/// parameter.
147///
148/// ```text
149/// auth:
150///   api_key:
151///     in: header          # or: query
152///     name: X-API-Key     # or a query param name
153///     value: {{api_key}}
154/// ```
155///
156/// `in: header` sets the header named `name` to `value`, the same as an
157/// entry under `headers:` would. `in: query` adds `name`/`value` as an
158/// additional query parameter through the same mechanism
159/// [`crate::Request::resolve_query`] already uses for `query:` — not a
160/// separate ad-hoc code path — so it inherits that mechanism's percent-
161/// encoding and its "the more structured source wins on a name collision
162/// with the URL's own query string" rule. See
163/// [`crate::Request::resolve_auth`] for exactly how.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166#[serde(deny_unknown_fields)]
167pub struct ApiKeyAuth {
168    pub r#in: ApiKeyLocation,
169    pub name: String,
170    pub value: String,
171}
172
173/// Where [`ApiKeyAuth`] places its `name`/`value` pair.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
176#[serde(rename_all = "lowercase")]
177pub enum ApiKeyLocation {
178    Header,
179    Query,
180}
181
182/// [`Auth::oauth`]: acquire a bearer token from an OAuth token endpoint
183/// before the request is sent, under one of three grants.
184///
185/// ```text
186/// auth:
187///   oauth:
188///     grant_type: client_credentials   # or: password, authorization_code
189///     token_url: https://auth.example.com/token
190///     client_id: {{client_id}}
191///     client_secret: {{client_secret}}
192///     scope: read write                 # optional
193///     # required only for grant_type: password
194///     username: {{username}}
195///     password: {{password}}
196///     # required only for grant_type: authorization_code
197///     authorization_url: https://auth.example.com/authorize
198///     redirect_uri: http://127.0.0.1:8899/callback
199/// ```
200///
201/// `client_credentials` and `password` are acquired automatically —
202/// [`crate::Request::resolve_oauth`] fetches (or reuses a cached) token
203/// before every send with no human involved. `authorization_code` cannot
204/// work that way: it needs a human to approve access in a browser, so
205/// `resolve_oauth` refuses to attempt it automatically and instead reports a
206/// clear error directing the caller to log in interactively first. The TUI
207/// is the one caller that can do that — see [`crate::oauth`]'s module docs
208/// for the interactive flow and why `client_secret` is optional only for
209/// this grant (a desktop client cannot keep a secret confidential, so PKCE
210/// stands in for it; see [RFC 8252 §8.1/§8.5]).
211///
212/// [`crate::Request::resolve_oauth`] acquires the token — through
213/// [`crate::oauth::OAuthTokenCache`], reusing one already acquired for the
214/// same `token_url`/`client_id`/`grant_type`/`scope` within this run rather
215/// than re-authenticating per request — and hands it to the exact same
216/// `Authorization: Bearer` code path [`Auth::bearer`] already resolves to;
217/// see [`crate::Request::resolve_auth`].
218///
219/// [RFC 8252 §8.1/§8.5]: https://www.rfc-editor.org/rfc/rfc8252#section-8.1
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222#[serde(deny_unknown_fields)]
223pub struct OAuthAuth {
224    pub grant_type: OAuthGrantType,
225    pub token_url: String,
226    pub client_id: String,
227    /// Required for [`OAuthGrantType::ClientCredentials`] and
228    /// [`OAuthGrantType::Password`]; optional for
229    /// [`OAuthGrantType::AuthorizationCode`], whose native/desktop clients
230    /// typically have none — see [`validate_grant_fields`](Self::validate_grant_fields).
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub client_secret: Option<String>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub scope: Option<String>,
235    /// Required when [`grant_type`](Self::grant_type) is
236    /// [`OAuthGrantType::Password`] — see [`validate_grant_fields`](Self::validate_grant_fields).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub username: Option<String>,
239    /// Required when [`grant_type`](Self::grant_type) is
240    /// [`OAuthGrantType::Password`] — see [`validate_grant_fields`](Self::validate_grant_fields).
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub password: Option<String>,
243    /// The provider's authorization endpoint — where the browser is sent to
244    /// let the user approve access. Required when
245    /// [`grant_type`](Self::grant_type) is
246    /// [`OAuthGrantType::AuthorizationCode`]; unused by the other two grants,
247    /// which never open a browser. See [`validate_grant_fields`](Self::validate_grant_fields).
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub authorization_url: Option<String>,
250    /// Where the provider redirects back with the authorization code — must
251    /// match what is registered with the provider. Required when
252    /// [`grant_type`](Self::grant_type) is
253    /// [`OAuthGrantType::AuthorizationCode`]; the TUI's local callback
254    /// listener binds to this exact address. See
255    /// [`validate_grant_fields`](Self::validate_grant_fields).
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub redirect_uri: Option<String>,
258}
259
260impl OAuthAuth {
261    /// `grant_type: password` requires both `username` and `password`;
262    /// `grant_type: client_credentials` requires `client_secret`;
263    /// `grant_type: authorization_code` requires `authorization_url` and
264    /// `redirect_uri` (`client_secret` stays optional — see the type's own
265    /// doc comment). Checked wherever [`Auth::validate_exclusivity`] already
266    /// is — a request's own `auth:` block and an environment's default —
267    /// for the same typed-error rigor every other schema rule in this
268    /// project gets, rather than discovering the gap only once a token
269    /// request is attempted.
270    pub(crate) fn validate_grant_fields(&self) -> Result<(), String> {
271        match self.grant_type {
272            OAuthGrantType::Password if self.username.is_none() || self.password.is_none() => Err(
273                "`auth.oauth` with `grant_type: password` requires both `username` and \
274                     `password` to be set"
275                    .to_string(),
276            ),
277            OAuthGrantType::ClientCredentials if self.client_secret.is_none() => Err(
278                "`auth.oauth` with `grant_type: client_credentials` requires `client_secret` to \
279                 be set"
280                    .to_string(),
281            ),
282            OAuthGrantType::AuthorizationCode
283                if self.authorization_url.is_none() || self.redirect_uri.is_none() =>
284            {
285                Err(
286                    "`auth.oauth` with `grant_type: authorization_code` requires both \
287                     `authorization_url` and `redirect_uri` to be set"
288                        .to_string(),
289                )
290            }
291            _ => Ok(()),
292        }
293    }
294}
295
296/// [`OAuthAuth::grant_type`]: which of the three supported OAuth grants to
297/// use. See [`crate::oauth`]'s module docs for how `authorization_code`
298/// differs from the other two.
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
300#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
301#[serde(rename_all = "snake_case")]
302pub enum OAuthGrantType {
303    ClientCredentials,
304    Password,
305    AuthorizationCode,
306}
307
308impl OAuthGrantType {
309    /// The exact `grant_type` value OAuth's token request wire format
310    /// expects — see [RFC 6749 §4.3.2/§4.4.2/§4.1.3].
311    pub(crate) fn as_str(self) -> &'static str {
312        match self {
313            OAuthGrantType::ClientCredentials => "client_credentials",
314            OAuthGrantType::Password => "password",
315            OAuthGrantType::AuthorizationCode => "authorization_code",
316        }
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn base(grant_type: OAuthGrantType) -> OAuthAuth {
325        OAuthAuth {
326            grant_type,
327            token_url: "https://auth.example.com/token".to_string(),
328            client_id: "client".to_string(),
329            client_secret: None,
330            scope: None,
331            username: None,
332            password: None,
333            authorization_url: None,
334            redirect_uri: None,
335        }
336    }
337
338    #[test]
339    fn client_credentials_requires_a_client_secret() {
340        let err = base(OAuthGrantType::ClientCredentials)
341            .validate_grant_fields()
342            .expect_err("no client_secret must be rejected");
343        assert!(err.contains("client_secret"), "got {err}");
344
345        let ok = OAuthAuth {
346            client_secret: Some("s".to_string()),
347            ..base(OAuthGrantType::ClientCredentials)
348        };
349        assert!(ok.validate_grant_fields().is_ok());
350    }
351
352    #[test]
353    fn password_requires_username_and_password() {
354        let err = base(OAuthGrantType::Password)
355            .validate_grant_fields()
356            .expect_err("no username/password must be rejected");
357        assert!(
358            err.contains("username") && err.contains("password"),
359            "got {err}"
360        );
361
362        let ok = OAuthAuth {
363            username: Some("ada".to_string()),
364            password: Some("hunter2".to_string()),
365            ..base(OAuthGrantType::Password)
366        };
367        assert!(ok.validate_grant_fields().is_ok());
368    }
369
370    #[test]
371    fn authorization_code_requires_authorization_url_and_redirect_uri_but_not_client_secret() {
372        let err = base(OAuthGrantType::AuthorizationCode)
373            .validate_grant_fields()
374            .expect_err("no authorization_url/redirect_uri must be rejected");
375        assert!(
376            err.contains("authorization_url") && err.contains("redirect_uri"),
377            "got {err}"
378        );
379
380        let ok = OAuthAuth {
381            authorization_url: Some("https://auth.example.com/authorize".to_string()),
382            redirect_uri: Some("http://127.0.0.1:8899/callback".to_string()),
383            // client_secret deliberately left None — a public client is a
384            // valid authorization_code config, unlike the other two grants.
385            ..base(OAuthGrantType::AuthorizationCode)
386        };
387        assert!(
388            ok.validate_grant_fields().is_ok(),
389            "authorization_code must not require client_secret"
390        );
391    }
392
393    #[test]
394    fn authorization_code_missing_only_redirect_uri_is_still_rejected() {
395        let err = OAuthAuth {
396            authorization_url: Some("https://auth.example.com/authorize".to_string()),
397            ..base(OAuthGrantType::AuthorizationCode)
398        }
399        .validate_grant_fields()
400        .expect_err("redirect_uri alone missing must still be rejected");
401        assert!(err.contains("redirect_uri"), "got {err}");
402    }
403}