sendra_core/oauth.rs
1//! OAuth token acquisition for `auth.oauth`'s three supported grants —
2//! `client_credentials`, `password`, and `authorization_code` — and
3//! [`OAuthTokenCache`], the in-run, in-memory cache that lets many requests
4//! sharing one `oauth:` config reuse one token instead of re-authenticating
5//! per request.
6//!
7//! **`client_credentials` and `password` acquire automatically.**
8//! [`acquire_token`] makes the token request itself, with no human
9//! involved — the shape [`crate::Request::resolve_oauth`] calls before every
10//! send.
11//!
12//! **`authorization_code` cannot acquire automatically.** It needs a human
13//! to approve access in a browser and a local callback listener to catch
14//! the resulting code — a fundamentally different problem from making an
15//! HTTP call, and not something a headless send can do on its own.
16//! [`acquire_token`] refuses this grant outright (see its doc comment)
17//! rather than pretend it can proceed; instead, three building blocks let a
18//! front end — sendra-tui, concretely — drive the flow itself:
19//! [`generate_pkce`] (a fresh verifier/challenge pair — see [RFC 7636]),
20//! [`build_authorization_url`] (the URL to open a browser to), and
21//! [`exchange_authorization_code`] (trading the code the callback caught for
22//! a token). Once that exchange succeeds, [`OAuthTokenCache::insert_token`]
23//! puts the result in the exact same cache [`acquire_token`] reads, so every
24//! later request sharing this `oauth:` config — via the ordinary
25//! `resolve_oauth` path, completely unchanged — reuses it instead of asking
26//! the human to log in again.
27//!
28//! PKCE is used unconditionally for `authorization_code`, not offered as a
29//! config toggle: [RFC 8252 §8.1] (OAuth for native apps) treats it as
30//! required for exactly this kind of client, since a desktop/TUI app cannot
31//! keep a `client_secret` confidential the way a server-side client can, and
32//! is equally exposed to authorization-code interception on the loopback
33//! redirect either way.
34//!
35//! `refresh_token` is not implemented: once expiry-checking exists here, it
36//! is not worth its own scope for the marginal request `refresh_token` would
37//! save over just re-running the same grant (or, for `authorization_code`,
38//! logging in again), so it is deferred rather than treated as a real gap.
39//!
40//! **No cross-invocation persistence.** [`OAuthTokenCache`] lives only as
41//! long as the process that built it — never written to disk — the same
42//! conservative stance this project already takes on captured variables and
43//! the cookie jar: new statefulness is opt-in and scoped to one run, not
44//! silently carried between separate `sendra` invocations. A cached token on
45//! disk would need the same protection `${VAR}` passthrough was designed
46//! around, for a feature nothing has asked for yet. This holds just as much
47//! for a token acquired interactively through `authorization_code`: it lives
48//! only in this cache, for this run, and is never written anywhere.
49//!
50//! [RFC 7636]: https://www.rfc-editor.org/rfc/rfc7636
51//! [RFC 8252 §8.1]: https://www.rfc-editor.org/rfc/rfc8252#section-8.1
52
53use std::collections::HashMap;
54use std::sync::Mutex;
55use std::time::{Duration, Instant};
56
57use base64::engine::general_purpose::URL_SAFE_NO_PAD;
58use base64::Engine;
59use serde::Deserialize;
60use sha2::{Digest, Sha256};
61
62use crate::http::client::HttpClient;
63use crate::http::send_prepared;
64use crate::request::auth::{OAuthAuth, OAuthGrantType};
65use crate::request::{Method, Request};
66use crate::SendraError;
67
68/// How much earlier than a token's actual `expires_in` it is treated as
69/// expired.
70///
71/// Guards against acquiring a token, caching it, and then racing its own
72/// expiry: "the token is still valid" is only ever true at the instant it is
73/// checked, and the request it authorizes reaches the wire some — usually
74/// small — amount of time later. 30 seconds is generous enough to cover that
75/// gap (and ordinary clock skew against the token server) without
76/// discarding a meaningful fraction of the lifetime of the short-lived
77/// tokens (a minute or two) some servers issue.
78const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
79
80/// [`Auth::oauth`](crate::Auth::oauth)'s identity for caching: two requests
81/// with the same `token_url`, `client_id`, `grant_type` and `scope` share
82/// one token rather than each acquiring their own.
83///
84/// `scope` is part of the key — not just the three "which client, at which
85/// endpoint, under which grant" fields — because a server is free to issue a
86/// narrower or differently-scoped token for the same client under a
87/// different `scope`; folding two different scopes into one cache entry
88/// could hand a request a token that cannot actually do what it asked for.
89///
90/// `client_secret`/`username`/`password` are deliberately **not** part of
91/// the key: they are credentials, not identity. The same
92/// `token_url`/`client_id`/`grant_type`/`scope` with a different secret is a
93/// configuration error `auth.oauth` has no business caching around, not a
94/// second legitimate identity to track.
95#[derive(Debug, Clone, PartialEq, Eq, Hash)]
96struct CacheKey {
97 token_url: String,
98 client_id: String,
99 grant_type: OAuthGrantType,
100 scope: Option<String>,
101}
102
103impl CacheKey {
104 fn from(auth: &OAuthAuth) -> Self {
105 Self {
106 token_url: auth.token_url.clone(),
107 client_id: auth.client_id.clone(),
108 grant_type: auth.grant_type,
109 scope: auth.scope.clone(),
110 }
111 }
112}
113
114/// One acquired-or-failed OAuth token, keyed by [`CacheKey`].
115enum CacheEntry {
116 Token {
117 access_token: String,
118 /// `None` when the token response omitted `expires_in` — see
119 /// [`acquire_token`]'s doc comment for why that is treated as "does
120 /// not expire for this run" rather than guessed at.
121 expires_at: Option<Instant>,
122 },
123 /// A remembered acquisition failure for this exact config, so a second
124 /// request sharing a broken `oauth:` config fails immediately rather
125 /// than hitting an endpoint that already refused it. See
126 /// [`OAuthTokenCache`]'s doc comment for the tradeoff this makes.
127 Failed(String),
128}
129
130/// The in-run OAuth token cache: acquired (or failed) tokens, keyed by
131/// [`CacheKey`], shared by every request in one `sendra` invocation that
132/// resolves the same `oauth:` config.
133///
134/// **Scoped to one run, never persisted.** See the module doc comment.
135/// Built once per invocation — in `sendra-cli`, alongside the one
136/// [`HttpClient`] the whole run shares — and, under `--repeat`, deliberately
137/// **outlives every pass** rather than being rebuilt per iteration: a
138/// `--repeat 5` run is still one invocation, and the waste this cache exists
139/// to eliminate — a token request on every single call — would otherwise
140/// resurface once per pass instead of once for the run. This is the same
141/// reasoning that keeps the shared `HttpClient` across passes; it is the
142/// per-*pass* capture store, and (when `--cookie-jar` is set) the client's
143/// cookie jar, that are deliberately reset instead, because captures and
144/// cookies are meant to model one fresh run each time, while
145/// re-authenticating every pass is exactly the waste this cache exists to
146/// eliminate.
147///
148/// **A failed acquisition is cached too, and is not retried.** Once a given
149/// `oauth:` config has failed once in this run, every later request sharing
150/// it fails immediately with the same reason rather than hitting the token
151/// endpoint again. A broken config (bad credentials, a typo'd `token_url`,
152/// an endpoint that is genuinely down) is not going to fix itself between
153/// one request and the next *within the same invocation* — retrying it for
154/// every request in a large collection would only hammer an endpoint that
155/// has already said no, and would queue the run's other, unrelated failures
156/// behind a string of repeated timeouts. The cost is that a config which
157/// failed on a one-off transient blip (a dropped connection, a token server
158/// mid-restart) stays failed for the rest of this run — but the fix there is
159/// simply running `sendra` again, which is cheap, whereas there is no cheap
160/// way to walk back having hammered a struggling endpoint once per request
161/// instead of once.
162pub struct OAuthTokenCache {
163 entries: Mutex<HashMap<CacheKey, CacheEntry>>,
164}
165
166impl OAuthTokenCache {
167 pub fn new() -> Self {
168 Self {
169 entries: Mutex::new(HashMap::new()),
170 }
171 }
172}
173
174impl Default for OAuthTokenCache {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180impl std::fmt::Debug for OAuthTokenCache {
181 /// Deliberately never prints a cached access token — only how many
182 /// entries exist. `sendra-tui`'s `AppState` derives `Debug` (used only
183 /// for `assert_eq!`/panic messages in its own tests, never logged), and
184 /// this cache is one of its fields; a token leaking into a debug print
185 /// anywhere would defeat the whole "never persisted, never written
186 /// anywhere but this in-memory cache" guarantee the module doc comment
187 /// makes.
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 let count = self
190 .entries
191 .lock()
192 .map(|entries| entries.len())
193 .unwrap_or(0);
194 f.debug_struct("OAuthTokenCache")
195 .field("entries", &count)
196 .finish()
197 }
198}
199
200impl OAuthTokenCache {
201 /// Records a token acquired outside the ordinary [`acquire_token`] path —
202 /// the one case that needs this is an `authorization_code` login
203 /// completed interactively (see the module doc comment), whose caller
204 /// exchanged a code for a token itself via [`exchange_authorization_code`]
205 /// and now wants every later request sharing this exact `oauth:` config
206 /// to reuse it instead of asking the human to log in again.
207 ///
208 /// Keyed and stored exactly like a token [`acquire_token`] acquired
209 /// itself — same [`CacheKey`], same expiry-margin handling — so from
210 /// `acquire_token`'s perspective afterward there is no difference
211 /// between a token it fetched and one handed to it this way.
212 pub fn insert_token(&self, auth: &OAuthAuth, access_token: String, expires_in: Option<u64>) {
213 let key = CacheKey::from(auth);
214 let expires_at = expires_in.map(|secs| Instant::now() + Duration::from_secs(secs));
215 let mut entries = self
216 .entries
217 .lock()
218 .expect("the cache mutex is never held across a panic");
219 entries.insert(
220 key,
221 CacheEntry::Token {
222 access_token,
223 expires_at,
224 },
225 );
226 }
227}
228
229/// A token endpoint's JSON response — only the fields Sendra reads. Every
230/// other field a server includes (`refresh_token`, `id_token`, `token_type`,
231/// ...) is ignored: Sendra implements neither `refresh_token` nor OIDC ID
232/// tokens, and the bearer form this becomes needs nothing else. See the
233/// module doc comment.
234#[derive(Deserialize)]
235struct TokenResponse {
236 access_token: String,
237 #[serde(default)]
238 expires_in: Option<u64>,
239}
240
241/// Acquire (or reuse a cached) access token for `auth`.
242///
243/// [`crate::Request::resolve_oauth`] is the only caller, and hands the plain
244/// bearer token string this returns to the exact same code path
245/// [`crate::Request::resolve_auth`] already uses for `auth.bearer`.
246///
247/// **`grant_type: authorization_code` never reaches the token endpoint from
248/// here.** A cache hit is served exactly like any other grant's, since a
249/// token acquired interactively via [`exchange_authorization_code`] and
250/// stored with [`OAuthTokenCache::insert_token`] is indistinguishable from
251/// one acquired automatically once cached. On a cache miss, though, there is
252/// no `code` to send and no way to obtain one without a browser and a human
253/// — see the module doc comment — so this returns
254/// [`SendraError::OAuthAcquisition`] naming that directly, instead of
255/// attempting a token request that could not possibly succeed.
256///
257/// Reuses [`crate::http::send_prepared`] to make the token request itself —
258/// through the same shared [`HttpClient`] every other request in the run
259/// sends through, so a token acquisition is not a second, unrelated HTTP
260/// stack, and inherits the same timeout, proxy and TLS settings. The
261/// request sent is `POST <token_url>` with
262/// `Content-Type: application/x-www-form-urlencoded` and a body of
263/// `grant_type`, `client_id`, `client_secret`, and — for
264/// `grant_type: password` — `username`/`password`, plus `scope` when set:
265/// the standard shape an OAuth 2.0 token request takes (RFC 6749 §4.3.2,
266/// §4.4.2).
267///
268/// A response outside 2xx, or a 2xx body with no `access_token`, is
269/// [`SendraError::OAuthAcquisition`], naming `token_url` and why. So is a
270/// network/timeout failure reaching the endpoint at all —
271/// `send_prepared`'s own [`SendraError::Network`]/[`SendraError::Timeout`],
272/// re-described here rather than passed through directly, so every
273/// acquisition failure a caller can match on is the one variant regardless
274/// of which of these three things went wrong.
275///
276/// **`expires_in` omitted by the server** is treated as "this token does not
277/// expire for the rest of this run" — no expiry is recorded, and the cached
278/// token is reused until the process exits — rather than guessed at with an
279/// arbitrary default lifetime. A server that does not say when a token
280/// expires has given no basis for picking one duration over another, and a
281/// wrong guess is bad in both directions: too short reacquires (and
282/// re-spends any rate limit) needlessly, too long risks sending an
283/// already-invalid token. `client_credentials` tokens in particular are
284/// commonly long-lived or effectively static per client, which is the
285/// ordinary case this default fits.
286///
287/// See [`OAuthTokenCache`] for the cache key, the expiry margin, and the
288/// retry-vs-fail-fast decision for a config that has already failed once in
289/// this run.
290pub async fn acquire_token(
291 auth: &OAuthAuth,
292 client: &HttpClient,
293 cache: &OAuthTokenCache,
294) -> Result<String, SendraError> {
295 let key = CacheKey::from(auth);
296
297 {
298 let entries = cache
299 .entries
300 .lock()
301 .expect("the cache mutex is never held across a panic");
302 match entries.get(&key) {
303 Some(CacheEntry::Token {
304 access_token,
305 expires_at,
306 }) => {
307 let still_valid = match expires_at {
308 Some(expires_at) => Instant::now() + EXPIRY_MARGIN < *expires_at,
309 None => true,
310 };
311 if still_valid {
312 return Ok(access_token.clone());
313 }
314 // Expired (or within the margin of expiring): fall through
315 // and acquire a fresh one below, using the same
316 // cache-and-reuse logic as a first-time acquisition.
317 }
318 Some(CacheEntry::Failed(reason)) => {
319 return Err(SendraError::OAuthAcquisition {
320 token_url: auth.token_url.clone(),
321 reason: reason.clone(),
322 });
323 }
324 None => {}
325 }
326 // Lock dropped here, before the `.await` below — never held across
327 // one.
328 }
329
330 if auth.grant_type == OAuthGrantType::AuthorizationCode {
331 let reason = "grant_type: authorization_code requires an interactive browser login — \
332 trigger it from the auth editor, then re-run this request"
333 .to_string();
334 let mut entries = cache
335 .entries
336 .lock()
337 .expect("the cache mutex is never held across a panic");
338 entries.insert(key, CacheEntry::Failed(reason.clone()));
339 return Err(SendraError::OAuthAcquisition {
340 token_url: auth.token_url.clone(),
341 reason,
342 });
343 }
344
345 match acquire_fresh(auth, client).await {
346 Ok((access_token, expires_in)) => {
347 let expires_at = expires_in.map(|secs| Instant::now() + Duration::from_secs(secs));
348 let mut entries = cache
349 .entries
350 .lock()
351 .expect("the cache mutex is never held across a panic");
352 entries.insert(
353 key,
354 CacheEntry::Token {
355 access_token: access_token.clone(),
356 expires_at,
357 },
358 );
359 Ok(access_token)
360 }
361 Err(reason) => {
362 let mut entries = cache
363 .entries
364 .lock()
365 .expect("the cache mutex is never held across a panic");
366 entries.insert(key, CacheEntry::Failed(reason.clone()));
367 Err(SendraError::OAuthAcquisition {
368 token_url: auth.token_url.clone(),
369 reason,
370 })
371 }
372 }
373}
374
375/// The actual token request, with no cache involved — [`acquire_token`]'s
376/// only caller, split out so the cache-locking there stays free of the
377/// request-building and response-parsing detail.
378async fn acquire_fresh(
379 auth: &OAuthAuth,
380 client: &HttpClient,
381) -> Result<(String, Option<u64>), String> {
382 let mut form: Vec<(String, String)> = vec![
383 (
384 "grant_type".to_string(),
385 auth.grant_type.as_str().to_string(),
386 ),
387 ("client_id".to_string(), auth.client_id.clone()),
388 ];
389 if let Some(client_secret) = &auth.client_secret {
390 form.push(("client_secret".to_string(), client_secret.clone()));
391 }
392 if let Some(scope) = &auth.scope {
393 form.push(("scope".to_string(), scope.clone()));
394 }
395 if auth.grant_type == OAuthGrantType::Password {
396 form.push((
397 "username".to_string(),
398 auth.username.clone().unwrap_or_default(),
399 ));
400 form.push((
401 "password".to_string(),
402 auth.password.clone().unwrap_or_default(),
403 ));
404 }
405 post_token_form(auth, client, form).await
406}
407
408/// A code exchanged for [`exchange_authorization_code`], obtained interactively
409/// (see the module doc comment) rather than from `auth.oauth` itself, so it
410/// cannot travel through [`OAuthAuth`] the way every other grant's fields do.
411///
412/// Trades `code` (plus the PKCE `code_verifier` matching the `code_challenge`
413/// [`build_authorization_url`] sent) for a token, via the standard
414/// `authorization_code` token request (RFC 6749 §4.1.3): `POST
415/// auth.token_url` with `grant_type=authorization_code`, `code`,
416/// `redirect_uri`, `client_id`, `code_verifier`, and `client_secret` when
417/// `auth` has one — reusing [`send_prepared`] through the same shared
418/// [`HttpClient`] every other request in the run sends through, exactly like
419/// [`acquire_fresh`] does for the other two grants.
420///
421/// This is a standalone entry point, not folded into [`acquire_token`]: it is
422/// driven by a front end that already has a `code` in hand from its own
423/// browser/callback-listener flow, not by the automatic "acquire whatever
424/// this `oauth:` config needs" path every other grant uses. Once this
425/// succeeds, the caller is expected to hand the result to
426/// [`OAuthTokenCache::insert_token`] so later automatic resolution reuses it
427/// — see the module doc comment.
428///
429/// Errors exactly like [`acquire_token`] does: a non-2xx response or an
430/// unparseable body is [`SendraError::OAuthAcquisition`] naming
431/// `auth.token_url` and why; so is a network/timeout failure reaching it.
432pub async fn exchange_authorization_code(
433 auth: &OAuthAuth,
434 client: &HttpClient,
435 code: &str,
436 code_verifier: &str,
437) -> Result<(String, Option<u64>), SendraError> {
438 let redirect_uri = auth.redirect_uri.clone().unwrap_or_default();
439 let mut form: Vec<(String, String)> = vec![
440 (
441 "grant_type".to_string(),
442 OAuthGrantType::AuthorizationCode.as_str().to_string(),
443 ),
444 ("code".to_string(), code.to_string()),
445 ("redirect_uri".to_string(), redirect_uri),
446 ("client_id".to_string(), auth.client_id.clone()),
447 ("code_verifier".to_string(), code_verifier.to_string()),
448 ];
449 if let Some(client_secret) = &auth.client_secret {
450 form.push(("client_secret".to_string(), client_secret.clone()));
451 }
452
453 post_token_form(auth, client, form)
454 .await
455 .map_err(|reason| SendraError::OAuthAcquisition {
456 token_url: auth.token_url.clone(),
457 reason,
458 })
459}
460
461/// The wire mechanics shared by [`acquire_fresh`] and
462/// [`exchange_authorization_code`]: `POST auth.token_url` with `form` as
463/// `application/x-www-form-urlencoded`, then parse the response as
464/// [`TokenResponse`] — everything past "which fields does this grant send"
465/// is identical between every grant, so it lives here once.
466async fn post_token_form(
467 auth: &OAuthAuth,
468 client: &HttpClient,
469 form: Vec<(String, String)>,
470) -> Result<(String, Option<u64>), String> {
471 let body = serde_urlencoded::to_string(&form)
472 .expect("a Vec<(String, String)> always encodes as x-www-form-urlencoded pairs");
473
474 let request = Request {
475 name: None,
476 method: Method::Post,
477 url: auth.token_url.clone(),
478 headers: vec![(
479 "Content-Type".to_string(),
480 "application/x-www-form-urlencoded".to_string(),
481 )],
482 query: Vec::new(),
483 body: Some(body),
484 json: None,
485 body_file: None,
486 form: Vec::new(),
487 multipart: Vec::new(),
488 auth: None,
489 assertions: None,
490 pre_request: None,
491 post_request: None,
492 capture: None,
493 retry: None,
494 };
495
496 let response = send_prepared(&request, client)
497 .await
498 .map_err(|err| err.to_string())?;
499
500 if !(200..300).contains(&response.status) {
501 return Err(format!(
502 "token endpoint responded {} {}: {}",
503 response.status,
504 response.status_text,
505 truncate(&response.body)
506 ));
507 }
508
509 let parsed: TokenResponse = serde_json::from_str(&response.body).map_err(|err| {
510 format!(
511 "could not parse the token response as JSON: {err} (body: {})",
512 truncate(&response.body)
513 )
514 })?;
515
516 Ok((parsed.access_token, parsed.expires_in))
517}
518
519/// A fresh PKCE verifier/challenge pair for one `authorization_code` login
520/// attempt (RFC 7636) — see the module doc comment for why this is used
521/// unconditionally rather than offered as config.
522///
523/// `verifier` is 32 bytes (256 bits) of randomness — two
524/// [`uuid::Uuid::new_v4`] values concatenated, reusing the dependency this
525/// workspace already pulls in for `uuid()` rather than adding `rand` for the
526/// entropy source alone — base64url-encoded without padding, which RFC
527/// 7636's `code_verifier` charset (unreserved URL characters) accepts
528/// directly and yields exactly 43 characters, the shortest length the RFC
529/// allows. `challenge` is `BASE64URL-ENCODE(SHA256(verifier))`, the `S256`
530/// method [`build_authorization_url`] declares — the method RFC 7636 §4.2
531/// requires clients to use "if the client is capable of doing so", which a
532/// TUI, unlike some constrained embedded clients, always is.
533pub struct PkcePair {
534 pub verifier: String,
535 pub challenge: String,
536}
537
538pub fn generate_pkce() -> PkcePair {
539 let mut bytes = [0u8; 32];
540 bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
541 bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
542 let verifier = URL_SAFE_NO_PAD.encode(bytes);
543 let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
544 PkcePair {
545 verifier,
546 challenge,
547 }
548}
549
550/// A fresh CSRF `state` value for one `authorization_code` login attempt
551/// (RFC 6749 §10.12) — checked against the callback's own `state` parameter
552/// by whichever front end ran [`build_authorization_url`], not by anything
553/// in this module, since holding onto the value being checked against is
554/// specific to how that front end tracks a pending login.
555pub fn generate_state() -> String {
556 uuid::Uuid::new_v4().to_string()
557}
558
559/// The URL to open a browser to for one `authorization_code` login attempt:
560/// `auth.authorization_url` with `response_type=code`, `client_id`,
561/// `redirect_uri`, `code_challenge`/`code_challenge_method=S256` (from
562/// [`generate_pkce`]), `state` (from [`generate_state`]), and `scope` when
563/// `auth` has one, appended as query parameters — the standard shape an
564/// OAuth 2.0 authorization request takes (RFC 6749 §4.1.1) plus PKCE's two
565/// parameters (RFC 7636 §4.3).
566///
567/// Uses [`reqwest::Url`]'s query-pair API — already a dependency, and the
568/// same one [`crate::Request::resolve_query`] uses — rather than string
569/// concatenation, so `redirect_uri` and `scope` are percent-encoded
570/// correctly regardless of what characters they contain.
571///
572/// Errors with [`SendraError::OAuthAuthorizationUrl`] if
573/// `auth.authorization_url` does not parse as a URL — the one failure mode
574/// possible before any browser or network is involved.
575pub fn build_authorization_url(
576 auth: &OAuthAuth,
577 state: &str,
578 code_challenge: &str,
579) -> Result<String, SendraError> {
580 let authorization_url = auth.authorization_url.clone().unwrap_or_default();
581 let mut url = reqwest::Url::parse(&authorization_url).map_err(|source| {
582 SendraError::OAuthAuthorizationUrl {
583 authorization_url: authorization_url.clone(),
584 reason: source.to_string(),
585 }
586 })?;
587
588 {
589 let mut pairs = url.query_pairs_mut();
590 pairs.append_pair("response_type", "code");
591 pairs.append_pair("client_id", &auth.client_id);
592 pairs.append_pair(
593 "redirect_uri",
594 auth.redirect_uri.as_deref().unwrap_or_default(),
595 );
596 pairs.append_pair("code_challenge", code_challenge);
597 pairs.append_pair("code_challenge_method", "S256");
598 pairs.append_pair("state", state);
599 if let Some(scope) = &auth.scope {
600 pairs.append_pair("scope", scope);
601 }
602 }
603
604 Ok(url.into())
605}
606
607/// Keeps an acquisition-failure message from embedding an entire
608/// (possibly huge, possibly HTML) response body.
609fn truncate(body: &str) -> String {
610 const MAX_CHARS: usize = 200;
611 if body.chars().count() <= MAX_CHARS {
612 body.to_string()
613 } else {
614 format!("{}...", body.chars().take(MAX_CHARS).collect::<String>())
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::config::Config;
622 use crate::http::client::build_client;
623 use std::io::{BufRead, BufReader, Write};
624 use std::net::{SocketAddr, TcpListener};
625 use std::sync::atomic::{AtomicUsize, Ordering};
626 use std::sync::Arc;
627
628 fn oauth_auth(token_url: &str) -> OAuthAuth {
629 OAuthAuth {
630 grant_type: OAuthGrantType::ClientCredentials,
631 token_url: token_url.to_string(),
632 client_id: "client-id".to_string(),
633 client_secret: Some("client-secret".to_string()),
634 scope: None,
635 username: None,
636 password: None,
637 authorization_url: None,
638 redirect_uri: None,
639 }
640 }
641
642 fn client() -> HttpClient {
643 build_client(&Config::default()).expect("a client builds")
644 }
645
646 /// A token endpoint that answers every request on `/token` with the
647 /// same fixed raw HTTP response and counts how many times it was hit —
648 /// hand-rolled over a blocking `TcpListener`, the same pattern
649 /// `crate::test_support` uses, since what these tests need to observe
650 /// (whether the endpoint was hit a second time at all) is below the
651 /// level a mock-server crate would add anything over.
652 struct TokenServer {
653 addr: SocketAddr,
654 hits: Arc<AtomicUsize>,
655 }
656
657 impl TokenServer {
658 fn start(response: Vec<u8>) -> Self {
659 let listener = TcpListener::bind("127.0.0.1:0").expect("an ephemeral port is free");
660 let addr = listener.local_addr().expect("the listener has an address");
661 let hits = Arc::new(AtomicUsize::new(0));
662
663 let counted = hits.clone();
664 std::thread::spawn(move || {
665 for stream in listener.incoming() {
666 let Ok(stream) = stream else { continue };
667 let mut writer = stream.try_clone().expect("the socket clones");
668 let mut reader = BufReader::new(stream);
669
670 let mut request_line = String::new();
671 if reader.read_line(&mut request_line).unwrap_or(0) == 0 {
672 continue;
673 }
674 let mut content_length = 0usize;
675 loop {
676 let mut header = String::new();
677 match reader.read_line(&mut header) {
678 Ok(0) | Err(_) => break,
679 Ok(_) if header == "\r\n" => break,
680 Ok(_) => {
681 if let Some((name, value)) = header.split_once(':') {
682 if name.trim().eq_ignore_ascii_case("content-length") {
683 content_length = value.trim().parse().unwrap_or(0);
684 }
685 }
686 }
687 }
688 }
689 let mut body = vec![0u8; content_length];
690 if content_length > 0 {
691 use std::io::Read;
692 let _ = reader.read_exact(&mut body);
693 }
694
695 counted.fetch_add(1, Ordering::SeqCst);
696 if writer.write_all(&response).is_err() {
697 continue;
698 }
699 let _ = writer.flush();
700 }
701 });
702
703 Self { addr, hits }
704 }
705
706 fn token_url(&self) -> String {
707 format!("http://{}/token", self.addr)
708 }
709
710 fn hits(&self) -> usize {
711 self.hits.load(Ordering::SeqCst)
712 }
713 }
714
715 fn token_response(body: &'static str) -> Vec<u8> {
716 format!(
717 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
718 body.len()
719 )
720 .into_bytes()
721 }
722
723 #[tokio::test]
724 async fn client_credentials_acquires_a_token() {
725 let server = TokenServer::start(token_response(
726 r#"{"access_token": "abc123", "token_type": "Bearer"}"#,
727 ));
728 let auth = oauth_auth(&server.token_url());
729 let client = client();
730 let cache = OAuthTokenCache::new();
731
732 let token = acquire_token(&auth, &client, &cache)
733 .await
734 .expect("the mock token endpoint answers");
735 assert_eq!(token, "abc123");
736 }
737
738 #[tokio::test]
739 async fn password_grant_acquires_a_token() {
740 let server = TokenServer::start(token_response(r#"{"access_token": "pwd-token"}"#));
741 let auth = OAuthAuth {
742 grant_type: OAuthGrantType::Password,
743 username: Some("ada".to_string()),
744 password: Some("s3cr3t".to_string()),
745 ..oauth_auth(&server.token_url())
746 };
747 let client = client();
748 let cache = OAuthTokenCache::new();
749
750 let token = acquire_token(&auth, &client, &cache)
751 .await
752 .expect("the mock token endpoint answers");
753 assert_eq!(token, "pwd-token");
754 }
755
756 #[tokio::test]
757 async fn a_token_is_reused_across_requests_sharing_the_same_config() {
758 let server = TokenServer::start(token_response(r#"{"access_token": "shared"}"#));
759 let auth = oauth_auth(&server.token_url());
760 let client = client();
761 let cache = OAuthTokenCache::new();
762
763 for _ in 0..3 {
764 let token = acquire_token(&auth, &client, &cache)
765 .await
766 .expect("acquires or reuses successfully");
767 assert_eq!(token, "shared");
768 }
769
770 assert_eq!(
771 server.hits(),
772 1,
773 "three requests through one config must acquire exactly one token"
774 );
775 }
776
777 #[tokio::test]
778 async fn a_token_with_no_expires_in_is_reused_indefinitely() {
779 let server = TokenServer::start(token_response(r#"{"access_token": "no-expiry"}"#));
780 let auth = oauth_auth(&server.token_url());
781 let client = client();
782 let cache = OAuthTokenCache::new();
783
784 for _ in 0..5 {
785 acquire_token(&auth, &client, &cache)
786 .await
787 .expect("acquires or reuses successfully");
788 }
789
790 assert_eq!(
791 server.hits(),
792 1,
793 "omitting `expires_in` must be treated as not expiring for this run, not \
794 reacquired on every call"
795 );
796 }
797
798 #[tokio::test]
799 async fn an_expired_cached_token_triggers_reacquisition() {
800 let server = TokenServer::start(token_response(
801 r#"{"access_token": "still-first", "expires_in": 0}"#,
802 ));
803 let auth = oauth_auth(&server.token_url());
804 let client = client();
805 let cache = OAuthTokenCache::new();
806
807 acquire_token(&auth, &client, &cache)
808 .await
809 .expect("the first acquisition succeeds");
810 // `expires_in: 0` is already inside the expiry margin at the moment
811 // it is cached, so this second call must reacquire rather than
812 // reuse — visible as a second hit on the endpoint, not just an
813 // equal token value (the server always answers the same body).
814 acquire_token(&auth, &client, &cache)
815 .await
816 .expect("reacquisition against the same, still-up server succeeds");
817
818 assert_eq!(
819 server.hits(),
820 2,
821 "an expired cached token must trigger a fresh acquisition"
822 );
823 }
824
825 #[tokio::test]
826 async fn a_non_2xx_token_response_is_a_typed_acquisition_error() {
827 let server = TokenServer::start(
828 b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 20\r\n\r\n{\"error\":\"denied\"}\r\n"
829 .to_vec(),
830 );
831 let auth = oauth_auth(&server.token_url());
832 let client = client();
833 let cache = OAuthTokenCache::new();
834
835 let err = acquire_token(&auth, &client, &cache)
836 .await
837 .expect_err("a 401 must not be treated as success");
838 match err {
839 SendraError::OAuthAcquisition { reason, .. } => {
840 assert!(reason.contains("401"), "got {reason}");
841 }
842 other => panic!("expected OAuthAcquisition, got {other:?}"),
843 }
844 }
845
846 #[tokio::test]
847 async fn a_malformed_token_response_is_a_typed_acquisition_error() {
848 let server = TokenServer::start(token_response("not json"));
849 let auth = oauth_auth(&server.token_url());
850 let client = client();
851 let cache = OAuthTokenCache::new();
852
853 let err = acquire_token(&auth, &client, &cache)
854 .await
855 .expect_err("a non-JSON body must not be treated as success");
856 assert!(matches!(err, SendraError::OAuthAcquisition { .. }));
857 }
858
859 #[tokio::test]
860 async fn a_failed_acquisition_is_remembered_and_not_retried() {
861 let server = TokenServer::start(token_response("not json"));
862 let auth = oauth_auth(&server.token_url());
863 let client = client();
864 let cache = OAuthTokenCache::new();
865
866 assert!(acquire_token(&auth, &client, &cache).await.is_err());
867 assert!(acquire_token(&auth, &client, &cache).await.is_err());
868
869 assert_eq!(
870 server.hits(),
871 1,
872 "a config that already failed once in this run must not be retried against \
873 the endpoint for a second request"
874 );
875 }
876
877 #[tokio::test]
878 async fn two_different_scopes_are_cached_separately() {
879 let server = TokenServer::start(token_response(r#"{"access_token": "tok"}"#));
880 let base = oauth_auth(&server.token_url());
881 let client = client();
882 let cache = OAuthTokenCache::new();
883
884 let scoped_a = OAuthAuth {
885 scope: Some("read".to_string()),
886 ..base.clone()
887 };
888 let scoped_b = OAuthAuth {
889 scope: Some("write".to_string()),
890 ..base
891 };
892
893 acquire_token(&scoped_a, &client, &cache)
894 .await
895 .expect("acquires");
896 acquire_token(&scoped_b, &client, &cache)
897 .await
898 .expect("acquires");
899
900 assert_eq!(
901 server.hits(),
902 2,
903 "two different scopes must not share one cache entry"
904 );
905 }
906
907 // --- authorization_code: cannot auto-acquire ---------------------------
908
909 fn authorization_code_auth(token_url: &str) -> OAuthAuth {
910 OAuthAuth {
911 grant_type: OAuthGrantType::AuthorizationCode,
912 token_url: token_url.to_string(),
913 client_id: "client-id".to_string(),
914 client_secret: None,
915 scope: None,
916 username: None,
917 password: None,
918 authorization_url: Some("https://auth.example.com/authorize".to_string()),
919 redirect_uri: Some("http://127.0.0.1:8899/callback".to_string()),
920 }
921 }
922
923 #[tokio::test]
924 async fn authorization_code_never_reaches_the_token_endpoint_via_acquire_token() {
925 let server = TokenServer::start(token_response(r#"{"access_token": "unused"}"#));
926 let auth = authorization_code_auth(&server.token_url());
927 let client = client();
928 let cache = OAuthTokenCache::new();
929
930 let err = acquire_token(&auth, &client, &cache)
931 .await
932 .expect_err("no code is available for an automatic acquisition");
933 match err {
934 SendraError::OAuthAcquisition { reason, .. } => {
935 assert!(reason.contains("interactive"), "got {reason}");
936 }
937 other => panic!("expected OAuthAcquisition, got {other:?}"),
938 }
939 assert_eq!(
940 server.hits(),
941 0,
942 "acquire_token must never hit the token endpoint for authorization_code — there is \
943 no code to send"
944 );
945 }
946
947 #[tokio::test]
948 async fn a_token_inserted_via_insert_token_is_served_by_acquire_token_afterward() {
949 let server = TokenServer::start(token_response(r#"{"access_token": "unused"}"#));
950 let auth = authorization_code_auth(&server.token_url());
951 let cache = OAuthTokenCache::new();
952
953 // Simulates the interactive login flow's own conclusion: exchange
954 // happened elsewhere (`exchange_authorization_code`), and the result
955 // is written straight into the cache — never through `acquire_fresh`.
956 cache.insert_token(&auth, "interactively-acquired".to_string(), Some(3600));
957
958 let client = client();
959 let token = acquire_token(&auth, &client, &cache)
960 .await
961 .expect("a token inserted via insert_token must be served like any other");
962 assert_eq!(token, "interactively-acquired");
963 assert_eq!(
964 server.hits(),
965 0,
966 "serving an inserted token must never itself hit the token endpoint"
967 );
968 }
969
970 // --- PKCE / authorization URL / code exchange ---------------------------
971
972 #[test]
973 fn generate_pkce_produces_a_verifier_and_a_matching_s256_challenge() {
974 let pkce = generate_pkce();
975
976 assert_eq!(
977 pkce.verifier.len(),
978 43,
979 "32 bytes base64url-no-pad encodes to exactly 43 characters"
980 );
981 assert!(
982 pkce.verifier
983 .chars()
984 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
985 "the verifier must use only RFC 7636's unreserved base64url characters: {}",
986 pkce.verifier
987 );
988
989 let expected_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(pkce.verifier.as_bytes()));
990 assert_eq!(pkce.challenge, expected_challenge);
991 }
992
993 #[test]
994 fn generate_pkce_never_repeats_across_calls() {
995 let a = generate_pkce();
996 let b = generate_pkce();
997 assert_ne!(a.verifier, b.verifier);
998 assert_ne!(a.challenge, b.challenge);
999 }
1000
1001 #[test]
1002 fn generate_state_never_repeats_across_calls() {
1003 assert_ne!(generate_state(), generate_state());
1004 }
1005
1006 #[test]
1007 fn build_authorization_url_includes_every_required_parameter() {
1008 let mut auth = authorization_code_auth("https://auth.example.com/token");
1009 auth.scope = Some("read write".to_string());
1010
1011 let url = build_authorization_url(&auth, "csrf-state", "the-challenge")
1012 .expect("a valid authorization_url must build");
1013 let parsed = reqwest::Url::parse(&url).expect("the result is itself a valid URL");
1014
1015 let pairs: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
1016 assert_eq!(pairs.get("response_type").map(String::as_str), Some("code"));
1017 assert_eq!(
1018 pairs.get("client_id").map(String::as_str),
1019 Some("client-id")
1020 );
1021 assert_eq!(
1022 pairs.get("redirect_uri").map(String::as_str),
1023 Some("http://127.0.0.1:8899/callback")
1024 );
1025 assert_eq!(
1026 pairs.get("code_challenge").map(String::as_str),
1027 Some("the-challenge")
1028 );
1029 assert_eq!(
1030 pairs.get("code_challenge_method").map(String::as_str),
1031 Some("S256")
1032 );
1033 assert_eq!(pairs.get("state").map(String::as_str), Some("csrf-state"));
1034 assert_eq!(pairs.get("scope").map(String::as_str), Some("read write"));
1035 }
1036
1037 #[test]
1038 fn build_authorization_url_rejects_an_unparseable_authorization_url() {
1039 let mut auth = authorization_code_auth("https://auth.example.com/token");
1040 auth.authorization_url = Some("not a url".to_string());
1041
1042 let err = build_authorization_url(&auth, "state", "challenge").expect_err(
1043 "an unparseable authorization_url must be rejected before any network call",
1044 );
1045 assert!(matches!(err, SendraError::OAuthAuthorizationUrl { .. }));
1046 }
1047
1048 #[tokio::test]
1049 async fn exchange_authorization_code_acquires_a_token() {
1050 let server = TokenServer::start(token_response(r#"{"access_token": "exchanged"}"#));
1051 let auth = authorization_code_auth(&server.token_url());
1052 let client = client();
1053
1054 let (token, expires_in) =
1055 exchange_authorization_code(&auth, &client, "the-code", "the-verifier")
1056 .await
1057 .expect("the mock token endpoint answers");
1058 assert_eq!(token, "exchanged");
1059 assert_eq!(expires_in, None);
1060 }
1061
1062 #[tokio::test]
1063 async fn exchange_authorization_code_surfaces_a_non_2xx_response_as_a_typed_error() {
1064 let server = TokenServer::start(
1065 b"HTTP/1.1 400 Bad Request\r\nContent-Length: 20\r\n\r\n{\"error\":\"invalid\"}\r\n"
1066 .to_vec(),
1067 );
1068 let auth = authorization_code_auth(&server.token_url());
1069 let client = client();
1070
1071 let err = exchange_authorization_code(&auth, &client, "the-code", "the-verifier")
1072 .await
1073 .expect_err("a 400 must not be treated as success");
1074 match err {
1075 SendraError::OAuthAcquisition { reason, .. } => {
1076 assert!(reason.contains("400"), "got {reason}");
1077 }
1078 other => panic!("expected OAuthAcquisition, got {other:?}"),
1079 }
1080 }
1081}