From 9b6d51c8565937f7b49494b38175cd494a933890 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 09:31:05 -0700 Subject: [PATCH] feat: auth logout, removing an environment's session and a role's credentials `source-coop auth logout` deletes the environment's login session and the role's cached credentials from the keyring and the file fallback, under their locks, session first so a `creds` that's minting finishes before its result is removed. For a non-production environment it also removes an old-format entry of that environment left in production's slot, which would otherwise be copied straight back. Other roles' credentials can't be listed, so they stay until they expire and can't be renewed. Tokens aren't revoked at the IdP. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +++++++++++ src/cache.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 37 +++++++++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f6dd87b..0cc15bd 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,17 @@ credential_process = source-coop creds --proxy-url https://data.staging.source.c endpoint_url = https://data.staging.source.coop ``` +### Logging out + +`auth logout` removes an environment's login session and one role's cached credentials, from both the keyring and the [file fallback](#file-fallback): + +```bash +source-coop auth logout +source-coop auth logout --proxy-url https://data.staging.source.coop --role-arn reader-role +``` + +Like `creds`, it takes `--proxy-url` (`SOURCE_PROXY_URL`) and `--role-arn` (`SOURCE_ROLE_ARN`). The keyring can't list its entries, so other roles' cached credentials stay usable until they expire, though without the session `creds` can't renew them. To remove them now, run `auth logout --role-arn ` for each role. Tokens aren't revoked at the identity provider. + ### Login options | Flag | Env var | Default | Description | diff --git a/src/cache.rs b/src/cache.rs index 84e2ca3..2ac7e39 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -236,6 +236,49 @@ pub fn read_session(env: Option<&str>) -> Result, String> { Ok(session.filter(|s| s.proxy_url.as_deref().is_none_or(|p| in_env(p, env)))) } +/// Remove the role's cached credentials. Returns whether there were any. +pub fn delete_credentials(env: Option<&str>, role_arn: &str) -> Result { + let mut removed = delete_slot(&role_slot(env, role_arn)?)?; + // 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)) { + removed |= delete_slot(&legacy)?; + } + } + Ok(removed) +} + +/// Remove `env`'s login session. Returns whether there was one. +pub fn delete_session(env: Option<&str>) -> Result { + delete_slot(&session_slot(env)?) +} + +/// Remove a slot from both the keyring and its file, under the slot's lock. +fn delete_slot(slot: &Slot) -> Result { + let _lock = lock_slot(slot)?; + let mut removed = false; + if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &slot.account) { + match entry.delete_credential() { + Ok(()) => removed = true, + Err(keyring::Error::NoEntry) => {} + Err(ref e) if is_keyring_unavailable(e) => {} + Err(e) => return Err(format!("Failed to delete from keyring: {e}")), + } + } + Ok(remove_file(&slot.path)? || removed) +} + +/// Remove a cache file. Returns whether it existed. +fn remove_file(path: &Path) -> Result { + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(format!("Failed to remove cache {}: {e}", path.display())), + } +} + fn write_slot(slot: &Slot, value: &T) -> Result { let json = serde_json::to_string(value).map_err(|e| format!("Failed to serialize cache: {e}"))?; @@ -408,6 +451,16 @@ mod tests { assert!(!bare.minted_in(Some("data.staging.source.coop"))); } + #[test] + fn remove_file_reports_whether_it_existed() { + let path = + std::env::temp_dir().join(format!("source-coop-test-{}.json", std::process::id())); + write_file(&path, "{}").unwrap(); + assert!(remove_file(&path).unwrap()); + assert!(!path.exists()); + assert!(!remove_file(&path).unwrap()); + } + #[test] fn sanitize_arn_with_special_chars() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 7d355fe..d696985 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,7 +64,7 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Log in, and print tokens for the source.coop API + /// Log in, log out, and print tokens for the source.coop API #[command(subcommand)] Auth(AuthCommand), /// Output cached credentials as credential_process JSON, shell env vars, @@ -114,11 +114,26 @@ enum AuthCommand { /// Log in through the browser, keeping the session and a role's S3 /// credentials for `creds` and the API Login(LoginArgs), + /// Remove an environment's login session and a role's cached credentials. + /// Other roles' credentials can't be found to remove; they stay usable + /// until they expire, but can't be renewed without the session + Logout(LogoutArgs), /// Print an access token for the source.coop API, refreshing it if expired /// (e.g. `curl -H "Authorization: Bearer $(source-coop auth token)" ...`) Token(TokenArgs), } +#[derive(Parser)] +struct LogoutArgs { + /// S3 proxy URL: selects the environment to log out of + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, + + /// Role ARN whose cached credentials to remove + #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] + role_arn: String, +} + #[derive(Parser)] struct TokenArgs { /// S3 proxy URL of the environment whose login session to use @@ -228,6 +243,11 @@ async fn main() { fail(e); } } + Commands::Auth(AuthCommand::Logout(args)) => { + if let Err(e) = run_logout(args) { + fail(e); + } + } Commands::Auth(AuthCommand::Token(args)) => { if let Err(e) = run_auth_token(args, verbose).await { fail(e); @@ -263,6 +283,21 @@ async fn main() { } } +fn run_logout(args: LogoutArgs) -> Result<(), String> { + let env = cache::env(&args.proxy_url)?; + let name = env.as_deref().unwrap_or("production"); + // The session first: a `creds` that still had it may be minting, and the + // role's lock makes us wait for it before removing what it caches. + let session = cache::delete_session(env.as_deref())?; + let creds = cache::delete_credentials(env.as_deref(), &args.role_arn)?; + if session || creds { + eprintln!("Logged out of {name} ({}).", args.proxy_url); + } else { + eprintln!("Not logged in to {name} ({}).", args.proxy_url); + } + Ok(()) +} + 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)