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}