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/AGENTS.md b/AGENTS.md index 7f5b672..3838695 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 7f0cc5d..35eaabb 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 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: @@ -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,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 ` for each role. Tokens aren't revoked at the identity provider. + ### Login options | Flag | Env var | Default | Description | @@ -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: @@ -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`. 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. @@ -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..3e65db6 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -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, 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, String> { + find_credentials(env, role_arn, false) +} + +fn find_credentials( + env: Option<&str>, + role_arn: &str, + migrate: bool, +) -> Result, String> { let slot = role_slot(env, role_arn)?; let entry: Option = read_slot(&slot)?; if entry.is_some() || env.is_none() { @@ -221,7 +234,9 @@ pub fn read_credentials(env: Option<&str>, role_arn: &str) -> Result) -> 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 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::(&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::(&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 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 { + 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 +345,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 +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!( 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..44874b5 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,51 @@ 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, + + /// Print what was removed as JSON + #[arg(long)] + json: bool, +} + +#[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 @@ -211,7 +256,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 +266,18 @@ 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)) => { + let json = args.json; + if let Err(e) = run_logout(args) { + fail(e, json); + } + } Commands::Auth(AuthCommand::Token(args)) => { if let Err(e) = run_auth_token(args, verbose).await { fail(e, false); @@ -239,32 +296,379 @@ 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") +/// The account `access_token` acts as, from `/whoami` on the API at +/// `api_url` (the one the token is for), 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 me = match api::Client::new(api_url, Some(access_token?), verbose) { + Ok(client) => client + .get::(client.url(&["whoami"])) + .await + .map_err(|e| e.to_string()), + Err(e) => Err(e), + }; + match me { + Ok(me) => session::Account::from_whoami(&me), + Err(e) => { + eprintln!("Couldn't look up your account ({e}); `auth status` won't name it."); + None + } + } +} + +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::peek_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 = lifetime.as_ref().map(|(secs, text)| (*secs, text.as_str())); + if args.json { + output::json(&status_json( + &args.proxy_url, + &args.role_arn, + session, + creds, + now, + lifetime, + )); + } else { + print!( + "{}", + status_report( + &args.proxy_url, + &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. +/// An unreadable value is warned about and leaves the estimate out. +fn refresh_lifetime() -> Option<(i64, String)> { + let text = std::env::var("SOURCE_REFRESH_TOKEN_LIFETIME") .ok() - .filter(|t| !t.trim().is_empty()) - { - return Some(token); + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| defaults::REFRESH_TOKEN_LIFETIME.to_string()); + match parse_duration(&text) { + Ok(secs) => Some((secs as i64, text.trim().to_string())), + Err(e) => { + eprintln!("Warning: ignoring SOURCE_REFRESH_TOKEN_LIFETIME: {e}"); + None + } } - let token = async { - let env = cache::env(proxy_url)?; - session::token(env.as_deref(), session::Want::Access, verbose).await +} + +/// 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)) +} + +/// When `s`'s refresh token lapses, if it has one and that's known: the +/// provider's expiry, else (`true`) an estimate, the last renewal plus +/// `lifetime`. Ory gives no expiry, so there it's always the estimate. +fn refresh_expiry(s: &session::Session, lifetime: Option<(i64, &str)>) -> Option<(i64, bool)> { + s.refresh_token.as_ref()?; + match s.refresh_token_expires_at { + Some(at) => Some((at, false)), + None => Some((s.refresh_token_issued_at? + lifetime?.0, true)), + } +} + +/// The login's state: `expired` once its refresh token is past the expiry +/// the provider gave (or, without a refresh token, once its ID token is), +/// `likely_expired` once only the estimate has passed, as that's a guess at +/// the server's setting, else `logged_in`. +fn login_state(s: &session::Session, now: i64, lifetime: Option<(i64, &str)>) -> &'static str { + let lapsed = match &s.refresh_token { + Some(_) => refresh_expiry(s, lifetime) + .filter(|(at, _)| *at <= now) + .map(|(_, estimated)| estimated), + None => session::jwt_exp(&s.id_token) + .filter(|e| *e <= now) + .map(|_| false), + }; + match lapsed { + None => "logged_in", + Some(true) => "likely_expired", + Some(false) => "expired", + } +} + +/// `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, + 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 state = login_state(s, now, refresh_lifetime); + let estimate = refresh_expiry(s, refresh_lifetime) + .filter(|(_, estimated)| *estimated) + .zip(refresh_lifetime) + .map(|((at, _), (_, label))| (at, label)); + json!({ + "state": state, + "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!({ + "proxy_url": proxy_url, + // Only a known expiry ends it: a likely-expired login may still work. + "logged_in": login.as_ref().is_some_and(|l| l["state"] != "expired"), + "login": login, + "credentials": credentials, }) } +/// `