Skip to content
Draft
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
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,29 +445,31 @@ 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 |
|----------|---------|
| macOS | Keychain (`security` / Keychain Access) |
| 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 |
|----------|------|
| macOS | `~/Library/Caches/source-coop/credentials/<role>.json` |
| Linux | `~/.cache/source-coop/credentials/<role>.json` |
| Windows | `%LocalAppData%\source-coop\credentials\<role>.json` |

The session sits beside them, at `source-coop/session.json`. Other environments keep the same layout under `source-coop/env/<host>/`, or `source-coop/env/<host>/<port>/` 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/<host>/`, or `source-coop/env/<host>/<port>/` 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

Expand Down
154 changes: 121 additions & 33 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,17 +193,17 @@ fn lock_slot(slot: &Slot) -> Result<fs::File, String> {
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<String, String> {
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.
///
Expand All @@ -225,17 +225,17 @@ fn find_credentials(
migrate: bool,
) -> Result<Option<CacheEntry>, String> {
let slot = role_slot(env, role_arn)?;
let entry: Option<CacheEntry> = 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<CacheEntry> = 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))
}
Expand All @@ -257,14 +257,14 @@ pub fn delete_credentials(env: Option<&str>, role_arn: &str) -> Result<bool, Str
// 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,
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::<CacheEntry>(&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)?;
}
}
Expand Down Expand Up @@ -302,41 +302,102 @@ fn remove_file(path: &Path) -> Result<bool, String> {
}
}

fn to_json<T: Serialize>(value: &T) -> Result<String, String> {
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<bool, String> {
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<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}"))?;

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<T: DeserializeOwned>(slot: &Slot) -> Result<Option<T>, 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<String, String> {
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<Option<CacheEntry>, 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<T: DeserializeOwned>(slot: &Slot) -> Result<Option<T>, 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<T: DeserializeOwned>(slot: &Slot) -> Result<Option<T>, 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<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, 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())),
}
}

Expand Down Expand Up @@ -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!(
Expand Down
4 changes: 3 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
Loading