From 68f20c4e1497a9ea8cd044db340a0b16947a61e7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 30 Sep 2026 11:28:05 -0700 Subject: [PATCH 1/2] 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/2] 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"