From 51834f60bd911c929c9d13c80a57e6187c7633a4 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Fri, 9 Oct 2026 14:02:26 +0300 Subject: [PATCH 1/6] Add a CLI token for requests the CLI makes on its own behalf Requests the CLI sends for itself, starting with telemetry delivery, must not borrow the token behind a user's command. Resolve a separate one: the MAPBOX_CLI_TOKEN override, then the stored login if it is not about to expire (never refreshed, so a background request cannot spend the refresh token), then a token bundled at build time. Nothing calls it yet. --- README.md | 6 ++ src/auth.rs | 5 +- src/cli_token.rs | 200 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/cli_token.rs diff --git a/README.md b/README.md index 5386e99..6c26c4c 100644 --- a/README.md +++ b/README.md @@ -451,6 +451,12 @@ process sends, in the same `k1=v1&k2=v2` shape as a URL's own query string — for an API parameter this CLI's specs don't declare a flag for. `--debug` and `--dry-run` show it alongside everything else on the request. +### The CLI's own requests + +`MAPBOX_CLI_TOKEN` sets the token the CLI uses for requests it makes on its +own behalf, not for your commands. It overrides your login and the built-in +token. + ### Proxies `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY` and `NO_PROXY` are all honored, so diff --git a/src/auth.rs b/src/auth.rs index 0a0db62..bf1454c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -479,8 +479,9 @@ fn credentials_path_readonly(profile: Option<&str>) -> Option { /// directory as a side effect of reading it — see /// [`credentials_path_readonly`]. What [`profiles`] reads each stored /// profile through, since listing what exists must not be the reason a -/// directory starts to exist or its permissions change. -fn load_credentials_readonly(profile: Option<&str>) -> Option { +/// directory starts to exist or its permissions change, and what +/// [`crate::cli_token`] reads the login through for the same reason. +pub(crate) fn load_credentials_readonly(profile: Option<&str>) -> Option { let data = std::fs::read_to_string(credentials_path_readonly(profile)?).ok()?; serde_json::from_str(&data).ok() } diff --git a/src/cli_token.rs b/src/cli_token.rs new file mode 100644 index 0000000..2e3ee7f --- /dev/null +++ b/src/cli_token.rs @@ -0,0 +1,200 @@ +//! The token for requests the CLI makes on its own behalf, as opposed to the +//! ones the user asked for. +//! +//! A user's `--token` and `MAPBOX_ACCESS_TOKEN` are for the commands they +//! run. A background request (the first is telemetry delivery) is not theirs, +//! so it never borrows them; it resolves its own token, highest first: +//! +//! 1. `MAPBOX_CLI_TOKEN` at run time. The override: a developer pointing at +//! staging supplies a staging token themselves. +//! 2. The stored login for the active profile, when it is still good for +//! another minute. It is never refreshed here: a refresh takes the +//! credentials lock and spends a single-use refresh token, and a request +//! nobody is waiting for must not be the reason the next command's login +//! is gone. +//! 3. The token compiled in from `MAPBOX_CLI_TOKEN` at build time, so someone +//! who never logged in still has one. A plain `cargo build` has none. +//! +//! # The bundled token is public +//! +//! Anything compiled into a distributed binary can be pulled out with +//! `strings`. The bundled `pk.` token must therefore be a dedicated one with +//! the minimum scopes, and nothing may ever treat it as a secret. + +use crate::auth; + +/// Seconds of remaining life below which a stored login is not used. +const EXPIRY_MARGIN_SECS: u64 = 60; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) enum Source { + Override, + Login, + Bundled, +} + +/// The token a background request should carry, or `None` when there is +/// nothing to send it with. +// The first caller is telemetry delivery, which lands separately. +#[allow(dead_code)] +pub(crate) fn for_background(profile: Option<&str>) -> Option { + let override_token = std::env::var("MAPBOX_CLI_TOKEN").ok(); + // Read-only: a background request must not be what creates or + // re-permissions the config directory on a machine that never logged in. + let login = auth::load_credentials_readonly(profile).map(|c| c.access_token); + choose( + override_token.as_deref(), + login.as_deref(), + option_env!("MAPBOX_CLI_TOKEN"), + now_secs(), + ) + .map(|(_, token)| token) +} + +/// The precedence itself, with the environment, the disk and the clock passed +/// in so each branch can be tested without any of them. +fn choose( + override_token: Option<&str>, + login: Option<&str>, + bundled: Option<&str>, + now_secs: u64, +) -> Option<(Source, String)> { + fn usable(t: Option<&str>) -> Option<&str> { + t.map(str::trim).filter(|t| !t.is_empty()) + } + + if let Some(token) = usable(override_token) { + return Some((Source::Override, token.to_owned())); + } + if let Some(token) = usable(login) { + // No `exp` claim means no expiry to honor, same as `needs_refresh`. + let alive = auth::token_expires_at(token) + .is_none_or(|exp| exp > now_secs.saturating_add(EXPIRY_MARGIN_SECS)); + if alive { + return Some((Source::Login, token.to_owned())); + } + } + usable(bundled).map(|token| (Source::Bundled, token.to_owned())) +} + +#[allow(dead_code)] +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + const NOW: u64 = 1_000_000; + + fn token_expiring_at(exp: u64) -> String { + let payload = URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#)); + format!("pk.{payload}.signature") + } + + fn token_without_exp() -> String { + let payload = URL_SAFE_NO_PAD.encode(r#"{"u":"someone"}"#); + format!("pk.{payload}.signature") + } + + fn won( + override_token: Option<&str>, + login: Option<&str>, + bundled: Option<&str>, + ) -> Option<(Source, String)> { + choose(override_token, login, bundled, NOW) + } + + #[test] + fn override_wins_over_everything() { + let login = token_expiring_at(NOW + 3600); + assert_eq!( + won(Some("pk.override"), Some(&login), Some("pk.bundled")), + Some((Source::Override, "pk.override".into())) + ); + } + + #[test] + fn override_is_trimmed() { + assert_eq!( + won(Some(" pk.override\n"), None, None), + Some((Source::Override, "pk.override".into())) + ); + } + + #[test] + fn empty_or_whitespace_override_is_ignored() { + for blank in ["", " ", "\n"] { + assert_eq!( + won(Some(blank), None, Some("pk.bundled")), + Some((Source::Bundled, "pk.bundled".into())) + ); + } + } + + #[test] + fn login_wins_over_bundled() { + let login = token_expiring_at(NOW + 3600); + assert_eq!( + won(None, Some(&login), Some("pk.bundled")), + Some((Source::Login, login)) + ); + } + + #[test] + fn expired_login_falls_through_to_bundled() { + let login = token_expiring_at(NOW - 1); + assert_eq!( + won(None, Some(&login), Some("pk.bundled")), + Some((Source::Bundled, "pk.bundled".into())) + ); + } + + #[test] + fn login_expiring_within_the_margin_falls_through() { + for exp in [NOW + 59, NOW + 60] { + let login = token_expiring_at(exp); + assert_eq!( + won(None, Some(&login), Some("pk.bundled")), + Some((Source::Bundled, "pk.bundled".into())), + "exp = now + {}", + exp - NOW + ); + } + } + + #[test] + fn login_just_past_the_margin_is_used() { + let login = token_expiring_at(NOW + 61); + assert_eq!( + won(None, Some(&login), Some("pk.bundled")), + Some((Source::Login, login)) + ); + } + + #[test] + fn login_without_exp_is_used() { + let login = token_without_exp(); + assert_eq!( + won(None, Some(&login), Some("pk.bundled")), + Some((Source::Login, login)) + ); + } + + #[test] + fn expired_login_with_no_bundled_token_is_none() { + let login = token_expiring_at(NOW - 1); + assert_eq!(won(None, Some(&login), None), None); + } + + #[test] + fn nothing_available_is_none() { + assert_eq!(won(None, None, None), None); + assert_eq!(won(Some(" "), Some(""), Some("")), None); + } +} diff --git a/src/main.rs b/src/main.rs index e4327eb..6435c52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod agent_skills; #[cfg(test)] mod api_command_surface; mod auth; +mod cli_token; mod completion; mod config; mod confirm; From c1d32c24811ecfa6cd67a5514a62fa3334737f48 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Fri, 9 Oct 2026 14:58:38 +0300 Subject: [PATCH 2/6] Prefer the login for the CLI token and split the build-time variable The stored login now wins over MAPBOX_CLI_TOKEN, which applies only when the user is not logged in. The bundled token comes from its own build-time variable, MAPBOX_CLI_BUNDLED_TOKEN, so a token exported in a dev shell is not compiled into local builds, and build.rs refuses anything but pk. --- README.md | 6 +-- build.rs | 16 +++++++ src/cli_token.rs | 115 +++++++++++++++++++++++++++++------------------ 3 files changed, 90 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 6c26c4c..446bcff 100644 --- a/README.md +++ b/README.md @@ -453,9 +453,9 @@ and `--dry-run` show it alongside everything else on the request. ### The CLI's own requests -`MAPBOX_CLI_TOKEN` sets the token the CLI uses for requests it makes on its -own behalf, not for your commands. It overrides your login and the built-in -token. +`MAPBOX_CLI_TOKEN` sets the token the CLI uses for requests it makes for +itself rather than for your commands. It is used only when you are not logged +in, and takes precedence over any token built into the binary. ### Proxies diff --git a/build.rs b/build.rs index 41451f3..e22d60c 100644 --- a/build.rs +++ b/build.rs @@ -41,6 +41,8 @@ use std::path::Path; fn main() { + check_bundled_token(); + let manifest = Path::new("../internal/openapi-command-config/PINNED_SOURCE"); // The vendored specs and their provenance are ordinary build inputs now, @@ -91,6 +93,20 @@ fn main() { warn_if_stale(committed_at); } +/// `src/cli_token.rs` compiles `MAPBOX_CLI_BUNDLED_TOKEN` into the binary, +/// where `strings` can read it. Only a public `pk.` token may go there, so +/// anything else fails the build rather than shipping a secret. +fn check_bundled_token() { + println!("cargo:rerun-if-env-changed=MAPBOX_CLI_BUNDLED_TOKEN"); + let Ok(token) = std::env::var("MAPBOX_CLI_BUNDLED_TOKEN") else { + return; + }; + let token = token.trim(); + if !token.is_empty() && !token.starts_with("pk.") { + panic!("MAPBOX_CLI_BUNDLED_TOKEN must be a public pk. token"); + } +} + /// New API surface lands in `openapi-specs` at something closer to a monthly /// rate, and the maintainer-only sync that regenerates `openapi/` from it /// runs weekly, so two weeks without one is already long enough to be worth a diff --git a/src/cli_token.rs b/src/cli_token.rs index 2e3ee7f..bc7ecad 100644 --- a/src/cli_token.rs +++ b/src/cli_token.rs @@ -1,25 +1,30 @@ -//! The token for requests the CLI makes on its own behalf, as opposed to the -//! ones the user asked for. +//! The token for Mapbox API requests the CLI makes for itself rather than for +//! the user. Today that is telemetry delivery; requests that need no token, +//! like the update check, do not use this. //! -//! A user's `--token` and `MAPBOX_ACCESS_TOKEN` are for the commands they -//! run. A background request (the first is telemetry delivery) is not theirs, -//! so it never borrows them; it resolves its own token, highest first: +//! A user's `--token` and `MAPBOX_ACCESS_TOKEN` are scoped to the command they +//! were given for, so a background request never borrows them. The stored +//! login is the CLI's own credential for this user and is fair to use. The +//! token is resolved highest first: //! -//! 1. `MAPBOX_CLI_TOKEN` at run time. The override: a developer pointing at -//! staging supplies a staging token themselves. -//! 2. The stored login for the active profile, when it is still good for +//! 1. The stored login for the active profile, when it is still good for //! another minute. It is never refreshed here: a refresh takes the //! credentials lock and spends a single-use refresh token, and a request //! nobody is waiting for must not be the reason the next command's login //! is gone. -//! 3. The token compiled in from `MAPBOX_CLI_TOKEN` at build time, so someone -//! who never logged in still has one. A plain `cargo build` has none. +//! 2. `MAPBOX_CLI_TOKEN` at run time, for someone who is not logged in, such +//! as a developer pointing at staging with a staging token. +//! 3. The token compiled in from `MAPBOX_CLI_BUNDLED_TOKEN` at build time, so +//! someone who never logged in still has one. A plain `cargo build` has +//! none. It is a different variable from the run-time one so that a token +//! exported in a dev shell is not baked into every local build. //! //! # The bundled token is public //! //! Anything compiled into a distributed binary can be pulled out with -//! `strings`. The bundled `pk.` token must therefore be a dedicated one with -//! the minimum scopes, and nothing may ever treat it as a secret. +//! `strings`. The bundled token must therefore be a dedicated `pk.` token with +//! the minimum scopes, and nothing may ever treat it as a secret. `build.rs` +//! refuses anything that is not `pk.`. use crate::auth; @@ -28,8 +33,8 @@ const EXPIRY_MARGIN_SECS: u64 = 60; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub(crate) enum Source { - Override, Login, + Override, Bundled, } @@ -38,14 +43,14 @@ pub(crate) enum Source { // The first caller is telemetry delivery, which lands separately. #[allow(dead_code)] pub(crate) fn for_background(profile: Option<&str>) -> Option { - let override_token = std::env::var("MAPBOX_CLI_TOKEN").ok(); // Read-only: a background request must not be what creates or // re-permissions the config directory on a machine that never logged in. let login = auth::load_credentials_readonly(profile).map(|c| c.access_token); + let override_token = std::env::var("MAPBOX_CLI_TOKEN").ok(); choose( - override_token.as_deref(), login.as_deref(), - option_env!("MAPBOX_CLI_TOKEN"), + override_token.as_deref(), + option_env!("MAPBOX_CLI_BUNDLED_TOKEN"), now_secs(), ) .map(|(_, token)| token) @@ -54,8 +59,8 @@ pub(crate) fn for_background(profile: Option<&str>) -> Option { /// The precedence itself, with the environment, the disk and the clock passed /// in so each branch can be tested without any of them. fn choose( - override_token: Option<&str>, login: Option<&str>, + override_token: Option<&str>, bundled: Option<&str>, now_secs: u64, ) -> Option<(Source, String)> { @@ -63,21 +68,21 @@ fn choose( t.map(str::trim).filter(|t| !t.is_empty()) } - if let Some(token) = usable(override_token) { - return Some((Source::Override, token.to_owned())); - } if let Some(token) = usable(login) { - // No `exp` claim means no expiry to honor, same as `needs_refresh`. + // No `exp` claim means no expiry to honor, as in + // `auth::token_needs_refresh`. let alive = auth::token_expires_at(token) .is_none_or(|exp| exp > now_secs.saturating_add(EXPIRY_MARGIN_SECS)); if alive { return Some((Source::Login, token.to_owned())); } } + if let Some(token) = usable(override_token) { + return Some((Source::Override, token.to_owned())); + } usable(bundled).map(|token| (Source::Bundled, token.to_owned())) } -#[allow(dead_code)] fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -103,54 +108,76 @@ mod tests { } fn won( - override_token: Option<&str>, login: Option<&str>, + override_token: Option<&str>, bundled: Option<&str>, ) -> Option<(Source, String)> { - choose(override_token, login, bundled, NOW) + choose(login, override_token, bundled, NOW) } #[test] - fn override_wins_over_everything() { + fn login_wins_over_everything() { let login = token_expiring_at(NOW + 3600); assert_eq!( - won(Some("pk.override"), Some(&login), Some("pk.bundled")), + won(Some(&login), Some("pk.override"), Some("pk.bundled")), + Some((Source::Login, login)) + ); + } + + #[test] + fn override_wins_over_bundled() { + assert_eq!( + won(None, Some("pk.override"), Some("pk.bundled")), Some((Source::Override, "pk.override".into())) ); } #[test] - fn override_is_trimmed() { + fn expired_login_falls_through_to_override() { + let login = token_expiring_at(NOW - 1); assert_eq!( - won(Some(" pk.override\n"), None, None), + won(Some(&login), Some("pk.override"), Some("pk.bundled")), Some((Source::Override, "pk.override".into())) ); } #[test] - fn empty_or_whitespace_override_is_ignored() { - for blank in ["", " ", "\n"] { - assert_eq!( - won(Some(blank), None, Some("pk.bundled")), - Some((Source::Bundled, "pk.bundled".into())) - ); - } + fn override_is_trimmed() { + assert_eq!( + won(None, Some(" pk.override\n"), None), + Some((Source::Override, "pk.override".into())) + ); } #[test] - fn login_wins_over_bundled() { + fn login_is_trimmed_and_its_expiry_still_honored() { + let login = token_expiring_at(NOW - 1); + assert_eq!( + won(Some(&format!(" {login}\n")), None, Some("pk.bundled")), + Some((Source::Bundled, "pk.bundled".into())) + ); let login = token_expiring_at(NOW + 3600); assert_eq!( - won(None, Some(&login), Some("pk.bundled")), + won(Some(&format!(" {login}\n")), None, None), Some((Source::Login, login)) ); } + #[test] + fn blank_login_or_override_is_ignored() { + for blank in ["", " ", "\n"] { + assert_eq!( + won(Some(blank), Some(blank), Some("pk.bundled")), + Some((Source::Bundled, "pk.bundled".into())) + ); + } + } + #[test] fn expired_login_falls_through_to_bundled() { let login = token_expiring_at(NOW - 1); assert_eq!( - won(None, Some(&login), Some("pk.bundled")), + won(Some(&login), None, Some("pk.bundled")), Some((Source::Bundled, "pk.bundled".into())) ); } @@ -160,7 +187,7 @@ mod tests { for exp in [NOW + 59, NOW + 60] { let login = token_expiring_at(exp); assert_eq!( - won(None, Some(&login), Some("pk.bundled")), + won(Some(&login), None, Some("pk.bundled")), Some((Source::Bundled, "pk.bundled".into())), "exp = now + {}", exp - NOW @@ -172,7 +199,7 @@ mod tests { fn login_just_past_the_margin_is_used() { let login = token_expiring_at(NOW + 61); assert_eq!( - won(None, Some(&login), Some("pk.bundled")), + won(Some(&login), None, Some("pk.bundled")), Some((Source::Login, login)) ); } @@ -181,20 +208,20 @@ mod tests { fn login_without_exp_is_used() { let login = token_without_exp(); assert_eq!( - won(None, Some(&login), Some("pk.bundled")), + won(Some(&login), None, Some("pk.bundled")), Some((Source::Login, login)) ); } #[test] - fn expired_login_with_no_bundled_token_is_none() { + fn expired_login_with_nothing_else_is_none() { let login = token_expiring_at(NOW - 1); - assert_eq!(won(None, Some(&login), None), None); + assert_eq!(won(Some(&login), None, None), None); } #[test] fn nothing_available_is_none() { assert_eq!(won(None, None, None), None); - assert_eq!(won(Some(" "), Some(""), Some("")), None); + assert_eq!(won(Some(""), Some(" "), Some("")), None); } } From 91f465beddab66686a2232e872544c7c19a24655 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Fri, 9 Oct 2026 15:29:40 +0300 Subject: [PATCH 3/6] Send background requests with the account's default public token cli_token::send attaches the token itself and moves to the next one on a 401: the default public token kept with the login, then MAPBOX_CLI_TOKEN, then the bundled token. The OAuth login token is never sent as the request's token; it is only read, never refreshed, to fetch the default token when it is missing or was rotated. The save never waits on the credentials lock and never writes into a profile that has logged out or switched account. --- README.md | 9 +- src/auth.rs | 172 ++++++++++++++- src/cli_token.rs | 549 +++++++++++++++++++++++++++++++++++++---------- 3 files changed, 603 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index 446bcff..67b21b4 100644 --- a/README.md +++ b/README.md @@ -453,9 +453,12 @@ and `--dry-run` show it alongside everything else on the request. ### The CLI's own requests -`MAPBOX_CLI_TOKEN` sets the token the CLI uses for requests it makes for -itself rather than for your commands. It is used only when you are not logged -in, and takes precedence over any token built into the binary. +Requests the CLI makes for itself rather than for your commands use a public +token rather than your `--token`, `MAPBOX_ACCESS_TOKEN` or login. When you are +logged in, that is your account's default public token: the CLI fetches it +once, keeps it with your login, and fetches it again if you rotate it. +Otherwise `MAPBOX_CLI_TOKEN` sets it, and takes precedence over any token +built into the binary. ### Proxies diff --git a/src/auth.rs b/src/auth.rs index bf1454c..b3b5462 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -117,6 +117,11 @@ pub struct Credentials { pub username: Option, #[serde(skip_serializing_if = "Option::is_none")] pub client_id: Option, + /// The account's default public token, kept for [`crate::cli_token`]. + /// Filled in on first use rather than at login, so credentials written + /// before it existed work the same way. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_public_token: Option, } /// Restrict a directory to the owner (0700). No-op off Unix. @@ -486,6 +491,61 @@ pub(crate) fn load_credentials_readonly(profile: Option<&str>) -> Option, + account: &str, + token: &str, +) -> bool { + let (Some(path), Ok(lock)) = (credentials_path_readonly(profile), lock_filename(profile)) + else { + return false; + }; + store_default_public_token_at(&path, &path.with_file_name(lock), account, token) +} + +fn store_default_public_token_at(path: &Path, lock: &Path, account: &str, token: &str) -> bool { + // Checked before taking the lock, which would otherwise leave a lock file + // behind for a profile that has no credentials. + if !path.exists() { + return false; + } + let Some(_lock) = CredentialLock::try_at(lock) else { + return false; + }; + // Re-read under the lock, so a refresh that landed since the caller read + // the file is kept rather than overwritten with the older tokens. + let Some(mut creds) = std::fs::read_to_string(path) + .ok() + .and_then(|data| serde_json::from_str::(&data).ok()) + else { + return false; + }; + if credentials_account(&creds).as_deref() != Some(account) { + return false; + } + creds.default_public_token = Some(token.to_owned()); + serde_json::to_string_pretty(&creds) + .ok() + .is_some_and(|data| write_private(path, &data).is_ok()) +} + +/// The account stored credentials belong to: the saved username, or the +/// access token's own `u` claim for credentials saved without one. +pub(crate) fn credentials_account(creds: &Credentials) -> Option { + creds + .username + .clone() + .or_else(|| token_account(&creds.access_token)) +} + /// The reverse of [`credentials_filename`]: the profile name a credentials /// filename would have been written under, or `None` for anything else in /// the config directory — `credentials-.json.lock`, `config.json`, @@ -581,6 +641,20 @@ impl CredentialLock { /// Block until this lock file is ours. fn at(path: &Path) -> Result { + let file = Self::open(path)?; + file.lock() + .with_context(|| format!("Failed to lock {}", path.display()))?; + Ok(Self { file }) + } + + /// [`Self::at`], but `None` rather than waiting when someone else holds it. + fn try_at(path: &Path) -> Option { + let file = Self::open(path).ok()?; + file.try_lock().ok()?; + Some(Self { file }) + } + + fn open(path: &Path) -> Result { let mut opts = std::fs::OpenOptions::new(); opts.write(true).create(true).truncate(false); #[cfg(unix)] @@ -588,12 +662,8 @@ impl CredentialLock { use std::os::unix::fs::OpenOptionsExt; opts.mode(0o600); } - let file = opts - .open(path) - .with_context(|| format!("Failed to open lock file {}", path.display()))?; - file.lock() - .with_context(|| format!("Failed to lock {}", path.display()))?; - Ok(Self { file }) + opts.open(path) + .with_context(|| format!("Failed to open lock file {}", path.display())) } } @@ -2071,6 +2141,7 @@ fn exchange_code_for_token( refresh_token, username, client_id: None, + default_public_token: None, }) } @@ -3075,6 +3146,95 @@ mod tests { assert_eq!(describe(CALLBACK_TIMEOUT), "5 minutes"); } + fn stored_default_token(path: &Path) -> Option { + let creds: Credentials = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + creds.default_public_token + } + + #[test] + fn default_public_token_is_added_beside_the_login() { + let dir = scratch("default-token-added"); + let path = dir.join("credentials.json"); + let lock = dir.join("credentials.json.lock"); + std::fs::write( + &path, + r#"{"access_token":"sk.a","refresh_token":"r","username":"someone"}"#, + ) + .unwrap(); + + assert!(store_default_public_token_at( + &path, + &lock, + "someone", + "pk.default" + )); + + let creds: Credentials = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(creds.default_public_token.as_deref(), Some("pk.default")); + assert_eq!(creds.access_token, "sk.a"); + assert_eq!(creds.refresh_token.as_deref(), Some("r")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn default_public_token_never_recreates_a_logged_out_profile() { + let dir = scratch("default-token-logged-out"); + let path = dir.join("credentials.json"); + let lock = dir.join("credentials.json.lock"); + + assert!(!store_default_public_token_at( + &path, + &lock, + "someone", + "pk.default" + )); + assert!(!path.exists()); + assert!(!lock.exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn default_public_token_is_not_saved_over_another_account() { + let dir = scratch("default-token-other-account"); + let path = dir.join("credentials.json"); + let lock = dir.join("credentials.json.lock"); + std::fs::write( + &path, + r#"{"access_token":"sk.b","username":"someone-else"}"#, + ) + .unwrap(); + + assert!(!store_default_public_token_at( + &path, + &lock, + "someone", + "pk.default" + )); + assert_eq!(stored_default_token(&path), None); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn default_public_token_gives_up_rather_than_wait_for_the_lock() { + let dir = scratch("default-token-locked"); + let path = dir.join("credentials.json"); + let lock = dir.join("credentials.json.lock"); + std::fs::write(&path, r#"{"access_token":"sk.a","username":"someone"}"#).unwrap(); + + let held = CredentialLock::at(&lock).unwrap(); + assert!(!store_default_public_token_at( + &path, + &lock, + "someone", + "pk.default" + )); + drop(held); + assert_eq!(stored_default_token(&path), None); + std::fs::remove_dir_all(&dir).unwrap(); + } + /// A unique scratch dir, so tests never touch the real config dir. fn scratch(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( diff --git a/src/cli_token.rs b/src/cli_token.rs index bc7ecad..cff830a 100644 --- a/src/cli_token.rs +++ b/src/cli_token.rs @@ -1,17 +1,18 @@ -//! The token for Mapbox API requests the CLI makes for itself rather than for -//! the user. Today that is telemetry delivery; requests that need no token, -//! like the update check, do not use this. +//! Requests the CLI makes for itself rather than for the user, and the token +//! they carry. //! //! A user's `--token` and `MAPBOX_ACCESS_TOKEN` are scoped to the command they -//! were given for, so a background request never borrows them. The stored -//! login is the CLI's own credential for this user and is fair to use. The -//! token is resolved highest first: +//! were given for, and the OAuth token from `mapbox auth login` carries write +//! scopes, so a background request sends none of them. It sends a public +//! token, tried highest first: //! -//! 1. The stored login for the active profile, when it is still good for -//! another minute. It is never refreshed here: a refresh takes the +//! 1. The account's default public token, kept with the stored login. When +//! there is none yet, or the API rejects it because the user rotated or +//! deleted it, a new one is fetched with the login's OAuth token and saved. +//! That OAuth token is only read, never refreshed: a refresh takes the //! credentials lock and spends a single-use refresh token, and a request //! nobody is waiting for must not be the reason the next command's login -//! is gone. +//! is gone. An expired login just skips this step. //! 2. `MAPBOX_CLI_TOKEN` at run time, for someone who is not logged in, such //! as a developer pointing at staging with a staging token. //! 3. The token compiled in from `MAPBOX_CLI_BUNDLED_TOKEN` at build time, so @@ -19,6 +20,10 @@ //! none. It is a different variable from the run-time one so that a token //! exported in a dev shell is not baked into every local build. //! +//! A `401` moves on to the next token; any other answer is the caller's. +//! Callers never see the token, so every one of them gets the same fallback +//! and replacement without doing anything. +//! //! # The bundled token is public //! //! Anything compiled into a distributed binary can be pulled out with @@ -26,61 +31,173 @@ //! the minimum scopes, and nothing may ever treat it as a secret. `build.rs` //! refuses anything that is not `pk.`. -use crate::auth; +use reqwest::blocking::{Client, RequestBuilder, Response}; +use reqwest::StatusCode; + +use crate::auth::{self, Credentials}; +use crate::http; -/// Seconds of remaining life below which a stored login is not used. +/// Seconds of remaining life below which a stored OAuth token is not used. const EXPIRY_MARGIN_SECS: u64 = 60; -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub(crate) enum Source { - Login, - Override, - Bundled, -} +/// Lists an account's tokens; `?default=true` narrows it to the default one. +const TOKENS_ENDPOINT: &str = "https://api.mapbox.com/tokens/v2"; -/// The token a background request should carry, or `None` when there is -/// nothing to send it with. +/// Sends `request` with the CLI's token attached as `access_token`, trying the +/// next token whenever the API answers `401`. +/// +/// `request` is called once per attempt, since a sent request cannot be +/// reused. `None` when there is no token to send with, or the request could +/// not be sent at all; otherwise the first answer that was not a `401`, or +/// the last `401` when every token was rejected. // The first caller is telemetry delivery, which lands separately. #[allow(dead_code)] -pub(crate) fn for_background(profile: Option<&str>) -> Option { +pub(crate) fn send( + profile: Option<&str>, + request: impl Fn() -> RequestBuilder, +) -> Option { // Read-only: a background request must not be what creates or // re-permissions the config directory on a machine that never logged in. - let login = auth::load_credentials_readonly(profile).map(|c| c.access_token); + let creds = auth::load_credentials_readonly(profile); let override_token = std::env::var("MAPBOX_CLI_TOKEN").ok(); - choose( - login.as_deref(), - override_token.as_deref(), - option_env!("MAPBOX_CLI_BUNDLED_TOKEN"), - now_secs(), + let sources = Sources { + stored: creds + .as_ref() + .and_then(|c| c.default_public_token.as_deref()), + login: creds + .as_ref() + .and_then(|c| Login::from_credentials(c, now_secs())), + override_token: override_token.as_deref(), + bundled: option_env!("MAPBOX_CLI_BUNDLED_TOKEN"), + }; + resolve( + sources, + |login| { + let token = fetch_default_public_token(&http::client().ok()?, TOKENS_ENDPOINT, login)?; + // Used for this request even when it could not be saved; the + // next request simply fetches it again. + auth::store_default_public_token(profile, &login.account, &token); + Some(token) + }, + |token| http::send(request().query(&[("access_token", token)])).ok(), + |response| response.status() == StatusCode::UNAUTHORIZED, ) - .map(|(_, token)| token) } -/// The precedence itself, with the environment, the disk and the clock passed -/// in so each branch can be tested without any of them. -fn choose( - login: Option<&str>, - override_token: Option<&str>, - bundled: Option<&str>, - now_secs: u64, -) -> Option<(Source, String)> { - fn usable(t: Option<&str>) -> Option<&str> { - t.map(str::trim).filter(|t| !t.is_empty()) - } +struct Sources<'a> { + stored: Option<&'a str>, + login: Option, + override_token: Option<&'a str>, + bundled: Option<&'a str>, +} - if let Some(token) = usable(login) { +/// A stored OAuth login that is still good for long enough to fetch with. +#[derive(Debug, PartialEq, Eq)] +struct Login { + access_token: String, + account: String, +} + +impl Login { + fn from_credentials(creds: &Credentials, now_secs: u64) -> Option { + let access_token = usable(Some(&creds.access_token))?; // No `exp` claim means no expiry to honor, as in // `auth::token_needs_refresh`. - let alive = auth::token_expires_at(token) + let alive = auth::token_expires_at(access_token) .is_none_or(|exp| exp > now_secs.saturating_add(EXPIRY_MARGIN_SECS)); - if alive { - return Some((Source::Login, token.to_owned())); + // The account becomes a path segment. Mapbox usernames are plain, so + // anything else is refused rather than escaped. + let account = auth::credentials_account(creds).filter(|a| { + !a.is_empty() + && a.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + })?; + alive.then(|| Self { + access_token: access_token.to_owned(), + account, + }) + } +} + +/// The order and the fallback, with fetching and sending passed in so each +/// path can be tested without the network or the disk. +/// +/// The fetch runs only once the stored token is missing or rejected, and a +/// token that was already rejected is not tried again. A failed send stops +/// here: no other token fixes a request that never arrived. +fn resolve( + sources: Sources<'_>, + mut fetch: impl FnMut(&Login) -> Option, + mut attempt: impl FnMut(&str) -> Option, + rejected: impl Fn(&R) -> bool, +) -> Option { + enum Stage { + Stored, + Fetched, + Override, + Bundled, + } + + let mut tried: Vec = Vec::new(); + let mut last = None; + for stage in [ + Stage::Stored, + Stage::Fetched, + Stage::Override, + Stage::Bundled, + ] { + let token = match stage { + Stage::Stored => usable(sources.stored).map(str::to_owned), + Stage::Fetched => sources.login.as_ref().and_then(&mut fetch), + Stage::Override => usable(sources.override_token).map(str::to_owned), + Stage::Bundled => usable(sources.bundled).map(str::to_owned), + }; + let Some(token) = token else { continue }; + if tried.contains(&token) { + continue; } + let response = attempt(&token)?; + if !rejected(&response) { + return Some(response); + } + tried.push(token); + last = Some(response); } - if let Some(token) = usable(override_token) { - return Some((Source::Override, token.to_owned())); + last +} + +#[derive(serde::Deserialize)] +struct TokenEntry { + token: String, + #[serde(default)] + default: bool, + #[serde(default)] + usage: String, +} + +/// The account's default public token, or `None` on any failure: the caller +/// falls through to the next token rather than reporting anything. +fn fetch_default_public_token(client: &Client, endpoint: &str, login: &Login) -> Option { + let response = http::send(client.get(format!("{endpoint}/{}", login.account)).query(&[ + ("access_token", login.access_token.as_str()), + ("default", "true"), + ])) + .ok()?; + if !response.status().is_success() { + return None; } - usable(bundled).map(|token| (Source::Bundled, token.to_owned())) + // Checked rather than trusted: whatever comes back here is sent on every + // background request and saved next to the login. + response + .json::>() + .ok()? + .into_iter() + .find(|t| t.default && t.usage == "pk" && t.token.starts_with("pk.")) + .map(|t| t.token) +} + +fn usable(token: Option<&str>) -> Option<&str> { + token.map(str::trim).filter(|t| !t.is_empty()) } fn now_secs() -> u64 { @@ -94,134 +211,330 @@ fn now_secs() -> u64 { mod tests { use super::*; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use std::cell::RefCell; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::time::Duration; const NOW: u64 = 1_000_000; - fn token_expiring_at(exp: u64) -> String { - let payload = URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#)); - format!("pk.{payload}.signature") + fn oauth_token(claims: &str) -> String { + format!("sk.{}.signature", URL_SAFE_NO_PAD.encode(claims)) } - fn token_without_exp() -> String { - let payload = URL_SAFE_NO_PAD.encode(r#"{"u":"someone"}"#); - format!("pk.{payload}.signature") + fn creds(access_token: String, username: Option<&str>) -> Credentials { + Credentials { + access_token, + refresh_token: None, + username: username.map(str::to_owned), + client_id: None, + default_public_token: None, + } + } + + fn login() -> Login { + Login { + access_token: "sk.oauth".into(), + account: "someone".into(), + } } - fn won( - login: Option<&str>, - override_token: Option<&str>, - bundled: Option<&str>, - ) -> Option<(Source, String)> { - choose(login, override_token, bundled, NOW) + /// Runs [`resolve`] against a fake API that accepts only `accepted`, and + /// returns the answer, every token sent, and how many fetches ran. + fn run( + sources: Sources<'_>, + fetched: Option<&str>, + accepted: &[&str], + ) -> (Option, Vec, usize) { + let sent = RefCell::new(Vec::new()); + let mut fetches = 0; + let answer = resolve( + sources, + |_| { + fetches += 1; + fetched.map(str::to_owned) + }, + |token| { + sent.borrow_mut().push(token.to_owned()); + Some(if accepted.contains(&token) { 200 } else { 401 }) + }, + |status| *status == 401, + ); + (answer, sent.into_inner(), fetches) + } + + fn sources<'a>( + stored: Option<&'a str>, + login: Option, + override_token: Option<&'a str>, + bundled: Option<&'a str>, + ) -> Sources<'a> { + Sources { + stored, + login, + override_token, + bundled, + } } #[test] - fn login_wins_over_everything() { - let login = token_expiring_at(NOW + 3600); - assert_eq!( - won(Some(&login), Some("pk.override"), Some("pk.bundled")), - Some((Source::Login, login)) + fn an_accepted_stored_token_is_all_that_is_sent() { + let (answer, sent, fetches) = run( + sources( + Some("pk.stored"), + Some(login()), + Some("pk.override"), + Some("pk.bundled"), + ), + Some("pk.fresh"), + &["pk.stored"], ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.stored"]); + assert_eq!(fetches, 0); } #[test] - fn override_wins_over_bundled() { - assert_eq!( - won(None, Some("pk.override"), Some("pk.bundled")), - Some((Source::Override, "pk.override".into())) + fn a_rejected_stored_token_is_replaced_by_a_fetched_one() { + let (answer, sent, fetches) = run( + sources(Some("pk.revoked"), Some(login()), Some("pk.override"), None), + Some("pk.fresh"), + &["pk.fresh"], ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.revoked", "pk.fresh"]); + assert_eq!(fetches, 1); } #[test] - fn expired_login_falls_through_to_override() { - let login = token_expiring_at(NOW - 1); - assert_eq!( - won(Some(&login), Some("pk.override"), Some("pk.bundled")), - Some((Source::Override, "pk.override".into())) + fn a_missing_stored_token_is_fetched() { + let (answer, sent, _) = run( + sources(None, Some(login()), None, None), + Some("pk.fresh"), + &["pk.fresh"], ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.fresh"]); } #[test] - fn override_is_trimmed() { - assert_eq!( - won(None, Some(" pk.override\n"), None), - Some((Source::Override, "pk.override".into())) + fn without_a_usable_login_nothing_is_fetched() { + let (answer, sent, fetches) = run( + sources( + Some("pk.revoked"), + None, + Some("pk.override"), + Some("pk.bundled"), + ), + Some("pk.fresh"), + &["pk.override"], ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.revoked", "pk.override"]); + assert_eq!(fetches, 0); } #[test] - fn login_is_trimmed_and_its_expiry_still_honored() { - let login = token_expiring_at(NOW - 1); - assert_eq!( - won(Some(&format!(" {login}\n")), None, Some("pk.bundled")), - Some((Source::Bundled, "pk.bundled".into())) + fn a_failed_fetch_falls_through_to_the_override() { + let (answer, sent, _) = run( + sources(Some("pk.revoked"), Some(login()), Some("pk.override"), None), + None, + &["pk.override"], ); - let login = token_expiring_at(NOW + 3600); - assert_eq!( - won(Some(&format!(" {login}\n")), None, None), - Some((Source::Login, login)) + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.revoked", "pk.override"]); + } + + #[test] + fn a_rejected_token_is_not_sent_twice() { + // The fetch hands back the token that was just rejected, as it would + // if the API refuses the default token itself rather than a stale one. + let (answer, sent, _) = run( + sources(Some("pk.same"), Some(login()), None, Some("pk.bundled")), + Some("pk.same"), + &["pk.bundled"], ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.same", "pk.bundled"]); } #[test] - fn blank_login_or_override_is_ignored() { - for blank in ["", " ", "\n"] { - assert_eq!( - won(Some(blank), Some(blank), Some("pk.bundled")), - Some((Source::Bundled, "pk.bundled".into())) - ); - } + fn override_comes_before_bundled() { + let (answer, sent, _) = run( + sources(None, None, Some("pk.override"), Some("pk.bundled")), + None, + &["pk.override", "pk.bundled"], + ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.override"]); } #[test] - fn expired_login_falls_through_to_bundled() { - let login = token_expiring_at(NOW - 1); - assert_eq!( - won(Some(&login), None, Some("pk.bundled")), - Some((Source::Bundled, "pk.bundled".into())) + fn every_token_rejected_returns_the_last_answer() { + let (answer, sent, _) = run( + sources( + Some("pk.stored"), + None, + Some("pk.override"), + Some("pk.bundled"), + ), + None, + &[], + ); + assert_eq!(answer, Some(401)); + assert_eq!(sent, ["pk.stored", "pk.override", "pk.bundled"]); + } + + #[test] + fn a_failed_send_stops_without_trying_other_tokens() { + let mut sent = Vec::new(); + let answer: Option = resolve( + sources( + Some("pk.stored"), + None, + Some("pk.override"), + Some("pk.bundled"), + ), + |_| None, + |token| { + sent.push(token.to_owned()); + None + }, + |status| *status == 401, + ); + assert_eq!(answer, None); + assert_eq!(sent, ["pk.stored"]); + } + + #[test] + fn blank_tokens_are_skipped_and_the_rest_trimmed() { + let (answer, sent, _) = run( + sources(Some(" "), None, Some(""), Some(" pk.bundled\n")), + None, + &["pk.bundled"], ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.bundled"]); } #[test] - fn login_expiring_within_the_margin_falls_through() { - for exp in [NOW + 59, NOW + 60] { - let login = token_expiring_at(exp); + fn nothing_to_send_with_sends_nothing() { + let (answer, sent, _) = run(sources(None, None, None, None), None, &[]); + assert_eq!(answer, None); + assert!(sent.is_empty()); + } + + #[test] + fn a_login_is_usable_until_the_margin() { + for (exp, usable) in [(NOW - 1, false), (NOW + 60, false), (NOW + 61, true)] { + let c = creds( + oauth_token(&format!(r#"{{"u":"someone","exp":{exp}}}"#)), + None, + ); assert_eq!( - won(Some(&login), None, Some("pk.bundled")), - Some((Source::Bundled, "pk.bundled".into())), - "exp = now + {}", - exp - NOW + Login::from_credentials(&c, NOW).is_some(), + usable, + "exp = {exp}" ); } } #[test] - fn login_just_past_the_margin_is_used() { - let login = token_expiring_at(NOW + 61); + fn a_login_without_exp_is_usable() { + let c = creds(oauth_token(r#"{"u":"someone"}"#), None); assert_eq!( - won(Some(&login), None, Some("pk.bundled")), - Some((Source::Login, login)) + Login::from_credentials(&c, NOW), + Some(Login { + access_token: c.access_token.clone(), + account: "someone".into(), + }) ); } #[test] - fn login_without_exp_is_used() { - let login = token_without_exp(); - assert_eq!( - won(Some(&login), None, Some("pk.bundled")), - Some((Source::Login, login)) + fn the_saved_username_wins_over_the_token_claim() { + let c = creds(oauth_token(r#"{"u":"from-token"}"#), Some("saved")); + assert_eq!(Login::from_credentials(&c, NOW).unwrap().account, "saved"); + } + + #[test] + fn an_account_that_is_not_a_plain_username_is_refused() { + for account in ["a/b", "..", "a?b", "a b", ""] { + let c = creds(oauth_token(r#"{"u":"someone"}"#), Some(account)); + assert_eq!(Login::from_credentials(&c, NOW), None, "{account:?}"); + } + } + + /// A loopback server answering one request; returns the request head it + /// received and the address to send to. + fn serve_once(status_line: &str, body: &str) -> (std::thread::JoinHandle, String) { + let response = format!( + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() ); + let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port"); + let addr = listener.local_addr().expect("the bound address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("the client's connection"); + let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + match stream.read(&mut byte) { + Ok(1) => head.push(byte[0]), + _ => break, + } + } + let _ = stream.write_all(response.as_bytes()); + String::from_utf8_lossy(&head).into_owned() + }); + (server, format!("http://{addr}/tokens/v2")) + } + + fn fetch_from(status_line: &str, body: &str) -> (Option, String) { + let (server, endpoint) = serve_once(status_line, body); + let token = fetch_default_public_token(&http::client().unwrap(), &endpoint, &login()); + (token, server.join().unwrap()) } #[test] - fn expired_login_with_nothing_else_is_none() { - let login = token_expiring_at(NOW - 1); - assert_eq!(won(Some(&login), None, None), None); + fn fetch_asks_for_the_accounts_default_token() { + let (token, head) = fetch_from( + "200 OK", + r#"[{"token":"pk.default","default":true,"usage":"pk","scopes":["styles:read"]}]"#, + ); + assert_eq!(token.as_deref(), Some("pk.default")); + let request_line = head.lines().next().unwrap(); + assert!( + request_line.starts_with("GET /tokens/v2/someone?"), + "{request_line}" + ); + assert!( + request_line.contains("access_token=sk.oauth"), + "{request_line}" + ); + assert!(request_line.contains("default=true"), "{request_line}"); + } + + #[test] + fn fetch_refuses_anything_but_a_default_public_token() { + for body in [ + r#"[{"token":"sk.secret","default":true,"usage":"sk"}]"#, + r#"[{"token":"pk.other","default":false,"usage":"pk"}]"#, + r#"[{"token":"xx.odd","default":true,"usage":"pk"}]"#, + r#"[]"#, + r#"{"message":"not a list"}"#, + ] { + assert_eq!(fetch_from("200 OK", body).0, None, "{body}"); + } } #[test] - fn nothing_available_is_none() { - assert_eq!(won(None, None, None), None); - assert_eq!(won(Some(""), Some(" "), Some("")), None); + fn fetch_gives_up_on_an_error_status() { + let (token, _) = fetch_from( + "401 Unauthorized", + r#"[{"token":"pk.default","default":true,"usage":"pk"}]"#, + ); + assert_eq!(token, None); } } From 4cbe7a0c3efa0a478febcb272cb6246888a65fff Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Fri, 9 Oct 2026 15:34:36 +0300 Subject: [PATCH 4/6] Use the user's own token first for background requests The CLI's token is only the fallback for someone with none of their own. --token and MAPBOX_ACCESS_TOKEN now come first, ranked as a command ranks them, and the login's OAuth token is used when the default public token cannot be had. --- README.md | 11 ++-- src/cli_token.rs | 154 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 137 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 67b21b4..383f144 100644 --- a/README.md +++ b/README.md @@ -453,12 +453,11 @@ and `--dry-run` show it alongside everything else on the request. ### The CLI's own requests -Requests the CLI makes for itself rather than for your commands use a public -token rather than your `--token`, `MAPBOX_ACCESS_TOKEN` or login. When you are -logged in, that is your account's default public token: the CLI fetches it -once, keeps it with your login, and fetches it again if you rotate it. -Otherwise `MAPBOX_CLI_TOKEN` sets it, and takes precedence over any token -built into the binary. +Requests the CLI makes for itself rather than for your commands use your own +token whenever you have one: `--token` or `MAPBOX_ACCESS_TOKEN`, then your +account's default public token, which the CLI fetches with your login, keeps +beside it, and fetches again if you rotate it. Only when you have none does +it use the CLI's token: `MAPBOX_CLI_TOKEN`, or one built into the binary. ### Proxies diff --git a/src/cli_token.rs b/src/cli_token.rs index cff830a..a6cc981 100644 --- a/src/cli_token.rs +++ b/src/cli_token.rs @@ -1,24 +1,28 @@ //! Requests the CLI makes for itself rather than for the user, and the token //! they carry. //! -//! A user's `--token` and `MAPBOX_ACCESS_TOKEN` are scoped to the command they -//! were given for, and the OAuth token from `mapbox auth login` carries write -//! scopes, so a background request sends none of them. It sends a public -//! token, tried highest first: +//! The user's own token always comes first. The CLI's token exists only as +//! the fallback for someone who has none, so it is never sent while the user +//! has one that works. Tried highest first: //! -//! 1. The account's default public token, kept with the stored login. When +//! 1. `--token` or `MAPBOX_ACCESS_TOKEN`, ranked the way a command ranks them +//! (see [`user_token`]). +//! 2. The account's default public token, kept with the stored login. When //! there is none yet, or the API rejects it because the user rotated or //! deleted it, a new one is fetched with the login's OAuth token and saved. -//! That OAuth token is only read, never refreshed: a refresh takes the -//! credentials lock and spends a single-use refresh token, and a request -//! nobody is waiting for must not be the reason the next command's login -//! is gone. An expired login just skips this step. -//! 2. `MAPBOX_CLI_TOKEN` at run time, for someone who is not logged in, such -//! as a developer pointing at staging with a staging token. -//! 3. The token compiled in from `MAPBOX_CLI_BUNDLED_TOKEN` at build time, so -//! someone who never logged in still has one. A plain `cargo build` has -//! none. It is a different variable from the run-time one so that a token -//! exported in a dev shell is not baked into every local build. +//! Preferred over the OAuth token itself, which carries write scopes and +//! expires. +//! 3. The login's OAuth token, when the default public token could not be had. +//! It is only read, never refreshed: a refresh takes the credentials lock +//! and spends a single-use refresh token, and a request nobody is waiting +//! for must not be the reason the next command's login is gone. An expired +//! login skips this step and the one above. +//! 4. `MAPBOX_CLI_TOKEN` at run time, the CLI's token for someone with none +//! of their own, such as a developer pointing at staging. +//! 5. The token compiled in from `MAPBOX_CLI_BUNDLED_TOKEN` at build time. A +//! plain `cargo build` has none. It is a different variable from the +//! run-time one so that a token exported in a dev shell is not baked into +//! every local build. //! //! A `401` moves on to the next token; any other answer is the caller's. //! Callers never see the token, so every one of them gets the same fallback @@ -43,7 +47,20 @@ const EXPIRY_MARGIN_SECS: u64 = 60; /// Lists an account's tokens; `?default=true` narrows it to the default one. const TOKENS_ENDPOINT: &str = "https://api.mapbox.com/tokens/v2"; -/// Sends `request` with the CLI's token attached as `access_token`, trying the +/// The token the user gave this run, ranked as a command ranks it: with +/// `--use-login` only a typed `--token` counts, otherwise `MAPBOX_ACCESS_TOKEN` +/// does too. Resolved by the caller, which has the arguments, and handed to +/// [`send`]. +#[allow(dead_code)] +pub(crate) fn user_token(matches: &clap::ArgMatches) -> Option { + if matches.get_flag("use-login") { + auth::typed_token(matches) + } else { + matches.get_one::("token").cloned() + } +} + +/// Sends `request` with a token attached as `access_token`, trying the /// next token whenever the API answers `401`. /// /// `request` is called once per attempt, since a sent request cannot be @@ -53,6 +70,7 @@ const TOKENS_ENDPOINT: &str = "https://api.mapbox.com/tokens/v2"; // The first caller is telemetry delivery, which lands separately. #[allow(dead_code)] pub(crate) fn send( + user_token: Option<&str>, profile: Option<&str>, request: impl Fn() -> RequestBuilder, ) -> Option { @@ -61,6 +79,7 @@ pub(crate) fn send( let creds = auth::load_credentials_readonly(profile); let override_token = std::env::var("MAPBOX_CLI_TOKEN").ok(); let sources = Sources { + user: user_token, stored: creds .as_ref() .and_then(|c| c.default_public_token.as_deref()), @@ -85,13 +104,14 @@ pub(crate) fn send( } struct Sources<'a> { + user: Option<&'a str>, stored: Option<&'a str>, login: Option, override_token: Option<&'a str>, bundled: Option<&'a str>, } -/// A stored OAuth login that is still good for long enough to fetch with. +/// A stored OAuth login that is still good for long enough to use. #[derive(Debug, PartialEq, Eq)] struct Login { access_token: String, @@ -122,8 +142,8 @@ impl Login { /// The order and the fallback, with fetching and sending passed in so each /// path can be tested without the network or the disk. /// -/// The fetch runs only once the stored token is missing or rejected, and a -/// token that was already rejected is not tried again. A failed send stops +/// The fetch runs only once every token above it is missing or rejected, and +/// a token that was already rejected is not tried again. A failed send stops /// here: no other token fixes a request that never arrived. fn resolve( sources: Sources<'_>, @@ -132,8 +152,10 @@ fn resolve( rejected: impl Fn(&R) -> bool, ) -> Option { enum Stage { + User, Stored, Fetched, + Login, Override, Bundled, } @@ -141,14 +163,18 @@ fn resolve( let mut tried: Vec = Vec::new(); let mut last = None; for stage in [ + Stage::User, Stage::Stored, Stage::Fetched, + Stage::Login, Stage::Override, Stage::Bundled, ] { let token = match stage { + Stage::User => usable(sources.user).map(str::to_owned), Stage::Stored => usable(sources.stored).map(str::to_owned), Stage::Fetched => sources.login.as_ref().and_then(&mut fetch), + Stage::Login => sources.login.as_ref().map(|l| l.access_token.clone()), Stage::Override => usable(sources.override_token).map(str::to_owned), Stage::Bundled => usable(sources.bundled).map(str::to_owned), }; @@ -270,6 +296,7 @@ mod tests { bundled: Option<&'a str>, ) -> Sources<'a> { Sources { + user: None, stored, login, override_token, @@ -335,14 +362,59 @@ mod tests { } #[test] - fn a_failed_fetch_falls_through_to_the_override() { + fn a_failed_fetch_falls_back_to_the_oauth_token() { + let (answer, sent, _) = run( + sources(Some("pk.revoked"), Some(login()), Some("pk.override"), None), + None, + &["sk.oauth"], + ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.revoked", "sk.oauth"]); + } + + #[test] + fn the_clis_token_is_sent_only_once_every_user_token_is_rejected() { let (answer, sent, _) = run( sources(Some("pk.revoked"), Some(login()), Some("pk.override"), None), None, &["pk.override"], ); assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.revoked", "pk.override"]); + assert_eq!(sent, ["pk.revoked", "sk.oauth", "pk.override"]); + } + + #[test] + fn a_users_own_token_comes_first() { + let (answer, sent, fetches) = run( + Sources { + user: Some("pk.typed"), + ..sources( + Some("pk.stored"), + Some(login()), + Some("pk.override"), + Some("pk.bundled"), + ) + }, + Some("pk.fresh"), + &["pk.typed", "pk.stored"], + ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.typed"]); + assert_eq!(fetches, 0); + } + + #[test] + fn a_rejected_user_token_falls_through_to_the_login() { + let (answer, sent, _) = run( + Sources { + user: Some("pk.typed"), + ..sources(Some("pk.stored"), Some(login()), None, None) + }, + None, + &["pk.stored"], + ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["pk.typed", "pk.stored"]); } #[test] @@ -355,7 +427,7 @@ mod tests { &["pk.bundled"], ); assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.same", "pk.bundled"]); + assert_eq!(sent, ["pk.same", "sk.oauth", "pk.bundled"]); } #[test] @@ -465,6 +537,44 @@ mod tests { } } + fn user_token_for(args: &[&str]) -> Option { + let matches = crate::build_app(&[]) + .try_get_matches_from(args) + .expect("arguments parse"); + user_token(&matches) + } + + // The `MAPBOX_ACCESS_TOKEN` half is not covered here: setting it would + // race every other test in this process that reads the environment. + #[test] + fn a_typed_token_is_the_users_with_or_without_use_login() { + for args in [ + &["mapbox", "--token", "pk.typed", "auth", "whoami"][..], + &[ + "mapbox", + "--use-login", + "--token", + "pk.typed", + "auth", + "whoami", + ][..], + ] { + assert_eq!( + user_token_for(args).as_deref(), + Some("pk.typed"), + "{args:?}" + ); + } + } + + #[test] + fn use_login_without_a_typed_token_leaves_the_user_none() { + assert_eq!( + user_token_for(&["mapbox", "--use-login", "auth", "whoami"]), + None + ); + } + /// A loopback server answering one request; returns the request head it /// received and the address to send to. fn serve_once(status_line: &str, body: &str) -> (std::thread::JoinHandle, String) { From b9c53e9b2eeb0dcdb00d953b909d317137af2fcd Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Fri, 9 Oct 2026 16:23:04 +0300 Subject: [PATCH 5/6] Send the CLI's token only to APIs listed for it Every request made with the CLI's token is billed to the account that owns it, so CLI_TOKEN_APIS lists where it may go, by host and path prefix, each with its reason. send() skips the CLI's token for anything else, and a command whose API is listed now falls back to it when the user has no token of their own. The list holds only telemetry today, so no command changes. --- README.md | 2 + src/cli_token.rs | 169 ++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 10 +++ 3 files changed, 179 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 383f144..2f34021 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,8 @@ token whenever you have one: `--token` or `MAPBOX_ACCESS_TOKEN`, then your account's default public token, which the CLI fetches with your login, keeps beside it, and fetches again if you rotate it. Only when you have none does it use the CLI's token: `MAPBOX_CLI_TOKEN`, or one built into the binary. +The CLI's token is only ever sent to the few Mapbox APIs listed for it in +`src/cli_token.rs`, today just telemetry. ### Proxies diff --git a/src/cli_token.rs b/src/cli_token.rs index a6cc981..87ed364 100644 --- a/src/cli_token.rs +++ b/src/cli_token.rs @@ -28,6 +28,14 @@ //! Callers never see the token, so every one of them gets the same fallback //! and replacement without doing anything. //! +//! # Where the CLI's token may go +//! +//! Steps 4 and 5 are the CLI's token, and every request made with it is +//! billed to the account that owns it. So it is sent only to the APIs in +//! [`CLI_TOKEN_APIS`], whether the request is the CLI's own (through [`send`]) +//! or a command's (through [`for_command`]). Anything else gets the user's +//! token or none. +//! //! # The bundled token is public //! //! Anything compiled into a distributed binary can be pulled out with @@ -44,9 +52,66 @@ use crate::http; /// Seconds of remaining life below which a stored OAuth token is not used. const EXPIRY_MARGIN_SECS: u64 = 60; +/// APIs the CLI's token may be sent to, as host, path prefix and the reason +/// the CLI's account should carry their use. Matched on `https`, the exact +/// host with no port, and whole path segments, so `/events/v2` does not +/// cover `/events/v20`. +const CLI_TOKEN_APIS: &[(&str, &str, &str)] = &[ + ( + "events.mapbox.com", + "/events/v2", + "Telemetry; no user to bill.", + ), + ( + "api-events-staging.tilestream.net", + "/events/v2", + "Telemetry to staging; no user to bill.", + ), +]; + /// Lists an account's tokens; `?default=true` narrows it to the default one. const TOKENS_ENDPOINT: &str = "https://api.mapbox.com/tokens/v2"; +/// The CLI's token for a command's request, when the user has none of their +/// own and the command calls an API in [`CLI_TOKEN_APIS`]. +/// +/// Only for a user with no token at all: one whose token is rejected sees that +/// rejection rather than having it hidden behind the CLI's token. +pub(crate) fn for_command(op: &crate::spec::Operation) -> Option { + if allowed(CLI_TOKEN_APIS, &operation_url(op)?) { + cli_token(std::env::var("MAPBOX_CLI_TOKEN").ok().as_deref()) + } else { + None + } +} + +/// Where a command sends its request, enough to match against +/// [`CLI_TOKEN_APIS`]: path parameters stay as their `{placeholders}`. +fn operation_url(op: &crate::spec::Operation) -> Option { + reqwest::Url::parse(&format!("{}{}", op.base_url, op.path_template)).ok() +} + +/// `MAPBOX_CLI_TOKEN` at run time, then the bundled token. +fn cli_token(override_token: Option<&str>) -> Option { + usable(override_token) + .or_else(|| usable(option_env!("MAPBOX_CLI_BUNDLED_TOKEN"))) + .map(str::to_owned) +} + +fn allowed(apis: &[(&str, &str, &str)], url: &reqwest::Url) -> bool { + url.scheme() == "https" + && url.port().is_none() + && apis.iter().any(|(host, prefix, _)| { + url.host_str() == Some(*host) && { + let mut segments = url.path().split('/').filter(|s| !s.is_empty()); + prefix + .split('/') + .filter(|s| !s.is_empty()) + .all(|want| segments.next() == Some(want)) + } + }) +} + /// The token the user gave this run, ranked as a command ranks it: with /// `--use-login` only a typed `--token` counts, otherwise `MAPBOX_ACCESS_TOKEN` /// does too. Resolved by the caller, which has the arguments, and handed to @@ -78,6 +143,11 @@ pub(crate) fn send( // re-permissions the config directory on a machine that never logged in. let creds = auth::load_credentials_readonly(profile); let override_token = std::env::var("MAPBOX_CLI_TOKEN").ok(); + // Read off a request built only to be looked at; the one sent is built + // again per attempt. + let cli_allowed = request() + .build() + .is_ok_and(|built| allowed(CLI_TOKEN_APIS, built.url())); let sources = Sources { user: user_token, stored: creds @@ -88,6 +158,7 @@ pub(crate) fn send( .and_then(|c| Login::from_credentials(c, now_secs())), override_token: override_token.as_deref(), bundled: option_env!("MAPBOX_CLI_BUNDLED_TOKEN"), + cli_allowed, }; resolve( sources, @@ -109,6 +180,9 @@ struct Sources<'a> { login: Option, override_token: Option<&'a str>, bundled: Option<&'a str>, + /// Whether the request goes to an API in [`CLI_TOKEN_APIS`]; without it + /// the two tokens above are never sent. + cli_allowed: bool, } /// A stored OAuth login that is still good for long enough to use. @@ -175,8 +249,11 @@ fn resolve( Stage::Stored => usable(sources.stored).map(str::to_owned), Stage::Fetched => sources.login.as_ref().and_then(&mut fetch), Stage::Login => sources.login.as_ref().map(|l| l.access_token.clone()), - Stage::Override => usable(sources.override_token).map(str::to_owned), - Stage::Bundled => usable(sources.bundled).map(str::to_owned), + Stage::Override if sources.cli_allowed => { + usable(sources.override_token).map(str::to_owned) + } + Stage::Bundled if sources.cli_allowed => usable(sources.bundled).map(str::to_owned), + Stage::Override | Stage::Bundled => None, }; let Some(token) = token else { continue }; if tried.contains(&token) { @@ -301,6 +378,7 @@ mod tests { login, override_token, bundled, + cli_allowed: true, } } @@ -430,6 +508,21 @@ mod tests { assert_eq!(sent, ["pk.same", "sk.oauth", "pk.bundled"]); } + #[test] + fn the_clis_token_is_never_sent_to_an_api_outside_the_list() { + let (answer, sent, _) = run( + Sources { + user: Some("pk.typed"), + cli_allowed: false, + ..sources(None, None, Some("pk.override"), Some("pk.bundled")) + }, + None, + &["pk.override", "pk.bundled"], + ); + assert_eq!(answer, Some(401)); + assert_eq!(sent, ["pk.typed"]); + } + #[test] fn override_comes_before_bundled() { let (answer, sent, _) = run( @@ -575,6 +668,78 @@ mod tests { ); } + fn url(text: &str) -> reqwest::Url { + reqwest::Url::parse(text).unwrap() + } + + const APIS: &[(&str, &str, &str)] = &[("events.mapbox.com", "/events/v2", "test")]; + + #[test] + fn an_allowed_api_matches_on_whole_segments() { + for (text, ok) in [ + ("https://events.mapbox.com/events/v2", true), + ("https://events.mapbox.com/events/v2/", true), + ("https://events.mapbox.com/events/v2/batch?x=1", true), + ("https://events.mapbox.com/events/v20", false), + ("https://events.mapbox.com/events", false), + ("https://events.mapbox.com/other/events/v2", false), + ] { + assert_eq!(allowed(APIS, &url(text)), ok, "{text}"); + } + } + + #[test] + fn an_allowed_api_needs_the_exact_host_over_https() { + for text in [ + "http://events.mapbox.com/events/v2", + "https://events.mapbox.com:8443/events/v2", + "https://evil.events.mapbox.com/events/v2", + "https://events.mapbox.com.evil.example/events/v2", + "https://api.mapbox.com/events/v2", + ] { + assert!(!allowed(APIS, &url(text)), "{text}"); + } + assert!(!allowed(&[], &url("https://events.mapbox.com/events/v2"))); + } + + #[test] + fn every_cli_token_api_is_well_formed() { + for (host, prefix, reason) in CLI_TOKEN_APIS { + assert_eq!(*host, host.to_ascii_lowercase(), "{host}"); + assert!(prefix.starts_with('/'), "{prefix}"); + assert!(!reason.trim().is_empty(), "{host}{prefix} needs a reason"); + assert!( + allowed(CLI_TOKEN_APIS, &url(&format!("https://{host}{prefix}"))), + "{host}{prefix}" + ); + } + } + + // A URL that failed to parse would quietly keep every command off the + // CLI's token, listed or not. + #[test] + fn every_commands_url_can_be_matched() { + let specs = crate::spec::effective_services().expect("the bundled specs parse"); + for op in specs.iter().flat_map(|svc| &svc.operations) { + let url = operation_url(op).unwrap_or_else(|| panic!("{}", op.command())); + assert_eq!(url.scheme(), "https", "{}", op.command()); + } + } + + #[test] + fn the_runtime_override_comes_before_the_bundled_token() { + assert_eq!( + cli_token(Some(" pk.override ")).as_deref(), + Some("pk.override") + ); + assert_eq!( + cli_token(Some(" ")).as_deref(), + option_env!("MAPBOX_CLI_BUNDLED_TOKEN") + .map(str::trim) + .filter(|t| !t.is_empty()) + ); + } + /// A loopback server answering one request; returns the request head it /// received and the address to send to. fn serve_once(status_line: &str, body: &str) -> (std::thread::JoinHandle, String) { diff --git a/src/main.rs b/src/main.rs index 6435c52..ddc1d84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1454,6 +1454,16 @@ fn run(app: &Command, specs: &[ServiceSpec], matches: &ArgMatches, mode: Mode) - if let Some(token) = &token { run_record::set_resolved_token(matches, use_login, token); } + // After the record above, which knows only the user's own + // sources. `--use-login` already returned when it found nothing. + let token = token.or_else(|| { + let token = cli_token::for_command(op)?; + output::progress( + "No token of your own, so this request uses the CLI's token. \ + Run `mapbox auth login` to use yours.", + ); + Some(token) + }); let username: Option = matches .get_one::("username") .cloned() From 05b7fbd5160914d15f0b93ef9a835289b569acf3 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Fri, 9 Oct 2026 16:30:11 +0300 Subject: [PATCH 6/6] Use the login as commands do instead of a stored default public token The login is loaded with load_fresh_credentials, refresh included, so it never goes stale and there is nothing extra to store, fetch or replace. It is looked for read-only first, so a machine that never logged in does not get a ~/.mapbox from the credentials lock. --- README.md | 9 +- src/auth.rs | 172 +--------------- src/cli_token.rs | 513 +++++++++-------------------------------------- 3 files changed, 102 insertions(+), 592 deletions(-) diff --git a/README.md b/README.md index 2f34021..d446c7c 100644 --- a/README.md +++ b/README.md @@ -455,11 +455,10 @@ and `--dry-run` show it alongside everything else on the request. Requests the CLI makes for itself rather than for your commands use your own token whenever you have one: `--token` or `MAPBOX_ACCESS_TOKEN`, then your -account's default public token, which the CLI fetches with your login, keeps -beside it, and fetches again if you rotate it. Only when you have none does -it use the CLI's token: `MAPBOX_CLI_TOKEN`, or one built into the binary. -The CLI's token is only ever sent to the few Mapbox APIs listed for it in -`src/cli_token.rs`, today just telemetry. +login. Only when you have none does it use the CLI's token: +`MAPBOX_CLI_TOKEN`, or one built into the binary. The CLI's token is only +ever sent to the few Mapbox APIs listed for it in `src/cli_token.rs`, today +just telemetry. ### Proxies diff --git a/src/auth.rs b/src/auth.rs index b3b5462..bf1454c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -117,11 +117,6 @@ pub struct Credentials { pub username: Option, #[serde(skip_serializing_if = "Option::is_none")] pub client_id: Option, - /// The account's default public token, kept for [`crate::cli_token`]. - /// Filled in on first use rather than at login, so credentials written - /// before it existed work the same way. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_public_token: Option, } /// Restrict a directory to the owner (0700). No-op off Unix. @@ -491,61 +486,6 @@ pub(crate) fn load_credentials_readonly(profile: Option<&str>) -> Option, - account: &str, - token: &str, -) -> bool { - let (Some(path), Ok(lock)) = (credentials_path_readonly(profile), lock_filename(profile)) - else { - return false; - }; - store_default_public_token_at(&path, &path.with_file_name(lock), account, token) -} - -fn store_default_public_token_at(path: &Path, lock: &Path, account: &str, token: &str) -> bool { - // Checked before taking the lock, which would otherwise leave a lock file - // behind for a profile that has no credentials. - if !path.exists() { - return false; - } - let Some(_lock) = CredentialLock::try_at(lock) else { - return false; - }; - // Re-read under the lock, so a refresh that landed since the caller read - // the file is kept rather than overwritten with the older tokens. - let Some(mut creds) = std::fs::read_to_string(path) - .ok() - .and_then(|data| serde_json::from_str::(&data).ok()) - else { - return false; - }; - if credentials_account(&creds).as_deref() != Some(account) { - return false; - } - creds.default_public_token = Some(token.to_owned()); - serde_json::to_string_pretty(&creds) - .ok() - .is_some_and(|data| write_private(path, &data).is_ok()) -} - -/// The account stored credentials belong to: the saved username, or the -/// access token's own `u` claim for credentials saved without one. -pub(crate) fn credentials_account(creds: &Credentials) -> Option { - creds - .username - .clone() - .or_else(|| token_account(&creds.access_token)) -} - /// The reverse of [`credentials_filename`]: the profile name a credentials /// filename would have been written under, or `None` for anything else in /// the config directory — `credentials-.json.lock`, `config.json`, @@ -641,20 +581,6 @@ impl CredentialLock { /// Block until this lock file is ours. fn at(path: &Path) -> Result { - let file = Self::open(path)?; - file.lock() - .with_context(|| format!("Failed to lock {}", path.display()))?; - Ok(Self { file }) - } - - /// [`Self::at`], but `None` rather than waiting when someone else holds it. - fn try_at(path: &Path) -> Option { - let file = Self::open(path).ok()?; - file.try_lock().ok()?; - Some(Self { file }) - } - - fn open(path: &Path) -> Result { let mut opts = std::fs::OpenOptions::new(); opts.write(true).create(true).truncate(false); #[cfg(unix)] @@ -662,8 +588,12 @@ impl CredentialLock { use std::os::unix::fs::OpenOptionsExt; opts.mode(0o600); } - opts.open(path) - .with_context(|| format!("Failed to open lock file {}", path.display())) + let file = opts + .open(path) + .with_context(|| format!("Failed to open lock file {}", path.display()))?; + file.lock() + .with_context(|| format!("Failed to lock {}", path.display()))?; + Ok(Self { file }) } } @@ -2141,7 +2071,6 @@ fn exchange_code_for_token( refresh_token, username, client_id: None, - default_public_token: None, }) } @@ -3146,95 +3075,6 @@ mod tests { assert_eq!(describe(CALLBACK_TIMEOUT), "5 minutes"); } - fn stored_default_token(path: &Path) -> Option { - let creds: Credentials = - serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - creds.default_public_token - } - - #[test] - fn default_public_token_is_added_beside_the_login() { - let dir = scratch("default-token-added"); - let path = dir.join("credentials.json"); - let lock = dir.join("credentials.json.lock"); - std::fs::write( - &path, - r#"{"access_token":"sk.a","refresh_token":"r","username":"someone"}"#, - ) - .unwrap(); - - assert!(store_default_public_token_at( - &path, - &lock, - "someone", - "pk.default" - )); - - let creds: Credentials = - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(creds.default_public_token.as_deref(), Some("pk.default")); - assert_eq!(creds.access_token, "sk.a"); - assert_eq!(creds.refresh_token.as_deref(), Some("r")); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn default_public_token_never_recreates_a_logged_out_profile() { - let dir = scratch("default-token-logged-out"); - let path = dir.join("credentials.json"); - let lock = dir.join("credentials.json.lock"); - - assert!(!store_default_public_token_at( - &path, - &lock, - "someone", - "pk.default" - )); - assert!(!path.exists()); - assert!(!lock.exists()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn default_public_token_is_not_saved_over_another_account() { - let dir = scratch("default-token-other-account"); - let path = dir.join("credentials.json"); - let lock = dir.join("credentials.json.lock"); - std::fs::write( - &path, - r#"{"access_token":"sk.b","username":"someone-else"}"#, - ) - .unwrap(); - - assert!(!store_default_public_token_at( - &path, - &lock, - "someone", - "pk.default" - )); - assert_eq!(stored_default_token(&path), None); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn default_public_token_gives_up_rather_than_wait_for_the_lock() { - let dir = scratch("default-token-locked"); - let path = dir.join("credentials.json"); - let lock = dir.join("credentials.json.lock"); - std::fs::write(&path, r#"{"access_token":"sk.a","username":"someone"}"#).unwrap(); - - let held = CredentialLock::at(&lock).unwrap(); - assert!(!store_default_public_token_at( - &path, - &lock, - "someone", - "pk.default" - )); - drop(held); - assert_eq!(stored_default_token(&path), None); - std::fs::remove_dir_all(&dir).unwrap(); - } - /// A unique scratch dir, so tests never touch the real config dir. fn scratch(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( diff --git a/src/cli_token.rs b/src/cli_token.rs index 87ed364..f63aa85 100644 --- a/src/cli_token.rs +++ b/src/cli_token.rs @@ -7,30 +7,21 @@ //! //! 1. `--token` or `MAPBOX_ACCESS_TOKEN`, ranked the way a command ranks them //! (see [`user_token`]). -//! 2. The account's default public token, kept with the stored login. When -//! there is none yet, or the API rejects it because the user rotated or -//! deleted it, a new one is fetched with the login's OAuth token and saved. -//! Preferred over the OAuth token itself, which carries write scopes and -//! expires. -//! 3. The login's OAuth token, when the default public token could not be had. -//! It is only read, never refreshed: a refresh takes the credentials lock -//! and spends a single-use refresh token, and a request nobody is waiting -//! for must not be the reason the next command's login is gone. An expired -//! login skips this step and the one above. -//! 4. `MAPBOX_CLI_TOKEN` at run time, the CLI's token for someone with none +//! 2. The stored login, loaded the way a command loads it, refresh included. +//! 3. `MAPBOX_CLI_TOKEN` at run time, the CLI's token for someone with none //! of their own, such as a developer pointing at staging. -//! 5. The token compiled in from `MAPBOX_CLI_BUNDLED_TOKEN` at build time. A +//! 4. The token compiled in from `MAPBOX_CLI_BUNDLED_TOKEN` at build time. A //! plain `cargo build` has none. It is a different variable from the //! run-time one so that a token exported in a dev shell is not baked into //! every local build. //! //! A `401` moves on to the next token; any other answer is the caller's. //! Callers never see the token, so every one of them gets the same fallback -//! and replacement without doing anything. +//! without doing anything. //! //! # Where the CLI's token may go //! -//! Steps 4 and 5 are the CLI's token, and every request made with it is +//! Steps 3 and 4 are the CLI's token, and every request made with it is //! billed to the account that owns it. So it is sent only to the APIs in //! [`CLI_TOKEN_APIS`], whether the request is the CLI's own (through [`send`]) //! or a command's (through [`for_command`]). Anything else gets the user's @@ -43,14 +34,10 @@ //! the minimum scopes, and nothing may ever treat it as a secret. `build.rs` //! refuses anything that is not `pk.`. -use reqwest::blocking::{Client, RequestBuilder, Response}; +use reqwest::blocking::{RequestBuilder, Response}; use reqwest::StatusCode; -use crate::auth::{self, Credentials}; -use crate::http; - -/// Seconds of remaining life below which a stored OAuth token is not used. -const EXPIRY_MARGIN_SECS: u64 = 60; +use crate::{auth, http}; /// APIs the CLI's token may be sent to, as host, path prefix and the reason /// the CLI's account should carry their use. Matched on `https`, the exact @@ -69,9 +56,6 @@ const CLI_TOKEN_APIS: &[(&str, &str, &str)] = &[ ), ]; -/// Lists an account's tokens; `?default=true` narrows it to the default one. -const TOKENS_ENDPOINT: &str = "https://api.mapbox.com/tokens/v2"; - /// The CLI's token for a command's request, when the user has none of their /// own and the command calls an API in [`CLI_TOKEN_APIS`]. /// @@ -128,6 +112,10 @@ pub(crate) fn user_token(matches: &clap::ArgMatches) -> Option { /// Sends `request` with a token attached as `access_token`, trying the /// next token whenever the API answers `401`. /// +/// May block on the credentials lock and refresh the login over the network, +/// as a command does, so call it where nobody is waiting on the answer, such +/// as a detached child. +/// /// `request` is called once per attempt, since a sent request cannot be /// reused. `None` when there is no token to send with, or the request could /// not be sent at all; otherwise the first answer that was not a `401`, or @@ -139,45 +127,36 @@ pub(crate) fn send( profile: Option<&str>, request: impl Fn() -> RequestBuilder, ) -> Option { - // Read-only: a background request must not be what creates or - // re-permissions the config directory on a machine that never logged in. - let creds = auth::load_credentials_readonly(profile); let override_token = std::env::var("MAPBOX_CLI_TOKEN").ok(); // Read off a request built only to be looked at; the one sent is built // again per attempt. let cli_allowed = request() .build() .is_ok_and(|built| allowed(CLI_TOKEN_APIS, built.url())); - let sources = Sources { - user: user_token, - stored: creds - .as_ref() - .and_then(|c| c.default_public_token.as_deref()), - login: creds - .as_ref() - .and_then(|c| Login::from_credentials(c, now_secs())), - override_token: override_token.as_deref(), - bundled: option_env!("MAPBOX_CLI_BUNDLED_TOKEN"), - cli_allowed, - }; resolve( - sources, - |login| { - let token = fetch_default_public_token(&http::client().ok()?, TOKENS_ENDPOINT, login)?; - // Used for this request even when it could not be saved; the - // next request simply fetches it again. - auth::store_default_public_token(profile, &login.account, &token); - Some(token) + Sources { + user: user_token, + override_token: override_token.as_deref(), + bundled: option_env!("MAPBOX_CLI_BUNDLED_TOKEN"), + cli_allowed, }, + || login_token(profile), |token| http::send(request().query(&[("access_token", token)])).ok(), |response| response.status() == StatusCode::UNAUTHORIZED, ) } +/// The stored login's token, refreshed when it is about to expire. +fn login_token(profile: Option<&str>) -> Option { + // Looked for read-only first: `load_fresh_credentials` takes the + // credentials lock, which creates `~/.mapbox` on a machine that never + // logged in. + auth::load_credentials_readonly(profile)?; + auth::load_fresh_credentials(false, profile).map(|c| c.access_token) +} + struct Sources<'a> { user: Option<&'a str>, - stored: Option<&'a str>, - login: Option, override_token: Option<&'a str>, bundled: Option<&'a str>, /// Whether the request goes to an API in [`CLI_TOKEN_APIS`]; without it @@ -185,75 +164,29 @@ struct Sources<'a> { cli_allowed: bool, } -/// A stored OAuth login that is still good for long enough to use. -#[derive(Debug, PartialEq, Eq)] -struct Login { - access_token: String, - account: String, -} - -impl Login { - fn from_credentials(creds: &Credentials, now_secs: u64) -> Option { - let access_token = usable(Some(&creds.access_token))?; - // No `exp` claim means no expiry to honor, as in - // `auth::token_needs_refresh`. - let alive = auth::token_expires_at(access_token) - .is_none_or(|exp| exp > now_secs.saturating_add(EXPIRY_MARGIN_SECS)); - // The account becomes a path segment. Mapbox usernames are plain, so - // anything else is refused rather than escaped. - let account = auth::credentials_account(creds).filter(|a| { - !a.is_empty() - && a.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - })?; - alive.then(|| Self { - access_token: access_token.to_owned(), - account, - }) - } -} - -/// The order and the fallback, with fetching and sending passed in so each +/// The order and the fallback, with the login and the send passed in so each /// path can be tested without the network or the disk. /// -/// The fetch runs only once every token above it is missing or rejected, and -/// a token that was already rejected is not tried again. A failed send stops -/// here: no other token fixes a request that never arrived. +/// The login is loaded only once the user's own token is missing or +/// rejected, and a token that was already rejected is not tried again. A +/// failed send stops here: no other token fixes a request that never +/// arrived. fn resolve( sources: Sources<'_>, - mut fetch: impl FnMut(&Login) -> Option, + login: impl FnOnce() -> Option, mut attempt: impl FnMut(&str) -> Option, rejected: impl Fn(&R) -> bool, ) -> Option { - enum Stage { - User, - Stored, - Fetched, - Login, - Override, - Bundled, - } - + let mut login = Some(login); let mut tried: Vec = Vec::new(); let mut last = None; - for stage in [ - Stage::User, - Stage::Stored, - Stage::Fetched, - Stage::Login, - Stage::Override, - Stage::Bundled, - ] { + for stage in 0..4 { let token = match stage { - Stage::User => usable(sources.user).map(str::to_owned), - Stage::Stored => usable(sources.stored).map(str::to_owned), - Stage::Fetched => sources.login.as_ref().and_then(&mut fetch), - Stage::Login => sources.login.as_ref().map(|l| l.access_token.clone()), - Stage::Override if sources.cli_allowed => { - usable(sources.override_token).map(str::to_owned) - } - Stage::Bundled if sources.cli_allowed => usable(sources.bundled).map(str::to_owned), - Stage::Override | Stage::Bundled => None, + 0 => usable(sources.user).map(str::to_owned), + 1 => login.take().and_then(|load| load()), + 2 if sources.cli_allowed => usable(sources.override_token).map(str::to_owned), + 3 if sources.cli_allowed => usable(sources.bundled).map(str::to_owned), + _ => None, }; let Some(token) = token else { continue }; if tried.contains(&token) { @@ -269,93 +202,29 @@ fn resolve( last } -#[derive(serde::Deserialize)] -struct TokenEntry { - token: String, - #[serde(default)] - default: bool, - #[serde(default)] - usage: String, -} - -/// The account's default public token, or `None` on any failure: the caller -/// falls through to the next token rather than reporting anything. -fn fetch_default_public_token(client: &Client, endpoint: &str, login: &Login) -> Option { - let response = http::send(client.get(format!("{endpoint}/{}", login.account)).query(&[ - ("access_token", login.access_token.as_str()), - ("default", "true"), - ])) - .ok()?; - if !response.status().is_success() { - return None; - } - // Checked rather than trusted: whatever comes back here is sent on every - // background request and saved next to the login. - response - .json::>() - .ok()? - .into_iter() - .find(|t| t.default && t.usage == "pk" && t.token.starts_with("pk.")) - .map(|t| t.token) -} - fn usable(token: Option<&str>) -> Option<&str> { token.map(str::trim).filter(|t| !t.is_empty()) } -fn now_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or_default() -} - #[cfg(test)] mod tests { use super::*; - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - use std::cell::RefCell; - use std::io::{Read, Write}; - use std::net::TcpListener; - use std::time::Duration; - - const NOW: u64 = 1_000_000; - - fn oauth_token(claims: &str) -> String { - format!("sk.{}.signature", URL_SAFE_NO_PAD.encode(claims)) - } - - fn creds(access_token: String, username: Option<&str>) -> Credentials { - Credentials { - access_token, - refresh_token: None, - username: username.map(str::to_owned), - client_id: None, - default_public_token: None, - } - } - - fn login() -> Login { - Login { - access_token: "sk.oauth".into(), - account: "someone".into(), - } - } + use std::cell::{Cell, RefCell}; /// Runs [`resolve`] against a fake API that accepts only `accepted`, and - /// returns the answer, every token sent, and how many fetches ran. + /// returns the answer, every token sent, and whether the login was loaded. fn run( sources: Sources<'_>, - fetched: Option<&str>, + login: Option<&str>, accepted: &[&str], - ) -> (Option, Vec, usize) { + ) -> (Option, Vec, bool) { let sent = RefCell::new(Vec::new()); - let mut fetches = 0; + let loaded = Cell::new(false); let answer = resolve( sources, - |_| { - fetches += 1; - fetched.map(str::to_owned) + || { + loaded.set(true); + login.map(str::to_owned) }, |token| { sent.borrow_mut().push(token.to_owned()); @@ -363,19 +232,16 @@ mod tests { }, |status| *status == 401, ); - (answer, sent.into_inner(), fetches) + (answer, sent.into_inner(), loaded.get()) } fn sources<'a>( - stored: Option<&'a str>, - login: Option, + user: Option<&'a str>, override_token: Option<&'a str>, bundled: Option<&'a str>, ) -> Sources<'a> { Sources { - user: None, - stored, - login, + user, override_token, bundled, cli_allowed: true, @@ -383,138 +249,55 @@ mod tests { } #[test] - fn an_accepted_stored_token_is_all_that_is_sent() { - let (answer, sent, fetches) = run( - sources( - Some("pk.stored"), - Some(login()), - Some("pk.override"), - Some("pk.bundled"), - ), - Some("pk.fresh"), - &["pk.stored"], + fn an_accepted_user_token_is_all_that_is_sent() { + let (answer, sent, loaded) = run( + sources(Some("pk.typed"), Some("pk.override"), Some("pk.bundled")), + Some("sk.login"), + &["pk.typed"], ); assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.stored"]); - assert_eq!(fetches, 0); - } - - #[test] - fn a_rejected_stored_token_is_replaced_by_a_fetched_one() { - let (answer, sent, fetches) = run( - sources(Some("pk.revoked"), Some(login()), Some("pk.override"), None), - Some("pk.fresh"), - &["pk.fresh"], + assert_eq!(sent, ["pk.typed"]); + assert!( + !loaded, + "the login is not touched when the user's token works" ); - assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.revoked", "pk.fresh"]); - assert_eq!(fetches, 1); } #[test] - fn a_missing_stored_token_is_fetched() { + fn the_login_comes_after_the_users_token() { let (answer, sent, _) = run( - sources(None, Some(login()), None, None), - Some("pk.fresh"), - &["pk.fresh"], - ); - assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.fresh"]); - } - - #[test] - fn without_a_usable_login_nothing_is_fetched() { - let (answer, sent, fetches) = run( - sources( - Some("pk.revoked"), - None, - Some("pk.override"), - Some("pk.bundled"), - ), - Some("pk.fresh"), - &["pk.override"], + sources(Some("pk.typed"), Some("pk.override"), None), + Some("sk.login"), + &["sk.login"], ); assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.revoked", "pk.override"]); - assert_eq!(fetches, 0); + assert_eq!(sent, ["pk.typed", "sk.login"]); } #[test] - fn a_failed_fetch_falls_back_to_the_oauth_token() { - let (answer, sent, _) = run( - sources(Some("pk.revoked"), Some(login()), Some("pk.override"), None), - None, - &["sk.oauth"], - ); + fn the_login_is_used_when_the_user_typed_nothing() { + let (answer, sent, _) = run(sources(None, None, None), Some("sk.login"), &["sk.login"]); assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.revoked", "sk.oauth"]); + assert_eq!(sent, ["sk.login"]); } #[test] fn the_clis_token_is_sent_only_once_every_user_token_is_rejected() { let (answer, sent, _) = run( - sources(Some("pk.revoked"), Some(login()), Some("pk.override"), None), - None, + sources(Some("pk.typed"), Some("pk.override"), Some("pk.bundled")), + Some("sk.login"), &["pk.override"], ); assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.revoked", "sk.oauth", "pk.override"]); - } - - #[test] - fn a_users_own_token_comes_first() { - let (answer, sent, fetches) = run( - Sources { - user: Some("pk.typed"), - ..sources( - Some("pk.stored"), - Some(login()), - Some("pk.override"), - Some("pk.bundled"), - ) - }, - Some("pk.fresh"), - &["pk.typed", "pk.stored"], - ); - assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.typed"]); - assert_eq!(fetches, 0); - } - - #[test] - fn a_rejected_user_token_falls_through_to_the_login() { - let (answer, sent, _) = run( - Sources { - user: Some("pk.typed"), - ..sources(Some("pk.stored"), Some(login()), None, None) - }, - None, - &["pk.stored"], - ); - assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.typed", "pk.stored"]); - } - - #[test] - fn a_rejected_token_is_not_sent_twice() { - // The fetch hands back the token that was just rejected, as it would - // if the API refuses the default token itself rather than a stale one. - let (answer, sent, _) = run( - sources(Some("pk.same"), Some(login()), None, Some("pk.bundled")), - Some("pk.same"), - &["pk.bundled"], - ); - assert_eq!(answer, Some(200)); - assert_eq!(sent, ["pk.same", "sk.oauth", "pk.bundled"]); + assert_eq!(sent, ["pk.typed", "sk.login", "pk.override"]); } #[test] fn the_clis_token_is_never_sent_to_an_api_outside_the_list() { let (answer, sent, _) = run( Sources { - user: Some("pk.typed"), cli_allowed: false, - ..sources(None, None, Some("pk.override"), Some("pk.bundled")) + ..sources(Some("pk.typed"), Some("pk.override"), Some("pk.bundled")) }, None, &["pk.override", "pk.bundled"], @@ -526,7 +309,7 @@ mod tests { #[test] fn override_comes_before_bundled() { let (answer, sent, _) = run( - sources(None, None, Some("pk.override"), Some("pk.bundled")), + sources(None, Some("pk.override"), Some("pk.bundled")), None, &["pk.override", "pk.bundled"], ); @@ -534,33 +317,35 @@ mod tests { assert_eq!(sent, ["pk.override"]); } + #[test] + fn a_rejected_token_is_not_sent_twice() { + // The login and the typed token can be the same token. + let (answer, sent, _) = run( + sources(Some("sk.same"), None, Some("pk.bundled")), + Some("sk.same"), + &["pk.bundled"], + ); + assert_eq!(answer, Some(200)); + assert_eq!(sent, ["sk.same", "pk.bundled"]); + } + #[test] fn every_token_rejected_returns_the_last_answer() { let (answer, sent, _) = run( - sources( - Some("pk.stored"), - None, - Some("pk.override"), - Some("pk.bundled"), - ), - None, + sources(Some("pk.typed"), Some("pk.override"), Some("pk.bundled")), + Some("sk.login"), &[], ); assert_eq!(answer, Some(401)); - assert_eq!(sent, ["pk.stored", "pk.override", "pk.bundled"]); + assert_eq!(sent, ["pk.typed", "sk.login", "pk.override", "pk.bundled"]); } #[test] fn a_failed_send_stops_without_trying_other_tokens() { let mut sent = Vec::new(); let answer: Option = resolve( - sources( - Some("pk.stored"), - None, - Some("pk.override"), - Some("pk.bundled"), - ), - |_| None, + sources(Some("pk.typed"), Some("pk.override"), Some("pk.bundled")), + || Some("sk.login".to_owned()), |token| { sent.push(token.to_owned()); None @@ -568,13 +353,13 @@ mod tests { |status| *status == 401, ); assert_eq!(answer, None); - assert_eq!(sent, ["pk.stored"]); + assert_eq!(sent, ["pk.typed"]); } #[test] fn blank_tokens_are_skipped_and_the_rest_trimmed() { let (answer, sent, _) = run( - sources(Some(" "), None, Some(""), Some(" pk.bundled\n")), + sources(Some(" "), Some(""), Some(" pk.bundled\n")), None, &["pk.bundled"], ); @@ -584,52 +369,11 @@ mod tests { #[test] fn nothing_to_send_with_sends_nothing() { - let (answer, sent, _) = run(sources(None, None, None, None), None, &[]); + let (answer, sent, _) = run(sources(None, None, None), None, &[]); assert_eq!(answer, None); assert!(sent.is_empty()); } - #[test] - fn a_login_is_usable_until_the_margin() { - for (exp, usable) in [(NOW - 1, false), (NOW + 60, false), (NOW + 61, true)] { - let c = creds( - oauth_token(&format!(r#"{{"u":"someone","exp":{exp}}}"#)), - None, - ); - assert_eq!( - Login::from_credentials(&c, NOW).is_some(), - usable, - "exp = {exp}" - ); - } - } - - #[test] - fn a_login_without_exp_is_usable() { - let c = creds(oauth_token(r#"{"u":"someone"}"#), None); - assert_eq!( - Login::from_credentials(&c, NOW), - Some(Login { - access_token: c.access_token.clone(), - account: "someone".into(), - }) - ); - } - - #[test] - fn the_saved_username_wins_over_the_token_claim() { - let c = creds(oauth_token(r#"{"u":"from-token"}"#), Some("saved")); - assert_eq!(Login::from_credentials(&c, NOW).unwrap().account, "saved"); - } - - #[test] - fn an_account_that_is_not_a_plain_username_is_refused() { - for account in ["a/b", "..", "a?b", "a b", ""] { - let c = creds(oauth_token(r#"{"u":"someone"}"#), Some(account)); - assert_eq!(Login::from_credentials(&c, NOW), None, "{account:?}"); - } - } - fn user_token_for(args: &[&str]) -> Option { let matches = crate::build_app(&[]) .try_get_matches_from(args) @@ -739,77 +483,4 @@ mod tests { .filter(|t| !t.is_empty()) ); } - - /// A loopback server answering one request; returns the request head it - /// received and the address to send to. - fn serve_once(status_line: &str, body: &str) -> (std::thread::JoinHandle, String) { - let response = format!( - "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", - body.len() - ); - let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port"); - let addr = listener.local_addr().expect("the bound address"); - let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("the client's connection"); - let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); - let mut head = Vec::new(); - let mut byte = [0u8; 1]; - while !head.ends_with(b"\r\n\r\n") { - match stream.read(&mut byte) { - Ok(1) => head.push(byte[0]), - _ => break, - } - } - let _ = stream.write_all(response.as_bytes()); - String::from_utf8_lossy(&head).into_owned() - }); - (server, format!("http://{addr}/tokens/v2")) - } - - fn fetch_from(status_line: &str, body: &str) -> (Option, String) { - let (server, endpoint) = serve_once(status_line, body); - let token = fetch_default_public_token(&http::client().unwrap(), &endpoint, &login()); - (token, server.join().unwrap()) - } - - #[test] - fn fetch_asks_for_the_accounts_default_token() { - let (token, head) = fetch_from( - "200 OK", - r#"[{"token":"pk.default","default":true,"usage":"pk","scopes":["styles:read"]}]"#, - ); - assert_eq!(token.as_deref(), Some("pk.default")); - let request_line = head.lines().next().unwrap(); - assert!( - request_line.starts_with("GET /tokens/v2/someone?"), - "{request_line}" - ); - assert!( - request_line.contains("access_token=sk.oauth"), - "{request_line}" - ); - assert!(request_line.contains("default=true"), "{request_line}"); - } - - #[test] - fn fetch_refuses_anything_but_a_default_public_token() { - for body in [ - r#"[{"token":"sk.secret","default":true,"usage":"sk"}]"#, - r#"[{"token":"pk.other","default":false,"usage":"pk"}]"#, - r#"[{"token":"xx.odd","default":true,"usage":"pk"}]"#, - r#"[]"#, - r#"{"message":"not a list"}"#, - ] { - assert_eq!(fetch_from("200 OK", body).0, None, "{body}"); - } - } - - #[test] - fn fetch_gives_up_on_an_error_status() { - let (token, _) = fetch_from( - "401 Unauthorized", - r#"[{"token":"pk.default","default":true,"usage":"pk"}]"#, - ); - assert_eq!(token, None); - } }