Skip to main content

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}