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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ 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).

`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:
Expand Down Expand Up @@ -131,6 +137,7 @@ endpoint_url = https://data.source.coop
| `--profile` | | `source-coop` | Profile name for `--format aws-credentials` |
| `--duration` | | | Session duration, e.g. `3600`, `90s`, `5m`, `12h`, `1d` (bare number = seconds) |
| `--scope` | | `openid offline_access` | OAuth2 scopes (`offline_access` enables automatic refresh in `creds`) |
| `--audience` | `SOURCE_API_AUDIENCE` | `https://source.coop` | Audience of the access token for the source.coop API; empty to request none |
| `--port` | | `0` (random) | Local callback port |
| `--no-cache` | | | Skip caching credentials (just print to stdout) |

Expand Down Expand Up @@ -171,11 +178,11 @@ Use `--profile` to change the section name.
## Credential storage

The CLI caches temporary STS credentials so that `creds` can output them without re-authenticating.
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)

Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN:
Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN, with the session under `@session`:

| Platform | Backend |
|----------|---------|
Expand All @@ -193,6 +200,8 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f
| Linux | `~/.cache/source-coop/credentials/<role>.json` |
| Windows | `%LocalAppData%\source-coop\credentials\<role>.json` |

The session sits beside them, at `source-coop/session.json`.

The fallback is automatic — no configuration is needed.

## OIDC provider setup
Expand Down Expand Up @@ -221,5 +230,7 @@ The OAuth2 client should be configured as a **public client** (no client secret)

- **Grant type**: Authorization Code
- **Token endpoint auth method**: `none` (public client, PKCE used instead)
- **Allowed scopes**: `openid`
- **Allowed scopes**: `openid`, `offline_access`
- **Redirect URIs**: `http://127.0.0.1/callback` (see above)
- **Allowed audiences**: the source.coop site the API is served from (`https://source.coop`, or `https://staging.source.coop`). Without it, Ory refuses a login that asks for that audience.
- **Access token strategy**: `jwt`, so the API can verify access tokens against Ory's published keys rather than asking Ory about each one
204 changes: 116 additions & 88 deletions src/cache.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
use crate::session::Session;
use crate::sts::Credentials;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;
use std::path::{Path, PathBuf};

const KEYRING_SERVICE: &str = "source-coop-cli";

/// What gets cached per role: the STS credentials plus, when the IdP issued
/// one, what's needed to mint fresh credentials without a browser login.
/// `flatten` keeps caches written by older versions (bare credentials) readable.
/// What gets cached per role: the STS credentials, and how to mint new ones
/// from the login session when they expire. `flatten` keeps caches written by
/// older versions (bare credentials) readable.
#[derive(Serialize, Deserialize)]
pub struct CacheEntry {
#[serde(flatten)]
pub creds: Credentials,
/// The STS settings `login` was given, reused when minting new credentials.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sts: Option<StsSettings>,
/// A refresh token of the role's own, from versions before the login
/// session was shared. Still honoured so those caches keep working; a new
/// `login` replaces it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh: Option<RefreshState>,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct StsSettings {
pub proxy_url: String,
pub duration: Option<u64>,
}

#[derive(Serialize, Deserialize)]
pub struct RefreshState {
pub refresh_token: String,
Expand All @@ -27,6 +40,35 @@ pub struct RefreshState {
pub duration: Option<u64>,
}

/// Where one cached item lives: its keyring account, and its file when the
/// keyring is unavailable.
struct Slot {
account: String,
path: PathBuf,
}

fn cache_dir() -> Result<PathBuf, String> {
Ok(dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("source-coop"))
}

fn role_slot(role_arn: &str) -> Result<Slot, String> {
Ok(Slot {
account: role_arn.to_string(),
path: cache_path(role_arn)?,
})
}

/// The login session's slot. `@` can't begin a role ARN or role name, so its
/// keyring account never collides with a role's.
fn session_slot() -> Result<Slot, String> {
Ok(Slot {
account: "@session".to_string(),
path: cache_dir()?.join("session.json"),
})
}

/// Returns `true` for keyring errors that indicate the keyring backend is
/// unavailable (headless Linux, containers, CI). These trigger a fallback to
/// file-based caching. Other error variants are treated as hard errors.
Expand Down Expand Up @@ -57,20 +99,27 @@ fn sanitize_role_arn(role_arn: &str) -> String {
/// Uses the OS-idiomatic cache directory (`~/Library/Caches` on macOS,
/// `~/.cache` on Linux, `%LocalAppData%` on Windows).
fn cache_path(role_arn: &str) -> Result<PathBuf, String> {
let cache_dir = dirs::cache_dir().ok_or("Could not determine cache directory")?;
let sanitized = sanitize_role_arn(role_arn);
Ok(cache_dir
.join("source-coop")
Ok(cache_dir()?
.join("credentials")
.join(format!("{sanitized}.json")))
}

/// Take an exclusive per-role lock, held until the returned file is dropped.
/// Refresh tokens rotate on use, and the IdP may revoke the whole token family
/// if an old one is replayed, so concurrent `creds` calls must not refresh in
/// parallel.
/// Take an exclusive per-role lock, held until the returned file is dropped,
/// so concurrent `creds` calls for one role mint once.
pub fn lock(role_arn: &str) -> Result<fs::File, String> {
let path = cache_path(role_arn)?.with_extension("lock");
lock_slot(&role_slot(role_arn)?)
}

/// Take the login session's lock. Refresh tokens rotate on use, and the IdP
/// may revoke the whole token family if an old one is replayed, so nothing may
/// refresh the session in parallel. Taken after a role's lock, never before.
pub fn lock_session() -> Result<fs::File, String> {
lock_slot(&session_slot()?)
}

fn lock_slot(slot: &Slot) -> Result<fs::File, String> {
let path = slot.path.with_extension("lock");
let dir = path.parent().unwrap();
fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create cache directory {}: {e}", dir.display()))?;
Expand All @@ -81,99 +130,77 @@ pub fn lock(role_arn: &str) -> Result<fs::File, String> {
Ok(file)
}

/// Write credentials to a cache file. Returns the file path as a string.
fn write_credentials_file(role_arn: &str, creds: &CacheEntry) -> Result<String, String> {
let path = cache_path(role_arn)?;
let dir = path.parent().unwrap();

fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create cache directory {}: {e}", dir.display()))?;

let json = serde_json::to_string_pretty(creds)
.map_err(|e| format!("Failed to serialize credentials: {e}"))?;

fs::write(&path, &json)
.map_err(|e| format!("Failed to write credentials cache {}: {e}", path.display()))?;

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
.map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?;
}
/// Write credentials, trying the OS keyring first with file fallback.
/// Returns a human-readable description of where credentials were stored.
pub fn write_credentials(role_arn: &str, creds: &CacheEntry) -> Result<String, String> {
write_slot(&role_slot(role_arn)?, creds)
}

Ok(path.display().to_string())
/// Read credentials, trying the OS keyring first with file fallback.
/// Returns `None` if no cached credentials are found in either location.
pub fn read_credentials(role_arn: &str) -> Result<Option<CacheEntry>, String> {
read_slot(&role_slot(role_arn)?)
}

/// Read credentials from a cache file. Returns `None` if the file does not exist.
fn read_credentials_file(role_arn: &str) -> Result<Option<CacheEntry>, String> {
let path = cache_path(role_arn)?;
match fs::read_to_string(&path) {
Ok(contents) => {
let creds: CacheEntry = serde_json::from_str(&contents)
.map_err(|e| format!("Failed to parse credentials cache: {e}"))?;
Ok(Some(creds))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!(
"Failed to read credentials cache {}: {e}",
path.display()
)),
}
pub fn write_session(session: &Session) -> Result<String, String> {
write_slot(&session_slot()?, session)
}

/// Write credentials, trying the OS keyring first with file fallback.
/// Returns a human-readable description of where credentials were stored.
pub fn write_credentials(role_arn: &str, creds: &CacheEntry) -> Result<String, String> {
let json = serde_json::to_string(creds)
.map_err(|e| format!("Failed to serialize credentials: {e}"))?;
pub fn read_session() -> Result<Option<Session>, String> {
read_slot(&session_slot()?)
}

let entry = keyring::Entry::new(KEYRING_SERVICE, role_arn)
.map_err(|e| format!("Failed to create keyring entry: {e}"));
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) = entry {
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})"));
}
Err(ref e) if is_keyring_unavailable(e) => {
// Fall through to file-based caching
}
Err(e) => {
return Err(format!("Failed to write credentials to keyring: {e}"));
}
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}")),
}
}

write_credentials_file(role_arn, creds)
write_file(&slot.path, &json)
}

/// Read credentials, trying the OS keyring first with file fallback.
/// Returns `None` if no cached credentials are found in either location.
pub fn read_credentials(role_arn: &str) -> Result<Option<CacheEntry>, String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, role_arn)
.map_err(|e| format!("Failed to create keyring entry: {e}"));

if let Ok(entry) = entry {
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) => {
let creds: CacheEntry = serde_json::from_str(&json)
.map_err(|e| format!("Failed to parse credentials from keyring: {e}"))?;
return Ok(Some(creds));
}
Err(keyring::Error::NoEntry) => {
// Keyring works but nothing stored — fall through to file
}
Err(ref e) if is_keyring_unavailable(e) => {
// Keyring unavailable — fall through to file
}
Err(e) => {
return Err(format!("Failed to read credentials from keyring: {e}"));
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}")),
}
}
match fs::read_to_string(&slot.path) {
Ok(contents) => serde_json::from_str(&contents)
.map(Some)
.map_err(|e| format!("Failed to parse cache {}: {e}", slot.path.display())),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("Failed to read cache {}: {e}", slot.path.display())),
}
}

read_credentials_file(role_arn)
/// Write a cache file readable only by its owner. Returns the file path.
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()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?;
}
Ok(path.display().to_string())
}

/// Check if credentials are expired or will expire within a 60-second buffer.
Expand Down Expand Up @@ -268,6 +295,7 @@ mod tests {
assert!(entry.refresh.is_none());
assert_eq!(entry.creds.access_key_id, "AKIAIOSFODNN7EXAMPLE");

assert!(entry.sts.is_none());
let entry = CacheEntry {
refresh: Some(RefreshState {
refresh_token: "rt".to_string(),
Expand Down
Loading
Loading