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};