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)