diff --git a/README.md b/README.md index f6dd87b..bb22f4b 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,29 @@ 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). + +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: @@ -161,6 +184,7 @@ 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) | diff --git a/src/main.rs b/src/main.rs index 7d355fe..7c3919c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,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"))] @@ -29,6 +30,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 @@ -64,7 +69,7 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Log in, and print tokens for the source.coop API + /// Log in, see whether you are, and print tokens for the source.coop API #[command(subcommand)] Auth(AuthCommand), /// Output cached credentials as credential_process JSON, shell env vars, @@ -114,11 +119,25 @@ 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), /// 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 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, +} + #[derive(Parser)] struct TokenArgs { /// S3 proxy URL of the environment whose login session to use @@ -162,6 +181,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, @@ -228,6 +252,11 @@ async fn main() { fail(e); } } + Commands::Auth(AuthCommand::Status(args)) => { + if let Err(e) = run_status(args) { + fail(e); + } + } Commands::Auth(AuthCommand::Token(args)) => { if let Err(e) = run_auth_token(args, verbose).await { fail(e); @@ -263,6 +292,239 @@ async fn main() { } } +/// 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 + { + 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::read_credentials(env.as_deref(), &args.role_arn)?; + let lifetime = refresh_lifetime()?; + print!( + "{}", + status_report( + &args.proxy_url, + env.as_deref(), + &args.role_arn, + session.as_ref(), + entry.as_ref().map(|e| &e.creds), + chrono::Utc::now().timestamp(), + Some((lifetime.0, lifetime.1.as_str())), + 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(); + }; + if at <= now { + return product::ago(at_t, now_t); + } + match product::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)) +} + +/// `