From 68f20c4e1497a9ea8cd044db340a0b16947a61e7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 30 Sep 2026 11:28:05 -0700 Subject: [PATCH 1/6] feat!: cache credentials per proxy URL so staging and prod coexist The cache key was the role ARN alone, and both environments default to `_default`, so a staging login overwrote the prod entry (and `creds` would silently serve/refresh staging credentials for a prod profile). Key entries by `/` and let `creds` take `--proxy-url` / `SOURCE_PROXY_URL` to select the environment. BREAKING CHANGE: existing cached logins are no longer found; run `source-coop login` once after upgrading. Co-Authored-By: Claude Opus 5.5 --- README.md | 27 ++++++++++++++++++--- src/cache.rs | 67 ++++++++++++++++++++++++++++++++++------------------ src/main.rs | 17 ++++++++----- 3 files changed, 79 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 2d0143c..a748698 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,27 @@ credential_process = source-coop creds --role-arn admin-role endpoint_url = https://data.source.coop ``` +### Multiple environments (e.g. staging) + +Credentials are cached per proxy URL, so production and staging logins live side by side: + +```bash +source-coop login +( source .env.staging && source-coop login ) +``` + +`creds` reads `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment: + +```ini +[profile source-coop] +credential_process = source-coop creds +endpoint_url = https://data.source.coop + +[profile source-coop-staging] +credential_process = source-coop creds --proxy-url https://data.staging.source.coop +endpoint_url = https://data.staging.source.coop +``` + ### Login options | Flag | Env var | Default | Description | @@ -175,7 +196,7 @@ The CLI caches temporary STS credentials so that `creds` can output them without ### OS keyring (default) -Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN: +Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by proxy host and role ARN (e.g. `data.source.coop/_default`): | Platform | Backend | |----------|---------| @@ -189,8 +210,8 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Platform | Path | |----------|------| -| macOS | `~/Library/Caches/source-coop/credentials/.json` | -| Linux | `~/.cache/source-coop/credentials/.json` | +| macOS | `~/Library/Caches/source-coop/credentials/_.json` | +| Linux | `~/.cache/source-coop/credentials/_.json` | | Windows | `%LocalAppData%\source-coop\credentials\.json` | The fallback is automatic — no configuration is needed. diff --git a/src/cache.rs b/src/cache.rs index b7c5bad..0bb7759 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; const KEYRING_SERVICE: &str = "source-coop-cli"; -/// What gets cached per role: the STS credentials plus, when the IdP issued +/// What gets cached per proxy + role: the STS credentials plus, when the IdP issued /// one, what's needed to mint fresh credentials without a browser login. /// `flatten` keeps caches written by older versions (bare credentials) readable. #[derive(Serialize, Deserialize)] @@ -39,10 +39,19 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { ) } +/// Cache key for a role at a given proxy, e.g. `data.source.coop/_default`, so +/// logins against different environments (prod, staging) don't overwrite each other. +pub fn key(proxy_url: &str, role_arn: &str) -> String { + let host = proxy_url + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/'); + format!("{host}/{role_arn}") +} + /// Replace any character that isn't alphanumeric, `-`, or `_` with `_`. -fn sanitize_role_arn(role_arn: &str) -> String { - role_arn - .chars() +fn sanitize_key(key: &str) -> String { + key.chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c @@ -53,24 +62,24 @@ fn sanitize_role_arn(role_arn: &str) -> String { .collect() } -/// Full path to the credentials cache file for a given role. +/// Full path to the credentials cache file for a given cache key. /// Uses the OS-idiomatic cache directory (`~/Library/Caches` on macOS, /// `~/.cache` on Linux, `%LocalAppData%` on Windows). -fn cache_path(role_arn: &str) -> Result { +fn cache_path(key: &str) -> Result { let cache_dir = dirs::cache_dir().ok_or("Could not determine cache directory")?; - let sanitized = sanitize_role_arn(role_arn); + let sanitized = sanitize_key(key); Ok(cache_dir .join("source-coop") .join("credentials") .join(format!("{sanitized}.json"))) } -/// Take an exclusive per-role lock, held until the returned file is dropped. +/// Take an exclusive per-key lock, held until the returned file is dropped. /// Refresh tokens rotate on use, and the IdP may revoke the whole token family /// if an old one is replayed, so concurrent `creds` calls must not refresh in /// parallel. -pub fn lock(role_arn: &str) -> Result { - let path = cache_path(role_arn)?.with_extension("lock"); +pub fn lock(key: &str) -> Result { + let path = cache_path(key)?.with_extension("lock"); let dir = path.parent().unwrap(); fs::create_dir_all(dir) .map_err(|e| format!("Failed to create cache directory {}: {e}", dir.display()))?; @@ -82,8 +91,8 @@ pub fn lock(role_arn: &str) -> Result { } /// Write credentials to a cache file. Returns the file path as a string. -fn write_credentials_file(role_arn: &str, creds: &CacheEntry) -> Result { - let path = cache_path(role_arn)?; +fn write_credentials_file(key: &str, creds: &CacheEntry) -> Result { + let path = cache_path(key)?; let dir = path.parent().unwrap(); fs::create_dir_all(dir) @@ -106,8 +115,8 @@ fn write_credentials_file(role_arn: &str, creds: &CacheEntry) -> Result Result, String> { - let path = cache_path(role_arn)?; +fn read_credentials_file(key: &str) -> Result, String> { + let path = cache_path(key)?; match fs::read_to_string(&path) { Ok(contents) => { let creds: CacheEntry = serde_json::from_str(&contents) @@ -124,11 +133,11 @@ fn read_credentials_file(role_arn: &str) -> Result, String> { /// Write credentials, trying the OS keyring first with file fallback. /// Returns a human-readable description of where credentials were stored. -pub fn write_credentials(role_arn: &str, creds: &CacheEntry) -> Result { +pub fn write_credentials(key: &str, creds: &CacheEntry) -> Result { let json = serde_json::to_string(creds) .map_err(|e| format!("Failed to serialize credentials: {e}"))?; - let entry = keyring::Entry::new(KEYRING_SERVICE, role_arn) + let entry = keyring::Entry::new(KEYRING_SERVICE, key) .map_err(|e| format!("Failed to create keyring entry: {e}")); if let Ok(entry) = entry { @@ -145,13 +154,13 @@ pub fn write_credentials(role_arn: &str, creds: &CacheEntry) -> Result Result, String> { - let entry = keyring::Entry::new(KEYRING_SERVICE, role_arn) +pub fn read_credentials(key: &str) -> Result, String> { + let entry = keyring::Entry::new(KEYRING_SERVICE, key) .map_err(|e| format!("Failed to create keyring entry: {e}")); if let Ok(entry) = entry { @@ -173,7 +182,7 @@ pub fn read_credentials(role_arn: &str) -> Result, String> { } } - read_credentials_file(role_arn) + read_credentials_file(key) } /// Check if credentials are expired or will expire within a 60-second buffer. @@ -204,22 +213,34 @@ mod tests { } } + #[test] + fn key_separates_environments() { + assert_eq!( + key("https://data.source.coop", "_default"), + "data.source.coop/_default" + ); + assert_eq!( + key("https://data.staging.source.coop/", "_default"), + "data.staging.source.coop/_default" + ); + } + #[test] fn sanitize_simple_name() { - assert_eq!(sanitize_role_arn("source-coop-user"), "source-coop-user"); + assert_eq!(sanitize_key("source-coop-user"), "source-coop-user"); } #[test] fn sanitize_arn_with_special_chars() { assert_eq!( - sanitize_role_arn("arn:aws:iam::123:role/Foo"), + sanitize_key("arn:aws:iam::123:role/Foo"), "arn_aws_iam__123_role_Foo" ); } #[test] fn sanitize_preserves_underscores() { - assert_eq!(sanitize_role_arn("my_role-name"), "my_role-name"); + assert_eq!(sanitize_key("my_role-name"), "my_role-name"); } #[test] diff --git a/src/main.rs b/src/main.rs index 6516a00..594cf59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,6 +107,10 @@ struct LoginArgs { #[derive(Parser)] struct CredsArgs { + /// S3 proxy URL the credentials were issued by (selects the cache entry) + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, + /// Role ARN to read cached credentials for #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] role_arn: String, @@ -189,6 +193,7 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { "No refresh token issued; run 'source-coop login' again when credentials expire." ); } + let key = cache::key(&args.proxy_url, &args.role_arn); let entry = cache::CacheEntry { creds, refresh: tokens @@ -201,7 +206,7 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { duration: args.duration, }), }; - let location = cache::write_credentials(&args.role_arn, &entry)?; + let location = cache::write_credentials(&key, &entry)?; eprintln!("Credentials cached to {location}"); eprintln!("Run 'source-coop creds' to print them."); } @@ -211,7 +216,8 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { const NOT_FOUND: &str = "No cached credentials found. Run 'source-coop login' first."; - let mut entry = cache::read_credentials(&args.role_arn)?.ok_or(NOT_FOUND)?; + let key = cache::key(&args.proxy_url, &args.role_arn); + let mut entry = cache::read_credentials(&key)?.ok_or(NOT_FOUND)?; if cache::is_expired(&entry.creds)? { if entry.refresh.is_none() { @@ -220,11 +226,10 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { ); } // Re-read under the lock: another process may have refreshed already. - let _lock = cache::lock(&args.role_arn)?; - entry = cache::read_credentials(&args.role_arn)?.ok_or(NOT_FOUND)?; + let _lock = cache::lock(&key)?; + entry = cache::read_credentials(&key)?.ok_or(NOT_FOUND)?; if cache::is_expired(&entry.creds)? { - let save = - |e: &cache::CacheEntry| cache::write_credentials(&args.role_arn, e).map(drop); + let save = |e: &cache::CacheEntry| cache::write_credentials(&key, e).map(drop); entry = refresh(&args.role_arn, entry, verbose, save).await.map_err(|e| { format!("Cached credentials have expired and refresh failed ({e}). Run 'source-coop login'.") })?; From aa7c9378ff35b7322ecca803be8dffde5d344dc3 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 30 Sep 2026 11:31:10 -0700 Subject: [PATCH 2/6] fix: keep bare role cache key for the production proxy Existing prod caches (keyed by role ARN alone) stay readable, so upgrading needs no re-login. Only non-production proxies get the host prefix. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- src/cache.rs | 21 ++++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a748698..feb2455 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The CLI caches temporary STS credentials so that `creds` can output them without ### OS keyring (default) -Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by proxy host and role ARN (e.g. `data.source.coop/_default`): +Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN (e.g. `_default`), prefixed with the proxy host for non-production proxies (e.g. `data.staging.source.coop/_default`): | Platform | Backend | |----------|---------| @@ -210,8 +210,8 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Platform | Path | |----------|------| -| macOS | `~/Library/Caches/source-coop/credentials/_.json` | -| Linux | `~/.cache/source-coop/credentials/_.json` | +| macOS | `~/Library/Caches/source-coop/credentials/.json` (non-production: `_.json`) | +| Linux | `~/.cache/source-coop/credentials/.json` (non-production: `_.json`) | | Windows | `%LocalAppData%\source-coop\credentials\.json` | The fallback is automatic — no configuration is needed. diff --git a/src/cache.rs b/src/cache.rs index 0bb7759..9ced128 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -39,14 +39,23 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { ) } -/// Cache key for a role at a given proxy, e.g. `data.source.coop/_default`, so -/// logins against different environments (prod, staging) don't overwrite each other. +/// Production proxy host. Its entries keep the bare role ARN as the cache key, +/// so caches written before keys included the host remain readable. +const PROD_HOST: &str = "data.source.coop"; + +/// Cache key for a role at a given proxy, so logins against different +/// environments (prod, staging) don't overwrite each other: `_default` for +/// production, `data.staging.source.coop/_default` for any other proxy. pub fn key(proxy_url: &str, role_arn: &str) -> String { let host = proxy_url .trim_start_matches("https://") .trim_start_matches("http://") .trim_end_matches('/'); - format!("{host}/{role_arn}") + if host == PROD_HOST { + role_arn.to_string() + } else { + format!("{host}/{role_arn}") + } } /// Replace any character that isn't alphanumeric, `-`, or `_` with `_`. @@ -215,10 +224,8 @@ mod tests { #[test] fn key_separates_environments() { - assert_eq!( - key("https://data.source.coop", "_default"), - "data.source.coop/_default" - ); + assert_eq!(key("https://data.source.coop", "_default"), "_default"); + assert_eq!(key("https://data.source.coop/", "_default"), "_default"); assert_eq!( key("https://data.staging.source.coop/", "_default"), "data.staging.source.coop/_default" From 642d931d467796b02aea847db1e3bc380d483822 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 13 Oct 2026 10:28:58 -0700 Subject: [PATCH 3/6] feat: give each proxy environment its own login session and cache Key the login session by environment as well as role credentials, so a staging login no longer replaces production's session. Normalize proxy URLs with url::Url, keep non-default environments in their own cache directory, ignore cached entries minted at another environment's proxy, and add --proxy-url to 'auth token'. Co-Authored-By: Claude Opus 5.5 --- .env.staging | 1 + README.md | 14 ++-- src/cache.rs | 218 ++++++++++++++++++++++++++++++++++--------------- src/main.rs | 46 +++++++---- src/session.rs | 15 ++-- 5 files changed, 197 insertions(+), 97 deletions(-) diff --git a/.env.staging b/.env.staging index 4524918..448c8c6 100644 --- a/.env.staging +++ b/.env.staging @@ -11,3 +11,4 @@ export SOURCE_OIDC_ISSUER=https://auth.staging.source.coop export SOURCE_OIDC_CLIENT_ID=a79c9537-be78-454a-9ea1-b96a1be811cc export SOURCE_PROXY_URL=https://data.staging.source.coop +export SOURCE_API_AUDIENCE=https://staging.source.coop diff --git a/README.md b/README.md index 6ae228a..c15be66 100644 --- a/README.md +++ b/README.md @@ -127,14 +127,14 @@ endpoint_url = https://data.source.coop ### Multiple environments (e.g. staging) -Credentials are cached per proxy URL, so production and staging logins live side by side: +Each proxy host is its own environment, with its own login session and credentials, so production and staging logins live side by side: ```bash source-coop login ( source .env.staging && source-coop login ) ``` -`creds` reads `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment: +`creds` and `auth token` read `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment: ```ini [profile source-coop] @@ -152,7 +152,7 @@ endpoint_url = https://data.staging.source.coop |------|---------|---------|-------------| | `--issuer` | `SOURCE_OIDC_ISSUER` | `https://auth.source.coop` | OIDC issuer URL | | `--client-id` | `SOURCE_OIDC_CLIENT_ID` | `d037d00b-...` | OAuth2 client ID | -| `--proxy-url` | `SOURCE_PROXY_URL` | `https://data.source.coop` | S3 proxy URL for STS | +| `--proxy-url` | `SOURCE_PROXY_URL` | `https://data.source.coop` | S3 proxy URL for STS; also selects the environment the login is cached under | | `--role-arn` | `SOURCE_ROLE_ARN` | `source-coop-user` | Role ARN to assume | | `--format` | | `credential-process` | Output format: `credential-process`, `env`, or `aws-credentials` | | `--profile` | | `source-coop` | Profile name for `--format aws-credentials` | @@ -203,7 +203,7 @@ The CLI caches the login session (the Ory refresh, ID and access tokens) and the ### OS keyring (default) -Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN (e.g. `_default`), prefixed with the proxy host for non-production proxies (e.g. `data.staging.source.coop/_default`), with the session under `@session`: +Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN (e.g. `_default`), with the session under `@session`. Other environments prefix both with their proxy host (e.g. `data.staging.source.coop/_default`, `data.staging.source.coop/@session`): | Platform | Backend | |----------|---------| @@ -217,11 +217,11 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Platform | Path | |----------|------| -| macOS | `~/Library/Caches/source-coop/credentials/.json` (non-production: `_.json`) | -| Linux | `~/.cache/source-coop/credentials/.json` (non-production: `_.json`) | +| macOS | `~/Library/Caches/source-coop/credentials/.json` | +| Linux | `~/.cache/source-coop/credentials/.json` | | Windows | `%LocalAppData%\source-coop\credentials\.json` | -The session sits beside them, at `source-coop/session.json`. +The session sits beside them, at `source-coop/session.json`. Other environments keep the same layout under `source-coop/env//` (e.g. `source-coop/env/data.staging.source.coop/session.json`). The fallback is automatic — no configuration is needed. diff --git a/src/cache.rs b/src/cache.rs index 917ebe9..0655c09 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -25,6 +25,19 @@ pub struct CacheEntry { pub refresh: Option, } +impl CacheEntry { + /// Whether this entry was minted at a proxy in `env`. Bare credentials + /// record no proxy and can't be renewed, so they're taken as they are. + fn belongs_to(&self, env: Option<&str>) -> bool { + let proxy_url = match (&self.sts, &self.refresh) { + (Some(sts), _) => &sts.proxy_url, + (None, Some(refresh)) => &refresh.proxy_url, + (None, None) => return true, + }; + self::env(proxy_url).is_ok_and(|e| e.as_deref() == env) + } +} + #[derive(Serialize, Deserialize, Clone)] pub struct StsSettings { pub proxy_url: String, @@ -47,25 +60,72 @@ struct Slot { path: PathBuf, } +/// The OS-idiomatic cache directory (`~/Library/Caches` on macOS, `~/.cache` +/// on Linux, `%LocalAppData%` on Windows). fn cache_dir() -> Result { Ok(dirs::cache_dir() .ok_or("Could not determine cache directory")? .join("source-coop")) } -fn role_slot(key: &str) -> Result { +/// The environment a proxy URL belongs to: `None` for this build's default +/// proxy, whose slots keep the names they had before environments existed, +/// otherwise the proxy's host (and port, if not the scheme's default). +pub fn env(proxy_url: &str) -> Result, String> { + let host = host(proxy_url)?; + Ok((host != host_of_default()).then_some(host)) +} + +fn host(proxy_url: &str) -> Result { + let url = + url::Url::parse(proxy_url).map_err(|e| format!("Invalid proxy URL {proxy_url}: {e}"))?; + let host = url + .host_str() + .ok_or_else(|| format!("Proxy URL {proxy_url} has no host"))?; + Ok(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }) +} + +fn host_of_default() -> String { + host(crate::defaults::PROXY_URL).expect("default proxy URL is valid") +} + +/// The keyring account for `name` in `env`. Hosts can't contain `/`, so a +/// prefixed account never equals a default-environment one. +fn account(env: Option<&str>, name: &str) -> String { + match env { + None => name.to_string(), + Some(env) => format!("{env}/{name}"), + } +} + +/// The directory `env`'s cache files live in. Each non-default environment +/// gets its own, so their files can't collide with each other's or prod's. +fn env_dir(env: Option<&str>) -> Result { + let dir = cache_dir()?; + Ok(match env { + None => dir, + Some(env) => dir.join("env").join(sanitize(env, &['-', '.'])), + }) +} + +fn role_slot(env: Option<&str>, role_arn: &str) -> Result { Ok(Slot { - account: key.to_string(), - path: cache_path(key)?, + account: account(env, role_arn), + path: env_dir(env)? + .join("credentials") + .join(format!("{}.json", sanitize(role_arn, &['-', '_']))), }) } /// The login session's slot. `@` can't begin a role ARN or role name, so its /// keyring account never collides with a role's. -fn session_slot() -> Result { +fn session_slot(env: Option<&str>) -> Result { Ok(Slot { - account: "@session".to_string(), - path: cache_dir()?.join("session.json"), + account: account(env, "@session"), + path: env_dir(env)?.join("session.json"), }) } @@ -81,30 +141,11 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { ) } -/// Production proxy host. Its entries keep the bare role ARN as the cache key, -/// so caches written before keys included the host remain readable. -const PROD_HOST: &str = "data.source.coop"; - -/// Cache key for a role at a given proxy, so logins against different -/// environments (prod, staging) don't overwrite each other: `_default` for -/// production, `data.staging.source.coop/_default` for any other proxy. -pub fn key(proxy_url: &str, role_arn: &str) -> String { - let host = proxy_url - .trim_start_matches("https://") - .trim_start_matches("http://") - .trim_end_matches('/'); - if host == PROD_HOST { - role_arn.to_string() - } else { - format!("{host}/{role_arn}") - } -} - -/// Replace any character that isn't alphanumeric, `-`, or `_` with `_`. -fn sanitize_key(key: &str) -> String { - key.chars() +/// Replace any character that isn't alphanumeric or in `keep` with `_`. +fn sanitize(s: &str, keep: &[char]) -> String { + s.chars() .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + if c.is_ascii_alphanumeric() || keep.contains(&c) { c } else { '_' @@ -113,27 +154,17 @@ fn sanitize_key(key: &str) -> String { .collect() } -/// Full path to the credentials cache file for a given role. -/// Uses the OS-idiomatic cache directory (`~/Library/Caches` on macOS, -/// `~/.cache` on Linux, `%LocalAppData%` on Windows). -fn cache_path(key: &str) -> Result { - let sanitized = sanitize_key(key); - Ok(cache_dir()? - .join("credentials") - .join(format!("{sanitized}.json"))) -} - /// Take an exclusive per-role lock, held until the returned file is dropped, /// so concurrent `creds` calls for one role mint once. -pub fn lock(key: &str) -> Result { - lock_slot(&role_slot(key)?) +pub fn lock(env: Option<&str>, role_arn: &str) -> Result { + lock_slot(&role_slot(env, role_arn)?) } /// Take the login session's lock. Refresh tokens rotate on use, and the IdP /// may revoke the whole token family if an old one is replayed, so nothing may /// refresh the session in parallel. Taken after a role's lock, never before. -pub fn lock_session() -> Result { - lock_slot(&session_slot()?) +pub fn lock_session(env: Option<&str>) -> Result { + lock_slot(&session_slot(env)?) } fn lock_slot(slot: &Slot) -> Result { @@ -150,22 +181,29 @@ fn lock_slot(slot: &Slot) -> Result { /// Write credentials, trying the OS keyring first with file fallback. /// Returns a human-readable description of where credentials were stored. -pub fn write_credentials(key: &str, creds: &CacheEntry) -> Result { - write_slot(&role_slot(key)?, creds) +pub fn write_credentials( + env: Option<&str>, + role_arn: &str, + creds: &CacheEntry, +) -> Result { + write_slot(&role_slot(env, role_arn)?, creds) } /// Read credentials, trying the OS keyring first with file fallback. -/// Returns `None` if no cached credentials are found in either location. -pub fn read_credentials(key: &str) -> Result, String> { - read_slot(&role_slot(key)?) +/// Returns `None` if no cached credentials are found in either location, or +/// if they were minted at another environment's proxy: versions before +/// environments existed cached every proxy's credentials in the default slot. +pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result, String> { + let entry: Option = read_slot(&role_slot(env, role_arn)?)?; + Ok(entry.filter(|e| e.belongs_to(env))) } -pub fn write_session(session: &Session) -> Result { - write_slot(&session_slot()?, session) +pub fn write_session(env: Option<&str>, session: &Session) -> Result { + write_slot(&session_slot(env)?, session) } -pub fn read_session() -> Result, String> { - read_slot(&session_slot()?) +pub fn read_session(env: Option<&str>) -> Result, String> { + read_slot(&session_slot(env)?) } fn write_slot(slot: &Slot, value: &T) -> Result { @@ -250,31 +288,81 @@ mod tests { } #[test] - fn key_separates_environments() { - assert_eq!(key("https://data.source.coop", "_default"), "_default"); - assert_eq!(key("https://data.source.coop/", "_default"), "_default"); + fn env_normalizes_proxy_urls() { + let default = crate::defaults::PROXY_URL; + assert_eq!(env(default).unwrap(), None); + assert_eq!(env(&format!("{default}/")).unwrap(), None); + assert_eq!(env(&default.to_uppercase()).unwrap(), None); + assert_eq!(env(&format!("{default}:443")).unwrap(), None); + assert_eq!( + env("https://Data.Example.com/").unwrap().as_deref(), + Some("data.example.com") + ); + assert_eq!( + env("http://localhost:8080").unwrap().as_deref(), + Some("localhost:8080") + ); + assert!(env("not a url").is_err()); + } + + #[test] + fn slots_never_collide_across_environments() { + let default = cache_dir().unwrap(); + let prod = role_slot(None, "_default").unwrap(); + assert_eq!(prod.account, "_default"); + assert_eq!(prod.path, default.join("credentials").join("_default.json")); + assert_eq!(session_slot(None).unwrap().account, "@session"); + + // A role whose sanitized name looks like an environment's still lands elsewhere. + let local = role_slot(Some("localhost:8080"), "_default").unwrap(); + let lookalike = role_slot(None, "localhost_8080__default").unwrap(); + assert_eq!(local.account, "localhost:8080/_default"); assert_eq!( - key("https://data.staging.source.coop/", "_default"), - "data.staging.source.coop/_default" + local.path, + default + .join("env") + .join("localhost_8080") + .join("credentials") + .join("_default.json") + ); + assert_ne!(local.path, lookalike.path); + + let staging = session_slot(Some("data.staging.source.coop")).unwrap(); + assert_eq!(staging.account, "data.staging.source.coop/@session"); + assert_eq!( + staging.path, + default + .join("env") + .join("data.staging.source.coop") + .join("session.json") ); } #[test] - fn sanitize_simple_name() { - assert_eq!(sanitize_key("source-coop-user"), "source-coop-user"); + fn entries_belong_to_their_proxys_environment() { + let entry = |proxy_url: &str| CacheEntry { + creds: sample_creds("2026-03-01T00:00:00Z"), + sts: Some(StsSettings { + proxy_url: proxy_url.to_string(), + duration: None, + }), + refresh: None, + }; + let other = entry("https://data.example.com"); + assert!(other.belongs_to(Some("data.example.com"))); + assert!(!other.belongs_to(None)); + assert!(entry(crate::defaults::PROXY_URL).belongs_to(None)); + let bare = CacheEntry { sts: None, ..other }; + assert!(bare.belongs_to(None)); } #[test] fn sanitize_arn_with_special_chars() { assert_eq!( - sanitize_key("arn:aws:iam::123:role/Foo"), + sanitize("arn:aws:iam::123:role/Foo", &['-', '_']), "arn_aws_iam__123_role_Foo" ); - } - - #[test] - fn sanitize_preserves_underscores() { - assert_eq!(sanitize_key("my_role-name"), "my_role-name"); + assert_eq!(sanitize("my_role-name", &['-', '_']), "my_role-name"); } #[test] diff --git a/src/main.rs b/src/main.rs index 6fbf1d9..5e57cef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,7 +71,14 @@ enum Commands { enum AuthCommand { /// Print an access token for the source.coop API, refreshing it if expired /// (e.g. `curl -H "Authorization: Bearer $(source-coop auth token)" ...`) - Token, + Token(TokenArgs), +} + +#[derive(Parser)] +struct TokenArgs { + /// S3 proxy URL of the environment whose login session to use + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, } #[derive(Parser)] @@ -129,7 +136,7 @@ struct CredsArgs { #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] role_arn: String, - /// S3 proxy URL for STS, for a role with nothing cached yet + /// S3 proxy URL: selects the environment whose cache and login session to use #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] proxy_url: String, @@ -171,8 +178,8 @@ async fn main() { std::process::exit(1); } } - Commands::Auth(AuthCommand::Token) => { - if let Err(e) = run_auth_token(verbose).await { + Commands::Auth(AuthCommand::Token(args)) => { + if let Err(e) = run_auth_token(args, verbose).await { eprintln!("Error: {e}"); std::process::exit(1); } @@ -180,8 +187,9 @@ async fn main() { } } -async fn run_auth_token(verbose: bool) -> Result<(), String> { - let token = session::token(session::Want::Access, verbose) +async fn run_auth_token(args: TokenArgs, verbose: bool) -> Result<(), String> { + let env = cache::env(&args.proxy_url)?; + let token = session::token(env.as_deref(), session::Want::Access, verbose) .await? .ok_or("No access token cached. Run 'source-coop login' (with an API audience).")?; println!("{token}"); @@ -189,6 +197,8 @@ async fn run_auth_token(verbose: bool) -> Result<(), String> { } async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { + let env = cache::env(&args.proxy_url)?; + // 1. OIDC Discovery eprintln!("Discovering OIDC endpoints..."); let endpoints = oidc::discover(&args.issuer, verbose).await?; @@ -249,10 +259,9 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { chrono::Utc::now().timestamp(), ); { - let _lock = cache::lock_session()?; - cache::write_session(&session)?; + let _lock = cache::lock_session(env.as_deref())?; + cache::write_session(env.as_deref(), &session)?; } - let key = cache::key(&args.proxy_url, &args.role_arn); let entry = cache::CacheEntry { creds, sts: Some(cache::StsSettings { @@ -261,23 +270,24 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { }), refresh: None, }; - let location = cache::write_credentials(&key, &entry)?; + let location = cache::write_credentials(env.as_deref(), &args.role_arn, &entry)?; eprintln!("Credentials cached to {location}"); eprintln!("Run 'source-coop creds' to print them."); Ok(()) } async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { - let key = cache::key(&args.proxy_url, &args.role_arn); - let cached = cache::read_credentials(&key)?; + let env = cache::env(&args.proxy_url)?; + let env = env.as_deref(); + let cached = cache::read_credentials(env, &args.role_arn)?; let entry = match cached { Some(entry) if !cache::is_expired(&entry.creds)? => entry, _ => { // Re-check under the lock: another process may have minted already. - let _lock = cache::lock(&key)?; - match cache::read_credentials(&key)? { + let _lock = cache::lock(env, &args.role_arn)?; + match cache::read_credentials(env, &args.role_arn)? { Some(entry) if !cache::is_expired(&entry.creds)? => entry, - stale => mint(&args, stale, verbose).await.map_err(|e| { + stale => mint(&args, env, stale, verbose).await.map_err(|e| { format!("Couldn't get new credentials ({e}). Run 'source-coop login'.") })?, } @@ -297,11 +307,11 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { /// else exchanges the session's ID token, refreshing the session if need be. async fn mint( args: &CredsArgs, + env: Option<&str>, stale: Option, verbose: bool, ) -> Result { - let key = cache::key(&args.proxy_url, &args.role_arn); - let save = |e: &cache::CacheEntry| cache::write_credentials(&key, e).map(drop); + let save = |e: &cache::CacheEntry| cache::write_credentials(env, &args.role_arn, e).map(drop); match stale { Some(entry) if entry.sts.is_none() && entry.refresh.is_some() => { refresh(&args.role_arn, entry, verbose, save).await @@ -311,7 +321,7 @@ async fn mint( proxy_url: args.proxy_url.clone(), duration: None, }); - let id_token = session::token(session::Want::Id, verbose) + let id_token = session::token(env, session::Want::Id, verbose) .await? .ok_or("not logged in")?; let creds = sts::assume_role( diff --git a/src/session.rs b/src/session.rs index f349aca..8753316 100644 --- a/src/session.rs +++ b/src/session.rs @@ -143,22 +143,23 @@ pub async fn ensure( .map_err(|()| "The refreshed token has already expired; check the system clock.".into()) } -/// The wanted token from the cached session, or `None` if there is no session -/// (or it can't have that token). Refreshes under the session lock when needed. -pub async fn token(want: Want, verbose: bool) -> Result, String> { - let Some(session) = cache::read_session()? else { +/// The wanted token from `env`'s cached session, or `None` if there is no +/// session (or it can't have that token). Refreshes under the session lock +/// when needed. +pub async fn token(env: Option<&str>, want: Want, verbose: bool) -> Result, String> { + let Some(session) = cache::read_session(env)? else { return Ok(None); }; if let Ok(token) = session.current(want, now()) { return Ok(token.map(String::from)); } // Re-read under the lock: another process may have refreshed already. - let _lock = cache::lock_session()?; - let Some(mut session) = cache::read_session()? else { + let _lock = cache::lock_session(env)?; + let Some(mut session) = cache::read_session(env)? else { return Ok(None); }; ensure(&mut session, want, now(), verbose, |s| { - cache::write_session(s).map(drop) + cache::write_session(env, s).map(drop) }) .await } From 1e8c8d0ac7a3cac05dfeb1b4c2ac461fa3356d37 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 13 Oct 2026 10:41:02 -0700 Subject: [PATCH 4/6] fix: pin the bare cache slots to production and check sessions' environment - The bare (pre-environment) slots now always mean data.source.coop, so staging and production builds sharing the keyring can't read each other's caches. - Sessions record their proxy and are ignored under another environment. - An environment with nothing cached adopts its entry from the production slot, where versions before environments stored every proxy's. - Ports get their own cache-directory segment, since '_' is legal in hosts. - login writes credentials under the role lock. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- src/cache.rs | 111 +++++++++++++++++++++++++++++++------------------ src/main.rs | 4 ++ src/session.rs | 7 ++++ 4 files changed, 84 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index c15be66..c3f8b33 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ source-coop login ( source .env.staging && source-coop login ) ``` -`creds` and `auth token` read `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment: +`creds` and `auth token` read `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment, and without either they use production. A non-production profile must pass `--proxy-url`; one that relied on whichever environment was logged in last will now get production credentials: ```ini [profile source-coop] @@ -221,7 +221,7 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Linux | `~/.cache/source-coop/credentials/.json` | | Windows | `%LocalAppData%\source-coop\credentials\.json` | -The session sits beside them, at `source-coop/session.json`. Other environments keep the same layout under `source-coop/env//` (e.g. `source-coop/env/data.staging.source.coop/session.json`). +The session sits beside them, at `source-coop/session.json`. Other environments keep the same layout under `source-coop/env//`, or `source-coop/env///` for a non-default port (e.g. `source-coop/env/data.staging.source.coop/session.json`). The fallback is automatic — no configuration is needed. diff --git a/src/cache.rs b/src/cache.rs index 0655c09..ac7c208 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -34,10 +34,15 @@ impl CacheEntry { (None, Some(refresh)) => &refresh.proxy_url, (None, None) => return true, }; - self::env(proxy_url).is_ok_and(|e| e.as_deref() == env) + in_env(proxy_url, env) } } +/// Whether `proxy_url` is in `env`. An unparseable URL is in none. +fn in_env(proxy_url: &str, env: Option<&str>) -> bool { + self::env(proxy_url).is_ok_and(|e| e.as_deref() == env) +} + #[derive(Serialize, Deserialize, Clone)] pub struct StsSettings { pub proxy_url: String, @@ -68,30 +73,28 @@ fn cache_dir() -> Result { .join("source-coop")) } -/// The environment a proxy URL belongs to: `None` for this build's default -/// proxy, whose slots keep the names they had before environments existed, -/// otherwise the proxy's host (and port, if not the scheme's default). -pub fn env(proxy_url: &str) -> Result, String> { - let host = host(proxy_url)?; - Ok((host != host_of_default()).then_some(host)) -} +/// The production proxy's host. Its slots keep the names they had before +/// environments existed, in every build: tying them to the build's default +/// proxy would let a staging build and a production build, which share the +/// keyring, read each other's caches as their own. +const PROD_HOST: &str = "data.source.coop"; -fn host(proxy_url: &str) -> Result { - let url = - url::Url::parse(proxy_url).map_err(|e| format!("Invalid proxy URL {proxy_url}: {e}"))?; +/// The environment a proxy URL belongs to: `None` for production, otherwise +/// the proxy's host (and port, if not the scheme's default). +pub fn env(proxy_url: &str) -> Result, String> { + let url = url::Url::parse(proxy_url).map_err(|e| { + format!("Invalid proxy URL {proxy_url:?} (from --proxy-url or SOURCE_PROXY_URL): {e}") + })?; let host = url .host_str() - .ok_or_else(|| format!("Proxy URL {proxy_url} has no host"))?; + .ok_or_else(|| format!("Proxy URL {proxy_url:?} has no host"))?; Ok(match url.port() { - Some(port) => format!("{host}:{port}"), - None => host.to_string(), + Some(port) => Some(format!("{host}:{port}")), + None if host == PROD_HOST => None, + None => Some(host.to_string()), }) } -fn host_of_default() -> String { - host(crate::defaults::PROXY_URL).expect("default proxy URL is valid") -} - /// The keyring account for `name` in `env`. Hosts can't contain `/`, so a /// prefixed account never equals a default-environment one. fn account(env: Option<&str>, name: &str) -> String { @@ -101,14 +104,19 @@ fn account(env: Option<&str>, name: &str) -> String { } } -/// The directory `env`'s cache files live in. Each non-default environment -/// gets its own, so their files can't collide with each other's or prod's. +/// The directory `env`'s cache files live in: `env/[/]`. Each +/// non-production environment gets its own, so their files can't collide with +/// each other's or prod's. The port is its own segment because `_`, which +/// sanitizing produces, is allowed in hosts. fn env_dir(env: Option<&str>) -> Result { let dir = cache_dir()?; - Ok(match env { - None => dir, - Some(env) => dir.join("env").join(sanitize(env, &['-', '.'])), - }) + let Some(env) = env else { return Ok(dir) }; + let (host, port) = env + .rsplit_once(':') + .filter(|(_, port)| port.bytes().all(|b| b.is_ascii_digit())) + .unwrap_or((env, "")); + let dir = dir.join("env").join(sanitize(host, &['-', '.', '_'])); + Ok(if port.is_empty() { dir } else { dir.join(port) }) } fn role_slot(env: Option<&str>, role_arn: &str) -> Result { @@ -191,19 +199,34 @@ pub fn write_credentials( /// Read credentials, trying the OS keyring first with file fallback. /// Returns `None` if no cached credentials are found in either location, or -/// if they were minted at another environment's proxy: versions before -/// environments existed cached every proxy's credentials in the default slot. +/// if they were minted at another environment's proxy. +/// +/// Versions before environments existed cached every proxy's credentials in +/// the production slot, so an environment with nothing of its own takes its +/// entry from there, copying it into its own slot. pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result, String> { - let entry: Option = read_slot(&role_slot(env, role_arn)?)?; - Ok(entry.filter(|e| e.belongs_to(env))) + let slot = role_slot(env, role_arn)?; + let entry: Option = read_slot(&slot)?; + if entry.is_some() || env.is_none() { + return Ok(entry.filter(|e| e.belongs_to(env))); + } + let legacy: Option = read_slot(&role_slot(None, role_arn)?)?; + let Some(legacy) = legacy.filter(|e| e.belongs_to(env)) else { + return Ok(None); + }; + write_slot(&slot, &legacy)?; + Ok(Some(legacy)) } pub fn write_session(env: Option<&str>, session: &Session) -> Result { write_slot(&session_slot(env)?, session) } +/// Read `env`'s login session, or `None` if there is none or it belongs to +/// another environment's proxy. pub fn read_session(env: Option<&str>) -> Result, String> { - read_slot(&session_slot(env)?) + let session: Option = read_slot(&session_slot(env)?)?; + Ok(session.filter(|s| s.proxy_url.as_deref().is_none_or(|p| in_env(p, env)))) } fn write_slot(slot: &Slot, value: &T) -> Result { @@ -289,14 +312,18 @@ mod tests { #[test] fn env_normalizes_proxy_urls() { - let default = crate::defaults::PROXY_URL; - assert_eq!(env(default).unwrap(), None); - assert_eq!(env(&format!("{default}/")).unwrap(), None); - assert_eq!(env(&default.to_uppercase()).unwrap(), None); - assert_eq!(env(&format!("{default}:443")).unwrap(), None); + // Production is the bare environment in every build, staging never is. + assert_eq!(env("https://data.source.coop").unwrap(), None); + assert_eq!(env("https://data.source.coop/").unwrap(), None); + assert_eq!(env("HTTPS://DATA.SOURCE.COOP").unwrap(), None); + assert_eq!(env("https://data.source.coop:443").unwrap(), None); + assert_eq!( + env("https://Data.Staging.Source.Coop/").unwrap().as_deref(), + Some("data.staging.source.coop") + ); assert_eq!( - env("https://Data.Example.com/").unwrap().as_deref(), - Some("data.example.com") + env("https://data.source.coop:8443").unwrap().as_deref(), + Some("data.source.coop:8443") ); assert_eq!( env("http://localhost:8080").unwrap().as_deref(), @@ -321,11 +348,15 @@ mod tests { local.path, default .join("env") - .join("localhost_8080") + .join("localhost") + .join("8080") .join("credentials") .join("_default.json") ); assert_ne!(local.path, lookalike.path); + // `_` is legal in hosts, so the port can't be joined to the host with one. + let underscored = role_slot(Some("localhost_8080"), "_default").unwrap(); + assert_ne!(local.path, underscored.path); let staging = session_slot(Some("data.staging.source.coop")).unwrap(); assert_eq!(staging.account, "data.staging.source.coop/@session"); @@ -348,10 +379,10 @@ mod tests { }), refresh: None, }; - let other = entry("https://data.example.com"); - assert!(other.belongs_to(Some("data.example.com"))); + let other = entry("https://data.staging.source.coop"); + assert!(other.belongs_to(Some("data.staging.source.coop"))); assert!(!other.belongs_to(None)); - assert!(entry(crate::defaults::PROXY_URL).belongs_to(None)); + assert!(entry("https://data.source.coop").belongs_to(None)); let bare = CacheEntry { sts: None, ..other }; assert!(bare.belongs_to(None)); } diff --git a/src/main.rs b/src/main.rs index 5e57cef..4612ecb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -253,6 +253,7 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { } let session = session::Session::new( tokens, + args.proxy_url.clone(), args.client_id, endpoints.token_endpoint, audience.map(String::from), @@ -270,6 +271,9 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { }), refresh: None, }; + // Under the role's lock, so a `creds` minting from the old session can't + // overwrite this entry afterwards. + let _lock = cache::lock(env.as_deref(), &args.role_arn)?; let location = cache::write_credentials(env.as_deref(), &args.role_arn, &entry)?; eprintln!("Credentials cached to {location}"); eprintln!("Run 'source-coop creds' to print them."); diff --git a/src/session.rs b/src/session.rs index 8753316..ffcd5a2 100644 --- a/src/session.rs +++ b/src/session.rs @@ -18,6 +18,10 @@ const SKEW_SECONDS: i64 = 60; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { + /// The proxy `login` was given, which names the session's environment. + /// Absent in sessions written before environments existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_url: Option, pub client_id: String, pub token_endpoint: String, /// What the access token was requested for, if anything. @@ -45,12 +49,14 @@ pub enum Want { impl Session { pub fn new( tokens: oidc::Tokens, + proxy_url: String, client_id: String, token_endpoint: String, audience: Option, now: i64, ) -> Self { let mut session = Session { + proxy_url: Some(proxy_url), client_id, token_endpoint, audience, @@ -191,6 +197,7 @@ mod tests { fn session(server: &MockServer, t: oidc::Tokens) -> Session { Session::new( t, + "https://data.source.coop".into(), "cid".into(), format!("{}/oauth2/token", server.uri()), Some("https://source.coop".into()), From 30686be622f10988f4b47eb9a85a8b5f19c22a16 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 13 Oct 2026 13:35:36 -0700 Subject: [PATCH 5/6] fix: never migrate bare legacy credentials into another environment Bare entries record no proxy, so the ones in the production slot are production's; copying them into an empty staging slot served prod credentials as staging until they expired. Co-Authored-By: Claude Opus 5.5 --- src/cache.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index ac7c208..792393f 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -26,16 +26,22 @@ pub struct CacheEntry { } impl CacheEntry { - /// Whether this entry was minted at a proxy in `env`. Bare credentials - /// record no proxy and can't be renewed, so they're taken as they are. - fn belongs_to(&self, env: Option<&str>) -> bool { + /// Whether this entry records that it was minted at a proxy in `env`. + fn minted_in(&self, env: Option<&str>) -> bool { let proxy_url = match (&self.sts, &self.refresh) { (Some(sts), _) => &sts.proxy_url, (None, Some(refresh)) => &refresh.proxy_url, - (None, None) => return true, + (None, None) => return false, }; in_env(proxy_url, env) } + + /// Whether this entry can be used in its own slot in `env`. Bare + /// credentials record no proxy and can't be renewed, so they're taken as + /// they are. + fn belongs_to(&self, env: Option<&str>) -> bool { + (self.sts.is_none() && self.refresh.is_none()) || self.minted_in(env) + } } /// Whether `proxy_url` is in `env`. An unparseable URL is in none. @@ -211,7 +217,8 @@ pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result = read_slot(&role_slot(None, role_arn)?)?; - let Some(legacy) = legacy.filter(|e| e.belongs_to(env)) else { + // Only an entry that records its proxy: bare ones there are production's. + let Some(legacy) = legacy.filter(|e| e.minted_in(env)) else { return Ok(None); }; write_slot(&slot, &legacy)?; @@ -383,8 +390,11 @@ mod tests { assert!(other.belongs_to(Some("data.staging.source.coop"))); assert!(!other.belongs_to(None)); assert!(entry("https://data.source.coop").belongs_to(None)); + assert!(other.minted_in(Some("data.staging.source.coop"))); let bare = CacheEntry { sts: None, ..other }; assert!(bare.belongs_to(None)); + // A bare entry stays where it is: migration never hands it to another environment. + assert!(!bare.minted_in(Some("data.staging.source.coop"))); } #[test] From 972bcf21c03fc27ca4da39199921a060618a6462 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 13 Oct 2026 15:06:04 -0700 Subject: [PATCH 6/6] feat: cache role credentials in files, keep only the session in the keyring STS credentials are short-lived and re-minted from the login session, so they don't need the keyring. Keeping them out of it means a creds call with fresh credentials never touches the keyring, and macOS asks for the keychain password after each upgrade for one entry (the session) instead of one per role. Reads try the file first and fall back to the keyring, so credentials cached by earlier versions keep working. Entries that still carry their own refresh token (v0.3.0 and earlier) stay in the keyring. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++++--- src/cache.rs | 103 ++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 90 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c3f8b33..d065891 100644 --- a/README.md +++ b/README.md @@ -201,9 +201,9 @@ Use `--profile` to change the section name. The CLI caches the login session (the Ory refresh, ID and access tokens) and the temporary STS credentials for each role, so that `creds` and API commands work without re-authenticating. The refresh token is kept in the session only: it rotates on use, so every role and every API call refreshes through it, one at a time. -### OS keyring (default) +### Login session: OS keyring -Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN (e.g. `_default`), with the session under `@session`. Other environments prefix both with their proxy host (e.g. `data.staging.source.coop/_default`, `data.staging.source.coop/@session`): +The session, which holds the long-lived refresh token, is stored in the OS-native keyring under the service name `source-coop-cli` and the account `@session`. Other environments prefix it with their proxy host (e.g. `data.staging.source.coop/@session`): | Platform | Backend | |----------|---------| @@ -211,9 +211,11 @@ Credentials are stored in the OS-native keyring under the service name `source-c | Windows | Credential Manager | | Linux | Secret Service API (GNOME Keyring, KDE Wallet) via D-Bus | -### File fallback +When the OS keyring is unavailable (headless servers, containers, CI), the session falls back to a file, `source-coop/session.json` in the OS cache directory. The fallback is automatic; no configuration is needed. -When the OS keyring is unavailable (headless servers, containers, CI), the CLI falls back to JSON files in the OS cache directory with `0600` permissions on Unix: +### Role credentials: cache files + +Each role's STS credentials are short-lived and re-created from the session, so they're kept in JSON files in the OS cache directory (with `0600` permissions on Unix) rather than the keyring, much like the AWS CLI's own `~/.aws/cli/cache`. A `creds` call that finds fresh credentials doesn't touch the keyring at all, which matters on macOS: after every CLI upgrade, the Keychain asks for your password once for each entry it holds. | Platform | Path | |----------|------| @@ -221,9 +223,9 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Linux | `~/.cache/source-coop/credentials/.json` | | Windows | `%LocalAppData%\source-coop\credentials\.json` | -The session sits beside them, at `source-coop/session.json`. Other environments keep the same layout under `source-coop/env//`, or `source-coop/env///` for a non-default port (e.g. `source-coop/env/data.staging.source.coop/session.json`). +Other environments keep the same layout under `source-coop/env//`, or `source-coop/env///` for a non-default port (e.g. `source-coop/env/data.staging.source.coop/credentials/_default.json`). -The fallback is automatic — no configuration is needed. +Credentials cached in the keyring by earlier versions are still read, so upgrading doesn't log you out; the next `login` replaces them with a file. An entry from v0.3.0 or earlier that carries its own refresh token stays in the keyring until then. ## OIDC provider setup diff --git a/src/cache.rs b/src/cache.rs index 792393f..c6309b6 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -193,17 +193,17 @@ fn lock_slot(slot: &Slot) -> Result { Ok(file) } -/// Write credentials, trying the OS keyring first with file fallback. +/// Write credentials to their cache file (see `write_role`). /// Returns a human-readable description of where credentials were stored. pub fn write_credentials( env: Option<&str>, role_arn: &str, creds: &CacheEntry, ) -> Result { - write_slot(&role_slot(env, role_arn)?, creds) + write_role(&role_slot(env, role_arn)?, creds) } -/// Read credentials, trying the OS keyring first with file fallback. +/// Read credentials from their cache file, falling back to the keyring. /// Returns `None` if no cached credentials are found in either location, or /// if they were minted at another environment's proxy. /// @@ -212,16 +212,16 @@ pub fn write_credentials( /// entry from there, copying it into its own slot. pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result, String> { let slot = role_slot(env, role_arn)?; - let entry: Option = read_slot(&slot)?; + let entry = read_role(&slot)?; if entry.is_some() || env.is_none() { return Ok(entry.filter(|e| e.belongs_to(env))); } - let legacy: Option = read_slot(&role_slot(None, role_arn)?)?; + let legacy = read_role(&role_slot(None, role_arn)?)?; // Only an entry that records its proxy: bare ones there are production's. let Some(legacy) = legacy.filter(|e| e.minted_in(env)) else { return Ok(None); }; - write_slot(&slot, &legacy)?; + write_role(&slot, &legacy)?; Ok(Some(legacy)) } @@ -251,26 +251,60 @@ fn write_slot(slot: &Slot, value: &T) -> Result { write_file(&slot.path, &json) } +/// Write a role's entry to its file. STS credentials are short-lived and +/// minted again from the session, so they don't need the keyring, and keeping +/// them out of it spares the keychain prompt macOS shows for every entry after +/// each upgrade. An entry from before the session was shared still carries its +/// own refresh token, which stays in the keyring. +fn write_role(slot: &Slot, entry: &CacheEntry) -> Result { + if entry.refresh.is_some() { + return write_slot(slot, entry); + } + let json = + serde_json::to_string(entry).map_err(|e| format!("Failed to serialize cache: {e}"))?; + write_file(&slot.path, &json) +} + +/// Read a role's entry from its file, or from the keyring, where versions +/// before this one kept it. +fn read_role(slot: &Slot) -> Result, String> { + match read_file(&slot.path)? { + Some(entry) => Ok(Some(entry)), + None => read_keyring(slot), + } +} + +/// Read a slot, trying the OS keyring first with file fallback. fn read_slot(slot: &Slot) -> Result, String> { - if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &slot.account) { - match entry.get_password() { - Ok(json) => { - return serde_json::from_str(&json) - .map(Some) - .map_err(|e| format!("Failed to parse cache from keyring: {e}")); - } - // Keyring works but nothing stored, or is unavailable: try the file - Err(keyring::Error::NoEntry) => {} - Err(ref e) if is_keyring_unavailable(e) => {} - Err(e) => return Err(format!("Failed to read from keyring: {e}")), - } + match read_keyring(slot)? { + Some(value) => Ok(Some(value)), + None => read_file(&slot.path), + } +} + +/// Read a slot's keyring entry. `None` if there is none or the keyring is +/// unavailable. +fn read_keyring(slot: &Slot) -> Result, String> { + let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &slot.account) else { + return Ok(None); + }; + match entry.get_password() { + Ok(json) => serde_json::from_str(&json) + .map(Some) + .map_err(|e| format!("Failed to parse cache from keyring: {e}")), + Err(keyring::Error::NoEntry) => Ok(None), + Err(ref e) if is_keyring_unavailable(e) => Ok(None), + Err(e) => Err(format!("Failed to read from keyring: {e}")), } - match fs::read_to_string(&slot.path) { +} + +fn read_file(path: &Path) -> Result, String> { + match fs::read_to_string(path) { Ok(contents) => serde_json::from_str(&contents) .map(Some) - .map_err(|e| format!("Failed to parse cache {}: {e}", slot.path.display())), + .map_err(|e| format!("Failed to parse cache {}: {e}", path.display())), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(format!("Failed to read cache {}: {e}", slot.path.display())), + Err(e) => Err(format!("Failed to read cache {}: {e}", path.display())), } } @@ -397,6 +431,33 @@ mod tests { assert!(!bare.minted_in(Some("data.staging.source.coop"))); } + #[test] + fn role_entries_round_trip_through_their_file() { + let path = std::env::temp_dir() + .join(format!("source-coop-test-{}", std::process::id())) + .join("role.json"); + // An account no keyring holds, so a stray keyring lookup would find nothing. + let slot = Slot { + account: format!("source-coop-test-{}", std::process::id()), + path: path.clone(), + }; + let entry = CacheEntry { + creds: sample_creds("2099-01-01T00:00:00Z"), + sts: Some(StsSettings { + proxy_url: "https://data.source.coop".into(), + duration: None, + }), + refresh: None, + }; + assert_eq!( + write_role(&slot, &entry).unwrap(), + path.display().to_string() + ); + let read = read_role(&slot).unwrap().unwrap(); + assert_eq!(read.creds.access_key_id, entry.creds.access_key_id); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + #[test] fn sanitize_arn_with_special_chars() { assert_eq!(