Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .env.staging
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
# client + public URLs), not secrets. Load into your shell with:
#
# source .env.staging
# source-coop login
# source-coop auth login
#
# To avoid leaving these set in your shell afterward, scope them to a subshell:
#
# ( source .env.staging && source-coop login )
# ( source .env.staging && source-coop auth login )

export SOURCE_OIDC_ISSUER=https://auth.staging.source.coop
export SOURCE_OIDC_CLIENT_ID=a79c9537-be78-454a-9ea1-b96a1be811cc
export SOURCE_PROXY_URL=https://data.staging.source.coop
export SOURCE_API_AUDIENCE=https://staging.source.coop
export SOURCE_API_URL=https://staging.source.coop
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Everything goes through the `Prompter` trait (`src/prompt.rs`), so tests can scr

Scripts and AI agents use the CLI through `--json`, so it is a contract, not a second layout.

- **Every command that reports information takes `--json`:** reads, writes (the resulting record), `auth status`. Exceptions, which already print machine output or none of their own: `creds` (`--format`), `exec` (its command's output), `auth token` (a bare token), `api` (the API's response) and `-i` modes (interactive; `list --json` is their counterpart).
- **Every command that reports information takes `--json`:** reads, writes (the resulting record), `auth status`. Exceptions, which already print machine output or none of their own: `creds` (`--format`), `exec` (its command's output), `auth token` (a bare token), `auth login` (a browser sign-in; `auth status --json` reports the result), `api` (the API's response) and `-i` modes (interactive; `list --json` is their counterpart).
- **One JSON document on stdout and nothing else there.** Progress and notes go to stderr. No color, no padding, no pager.
- **Field names are the API's** (`account_id`, `created_at` in RFC 3339), plus derived fields only where they spare scripts work (`"expired": true`). Never human renderings like "3 years ago".
- **Adding fields is fine. Renaming or removing one is a breaking change.** Human output may change freely.
Expand Down
71 changes: 58 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ cargo install --git https://github.com/source-cooperative/source-coop-cli
1. Log in once (opens browser, caches credentials to the OS keyring):

```bash
source-coop login
source-coop auth login
```

`source-coop login`, its name before `auth` held it, still works.

2. Configure `~/.aws/config` to use cached credentials:

```ini
Expand All @@ -57,17 +59,17 @@ endpoint_url = https://data.source.coop
aws s3 ls s3://my-account/my-product --profile source-coop
```

When credentials expire, `source-coop creds` uses the cached refresh token to fetch new ones automatically. Run `source-coop login` again only when that fails (e.g. the refresh token has expired or been revoked).
When credentials expire, `source-coop creds` uses the cached refresh token to fetch new ones automatically. Run `source-coop auth login` again only when that fails (e.g. the refresh token has expired or been revoked).

`login` keeps one session for everything: `creds` for any role exchanges its ID token at the proxy, and commands that call the source.coop API send its access token. `source-coop auth token` prints that access token, refreshed if it has expired:
`auth login` keeps one session for everything: `creds` for any role exchanges its ID token at the proxy, and commands that call the source.coop API send its access token. `source-coop auth token` prints that access token, refreshed if it has expired:

```bash
curl -H "Authorization: Bearer $(source-coop auth token)" https://source.coop/api/v1/...
```

### Logging in on a remote server (no browser)

`login` receives the OAuth2 redirect on a local port, so on a headless machine, forward that port over SSH and complete the login in your local browser:
`auth login` receives the OAuth2 redirect on a local port, so on a headless machine, forward that port over SSH and complete the login in your local browser:

1. Connect with a port forward (any free port works; `8400` is used here):

Expand All @@ -78,7 +80,7 @@ ssh -L 8400:127.0.0.1:8400 user@server
2. On the server, log in using the forwarded port:

```bash
source-coop login --port 8400
source-coop auth login --port 8400
```

3. Open the URL the CLI prints in your local browser. After you sign in, the redirect to `http://127.0.0.1:8400/callback` travels through the tunnel to the CLI on the server.
Expand All @@ -91,6 +93,35 @@ Credentials are cached on the server (see [File fallback](#file-fallback)), and
source-coop --version
```

### Checking login status

`auth status` shows whether you're logged in and as whom, and when the cached tokens and a role's credentials expire. It works from the cache: nothing is refreshed and nothing is sent over the network. The account is the one `auth login` looked up, through the API its access token is for (`--audience`), when you logged in; for a login from before it did, `auth status` says so until you log in again. On a terminal, labels are dim and the login's state is green or red.

```bash
source-coop auth status
```

```
Environment https://data.source.coop
Login logged in
Account Anthony Lukach (alukach)
Profile https://source.coop/alukach
Refresh token renewed 2026-10-13T14:00:00Z (3 hours ago); expires 2026-11-12 (in 29 days)
ID token expires 2026-10-13T18:00:00Z
Access token for https://source.coop, expires 2026-10-13T18:00:00Z
Credentials _default: expire 2026-10-13T18:30:00Z
```

**How long until you must log in again** is set by the refresh token, which `creds` and API commands use to renew everything else, so the expiries of the ID token, access token and credentials don't force a login. source.coop's login (Ory) issues opaque refresh tokens, and its token response doesn't say how long they last: that's an Ory project setting, and each renewal starts the clock again. So `auth status` shows when the refresh token was last renewed, and when it expires if left unused: an estimate, to the day, assuming a 30-day lifetime (Ory's default). Set `SOURCE_REFRESH_TOKEN_LIFETIME` (e.g. `720h`, `14d`) if the server uses another; it only changes this estimate, since a login can't ask for a lifetime, and a value it can't read is ignored with a warning. Where a provider does give an expiry, that's shown instead. Once that expiry has passed, the login shows as expired; once only the estimate has, as likely expired (yellow), since the server's lifetime may be longer. A login from before renewals were recorded shows just `yes` until its next renewal or `auth login`. The profile link is on the site the login was for (`https://staging.source.coop` for a staging login).

`--json` prints the same as data, for scripts and agents: `logged_in` (false once the login has expired; a `likely_expired` state, past only the estimate, stays true), times in RFC 3339 with an `expired` flag, the account's `profile_url`, and, when the provider gave no refresh-token expiry, `estimated_lapse_at` with the `assumed_lifetime` it was counted from.

```bash
source-coop auth status --json | jq -r .login.refresh_token.issued_at
```

The keyring can't list its entries, so `auth status` reports on one environment and role. Pass `--proxy-url` and `--role-arn` (or set `SOURCE_PROXY_URL` and `SOURCE_ROLE_ARN`) to check others.

### Setting credentials on the environment

After logging in, you can export cached credentials as environment variables:
Expand All @@ -109,8 +140,8 @@ This sets `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN`
Each role's credentials are cached separately:

```bash
source-coop login --role-arn reader-role
source-coop login --role-arn admin-role
source-coop auth login --role-arn reader-role
source-coop auth login --role-arn admin-role
```

Use `creds` with `--role-arn` to select which role to output:
Expand All @@ -130,8 +161,8 @@ endpoint_url = https://data.source.coop
Each proxy host is its own environment, with its own login session and credentials, so production and staging logins live side by side:

```bash
source-coop login
( source .env.staging && source-coop login )
source-coop auth login
( source .env.staging && source-coop auth login )
```

`creds` and `auth token` read `SOURCE_PROXY_URL` (or `--proxy-url`) to pick the environment, and without either they use production. A non-production profile must pass `--proxy-url`; one that relied on whichever environment was logged in last will now get production credentials:
Expand All @@ -146,6 +177,18 @@ 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
source-coop auth logout --json # {"proxy_url": …, "role_arn": …, "removed": {"session": true, "credentials": true}}
```

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 All @@ -164,7 +207,7 @@ endpoint_url = https://data.staging.source.coop

### Output formats

Both `login` and `creds` support `--format` to control output:
Both `auth login` and `creds` support `--format` to control output:

**credential-process** (default) — AWS credential_process JSON:

Expand Down Expand Up @@ -210,11 +253,11 @@ source-coop api products/my-org/new -X PATCH --input changes.json

`-f key=value` sends a string, and `-F key=value` sends `true`, `false`, `null` and numbers as JSON. Fields go in the query string for `GET` and in a JSON body otherwise, and the method is `POST` when fields or `--input` are given; a `GET` takes no `--input`. Paths stay under `/api/v1`. A status other than success exits non-zero, with the exit code for its kind (see "JSON output and exit codes").

Reading public data needs no credentials. Anything else acts as whoever ran `source-coop login`, which asks Ory for an access token meant for the API (`--audience`, default `https://source.coop`) and refreshes it as needed. `SOURCE_TOKEN`, if set, is sent instead. `--api-url` (or `SOURCE_API_URL`) points the CLI at another deployment, such as a local `http://localhost:3000`.
Reading public data needs no credentials. Anything else acts as whoever ran `source-coop auth login`, which asks Ory for an access token meant for the API (`--audience`, default `https://source.coop`) and refreshes it as needed. `SOURCE_TOKEN`, if set, is sent instead. `--api-url` (or `SOURCE_API_URL`) points the CLI at another deployment, such as a local `http://localhost:3000`.

### JSON output and exit codes

Commands that report information take `--json`. Under `--json`, stdout holds one JSON document and nothing else, with the API's field names and RFC 3339 times; notes and progress go to stderr. Fields may be added over time, but not renamed or removed. `creds` (`--format`), `auth token` (a bare token) and `api` (the API's response) already print output for machines, so they don't take `--json`.
Every command that reports information takes `--json`, such as `auth status`. Under `--json`, stdout holds one JSON document and nothing else, with the API's field names and RFC 3339 times; notes and progress go to stderr. Fields may be added over time, but not renamed or removed.

A failure under `--json` is JSON too, on stderr, in the API's error shape. `field_errors` names what to fix when the API rejected the input:

Expand All @@ -234,6 +277,8 @@ Exit codes are the same for every command:
| 5 | Not found (HTTP 404) |
| 130 | Interrupted (Ctrl+C) |

`creds` (`--format`), `auth token` (a bare token) and `api` (the API's response) already print output for machines, so they don't take `--json`. Neither does `auth login`, which signs in through the browser: `auth status --json` reports the result.

## Credential storage

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.
Expand Down Expand Up @@ -279,7 +324,7 @@ The CLI defaults to `--port 0` (OS-assigned random available port), which works
Register a specific redirect URI (e.g. `http://127.0.0.1:8400/callback`) and run the CLI with the matching port:

```bash
source-coop login --role-arn <ARN> --port 8400
source-coop auth login --role-arn <ARN> --port 8400
```

### Client configuration
Expand Down
9 changes: 6 additions & 3 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ impl fmt::Display for ApiError {
}
}
if self.status == Some(StatusCode::UNAUTHORIZED) {
write!(f, "\nThis needs you signed in: run 'source-coop login'.")?;
write!(
f,
"\nThis needs you signed in: run 'source-coop auth login'."
)?;
}
Ok(())
}
Expand Down Expand Up @@ -218,7 +221,7 @@ impl Client {
pub async fn check_signed_in(&self) -> Result<serde_json::Value, ApiError> {
if self.token.is_none() {
return Err(ApiError::other(
"Not signed in: run 'source-coop login'.".into(),
"Not signed in: run 'source-coop auth login'.".into(),
));
}
self.get(self.url(&["whoami"])).await
Expand Down Expand Up @@ -305,6 +308,6 @@ mod tests {
fn says_how_to_authenticate_on_401() {
let body = r#"{"error":{"code":"unauthenticated","message":"Sign in first."}}"#;
let e = ApiError::from_response(StatusCode::UNAUTHORIZED, body);
assert!(e.to_string().contains("source-coop login"));
assert!(e.to_string().contains("source-coop auth login"));
}
}
93 changes: 90 additions & 3 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ pub fn write_credentials(
/// the production slot, so an environment with nothing of its own takes its
/// entry from there, copying it into its own slot.
pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result<Option<CacheEntry>, String> {
find_credentials(env, role_arn, true)
}

/// `read_credentials` without the copy, for reporting: writes nothing.
pub fn peek_credentials(env: Option<&str>, role_arn: &str) -> Result<Option<CacheEntry>, String> {
find_credentials(env, role_arn, false)
}

fn find_credentials(
env: Option<&str>,
role_arn: &str,
migrate: bool,
) -> Result<Option<CacheEntry>, String> {
let slot = role_slot(env, role_arn)?;
let entry: Option<CacheEntry> = read_slot(&slot)?;
if entry.is_some() || env.is_none() {
Expand All @@ -221,7 +234,9 @@ pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result<Option<Cach
let Some(legacy) = legacy.filter(|e| e.minted_in(env)) else {
return Ok(None);
};
write_slot(&slot, &legacy)?;
if migrate {
write_slot(&slot, &legacy)?;
}
Ok(Some(legacy))
}

Expand All @@ -236,6 +251,57 @@ 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 slot = role_slot(env, role_arn)?;
// Production's slot may still hold another environment's entry from
// before environments existed: that's for its own environment's logout.
let mut removed = match env {
None if read_slot::<CacheEntry>(&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::<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 keyring = match keyring::Entry::new(KEYRING_SERVICE, &slot.account) {
Err(_) => Ok(false),
Ok(entry) => match entry.delete_credential() {
Ok(()) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(ref e) if is_keyring_unavailable(e) => Ok(false),
Err(e) => Err(format!("Failed to delete from keyring: {e}")),
},
};
// The file goes even when the keyring fails, so no copy outlives a logout.
let file = remove_file(&slot.path)?;
Ok(keyring? || file)
}

/// 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 @@ -279,13 +345,24 @@ pub(crate) fn write_file(path: &Path, json: &str) -> Result<String, String> {
let dir = path.parent().unwrap();
fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create cache directory {}: {e}", dir.display()))?;
fs::write(path, json).map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?;
// Owner-only before anything is written, so a refresh token is never
// readable by others, even briefly.
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let mut file = options
.open(path)
.map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?;
#[cfg(unix)]
{
// An existing file keeps its mode through `open`.
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
file.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?;
}
io::Write::write_all(&mut file, json.as_bytes())
.map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?;
Ok(path.display().to_string())
}

Expand Down Expand Up @@ -397,6 +474,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
2 changes: 1 addition & 1 deletion src/failure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ mod tests {
assert_eq!(classify(&api_error(404, "not_found", &[])).exit_code(), 5);
assert_eq!(classify(&api_error(500, "internal", &[])).exit_code(), 1);
assert_eq!(
classify("Not signed in: run 'source-coop login'.").exit_code(),
classify("Not signed in: run 'source-coop auth login'.").exit_code(),
4
);
assert_eq!(classify(crate::prompt::INTERRUPTED).exit_code(), 130);
Expand Down
Loading
Loading