From 0ceb123f875e21becc675ab996643dba18868136 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 13 Oct 2026 15:06:04 -0700 Subject: [PATCH 1/2] 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 | 107 ++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 92 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index fade3e2..8460931 100644 --- a/README.md +++ b/README.md @@ -445,9 +445,9 @@ Without `--subject` or `--key`, in a terminal, `usage` asks which sign-in to sho 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 | |----------|---------| @@ -455,9 +455,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 | |----------|------| @@ -465,9 +467,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 4514ec1..b5d673d 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. /// @@ -225,17 +225,17 @@ fn find_credentials( migrate: bool, ) -> 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); }; if migrate { - write_slot(&slot, &legacy)?; + write_role(&slot, &legacy)?; } Ok(Some(legacy)) } @@ -257,14 +257,14 @@ pub fn delete_credentials(env: Option<&str>, role_arn: &str) -> Result(&slot)?.is_some_and(|e| !e.belongs_to(None)) => false, + None if read_role(&slot)?.is_some_and(|e| !e.belongs_to(None)) => false, _ => delete_slot(&slot)?, }; // Otherwise `read_credentials` would copy a legacy entry for `env` back // out of production's slot. if env.is_some() { let legacy = role_slot(None, role_arn)?; - if read_slot::(&legacy)?.is_some_and(|e| e.minted_in(env)) { + if read_role(&legacy)?.is_some_and(|e| e.minted_in(env)) { removed |= delete_slot(&legacy)?; } } @@ -317,26 +317,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())), } } @@ -504,6 +538,33 @@ mod tests { assert!(!remove_file(&path).unwrap()); } + #[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!( From a10eb7d21a24f6b6593c965b4ffab1804ef813cf Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 4 Nov 2026 12:56:25 -0700 Subject: [PATCH 2/2] fix(cache): keep role entries consistent and remove old keyring entries at login Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/cache.rs | 57 ++++++++++++++++++++++++++++++++++++++-------------- src/main.rs | 4 +++- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8460931..63ab2e7 100644 --- a/README.md +++ b/README.md @@ -469,7 +469,7 @@ Each role's STS credentials are short-lived and re-created from the session, so 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`). -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. +Credentials cached in the keyring by earlier versions are still read when their role has no file, so upgrading doesn't log you out. The next `creds` that renews them writes a file, which is read from then on, and the next `login` removes the keyring entry. An entry from v0.3.0 or earlier that carries its own refresh token stays in the keyring until that `login`. ## OIDC provider setup diff --git a/src/cache.rs b/src/cache.rs index b5d673d..45aec90 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -302,17 +302,31 @@ fn remove_file(path: &Path) -> Result { } } +fn to_json(value: &T) -> Result { + serde_json::to_string(value).map_err(|e| format!("Failed to serialize cache: {e}")) +} + +/// Store `json` in the slot's keyring entry. `Ok(false)` if the keyring is +/// unavailable, so the caller falls back to the file. +fn write_keyring(slot: &Slot, json: &str) -> Result { + let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &slot.account) else { + return Ok(false); + }; + match entry.set_password(json) { + Ok(()) => Ok(true), + Err(ref e) if is_keyring_unavailable(e) => Ok(false), + Err(e) => Err(format!("Failed to write to keyring: {e}")), + } +} + +fn keyring_location() -> String { + format!("OS keyring (service: {KEYRING_SERVICE})") +} + fn write_slot(slot: &Slot, value: &T) -> Result { - let json = - serde_json::to_string(value).map_err(|e| format!("Failed to serialize cache: {e}"))?; - - if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &slot.account) { - match entry.set_password(&json) { - Ok(()) => return Ok(format!("OS keyring (service: {KEYRING_SERVICE})")), - // Fall through to file-based caching - Err(ref e) if is_keyring_unavailable(e) => {} - Err(e) => return Err(format!("Failed to write to keyring: {e}")), - } + let json = to_json(value)?; + if write_keyring(slot, &json)? { + return Ok(keyring_location()); } write_file(&slot.path, &json) } @@ -321,16 +335,29 @@ fn write_slot(slot: &Slot, value: &T) -> Result { /// 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. +/// own refresh token, which stays in the keyring. Its file, left from when the +/// keyring was unavailable, is removed then: `read_role` reads the file first, +/// and would otherwise replay the spent refresh token it holds. fn write_role(slot: &Slot, entry: &CacheEntry) -> Result { - if entry.refresh.is_some() { - return write_slot(slot, entry); + let json = to_json(entry)?; + if entry.refresh.is_some() && write_keyring(slot, &json)? { + remove_file(&slot.path)?; + return Ok(keyring_location()); } - let json = - serde_json::to_string(entry).map_err(|e| format!("Failed to serialize cache: {e}"))?; write_file(&slot.path, &json) } +/// Remove a role's keyring entry, where versions before this one kept it, so +/// it can't come back if its file is deleted. Best effort: an entry that +/// can't be removed is only shadowed by the file. +pub fn forget_keyring_credentials(env: Option<&str>, role_arn: &str) { + if let Ok(slot) = role_slot(env, role_arn) { + if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &slot.account) { + let _ = entry.delete_credential(); + } + } +} + /// 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> { diff --git a/src/main.rs b/src/main.rs index ba4837f..1f6ff29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -940,7 +940,9 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { // overwrite this entry afterwards. let location = { let _lock = cache::lock(env.as_deref(), &args.role.role_arn)?; - cache::write_credentials(env.as_deref(), &args.role.role_arn, &entry)? + let location = cache::write_credentials(env.as_deref(), &args.role.role_arn, &entry)?; + cache::forget_keyring_credentials(env.as_deref(), &args.role.role_arn); + location }; eprintln!("Credentials cached to {location}"); eprintln!("Run 'source-coop creds' to print them.");