diff --git a/.env.staging b/.env.staging index 4524918..448c8c6 100644 --- a/.env.staging +++ b/.env.staging @@ -11,3 +11,4 @@ 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 diff --git a/README.md b/README.md index f48bd97..d065891 100644 --- a/README.md +++ b/README.md @@ -125,13 +125,34 @@ credential_process = source-coop creds --role-arn admin-role endpoint_url = https://data.source.coop ``` +### Multiple environments (e.g. staging) + +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 ) +``` + +`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: + +```ini +[profile source-coop] +credential_process = source-coop creds +endpoint_url = https://data.source.coop + +[profile source-coop-staging] +credential_process = source-coop creds --proxy-url https://data.staging.source.coop +endpoint_url = https://data.staging.source.coop +``` + ### Login options | Flag | Env var | Default | Description | |------|---------|---------|-------------| | `--issuer` | `SOURCE_OIDC_ISSUER` | `https://auth.source.coop` | OIDC issuer URL | | `--client-id` | `SOURCE_OIDC_CLIENT_ID` | `d037d00b-...` | OAuth2 client ID | -| `--proxy-url` | `SOURCE_PROXY_URL` | `https://data.source.coop` | S3 proxy URL for STS | +| `--proxy-url` | `SOURCE_PROXY_URL` | `https://data.source.coop` | S3 proxy URL for STS; also selects the environment the login is cached under | | `--role-arn` | `SOURCE_ROLE_ARN` | `source-coop-user` | Role ARN to assume | | `--format` | | `credential-process` | Output format: `credential-process`, `env`, or `aws-credentials` | | `--profile` | | `source-coop` | Profile name for `--format aws-credentials` | @@ -180,9 +201,9 @@ Use `--profile` to change the section name. 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, with the session under `@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 | |----------|---------| @@ -190,9 +211,11 @@ Credentials are stored in the OS-native keyring under the service name `source-c | 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. + +### Role credentials: cache files -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: +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 | |----------|------| @@ -200,9 +223,9 @@ When the OS keyring is unavailable (headless servers, containers, CI), the CLI f | Linux | `~/.cache/source-coop/credentials/.json` | | Windows | `%LocalAppData%\source-coop\credentials\.json` | -The session sits beside them, at `source-coop/session.json`. +Other environments keep the same layout under `source-coop/env//`, or `source-coop/env///` 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, so upgrading doesn't log you out; the next `login` replaces them with a file. An entry from v0.3.0 or earlier that carries its own refresh token stays in the keyring until then. ## OIDC provider setup diff --git a/src/cache.rs b/src/cache.rs index 973b498..c6309b6 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; const KEYRING_SERVICE: &str = "source-coop-cli"; -/// What gets cached per role: the STS credentials, and how to mint new ones +/// What gets cached per proxy + 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)] @@ -25,6 +25,30 @@ pub struct CacheEntry { pub refresh: Option, } +impl CacheEntry { + /// Whether this entry records that it was minted at a proxy in `env`. + fn minted_in(&self, env: Option<&str>) -> bool { + let proxy_url = match (&self.sts, &self.refresh) { + (Some(sts), _) => &sts.proxy_url, + (None, Some(refresh)) => &refresh.proxy_url, + (None, None) => return false, + }; + in_env(proxy_url, env) + } + + /// Whether this entry can be used in its own slot in `env`. Bare + /// credentials record no proxy and can't be renewed, so they're taken as + /// they are. + fn belongs_to(&self, env: Option<&str>) -> bool { + (self.sts.is_none() && self.refresh.is_none()) || self.minted_in(env) + } +} + +/// Whether `proxy_url` is in `env`. An unparseable URL is in none. +fn in_env(proxy_url: &str, env: Option<&str>) -> bool { + self::env(proxy_url).is_ok_and(|e| e.as_deref() == env) +} + #[derive(Serialize, Deserialize, Clone)] pub struct StsSettings { pub proxy_url: String, @@ -47,25 +71,75 @@ struct Slot { path: PathBuf, } +/// The OS-idiomatic cache directory (`~/Library/Caches` on macOS, `~/.cache` +/// on Linux, `%LocalAppData%` on Windows). fn cache_dir() -> Result { Ok(dirs::cache_dir() .ok_or("Could not determine cache directory")? .join("source-coop")) } -fn role_slot(role_arn: &str) -> Result { +/// The production proxy's host. Its slots keep the names they had before +/// environments existed, in every build: tying them to the build's default +/// proxy would let a staging build and a production build, which share the +/// keyring, read each other's caches as their own. +const PROD_HOST: &str = "data.source.coop"; + +/// The environment a proxy URL belongs to: `None` for production, otherwise +/// the proxy's host (and port, if not the scheme's default). +pub fn env(proxy_url: &str) -> Result, String> { + let url = url::Url::parse(proxy_url).map_err(|e| { + format!("Invalid proxy URL {proxy_url:?} (from --proxy-url or SOURCE_PROXY_URL): {e}") + })?; + let host = url + .host_str() + .ok_or_else(|| format!("Proxy URL {proxy_url:?} has no host"))?; + Ok(match url.port() { + Some(port) => Some(format!("{host}:{port}")), + None if host == PROD_HOST => None, + None => Some(host.to_string()), + }) +} + +/// The keyring account for `name` in `env`. Hosts can't contain `/`, so a +/// prefixed account never equals a default-environment one. +fn account(env: Option<&str>, name: &str) -> String { + match env { + None => name.to_string(), + Some(env) => format!("{env}/{name}"), + } +} + +/// The directory `env`'s cache files live in: `env/[/]`. Each +/// non-production environment gets its own, so their files can't collide with +/// each other's or prod's. The port is its own segment because `_`, which +/// sanitizing produces, is allowed in hosts. +fn env_dir(env: Option<&str>) -> Result { + let dir = cache_dir()?; + let Some(env) = env else { return Ok(dir) }; + let (host, port) = env + .rsplit_once(':') + .filter(|(_, port)| port.bytes().all(|b| b.is_ascii_digit())) + .unwrap_or((env, "")); + let dir = dir.join("env").join(sanitize(host, &['-', '.', '_'])); + Ok(if port.is_empty() { dir } else { dir.join(port) }) +} + +fn role_slot(env: Option<&str>, role_arn: &str) -> Result { Ok(Slot { - account: role_arn.to_string(), - path: cache_path(role_arn)?, + account: account(env, role_arn), + path: env_dir(env)? + .join("credentials") + .join(format!("{}.json", sanitize(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 { +fn session_slot(env: Option<&str>) -> Result { Ok(Slot { - account: "@session".to_string(), - path: cache_dir()?.join("session.json"), + account: account(env, "@session"), + path: env_dir(env)?.join("session.json"), }) } @@ -81,12 +155,11 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { ) } -/// Replace any character that isn't alphanumeric, `-`, or `_` with `_`. -fn sanitize_role_arn(role_arn: &str) -> String { - role_arn - .chars() +/// Replace any character that isn't alphanumeric or in `keep` with `_`. +fn sanitize(s: &str, keep: &[char]) -> String { + s.chars() .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + if c.is_ascii_alphanumeric() || keep.contains(&c) { c } else { '_' @@ -95,27 +168,17 @@ fn sanitize_role_arn(role_arn: &str) -> String { .collect() } -/// Full path to the credentials cache file for a given role. -/// Uses the OS-idiomatic cache directory (`~/Library/Caches` on macOS, -/// `~/.cache` on Linux, `%LocalAppData%` on Windows). -fn cache_path(role_arn: &str) -> Result { - let sanitized = sanitize_role_arn(role_arn); - Ok(cache_dir()? - .join("credentials") - .join(format!("{sanitized}.json"))) -} - /// 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 { - lock_slot(&role_slot(role_arn)?) +pub fn lock(env: Option<&str>, role_arn: &str) -> Result { + lock_slot(&role_slot(env, 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 { - lock_slot(&session_slot()?) +pub fn lock_session(env: Option<&str>) -> Result { + lock_slot(&session_slot(env)?) } fn lock_slot(slot: &Slot) -> Result { @@ -130,24 +193,47 @@ fn lock_slot(slot: &Slot) -> Result { 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(role_arn: &str, creds: &CacheEntry) -> Result { - write_slot(&role_slot(role_arn)?, creds) +pub fn write_credentials( + env: Option<&str>, + role_arn: &str, + creds: &CacheEntry, +) -> Result { + write_role(&role_slot(env, role_arn)?, creds) } -/// 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, String> { - read_slot(&role_slot(role_arn)?) +/// 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. +/// +/// Versions before environments existed cached every proxy's credentials in +/// 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, String> { + let slot = role_slot(env, role_arn)?; + let entry = read_role(&slot)?; + if entry.is_some() || env.is_none() { + return Ok(entry.filter(|e| e.belongs_to(env))); + } + 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); + }; + write_role(&slot, &legacy)?; + Ok(Some(legacy)) } -pub fn write_session(session: &Session) -> Result { - write_slot(&session_slot()?, session) +pub fn write_session(env: Option<&str>, session: &Session) -> Result { + write_slot(&session_slot(env)?, session) } -pub fn read_session() -> Result, String> { - read_slot(&session_slot()?) +/// Read `env`'s login session, or `None` if there is none or it belongs to +/// another environment's proxy. +pub fn read_session(env: Option<&str>) -> Result, String> { + let session: Option = read_slot(&session_slot(env)?)?; + Ok(session.filter(|s| s.proxy_url.as_deref().is_none_or(|p| in_env(p, env)))) } fn write_slot(slot: &Slot, value: &T) -> Result { @@ -165,26 +251,60 @@ fn write_slot(slot: &Slot, value: &T) -> Result { write_file(&slot.path, &json) } +/// 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. +fn write_role(slot: &Slot, entry: &CacheEntry) -> Result { + if entry.refresh.is_some() { + return write_slot(slot, entry); + } + let json = + serde_json::to_string(entry).map_err(|e| format!("Failed to serialize cache: {e}"))?; + write_file(&slot.path, &json) +} + +/// 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, 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(slot: &Slot) -> Result, 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}")), - } + match read_keyring(slot)? { + Some(value) => Ok(Some(value)), + None => read_file(&slot.path), } - match fs::read_to_string(&slot.path) { +} + +/// Read a slot's keyring entry. `None` if there is none or the keyring is +/// unavailable. +fn read_keyring(slot: &Slot) -> Result, 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(path: &Path) -> Result, 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())), } } @@ -232,21 +352,119 @@ mod tests { } #[test] - fn sanitize_simple_name() { - assert_eq!(sanitize_role_arn("source-coop-user"), "source-coop-user"); + fn env_normalizes_proxy_urls() { + // Production is the bare environment in every build, staging never is. + assert_eq!(env("https://data.source.coop").unwrap(), None); + assert_eq!(env("https://data.source.coop/").unwrap(), None); + assert_eq!(env("HTTPS://DATA.SOURCE.COOP").unwrap(), None); + assert_eq!(env("https://data.source.coop:443").unwrap(), None); + assert_eq!( + env("https://Data.Staging.Source.Coop/").unwrap().as_deref(), + Some("data.staging.source.coop") + ); + assert_eq!( + env("https://data.source.coop:8443").unwrap().as_deref(), + Some("data.source.coop:8443") + ); + assert_eq!( + env("http://localhost:8080").unwrap().as_deref(), + Some("localhost:8080") + ); + assert!(env("not a url").is_err()); } #[test] - fn sanitize_arn_with_special_chars() { + fn slots_never_collide_across_environments() { + let default = cache_dir().unwrap(); + let prod = role_slot(None, "_default").unwrap(); + assert_eq!(prod.account, "_default"); + assert_eq!(prod.path, default.join("credentials").join("_default.json")); + assert_eq!(session_slot(None).unwrap().account, "@session"); + + // A role whose sanitized name looks like an environment's still lands elsewhere. + let local = role_slot(Some("localhost:8080"), "_default").unwrap(); + let lookalike = role_slot(None, "localhost_8080__default").unwrap(); + assert_eq!(local.account, "localhost:8080/_default"); assert_eq!( - sanitize_role_arn("arn:aws:iam::123:role/Foo"), - "arn_aws_iam__123_role_Foo" + local.path, + default + .join("env") + .join("localhost") + .join("8080") + .join("credentials") + .join("_default.json") + ); + assert_ne!(local.path, lookalike.path); + // `_` is legal in hosts, so the port can't be joined to the host with one. + let underscored = role_slot(Some("localhost_8080"), "_default").unwrap(); + assert_ne!(local.path, underscored.path); + + let staging = session_slot(Some("data.staging.source.coop")).unwrap(); + assert_eq!(staging.account, "data.staging.source.coop/@session"); + assert_eq!( + staging.path, + default + .join("env") + .join("data.staging.source.coop") + .join("session.json") ); } #[test] - fn sanitize_preserves_underscores() { - assert_eq!(sanitize_role_arn("my_role-name"), "my_role-name"); + fn entries_belong_to_their_proxys_environment() { + let entry = |proxy_url: &str| CacheEntry { + creds: sample_creds("2026-03-01T00:00:00Z"), + sts: Some(StsSettings { + proxy_url: proxy_url.to_string(), + duration: None, + }), + refresh: None, + }; + let other = entry("https://data.staging.source.coop"); + assert!(other.belongs_to(Some("data.staging.source.coop"))); + assert!(!other.belongs_to(None)); + assert!(entry("https://data.source.coop").belongs_to(None)); + assert!(other.minted_in(Some("data.staging.source.coop"))); + let bare = CacheEntry { sts: None, ..other }; + assert!(bare.belongs_to(None)); + // A bare entry stays where it is: migration never hands it to another environment. + assert!(!bare.minted_in(Some("data.staging.source.coop"))); + } + + #[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!( + sanitize("arn:aws:iam::123:role/Foo", &['-', '_']), + "arn_aws_iam__123_role_Foo" + ); + assert_eq!(sanitize("my_role-name", &['-', '_']), "my_role-name"); } #[test] diff --git a/src/main.rs b/src/main.rs index 818657f..4612ecb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,7 +71,14 @@ enum Commands { enum AuthCommand { /// Print an access token for the source.coop API, refreshing it if expired /// (e.g. `curl -H "Authorization: Bearer $(source-coop auth token)" ...`) - Token, + Token(TokenArgs), +} + +#[derive(Parser)] +struct TokenArgs { + /// S3 proxy URL of the environment whose login session to use + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, } #[derive(Parser)] @@ -129,7 +136,7 @@ struct CredsArgs { #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] role_arn: String, - /// S3 proxy URL for STS, for a role with nothing cached yet + /// S3 proxy URL: selects the environment whose cache and login session to use #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] proxy_url: String, @@ -171,8 +178,8 @@ async fn main() { std::process::exit(1); } } - Commands::Auth(AuthCommand::Token) => { - if let Err(e) = run_auth_token(verbose).await { + Commands::Auth(AuthCommand::Token(args)) => { + if let Err(e) = run_auth_token(args, verbose).await { eprintln!("Error: {e}"); std::process::exit(1); } @@ -180,8 +187,9 @@ async fn main() { } } -async fn run_auth_token(verbose: bool) -> Result<(), String> { - let token = session::token(session::Want::Access, verbose) +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) .await? .ok_or("No access token cached. Run 'source-coop login' (with an API audience).")?; println!("{token}"); @@ -189,6 +197,8 @@ async fn run_auth_token(verbose: bool) -> Result<(), String> { } async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { + let env = cache::env(&args.proxy_url)?; + // 1. OIDC Discovery eprintln!("Discovering OIDC endpoints..."); let endpoints = oidc::discover(&args.issuer, verbose).await?; @@ -243,14 +253,15 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { } let session = session::Session::new( tokens, + args.proxy_url.clone(), args.client_id, endpoints.token_endpoint, audience.map(String::from), chrono::Utc::now().timestamp(), ); { - let _lock = cache::lock_session()?; - cache::write_session(&session)?; + let _lock = cache::lock_session(env.as_deref())?; + cache::write_session(env.as_deref(), &session)?; } let entry = cache::CacheEntry { creds, @@ -260,22 +271,27 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { }), refresh: None, }; - let location = cache::write_credentials(&args.role_arn, &entry)?; + // Under the role's lock, so a `creds` minting from the old session can't + // overwrite this entry afterwards. + let _lock = cache::lock(env.as_deref(), &args.role_arn)?; + let location = cache::write_credentials(env.as_deref(), &args.role_arn, &entry)?; eprintln!("Credentials cached to {location}"); eprintln!("Run 'source-coop creds' to print them."); Ok(()) } async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { - let cached = cache::read_credentials(&args.role_arn)?; + let env = cache::env(&args.proxy_url)?; + let env = env.as_deref(); + let cached = cache::read_credentials(env, &args.role_arn)?; let entry = match cached { Some(entry) if !cache::is_expired(&entry.creds)? => entry, _ => { // Re-check under the lock: another process may have minted already. - let _lock = cache::lock(&args.role_arn)?; - match cache::read_credentials(&args.role_arn)? { + let _lock = cache::lock(env, &args.role_arn)?; + match cache::read_credentials(env, &args.role_arn)? { Some(entry) if !cache::is_expired(&entry.creds)? => entry, - stale => mint(&args, stale, verbose).await.map_err(|e| { + stale => mint(&args, env, stale, verbose).await.map_err(|e| { format!("Couldn't get new credentials ({e}). Run 'source-coop login'.") })?, } @@ -295,10 +311,11 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { /// else exchanges the session's ID token, refreshing the session if need be. async fn mint( args: &CredsArgs, + env: Option<&str>, stale: Option, verbose: bool, ) -> Result { - let save = |e: &cache::CacheEntry| cache::write_credentials(&args.role_arn, e).map(drop); + let save = |e: &cache::CacheEntry| cache::write_credentials(env, &args.role_arn, e).map(drop); match stale { Some(entry) if entry.sts.is_none() && entry.refresh.is_some() => { refresh(&args.role_arn, entry, verbose, save).await @@ -308,7 +325,7 @@ async fn mint( proxy_url: args.proxy_url.clone(), duration: None, }); - let id_token = session::token(session::Want::Id, verbose) + let id_token = session::token(env, session::Want::Id, verbose) .await? .ok_or("not logged in")?; let creds = sts::assume_role( diff --git a/src/session.rs b/src/session.rs index f349aca..ffcd5a2 100644 --- a/src/session.rs +++ b/src/session.rs @@ -18,6 +18,10 @@ const SKEW_SECONDS: i64 = 60; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { + /// The proxy `login` was given, which names the session's environment. + /// Absent in sessions written before environments existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy_url: Option, pub client_id: String, pub token_endpoint: String, /// What the access token was requested for, if anything. @@ -45,12 +49,14 @@ pub enum Want { impl Session { pub fn new( tokens: oidc::Tokens, + proxy_url: String, client_id: String, token_endpoint: String, audience: Option, now: i64, ) -> Self { let mut session = Session { + proxy_url: Some(proxy_url), client_id, token_endpoint, audience, @@ -143,22 +149,23 @@ pub async fn ensure( .map_err(|()| "The refreshed token has already expired; check the system clock.".into()) } -/// The wanted token from the cached session, or `None` if there is no session -/// (or it can't have that token). Refreshes under the session lock when needed. -pub async fn token(want: Want, verbose: bool) -> Result, String> { - let Some(session) = cache::read_session()? else { +/// The wanted token from `env`'s cached session, or `None` if there is no +/// session (or it can't have that token). Refreshes under the session lock +/// when needed. +pub async fn token(env: Option<&str>, want: Want, verbose: bool) -> Result, String> { + let Some(session) = cache::read_session(env)? else { return Ok(None); }; if let Ok(token) = session.current(want, now()) { return Ok(token.map(String::from)); } // Re-read under the lock: another process may have refreshed already. - let _lock = cache::lock_session()?; - let Some(mut session) = cache::read_session()? else { + let _lock = cache::lock_session(env)?; + let Some(mut session) = cache::read_session(env)? else { return Ok(None); }; ensure(&mut session, want, now(), verbose, |s| { - cache::write_session(s).map(drop) + cache::write_session(env, s).map(drop) }) .await } @@ -190,6 +197,7 @@ mod tests { fn session(server: &MockServer, t: oidc::Tokens) -> Session { Session::new( t, + "https://data.source.coop".into(), "cid".into(), format!("{}/oauth2/token", server.uri()), Some("https://source.coop".into()),