Skip to main content

xurl/auth/
mod.rs

1//! Authentication orchestration — `OAuth2` PKCE, `OAuth1` HMAC-SHA1, Bearer.
2//!
3//! Mirrors the Go `auth.Auth` struct. Credentials are resolved in order:
4//! env-var config -> active app in `.xurl` store.
5
6pub mod callback;
7pub mod oauth1;
8pub mod oauth2;
9pub mod pending;
10
11use crate::config::Config;
12use crate::error::{Result, XurlError};
13use crate::output::OutputConfig;
14use crate::store::TokenStore;
15
16/// Manages authentication for X API requests.
17///
18/// Holds the credential matrix (env-supplied vs store-derived `client_id` /
19/// `client_secret`), the active app name, and the [`TokenStore`] that
20/// persists tokens to disk. Constructed via [`Auth::new`] (legacy
21/// `~/.xurl` path) or [`Auth::new_with_store_path`] (test-friendly explicit
22/// path).
23#[allow(clippy::struct_field_names)]
24pub struct Auth {
25    /// Token store backing this `Auth` instance.
26    pub token_store: TokenStore,
27    /// Owned application configuration. The redirect URI here is the
28    /// resolver output (env > app-stored > built-in default), written by
29    /// [`Auth::new_with_store_path`] and re-resolved by
30    /// [`Auth::with_app_name`]. This is the single source of truth per
31    /// KTD2 — no parallel `redirect_uri` field lives on `Auth`.
32    config: Config,
33    client_id: String,
34    client_secret: String,
35    /// `true` iff [`Self::client_id`] was supplied via the `CLIENT_ID` env
36    /// var at construction. Preserved across [`Self::with_app_name`] switches
37    /// so env precedence holds even after the active app changes. Without
38    /// this flag, the older "preserve if non-empty" check could not
39    /// distinguish env-supplied values from values copied off the previous
40    /// app's store entry, so subsequent `--app NAME` switches silently
41    /// re-used the previous app's stored client_id.
42    client_id_from_env: bool,
43    /// Counterpart to [`Self::client_id_from_env`] for client_secret.
44    client_secret_from_env: bool,
45    app_name: String,
46}
47
48impl Auth {
49    /// Creates a new `Auth` object using the legacy `~/.xurl` token-store path.
50    ///
51    /// Shim over [`Auth::new_with_store_path`] resolving to
52    /// [`Config::default_store_path`]. Credentials are resolved: env vars -> active app.
53    #[must_use]
54    pub fn new(cfg: &Config) -> Self {
55        Self::new_with_store_path(cfg, &Config::default_store_path())
56    }
57
58    /// Creates a new `Auth` object backed by an explicit token-store path.
59    ///
60    /// The canonical constructor. Tests pass a `TempDir`-rooted path to avoid
61    /// touching the real `~/.xurl`; the binary calls [`Auth::new`] which
62    /// resolves to [`Config::default_store_path`]. Credentials are resolved:
63    /// env vars -> active app at `store_path`.
64    ///
65    /// Runs the three-level redirect URI resolver (env > app-stored >
66    /// built-in default) against the constructed token store and writes the
67    /// result back into the owned `Config`, satisfying KTD2's single source
68    /// of truth invariant.
69    #[must_use]
70    pub fn new_with_store_path(cfg: &Config, store_path: &std::path::Path) -> Self {
71        let path_str = store_path.to_str().unwrap_or(".");
72        let ts =
73            TokenStore::new_with_credentials_and_path(&cfg.client_id, &cfg.client_secret, path_str);
74
75        // Env-origin is recorded BEFORE falling back to the store so a later
76        // `with_app_name` switch can correctly preserve env-supplied values
77        // and re-resolve store-derived ones from the new app's entry.
78        let client_id_from_env = !cfg.client_id.is_empty();
79        let client_secret_from_env = !cfg.client_secret.is_empty();
80
81        let mut client_id = cfg.client_id.clone();
82        let mut client_secret = cfg.client_secret.clone();
83        let app_name = cfg.app_name.clone();
84
85        let app = ts.resolve_app(&app_name);
86        if !client_id_from_env {
87            client_id.clone_from(&app.client_id);
88        }
89        if !client_secret_from_env {
90            client_secret.clone_from(&app.client_secret);
91        }
92
93        let mut config = cfg.clone();
94        let resolved = crate::config::resolve_redirect_uri_from(
95            std::env::var("REDIRECT_URI").ok(),
96            ts.get_app_redirect_uri(&app_name),
97        );
98        config.redirect_uri = resolved.uri;
99        config.redirect_uri_source = resolved.source;
100        config.redirect_uri_from_env = resolved.source.is_env_var();
101
102        Self {
103            token_store: ts,
104            config,
105            client_id,
106            client_secret,
107            client_id_from_env,
108            client_secret_from_env,
109            app_name,
110        }
111    }
112
113    /// Sets the explicit app name override and re-resolves the redirect URI.
114    ///
115    /// Credentials honor env precedence per the internal
116    /// `client_id_from_env` / `client_secret_from_env` flags:
117    /// env-supplied values survive the switch; store-derived values
118    /// get re-resolved from the new app's
119    /// entry, even when the previous app's stored value was non-empty.
120    /// The redirect URI is always re-resolved (env-precedence is enforced
121    /// inside the resolver itself, so re-running unconditionally produces
122    /// the right value per KTD3).
123    pub fn with_app_name(&mut self, app_name: &str) {
124        self.app_name = app_name.to_string();
125        let app = self.token_store.resolve_app(app_name);
126        if !self.client_id_from_env {
127            self.client_id = app.client_id.clone();
128        }
129        if !self.client_secret_from_env {
130            self.client_secret = app.client_secret.clone();
131        }
132
133        let resolved = crate::config::resolve_redirect_uri_from(
134            std::env::var("REDIRECT_URI").ok(),
135            self.token_store.get_app_redirect_uri(app_name),
136        );
137        self.config.redirect_uri = resolved.uri;
138        self.config.redirect_uri_source = resolved.source;
139        self.config.redirect_uri_from_env = resolved.source.is_env_var();
140    }
141
142    /// Gets the `OAuth1` Authorization header for a request.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if no `OAuth1` token is found or signature generation fails.
147    pub fn get_oauth1_header(
148        &self,
149        method: &str,
150        url_str: &str,
151        additional_params: Option<&std::collections::BTreeMap<String, String>>,
152    ) -> Result<String> {
153        // Multi-app resolution: read the OAuth1 token from the active app
154        // (set by `with_app_name` from the `--app NAME` CLI flag) rather
155        // than from the legacy no-arg `get_oauth1_tokens()` which falls
156        // back to the default app. Without this scoping, a token saved
157        // under NAME via `xr auth oauth1 --app NAME` would be invisible
158        // to subsequent `--app NAME --auth oauth1` invocations.
159        let token = self
160            .token_store
161            .get_oauth1_tokens_for_app(&self.app_name)
162            .ok_or_else(|| XurlError::auth("TokenNotFound: OAuth1 token not found"))?;
163
164        let oauth1_token = token
165            .oauth1
166            .as_ref()
167            .ok_or_else(|| XurlError::auth("TokenNotFound: OAuth1 token not found"))?;
168
169        oauth1::build_oauth1_header(method, url_str, oauth1_token, additional_params)
170    }
171
172    /// Gets or refreshes an `OAuth2` token and returns the Authorization header.
173    ///
174    /// Lookup precedence is intent-split per `KTD5`:
175    ///
176    /// - non-empty `username` (named caller): try the username's own token
177    ///   first; on miss, fall through to
178    ///   [`TokenStore::get_first_oauth2_token_for_app`] (which itself prefers
179    ///   `default_user`, then arbitrary-first); on total miss, trigger the
180    ///   full OAuth2 flow. The unnamed (`/me`-failed salvage) slot is
181    ///   **never** consulted on this branch — a caller who supplies a
182    ///   username has explicit identity intent and must not silently receive
183    ///   a salvage-state token under their name.
184    /// - empty `username`: try `get_first_oauth2_token_for_app` (which
185    ///   prefers `default_user`, then arbitrary-first); on miss, fall through
186    ///   to the unnamed slot via
187    ///   [`TokenStore::get_oauth2_token_unnamed_for_app`]; on total miss,
188    ///   trigger the OAuth2 flow.
189    ///
190    /// In both branches the cached-token path delegates to
191    /// [`Auth::refresh_oauth2_token`], which is a no-op if the token is still
192    /// valid.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if the `OAuth2` flow fails or token refresh fails.
197    pub fn get_oauth2_header(&mut self, username: &str) -> Result<String> {
198        let app_name = self.app_name.clone();
199
200        if username.is_empty() {
201            // Empty-caller precedence: default_user (via get_first) -> unnamed -> flow.
202            if self
203                .token_store
204                .get_first_oauth2_token_for_app(&app_name)
205                .is_some()
206            {
207                let access_token = self.refresh_oauth2_token(username)?;
208                return Ok(format!("Bearer {access_token}"));
209            }
210
211            if self
212                .token_store
213                .get_oauth2_token_unnamed_for_app(&app_name)
214                .is_some()
215            {
216                // Empty-caller + unnamed-hit: refresh delegates with empty username;
217                // if /me fails again the refresh path writes back to the unnamed slot.
218                let access_token = self.refresh_oauth2_token(username)?;
219                return Ok(format!("Bearer {access_token}"));
220            }
221        } else {
222            // Named-caller precedence: own token -> get_first fallback -> flow.
223            // The unnamed slot is never consulted here.
224            if self.token_store.get_oauth2_token(username).is_some()
225                || self
226                    .token_store
227                    .get_first_oauth2_token_for_app(&app_name)
228                    .is_some()
229            {
230                let access_token = self.refresh_oauth2_token(username)?;
231                return Ok(format!("Bearer {access_token}"));
232            }
233        }
234
235        // Deferred per U4 / KTD6: this site is the mid-request token-refresh
236        // path inside `ApiClient::send_request`. Plumbing writers from the
237        // runner all the way through `ApiClient::send_request` →
238        // `get_auth_header` → `get_oauth2_header` would expand 6 method
239        // signatures (send_request, send_multipart_request, stream_request,
240        // get_auth_header, get_auth_header_public, get_oauth2_header) and
241        // every call site in `cli/commands/` and `api/media.rs` for a path
242        // that runs only on the first request when no token is cached.
243        //
244        // The vast majority of OAuth2 flows run at explicit `xr auth oauth2`
245        // time (which U4 wires correctly via the runner's writers). This
246        // stub is the only remaining direct-stdio site after U4.
247        let out = OutputConfig::new(
248            crate::output::OutputFormat::Text,
249            false,
250            false,
251            crate::cli::ColorChoice::Auto,
252        );
253        let mut stdout = std::io::stdout();
254        let access_token = self.oauth2_flow(username, &out, &mut stdout)?;
255        Ok(format!("Bearer {access_token}"))
256    }
257
258    /// Starts the `OAuth2` PKCE flow with the default `open::that` browser opener.
259    ///
260    /// Browser-failure prompts are written to `stdout` via `out`'s
261    /// `print_message`, so callers can capture them in tests. Library
262    /// consumers needing a custom opener (recording / headless / tests for
263    /// the listener-before-browser ordering) call
264    /// [`oauth2::run_oauth2_flow`] directly with their own `fn(&str) ->
265    /// io::Result<()>` opener.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the authorization flow, token exchange, or username
270    /// resolution fails.
271    pub fn oauth2_flow(
272        &mut self,
273        username: &str,
274        out: &OutputConfig,
275        stdout: &mut dyn std::io::Write,
276    ) -> Result<String> {
277        oauth2::run_oauth2_flow(self, username, out, stdout, |url| open::that(url))
278    }
279
280    /// Validates and refreshes an `OAuth2` token if needed.
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if no token is found or the refresh request fails.
285    pub fn refresh_oauth2_token(&mut self, username: &str) -> Result<String> {
286        oauth2::refresh_oauth2_token(self, username)
287    }
288
289    /// Runs step 1 of the remote `OAuth2` PKCE flow.
290    ///
291    /// Returns the authorization URL that the user should open in a browser
292    /// on another machine.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if the authorization URL is invalid or the pending
297    /// state file cannot be written.
298    pub fn remote_oauth2_step1(&self, pending_path: &std::path::Path) -> Result<String> {
299        oauth2::run_remote_step1(self, pending_path)
300    }
301
302    /// Runs step 2 of the remote `OAuth2` PKCE flow.
303    ///
304    /// Takes the redirect URL from the browser, extracts the authorization
305    /// code, exchanges it for an access token, and saves the token.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the pending state is missing/expired/invalid,
310    /// the token exchange fails, or the username cannot be resolved.
311    pub fn remote_oauth2_step2(
312        &mut self,
313        redirect_url: &str,
314        username: &str,
315        pending_path: &std::path::Path,
316    ) -> Result<String> {
317        oauth2::run_remote_step2(self, redirect_url, username, pending_path)
318    }
319
320    /// Gets the bearer token Authorization header.
321    ///
322    /// Resolution order: `XURL_BEARER_TOKEN` env var first (one-shot agent
323    /// flows that pipe a secret without persisting it to disk), then the
324    /// token store entry on the resolved app. Empty env values fall through
325    /// to the store. The env var matches the precedence shape used by every
326    /// other agentic flag (`XURL_VERBOSE`, `XURL_OUTPUT`, `XURL_NO_BROWSER`,
327    /// etc.) and pairs with `XURL_OUTPUT=json xr ... --auth app` for stateless
328    /// container invocations.
329    ///
330    /// Production code reads `XURL_BEARER_TOKEN` from the process environment.
331    /// The resolution logic is factored into [`resolve_bearer_token`] so unit
332    /// tests can exercise every precedence path without mutating the global
333    /// environment (the project policy in MEMORY: no env mutation in tests,
334    /// no `#[serial]`).
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if `XURL_BEARER_TOKEN` is unset (or empty) AND no
339    /// bearer token is stored for the resolved app.
340    pub fn get_bearer_token_header(&self) -> Result<String> {
341        resolve_bearer_token(
342            std::env::var("XURL_BEARER_TOKEN").ok(),
343            &self.token_store,
344            &self.app_name,
345        )
346    }
347
348    /// Fetches the username for an access token from the /2/users/me endpoint.
349    pub(crate) fn fetch_username(&self, access_token: &str) -> Result<String> {
350        let client = reqwest::blocking::Client::builder()
351            .timeout(std::time::Duration::from_secs(
352                self.config.http_timeout_secs,
353            ))
354            .build()
355            .unwrap_or_else(|_| reqwest::blocking::Client::new());
356        let resp = client
357            .get(&self.config.info_url)
358            .header("Authorization", format!("Bearer {access_token}"))
359            .send()
360            .map_err(|e| XurlError::auth_with_cause("NetworkError", &e))?;
361
362        let body: serde_json::Value = resp
363            .json()
364            .map_err(|e| XurlError::auth_with_cause("JSONDeserializationError", &e))?;
365
366        body.get("data")
367            .and_then(|d| d.get("username"))
368            .and_then(|u| u.as_str())
369            .map(std::string::ToString::to_string)
370            .ok_or_else(|| {
371                XurlError::auth("UsernameNotFound: username not found when fetching username")
372            })
373    }
374
375    /// Replaces the token store (used in integration tests).
376    ///
377    /// Honors the internal env-precedence flags
378    /// (`client_id_from_env` / `client_secret_from_env`):
379    /// env-supplied values survive the swap; store-derived values get
380    /// re-resolved from the new store's
381    /// active app. Replaces the older equality-based heuristic
382    /// (`self.client_id == old_app.client_id`) which produced false
383    /// negatives when the old and new stores happened to share a value.
384    #[allow(dead_code)] // Public library API — used by consumers and integration tests
385    #[must_use]
386    pub fn with_token_store(mut self, token_store: TokenStore) -> Self {
387        let new_app = token_store.resolve_app(&self.app_name);
388        if !self.client_id_from_env {
389            self.client_id = new_app.client_id.clone();
390        }
391        if !self.client_secret_from_env {
392            self.client_secret = new_app.client_secret.clone();
393        }
394        self.token_store = token_store;
395        self
396    }
397
398    /// Returns a reference to the token store.
399    #[allow(dead_code)] // Public library API — used by consumers and integration tests
400    #[must_use]
401    pub fn token_store(&self) -> &TokenStore {
402        &self.token_store
403    }
404
405    // Accessors
406
407    /// Returns the active app name resolved against `--app` / `XURL_APP`.
408    #[must_use]
409    pub fn app_name(&self) -> &str {
410        &self.app_name
411    }
412    /// Returns the active `OAuth2` client ID (env-supplied or store-derived).
413    #[must_use]
414    pub fn client_id(&self) -> &str {
415        &self.client_id
416    }
417    /// Returns the active `OAuth2` client secret (env-supplied or store-derived).
418    #[must_use]
419    pub fn client_secret(&self) -> &str {
420        &self.client_secret
421    }
422    /// Returns the `OAuth2` authorization URL.
423    #[must_use]
424    pub fn auth_url(&self) -> &str {
425        &self.config.auth_url
426    }
427    /// Returns the `OAuth2` token-exchange URL.
428    #[must_use]
429    pub fn token_url(&self) -> &str {
430        &self.config.token_url
431    }
432    /// Returns the resolved `OAuth2` redirect URI.
433    ///
434    /// The value already reflects the three-level precedence (env >
435    /// app-stored > built-in default) applied at construction by
436    /// [`Auth::new_with_store_path`].
437    #[must_use]
438    pub fn redirect_uri(&self) -> &str {
439        &self.config.redirect_uri
440    }
441    /// Per-request HTTP timeout in seconds for OAuth2 token exchange,
442    /// refresh, and `/2/users/me` lookups. Mirrors the `--timeout` flag.
443    #[must_use]
444    pub fn http_timeout_secs(&self) -> u64 {
445        self.config.http_timeout_secs
446    }
447}
448
449/// Pure resolver for the bearer Authorization header.
450///
451/// Encapsulates the precedence between an env-supplied bearer (typically
452/// `XURL_BEARER_TOKEN`) and the active app's stored bearer in the token
453/// store. Factored out of [`Auth::get_bearer_token_header`] so unit tests
454/// can exercise every branch (env-only, store-only, env-overrides-store,
455/// env-empty-falls-through, neither-set) without touching the process
456/// environment, per the test-isolation policy.
457///
458/// `env_token` is `None` when the env var is unset, `Some("")` when it is set
459/// to the empty string (which falls through to the store), and `Some(value)`
460/// when it carries a non-empty token that wins.
461///
462/// `app_name` scopes the store lookup so multi-app callers reading a bearer
463/// via `xr <cmd> --app NAME --auth app` correctly target NAME's stored
464/// bearer rather than the default app's. Pass an empty string to fall back
465/// to the default app (the legacy no-arg behavior).
466///
467/// # Errors
468///
469/// Returns an error if `env_token` is `None` or `Some("")` AND no bearer
470/// token is stored for the resolved app.
471pub fn resolve_bearer_token(
472    env_token: Option<String>,
473    store: &TokenStore,
474    app_name: &str,
475) -> Result<String> {
476    if let Some(token) = env_token
477        && !token.is_empty()
478    {
479        return Ok(format!("Bearer {token}"));
480    }
481
482    let token = store
483        .get_bearer_token_for_app(app_name)
484        .ok_or_else(|| XurlError::auth("TokenNotFound: bearer token not found"))?;
485
486    let bearer = token
487        .bearer
488        .as_ref()
489        .ok_or_else(|| XurlError::auth("TokenNotFound: bearer token not found"))?;
490
491    Ok(format!("Bearer {bearer}"))
492}
493
494// Compile-time guarantee: `Auth` is `Send + Sync` so it can be shared across
495// threads in the planned async/concurrent `ApiClient` (see plan KTD8). Any
496// future change that introduces a `!Send` or `!Sync` field will fail this
497// assertion at compile time.
498const _: fn() = || {
499    fn _assert_send_sync<T: Send + Sync>() {}
500    _assert_send_sync::<Auth>();
501};