From e6c69b3d078cf73475681203839f915785cfc98c Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 11:37:33 -0700 Subject: [PATCH 1/6] feat: auth commands: login, status, logout and token under `auth` - auth login (the top-level login still works, hidden): records the account from /whoami (--api-url) and when the refresh token was issued. - auth status: environment, login state, account and profile link, when the refresh token was renewed and when it expires if unused (Ory's refresh tokens are opaque and its token response gives no lifetime, so this is estimated from 30 days, or SOURCE_REFRESH_TOKEN_LIFETIME), each token's expiry, and the role's credentials; --json gives the same as data, with estimated_lapse_at and assumed_lifetime. - auth logout: removes an environment's session and a role's cached credentials. - Hints now say 'source-coop auth login'. Built on the shared foundation (api client, failure/exit codes, output::json, style). Co-Authored-By: Claude Opus 5.5 --- .env.staging | 5 +- README.md | 71 ++++- src/api.rs | 9 +- src/cache.rs | 68 ++++- src/failure.rs | 2 +- src/main.rs | 811 +++++++++++++++++++++++++++++++++++++++++++++++-- src/oidc.rs | 7 + src/session.rs | 75 ++++- 8 files changed, 996 insertions(+), 52 deletions(-) diff --git a/.env.staging b/.env.staging index 448c8c6..f1cf2f2 100644 --- a/.env.staging +++ b/.env.staging @@ -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 diff --git a/README.md b/README.md index 7f0cc5d..b04a2f6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -57,9 +59,9 @@ 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/... @@ -67,7 +69,7 @@ curl -H "Authorization: Bearer $(source-coop auth token)" https://source.coop/ap ### 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): @@ -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. @@ -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 at `--api-url`) 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 production (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. Where a provider does give an expiry, that's shown instead. 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: 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: @@ -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: @@ -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: @@ -146,6 +177,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 ` for each role. Tokens aren't revoked at the identity provider. + ### Login options | Flag | Env var | Default | Description | @@ -159,12 +201,13 @@ endpoint_url = https://data.staging.source.coop | `--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 | +| `--api-url` | `SOURCE_API_URL` | `https://source.coop` | source.coop site whose API names the account you logged in as, for `auth status` | | `--port` | | `0` (random) | Local callback port | | `--no-cache` | | | Skip caching credentials (just print to stdout) | ### 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: @@ -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: @@ -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`. + ## 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. @@ -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 --port 8400 +source-coop auth login --role-arn --port 8400 ``` ### Client configuration diff --git a/src/api.rs b/src/api.rs index d785ac1..51aabc5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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(()) } @@ -218,7 +221,7 @@ impl Client { pub async fn check_signed_in(&self) -> Result { 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 @@ -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")); } } diff --git a/src/cache.rs b/src/cache.rs index 2757b1b..cabf494 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -236,6 +236,49 @@ pub fn read_session(env: Option<&str>) -> Result, 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 { + 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::(&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 { + 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 { + 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 { + 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(slot: &Slot, value: &T) -> Result { let json = serde_json::to_string(value).map_err(|e| format!("Failed to serialize cache: {e}"))?; @@ -279,13 +322,24 @@ pub(crate) fn write_file(path: &Path, json: &str) -> Result { 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()) } @@ -397,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!( diff --git a/src/failure.rs b/src/failure.rs index 0193a79..0153a9e 100644 --- a/src/failure.rs +++ b/src/failure.rs @@ -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); diff --git a/src/main.rs b/src/main.rs index 9269a03..3b4ed96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod api_cmd; mod cache; mod failure; mod oidc; +mod output; mod session; mod sts; // Reusable pieces for the commands built on them (resources such as @@ -12,8 +13,6 @@ mod api; #[allow(dead_code)] mod draft; #[allow(dead_code)] -mod output; -#[allow(dead_code)] mod picker; #[allow(dead_code)] mod prompt; @@ -29,6 +28,7 @@ mod defaults { pub const PROXY_URL: &str = "https://data.staging.source.coop"; pub const ROLE_ARN: &str = "_default"; pub const API_URL: &str = "https://staging.source.coop"; + pub const REFRESH_TOKEN_LIFETIME: &str = "30d"; } #[cfg(not(feature = "staging"))] @@ -38,6 +38,10 @@ mod defaults { pub const PROXY_URL: &str = "https://data.source.coop"; pub const ROLE_ARN: &str = "_default"; pub const API_URL: &str = "https://source.coop"; + /// Ory's default `oauth2.ttl.refresh_token`: its refresh tokens carry no + /// expiry and the token response gives none, so `auth status` counts from + /// the last renewal. Overridden by `SOURCE_REFRESH_TOKEN_LIFETIME`. + pub const REFRESH_TOKEN_LIFETIME: &str = "30d"; } /// Parse a duration into seconds. Accepts a bare number (seconds) or a value @@ -73,14 +77,15 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Authenticate via OIDC and obtain temporary S3 credentials - Login(LoginArgs), + /// Log in, see whether you are, log out, and print tokens for the source.coop API + #[command(subcommand)] + Auth(AuthCommand), /// Output cached credentials as credential_process JSON, shell env vars, /// or an AWS credentials-file profile, refreshing them first if expired Creds(CredsArgs), - /// Work with the login session - #[command(subcommand)] - Auth(AuthCommand), + /// Log in through the browser (now `auth login`; this name still works) + #[command(hide = true)] + Login(LoginArgs), /// Make any request to the source.coop API, signed in as you Api(ApiCommandArgs), } @@ -101,11 +106,47 @@ struct ApiCommandArgs { #[derive(Subcommand)] enum AuthCommand { + /// Log in through the browser, keeping the session and a role's S3 + /// credentials for `creds` and the API + Login(LoginArgs), + /// Show the cached login session and a role's credentials, without + /// refreshing either or going to the network + Status(StatusArgs), + /// 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 StatusArgs { + /// S3 proxy URL: selects the environment to report on + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, + + /// Role ARN whose cached credentials to report on + #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] + role_arn: String, + + /// Print the status as JSON + #[arg(long)] + json: bool, +} + #[derive(Parser)] struct TokenArgs { /// S3 proxy URL of the environment whose login session to use @@ -149,6 +190,11 @@ struct LoginArgs { #[arg(long, env = "SOURCE_API_AUDIENCE", default_value = defaults::API_URL)] audience: String, + /// source.coop site URL whose API names the account you logged in as, + /// shown by `auth status` + #[arg(long, env = "SOURCE_API_URL", default_value = defaults::API_URL)] + api_url: String, + /// Local callback port (0 for random available port) #[arg(long, default_value = "0")] port: u16, @@ -211,7 +257,7 @@ async fn main() { let verbose = cli.verbose; match cli.command { - Commands::Login(args) => { + Commands::Auth(AuthCommand::Login(args)) | Commands::Login(args) => { if let Err(e) = run_login(args, verbose).await { fail(e, false); } @@ -221,6 +267,17 @@ async fn main() { fail(e, false); } } + Commands::Auth(AuthCommand::Status(args)) => { + let json = args.json; + if let Err(e) = run_status(args) { + fail(e, json); + } + } + Commands::Auth(AuthCommand::Logout(args)) => { + if let Err(e) = run_logout(args) { + fail(e, false); + } + } Commands::Auth(AuthCommand::Token(args)) => { if let Err(e) = run_auth_token(args, verbose).await { fail(e, false); @@ -239,32 +296,347 @@ async fn main() { } } -/// The bearer for the source.coop API: `SOURCE_TOKEN` if set (for a token -/// obtained some other way), else the login session's access token. Without -/// either, commands run signed out, which is enough to read public data. -async fn api_token(proxy_url: &str, verbose: bool) -> Option { - // Set but empty is unset, so `SOURCE_TOKEN=` doesn't skip the session. - if let Some(token) = std::env::var("SOURCE_TOKEN") - .ok() - .filter(|t| !t.trim().is_empty()) +/// The account `access_token` acts as, from the API's `/whoami`, to keep +/// with the session. None without a token; a failed lookup is noted, not an +/// error, since the login itself worked. +async fn whoami( + api_url: &str, + access_token: Option, + verbose: bool, +) -> Option { + let client = api::Client::new(api_url, Some(access_token?), verbose).ok()?; + match client + .get::(client.url(&["whoami"])) + .await { - return Some(token); + Ok(me) => session::Account::from_whoami(&me), + Err(e) => { + eprintln!("Couldn't look up your account ({e}); `auth status` won't name it."); + None + } } - let token = async { - let env = cache::env(proxy_url)?; - session::token(env.as_deref(), session::Want::Access, verbose).await +} + +fn run_status(args: StatusArgs) -> Result<(), String> { + let env = cache::env(&args.proxy_url)?; + let session = cache::read_session(env.as_deref())?; + let entry = cache::read_credentials(env.as_deref(), &args.role_arn)?; + let (session, creds) = (session.as_ref(), entry.as_ref().map(|e| &e.creds)); + let now = chrono::Utc::now().timestamp(); + let lifetime = refresh_lifetime()?; + let lifetime = Some((lifetime.0, lifetime.1.as_str())); + if args.json { + output::json(&status_json( + &args.proxy_url, + env.as_deref(), + &args.role_arn, + session, + creds, + now, + lifetime, + )); + } else { + print!( + "{}", + status_report( + &args.proxy_url, + env.as_deref(), + &args.role_arn, + session, + creds, + now, + lifetime, + style::wanted(std::io::IsTerminal::is_terminal(&std::io::stdout())), + ) + ); + } + Ok(()) +} + +/// How long a refresh token lasts unused, in seconds, with how it was given +/// (`30d`): `SOURCE_REFRESH_TOKEN_LIFETIME`, else this build's default. The +/// provider doesn't say, so this is what `auth status` estimates from. It's +/// only for display: OAuth2 has no way to ask for a refresh token's lifetime, +/// so nothing sent at login depends on it; Ory's project config decides. +fn refresh_lifetime() -> Result<(i64, String), String> { + let text = std::env::var("SOURCE_REFRESH_TOKEN_LIFETIME") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| defaults::REFRESH_TOKEN_LIFETIME.to_string()); + let secs = parse_duration(&text).map_err(|e| format!("SOURCE_REFRESH_TOKEN_LIFETIME: {e}"))?; + Ok((secs as i64, text.trim().to_string())) +} + +/// The site a session's account lives on: the API audience it was issued +/// for (`https://source.coop`, or a staging or preview site), else this +/// build's. +fn site(s: &session::Session) -> &str { + s.audience + .as_deref() + .filter(|a| !a.is_empty()) + .unwrap_or(defaults::API_URL) + .trim_end_matches('/') +} + +/// `at` relative to `now`, in the largest whole unit: "3 hours ago", or +/// "in 29 days" while still ahead. +fn relative(at: i64, now: i64) -> String { + let Some((at_t, now_t)) = + chrono::DateTime::from_timestamp(at, 0).zip(chrono::DateTime::from_timestamp(now, 0)) + else { + return String::new(); }; - token.await.unwrap_or_else(|e| { - eprintln!("Warning: continuing signed out: {e}"); - None + if at <= now { + return style::ago(at_t, now_t); + } + match style::ago(now_t, at_t).as_str() { + "just now" => "in under a minute".into(), + ahead => format!("in {}", ahead.trim_end_matches(" ago")), + } +} + +/// A Unix timestamp as RFC 3339, the API's date format. +fn rfc3339(t: i64) -> Option { + chrono::DateTime::from_timestamp(t, 0) + .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) +} + +/// `auth status --json`: what `status_report` shows, as data. Times are RFC +/// 3339 with an `expired` flag, as of `now`; absent ones are null. +fn status_json( + proxy_url: &str, + env: Option<&str>, + role_arn: &str, + session: Option<&session::Session>, + creds: Option<&sts::Credentials>, + now: i64, + refresh_lifetime: Option<(i64, &str)>, +) -> serde_json::Value { + use serde_json::{json, Value}; + let at = |t: Option| match t { + Some(t) => json!({"expires_at": rfc3339(t), "expired": t <= now}), + None => json!({"expires_at": null, "expired": null}), + }; + let login = session.map(|s| { + let id_exp = session::jwt_exp(&s.id_token); + let expired = s.refresh_token.is_none() && id_exp.is_some_and(|e| e <= now); + let estimate = match (&s.refresh_token, s.refresh_token_expires_at) { + (Some(_), None) => s + .refresh_token_issued_at + .zip(refresh_lifetime) + .map(|(issued, (secs, label))| (issued + secs, label)), + _ => None, + }; + json!({ + "state": if expired { "expired" } else { "logged_in" }, + "account": s.account.as_ref().map(|a| json!({ + "account_id": a.account_id, + "name": a.name, + "profile_url": format!("{}/{}", site(s), a.account_id), + })), + "refresh_token": { + "present": s.refresh_token.is_some(), + "issued_at": s.refresh_token_issued_at.and_then(rfc3339), + "expires_at": s.refresh_token_expires_at.and_then(rfc3339), + // When the provider gave no expiry: the last renewal plus the + // assumed lifetime, as `auth status` estimates it. + "estimated_lapse_at": estimate.and_then(|(at, _)| rfc3339(at)), + "assumed_lifetime": estimate.map(|(_, label)| label), + }, + "id_token": at(id_exp), + "access_token": match &s.access_token { + None => Value::Null, + Some(_) => { + let mut a = at(s.access_token_expires_at); + a["audience"] = json!(s.audience); + a + } + }, + }) + }); + let credentials = match creds { + None => json!({"role_arn": role_arn, "cached": false}), + Some(c) => { + let exp = chrono::DateTime::parse_from_rfc3339(&c.expiration).ok(); + json!({ + "role_arn": role_arn, + "cached": true, + "expires_at": c.expiration, + "expired": exp.map(|t| t.timestamp() <= now), + }) + } + }; + json!({ + "environment": {"name": env.unwrap_or("production"), "proxy_url": proxy_url}, + "logged_in": login.as_ref().is_some_and(|l| l["state"] == "logged_in"), + "login": login, + "credentials": credentials, }) } +/// `