Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <role>` for each role. Tokens aren't revoked at the identity provider.

### Login options

| Flag | Env var | Default | Description |
Expand Down
53 changes: 53 additions & 0 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,49 @@ pub fn read_session(env: Option<&str>) -> Result<Option<Session>, 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<bool, String> {
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::<CacheEntry>(&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<bool, String> {
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<bool, String> {
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<bool, String> {
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<T: Serialize>(slot: &Slot, value: &T) -> Result<String, String> {
let json =
serde_json::to_string(value).map_err(|e| format!("Failed to serialize cache: {e}"))?;
Expand Down Expand Up @@ -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!(
Expand Down
37 changes: 36 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
Loading