diff --git a/README.md b/README.md index fade3e2..63ab2e7 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 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 4514ec1..45aec90 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)?; } } @@ -302,41 +302,102 @@ 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) } -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}")), +/// 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. 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 { + let json = to_json(entry)?; + if entry.refresh.is_some() && write_keyring(slot, &json)? { + remove_file(&slot.path)?; + return Ok(keyring_location()); + } + 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(); } } - match fs::read_to_string(&slot.path) { +} + +/// 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> { + 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}")), + } +} + +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 +565,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!( 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.");