diff --git a/README.md b/README.md index 2d0143c..f48bd97 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ aws s3 ls s3://my-account/my-product --profile source-coop When credentials expire, `source-coop creds` uses the cached refresh token to fetch new ones automatically. Run `source-coop login` again only when that fails (e.g. the refresh token has expired or been revoked). +`login` keeps one session for everything: `creds` for any role exchanges its ID token at the proxy, and commands that call the source.coop API send its access token. `source-coop auth token` prints that access token, refreshed if it has expired: + +```bash +curl -H "Authorization: Bearer $(source-coop auth token)" https://source.coop/api/v1/... +``` + ### Logging in on a remote server (no browser) `login` receives the OAuth2 redirect on a local port, so on a headless machine, forward that port over SSH and complete the login in your local browser: @@ -131,6 +137,7 @@ endpoint_url = https://data.source.coop | `--profile` | | `source-coop` | Profile name for `--format aws-credentials` | | `--duration` | | | Session duration, e.g. `3600`, `90s`, `5m`, `12h`, `1d` (bare number = seconds) | | `--scope` | | `openid offline_access` | OAuth2 scopes (`offline_access` enables automatic refresh in `creds`) | +| `--audience` | `SOURCE_API_AUDIENCE` | `https://source.coop` | Audience of the access token for the source.coop API; empty to request none | | `--port` | | `0` (random) | Local callback port | | `--no-cache` | | | Skip caching credentials (just print to stdout) | @@ -171,11 +178,11 @@ Use `--profile` to change the section name. ## Credential storage -The CLI caches temporary STS credentials so that `creds` can output them without re-authenticating. +The CLI caches the login session (the Ory refresh, ID and access tokens) and the temporary STS credentials for each role, so that `creds` and API commands work without re-authenticating. The refresh token is kept in the session only: it rotates on use, so every role and every API call refreshes through it, one at a time. ### OS keyring (default) -Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN: +Credentials are stored in the OS-native keyring under the service name `source-coop-cli`, keyed by role ARN, with the session under `@session`: | Platform | Backend | |----------|---------| @@ -193,6 +200,8 @@ 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`. + The fallback is automatic — no configuration is needed. ## OIDC provider setup @@ -221,5 +230,7 @@ The OAuth2 client should be configured as a **public client** (no client secret) - **Grant type**: Authorization Code - **Token endpoint auth method**: `none` (public client, PKCE used instead) -- **Allowed scopes**: `openid` +- **Allowed scopes**: `openid`, `offline_access` - **Redirect URIs**: `http://127.0.0.1/callback` (see above) +- **Allowed audiences**: the source.coop site the API is served from (`https://source.coop`, or `https://staging.source.coop`). Without it, Ory refuses a login that asks for that audience. +- **Access token strategy**: `jwt`, so the API can verify access tokens against Ory's published keys rather than asking Ory about each one diff --git a/src/cache.rs b/src/cache.rs index b7c5bad..973b498 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,23 +1,36 @@ +use crate::session::Session; use crate::sts::Credentials; use chrono::Utc; -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::fs; use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const KEYRING_SERVICE: &str = "source-coop-cli"; -/// What gets cached per role: the STS credentials plus, when the IdP issued -/// one, what's needed to mint fresh credentials without a browser login. -/// `flatten` keeps caches written by older versions (bare credentials) readable. +/// What gets cached per role: the STS credentials, and how to mint new ones +/// from the login session when they expire. `flatten` keeps caches written by +/// older versions (bare credentials) readable. #[derive(Serialize, Deserialize)] pub struct CacheEntry { #[serde(flatten)] pub creds: Credentials, + /// The STS settings `login` was given, reused when minting new credentials. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sts: Option, + /// A refresh token of the role's own, from versions before the login + /// session was shared. Still honoured so those caches keep working; a new + /// `login` replaces it. #[serde(default, skip_serializing_if = "Option::is_none")] pub refresh: Option, } +#[derive(Serialize, Deserialize, Clone)] +pub struct StsSettings { + pub proxy_url: String, + pub duration: Option, +} + #[derive(Serialize, Deserialize)] pub struct RefreshState { pub refresh_token: String, @@ -27,6 +40,35 @@ pub struct RefreshState { pub duration: Option, } +/// Where one cached item lives: its keyring account, and its file when the +/// keyring is unavailable. +struct Slot { + account: String, + path: PathBuf, +} + +fn cache_dir() -> Result { + Ok(dirs::cache_dir() + .ok_or("Could not determine cache directory")? + .join("source-coop")) +} + +fn role_slot(role_arn: &str) -> Result { + Ok(Slot { + account: role_arn.to_string(), + path: cache_path(role_arn)?, + }) +} + +/// The login session's slot. `@` can't begin a role ARN or role name, so its +/// keyring account never collides with a role's. +fn session_slot() -> Result { + Ok(Slot { + account: "@session".to_string(), + path: cache_dir()?.join("session.json"), + }) +} + /// Returns `true` for keyring errors that indicate the keyring backend is /// unavailable (headless Linux, containers, CI). These trigger a fallback to /// file-based caching. Other error variants are treated as hard errors. @@ -57,20 +99,27 @@ fn sanitize_role_arn(role_arn: &str) -> String { /// Uses the OS-idiomatic cache directory (`~/Library/Caches` on macOS, /// `~/.cache` on Linux, `%LocalAppData%` on Windows). fn cache_path(role_arn: &str) -> Result { - let cache_dir = dirs::cache_dir().ok_or("Could not determine cache directory")?; let sanitized = sanitize_role_arn(role_arn); - Ok(cache_dir - .join("source-coop") + Ok(cache_dir()? .join("credentials") .join(format!("{sanitized}.json"))) } -/// Take an exclusive per-role lock, held until the returned file is dropped. -/// Refresh tokens rotate on use, and the IdP may revoke the whole token family -/// if an old one is replayed, so concurrent `creds` calls must not refresh in -/// parallel. +/// Take an exclusive per-role lock, held until the returned file is dropped, +/// so concurrent `creds` calls for one role mint once. pub fn lock(role_arn: &str) -> Result { - let path = cache_path(role_arn)?.with_extension("lock"); + lock_slot(&role_slot(role_arn)?) +} + +/// Take the login session's lock. Refresh tokens rotate on use, and the IdP +/// may revoke the whole token family if an old one is replayed, so nothing may +/// refresh the session in parallel. Taken after a role's lock, never before. +pub fn lock_session() -> Result { + lock_slot(&session_slot()?) +} + +fn lock_slot(slot: &Slot) -> Result { + let path = slot.path.with_extension("lock"); let dir = path.parent().unwrap(); fs::create_dir_all(dir) .map_err(|e| format!("Failed to create cache directory {}: {e}", dir.display()))?; @@ -81,99 +130,77 @@ pub fn lock(role_arn: &str) -> Result { Ok(file) } -/// Write credentials to a cache file. Returns the file path as a string. -fn write_credentials_file(role_arn: &str, creds: &CacheEntry) -> Result { - let path = cache_path(role_arn)?; - let dir = path.parent().unwrap(); - - fs::create_dir_all(dir) - .map_err(|e| format!("Failed to create cache directory {}: {e}", dir.display()))?; - - let json = serde_json::to_string_pretty(creds) - .map_err(|e| format!("Failed to serialize credentials: {e}"))?; - - fs::write(&path, &json) - .map_err(|e| format!("Failed to write credentials cache {}: {e}", path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) - .map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?; - } +/// Write credentials, trying the OS keyring first with file fallback. +/// Returns a human-readable description of where credentials were stored. +pub fn write_credentials(role_arn: &str, creds: &CacheEntry) -> Result { + write_slot(&role_slot(role_arn)?, creds) +} - Ok(path.display().to_string()) +/// Read credentials, trying the OS keyring first with file fallback. +/// Returns `None` if no cached credentials are found in either location. +pub fn read_credentials(role_arn: &str) -> Result, String> { + read_slot(&role_slot(role_arn)?) } -/// Read credentials from a cache file. Returns `None` if the file does not exist. -fn read_credentials_file(role_arn: &str) -> Result, String> { - let path = cache_path(role_arn)?; - match fs::read_to_string(&path) { - Ok(contents) => { - let creds: CacheEntry = serde_json::from_str(&contents) - .map_err(|e| format!("Failed to parse credentials cache: {e}"))?; - Ok(Some(creds)) - } - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(format!( - "Failed to read credentials cache {}: {e}", - path.display() - )), - } +pub fn write_session(session: &Session) -> Result { + write_slot(&session_slot()?, session) } -/// Write credentials, trying the OS keyring first with file fallback. -/// Returns a human-readable description of where credentials were stored. -pub fn write_credentials(role_arn: &str, creds: &CacheEntry) -> Result { - let json = serde_json::to_string(creds) - .map_err(|e| format!("Failed to serialize credentials: {e}"))?; +pub fn read_session() -> Result, String> { + read_slot(&session_slot()?) +} - let entry = keyring::Entry::new(KEYRING_SERVICE, role_arn) - .map_err(|e| format!("Failed to create keyring entry: {e}")); +fn write_slot(slot: &Slot, value: &T) -> Result { + let json = + serde_json::to_string(value).map_err(|e| format!("Failed to serialize cache: {e}"))?; - if let Ok(entry) = entry { + if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &slot.account) { match entry.set_password(&json) { - Ok(()) => { - return Ok(format!("OS keyring (service: {KEYRING_SERVICE})")); - } - Err(ref e) if is_keyring_unavailable(e) => { - // Fall through to file-based caching - } - Err(e) => { - return Err(format!("Failed to write credentials to keyring: {e}")); - } + Ok(()) => return Ok(format!("OS keyring (service: {KEYRING_SERVICE})")), + // Fall through to file-based caching + Err(ref e) if is_keyring_unavailable(e) => {} + Err(e) => return Err(format!("Failed to write to keyring: {e}")), } } - - write_credentials_file(role_arn, creds) + write_file(&slot.path, &json) } -/// Read credentials, trying the OS keyring first with file fallback. -/// Returns `None` if no cached credentials are found in either location. -pub fn read_credentials(role_arn: &str) -> Result, String> { - let entry = keyring::Entry::new(KEYRING_SERVICE, role_arn) - .map_err(|e| format!("Failed to create keyring entry: {e}")); - - if let Ok(entry) = entry { +fn read_slot(slot: &Slot) -> Result, String> { + if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &slot.account) { match entry.get_password() { Ok(json) => { - let creds: CacheEntry = serde_json::from_str(&json) - .map_err(|e| format!("Failed to parse credentials from keyring: {e}"))?; - return Ok(Some(creds)); - } - Err(keyring::Error::NoEntry) => { - // Keyring works but nothing stored — fall through to file - } - Err(ref e) if is_keyring_unavailable(e) => { - // Keyring unavailable — fall through to file - } - Err(e) => { - return Err(format!("Failed to read credentials from keyring: {e}")); + return serde_json::from_str(&json) + .map(Some) + .map_err(|e| format!("Failed to parse cache from keyring: {e}")); } + // Keyring works but nothing stored, or is unavailable: try the file + Err(keyring::Error::NoEntry) => {} + Err(ref e) if is_keyring_unavailable(e) => {} + Err(e) => return Err(format!("Failed to read from keyring: {e}")), } } + match fs::read_to_string(&slot.path) { + Ok(contents) => serde_json::from_str(&contents) + .map(Some) + .map_err(|e| format!("Failed to parse cache {}: {e}", slot.path.display())), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("Failed to read cache {}: {e}", slot.path.display())), + } +} - read_credentials_file(role_arn) +/// Write a cache file readable only by its owner. Returns the file path. +fn write_file(path: &Path, json: &str) -> Result { + let dir = path.parent().unwrap(); + fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create cache directory {}: {e}", dir.display()))?; + fs::write(path, json).map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?; + } + Ok(path.display().to_string()) } /// Check if credentials are expired or will expire within a 60-second buffer. @@ -268,6 +295,7 @@ mod tests { assert!(entry.refresh.is_none()); assert_eq!(entry.creds.access_key_id, "AKIAIOSFODNN7EXAMPLE"); + assert!(entry.sts.is_none()); let entry = CacheEntry { refresh: Some(RefreshState { refresh_token: "rt".to_string(), diff --git a/src/main.rs b/src/main.rs index 6516a00..818657f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod cache; mod oidc; mod output; +mod session; mod sts; use clap::{Parser, Subcommand, ValueEnum}; @@ -11,6 +12,7 @@ mod defaults { pub const CLIENT_ID: &str = "a79c9537-be78-454a-9ea1-b96a1be811cc"; 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"; } #[cfg(not(feature = "staging"))] @@ -19,6 +21,7 @@ mod defaults { pub const CLIENT_ID: &str = "197e20e7-d52d-4d1d-9e54-4b73a342034b"; pub const PROXY_URL: &str = "https://data.source.coop"; pub const ROLE_ARN: &str = "_default"; + pub const API_URL: &str = "https://source.coop"; } /// Parse a duration into seconds. Accepts a bare number (seconds) or a value @@ -59,6 +62,16 @@ enum Commands { /// 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), +} + +#[derive(Subcommand)] +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, } #[derive(Parser)] @@ -92,6 +105,11 @@ struct LoginArgs { #[arg(long, default_value = "openid offline_access")] scope: String, + /// Audience for the access token the source.coop API takes; pass an empty + /// value to request none (then only `creds` works) + #[arg(long, env = "SOURCE_API_AUDIENCE", default_value = defaults::API_URL)] + audience: String, + /// Local callback port (0 for random available port) #[arg(long, default_value = "0")] port: u16, @@ -111,6 +129,10 @@ 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 + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, + /// Output format #[arg(long, default_value = "credential-process")] format: OutputFormat, @@ -149,16 +171,39 @@ async fn main() { std::process::exit(1); } } + Commands::Auth(AuthCommand::Token) => { + if let Err(e) = run_auth_token(verbose).await { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } } } +async fn run_auth_token(verbose: bool) -> Result<(), String> { + let token = session::token(session::Want::Access, verbose) + .await? + .ok_or("No access token cached. Run 'source-coop login' (with an API audience).")?; + println!("{token}"); + Ok(()) +} + async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { // 1. OIDC Discovery eprintln!("Discovering OIDC endpoints..."); let endpoints = oidc::discover(&args.issuer, verbose).await?; // 2. Browser-based OIDC login - let tokens = oidc::login(&endpoints, &args.client_id, &args.scope, args.port, verbose).await?; + let audience = Some(args.audience.trim()).filter(|a| !a.is_empty()); + let tokens = oidc::login( + &endpoints, + &args.client_id, + &args.scope, + audience, + args.port, + verbose, + ) + .await?; eprintln!("Authentication successful."); // 3. STS credential exchange @@ -183,53 +228,59 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> { OutputFormat::Env => output::print_env(&creds), OutputFormat::AwsCredentials => output::print_aws_credentials(&creds, &args.profile), } - } else { - if tokens.refresh_token.is_none() { - eprintln!( - "No refresh token issued; run 'source-coop login' again when credentials expire." - ); - } - let entry = cache::CacheEntry { - creds, - refresh: tokens - .refresh_token - .map(|refresh_token| cache::RefreshState { - refresh_token, - token_endpoint: endpoints.token_endpoint, - client_id: args.client_id, - proxy_url: args.proxy_url, - duration: args.duration, - }), - }; - let location = cache::write_credentials(&args.role_arn, &entry)?; - eprintln!("Credentials cached to {location}"); - eprintln!("Run 'source-coop creds' to print them."); + return Ok(()); } + if tokens.refresh_token.is_none() { + eprintln!( + "No refresh token issued; run 'source-coop login' again when credentials expire." + ); + } + if audience.is_some() && tokens.access_token.is_none() { + eprintln!( + "No access token issued; commands that call the source.coop API will run signed out." + ); + } + let session = session::Session::new( + tokens, + args.client_id, + endpoints.token_endpoint, + audience.map(String::from), + chrono::Utc::now().timestamp(), + ); + { + let _lock = cache::lock_session()?; + cache::write_session(&session)?; + } + let entry = cache::CacheEntry { + creds, + sts: Some(cache::StsSettings { + proxy_url: args.proxy_url, + duration: args.duration, + }), + refresh: None, + }; + let location = cache::write_credentials(&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> { - const NOT_FOUND: &str = "No cached credentials found. Run 'source-coop login' first."; - let mut entry = cache::read_credentials(&args.role_arn)?.ok_or(NOT_FOUND)?; - - if cache::is_expired(&entry.creds)? { - if entry.refresh.is_none() { - return Err( - "Cached credentials have expired. Run 'source-coop login' to refresh.".to_string(), - ); - } - // Re-read under the lock: another process may have refreshed already. - let _lock = cache::lock(&args.role_arn)?; - entry = cache::read_credentials(&args.role_arn)?.ok_or(NOT_FOUND)?; - if cache::is_expired(&entry.creds)? { - let save = - |e: &cache::CacheEntry| cache::write_credentials(&args.role_arn, e).map(drop); - entry = refresh(&args.role_arn, entry, verbose, save).await.map_err(|e| { - format!("Cached credentials have expired and refresh failed ({e}). Run 'source-coop login'.") - })?; + let cached = cache::read_credentials(&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)? { + Some(entry) if !cache::is_expired(&entry.creds)? => entry, + stale => mint(&args, stale, verbose).await.map_err(|e| { + format!("Couldn't get new credentials ({e}). Run 'source-coop login'.") + })?, + } } - } + }; match args.format { OutputFormat::CredentialProcess => output::print_credential_process(&entry.creds), @@ -239,6 +290,46 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> { Ok(()) } +/// New credentials for the role, cached before they're returned. An entry +/// from before the session was shared refreshes with its own token; anything +/// else exchanges the session's ID token, refreshing the session if need be. +async fn mint( + args: &CredsArgs, + stale: Option, + verbose: bool, +) -> Result { + let save = |e: &cache::CacheEntry| cache::write_credentials(&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 + } + stale => { + let sts = stale.and_then(|e| e.sts).unwrap_or(cache::StsSettings { + proxy_url: args.proxy_url.clone(), + duration: None, + }); + let id_token = session::token(session::Want::Id, verbose) + .await? + .ok_or("not logged in")?; + let creds = sts::assume_role( + &sts.proxy_url, + &args.role_arn, + &id_token, + sts.duration, + verbose, + ) + .await?; + let entry = cache::CacheEntry { + creds, + sts: Some(sts), + refresh: None, + }; + save(&entry)?; + Ok(entry) + } + } +} + /// Trade the cached refresh token for a new id_token, exchange that for new STS /// credentials, and cache the result (including the rotated refresh token) via `save`. async fn refresh( @@ -293,6 +384,7 @@ mod tests { session_token: "OLDSESSION".into(), expiration: "2020-01-01T00:00:00Z".into(), }, + sts: None, refresh: Some(cache::RefreshState { refresh_token: "old-rt".into(), token_endpoint: format!("{}/oauth2/token", server.uri()), diff --git a/src/oidc.rs b/src/oidc.rs index 31c69d0..bf525bc 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -63,10 +63,14 @@ pub async fn discover(issuer: &str, verbose: bool) -> Result, + pub access_token: Option, + pub expires_in: Option, } /// Run the browser-based OAuth2 Authorization Code flow with PKCE. @@ -76,6 +80,7 @@ pub async fn login( endpoints: &OidcEndpoints, client_id: &str, scope: &str, + audience: Option<&str>, port: u16, verbose: bool, ) -> Result { @@ -109,6 +114,11 @@ pub async fn login( .append_pair("code_challenge", &pkce.challenge) .append_pair("code_challenge_method", "S256") .append_pair("state", &state); + // The audience the access token is for, so the API can tell a token meant + // for it from one issued to the same client for something else. + if let Some(audience) = audience { + auth_url.query_pairs_mut().append_pair("audience", audience); + } if verbose { eprintln!("[verbose] Authorization URL: {auth_url}"); @@ -298,10 +308,11 @@ async fn token_request( .as_str() .ok_or("No id_token in token response")? .to_string(); - let refresh_token = body["refresh_token"].as_str().map(String::from); Ok(Tokens { id_token, - refresh_token, + refresh_token: body["refresh_token"].as_str().map(String::from), + access_token: body["access_token"].as_str().map(String::from), + expires_in: body["expires_in"].as_i64(), }) } diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..f349aca --- /dev/null +++ b/src/session.rs @@ -0,0 +1,308 @@ +//! The Ory login session, shared by everything that needs to act as the person +//! who ran `login`. +//! +//! One session holds one refresh token, and it is the only copy: refresh +//! tokens rotate on use and a replayed one can revoke the whole family, so +//! every role's STS credentials and every API call refresh through here, under +//! one lock. A refresh returns a new ID token (exchanged at the proxy's `/.sts` +//! for S3 credentials) and a new access token (sent to the source.coop API). + +use crate::{cache, oidc}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use serde::{Deserialize, Serialize}; + +/// A token this close to expiry is treated as expired, so it can't lapse +/// between being read here and being checked by the server. +const SKEW_SECONDS: i64 = 60; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub client_id: String, + pub token_endpoint: String, + /// What the access token was requested for, if anything. + #[serde(default)] + pub audience: Option, + #[serde(default)] + pub refresh_token: Option, + pub id_token: String, + #[serde(default)] + pub access_token: Option, + /// When the access token expires, in Unix seconds. + #[serde(default)] + pub access_token_expires_at: Option, +} + +/// Which token a caller needs from the session. +#[derive(Clone, Copy, Debug)] +pub enum Want { + /// The access token, for the source.coop API. + Access, + /// The ID token, for the proxy's STS exchange. + Id, +} + +impl Session { + pub fn new( + tokens: oidc::Tokens, + client_id: String, + token_endpoint: String, + audience: Option, + now: i64, + ) -> Self { + let mut session = Session { + client_id, + token_endpoint, + audience, + refresh_token: None, + id_token: String::new(), + access_token: None, + access_token_expires_at: None, + }; + session.apply(tokens, now); + session + } + + /// Take a token response, keeping the refresh token if it wasn't rotated. + fn apply(&mut self, tokens: oidc::Tokens, now: i64) { + self.id_token = tokens.id_token; + if let Some(rotated) = tokens.refresh_token { + self.refresh_token = Some(rotated); + } + self.access_token_expires_at = tokens + .expires_in + .map(|s| now + s) + .or_else(|| tokens.access_token.as_deref().and_then(jwt_exp)); + self.access_token = tokens.access_token; + } + + /// The wanted token if it's still good at `now`. `Ok(None)` means the + /// session has no such token at all (an access token, when login asked + /// for no audience); `Err(())` means it has one that has expired. + fn current(&self, want: Want, now: i64) -> Result, ()> { + let (token, expires_at) = match want { + Want::Id => (Some(self.id_token.as_str()), jwt_exp(&self.id_token)), + Want::Access => (self.access_token.as_deref(), self.access_token_expires_at), + }; + match (token, expires_at) { + (None, _) => Ok(None), + // A token whose expiry can't be read is left to the server to judge. + (Some(t), None) => Ok(Some(t)), + (Some(t), Some(exp)) if exp > now + SKEW_SECONDS => Ok(Some(t)), + (Some(_), Some(_)) => Err(()), + } + } +} + +/// The `exp` claim of a JWT, read without verifying it: only to know when to +/// refresh, never to trust the token. +fn jwt_exp(token: &str) -> Option { + let payload = URL_SAFE_NO_PAD.decode(token.split('.').nth(1)?).ok()?; + serde_json::from_slice::(&payload) + .ok()? + .get("exp")? + .as_i64() +} + +fn now() -> i64 { + chrono::Utc::now().timestamp() +} + +const EXPIRED: &str = "Your login has expired. Run 'source-coop login'."; + +/// The wanted token from `session`, refreshing the session first if that +/// token has expired. `save` persists the session after a refresh, before the +/// new token is used. Returns `None` if the session can't have the token. +pub async fn ensure( + session: &mut Session, + want: Want, + now: i64, + verbose: bool, + mut save: impl FnMut(&Session) -> Result<(), String>, +) -> Result, String> { + if let Ok(token) = session.current(want, now) { + return Ok(token.map(String::from)); + } + let refresh_token = session.refresh_token.as_deref().ok_or(EXPIRED)?; + if verbose { + eprintln!("[verbose] Refreshing the login session"); + } + let tokens = oidc::refresh( + &session.token_endpoint, + &session.client_id, + refresh_token, + verbose, + ) + .await?; + session.apply(tokens, now); + // The old refresh token is spent; persist the new one before anything can fail. + save(session)?; + session + .current(want, now) + .map(|t| t.map(String::from)) + .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 { + 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 { + return Ok(None); + }; + ensure(&mut session, want, now(), verbose, |s| { + cache::write_session(s).map(drop) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const NOW: i64 = 1_800_000_000; + + /// An unsigned JWT that expires at `exp`; only its claims are ever read. + fn jwt(exp: i64) -> String { + let claims = URL_SAFE_NO_PAD.encode(json!({ "exp": exp }).to_string()); + format!("e30.{claims}.sig") + } + + fn tokens(id_exp: i64, access: Option<&str>, expires_in: Option) -> oidc::Tokens { + oidc::Tokens { + id_token: jwt(id_exp), + refresh_token: Some("rt-1".into()), + access_token: access.map(String::from), + expires_in, + } + } + + fn session(server: &MockServer, t: oidc::Tokens) -> Session { + Session::new( + t, + "cid".into(), + format!("{}/oauth2/token", server.uri()), + Some("https://source.coop".into()), + NOW, + ) + } + + async fn mock_refresh(server: &MockServer, body: serde_json::Value, times: u64) { + Mock::given(method("POST")) + .and(path("/oauth2/token")) + .and(body_string_contains("grant_type=refresh_token")) + .and(body_string_contains("refresh_token=rt-1")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(times) + .mount(server) + .await; + } + + #[test] + fn reads_jwt_expiry() { + assert_eq!(jwt_exp(&jwt(42)), Some(42)); + assert_eq!(jwt_exp("opaque-token"), None); + } + + #[tokio::test] + async fn fresh_tokens_are_used_without_refreshing() { + let server = MockServer::start().await; + mock_refresh(&server, json!({}), 0).await; + let mut s = session(&server, tokens(NOW + 3600, Some("at"), Some(3600))); + + let saves = std::cell::Cell::new(0); + let save = |_: &Session| { + saves.set(saves.get() + 1); + Ok(()) + }; + let access = ensure(&mut s, Want::Access, NOW, false, save) + .await + .unwrap(); + assert_eq!(access.as_deref(), Some("at")); + let id = ensure(&mut s, Want::Id, NOW, false, save).await.unwrap(); + assert_eq!(id, Some(jwt(NOW + 3600))); + assert_eq!(saves.get(), 0); + } + + #[tokio::test] + async fn an_expired_access_token_refreshes_and_rotates() { + let server = MockServer::start().await; + mock_refresh( + &server, + json!({"id_token": jwt(NOW + 3600), "access_token": "at-2", + "expires_in": 3600, "refresh_token": "rt-2"}), + 1, + ) + .await; + // Expires inside the skew window, so it counts as expired. + let mut s = session(&server, tokens(NOW + 3600, Some("at-1"), Some(30))); + + let mut saved = vec![]; + let access = ensure(&mut s, Want::Access, NOW, false, |s| { + saved.push(s.refresh_token.clone().unwrap()); + Ok(()) + }) + .await + .unwrap(); + assert_eq!(access.as_deref(), Some("at-2")); + assert_eq!(saved, ["rt-2"]); + assert_eq!(s.access_token_expires_at, Some(NOW + 3600)); + } + + #[tokio::test] + async fn an_expired_id_token_refreshes_for_sts() { + let server = MockServer::start().await; + mock_refresh(&server, json!({"id_token": jwt(NOW + 3600)}), 1).await; + let mut s = session(&server, tokens(NOW - 10, None, None)); + + let id = ensure(&mut s, Want::Id, NOW, false, |_| Ok(())) + .await + .unwrap(); + assert_eq!(id, Some(jwt(NOW + 3600))); + // Not rotated, so the old refresh token stays. + assert_eq!(s.refresh_token.as_deref(), Some("rt-1")); + } + + #[tokio::test] + async fn no_access_token_without_an_audience() { + let server = MockServer::start().await; + mock_refresh(&server, json!({}), 0).await; + let mut s = session(&server, tokens(NOW + 3600, None, None)); + let access = ensure(&mut s, Want::Access, NOW, false, |_| Ok(())) + .await + .unwrap(); + assert_eq!(access, None); + } + + #[tokio::test] + async fn an_expired_session_without_a_refresh_token_says_to_log_in() { + let server = MockServer::start().await; + let mut t = tokens(NOW - 10, Some("at"), Some(-10)); + t.refresh_token = None; + let mut s = session(&server, t); + let err = ensure(&mut s, Want::Access, NOW, false, |_| Ok(())) + .await + .unwrap_err(); + assert!(err.contains("source-coop login")); + } + + #[test] + fn reads_a_session_without_optional_fields() { + let s: Session = serde_json::from_value(json!({ + "client_id": "cid", "token_endpoint": "https://auth.test/oauth2/token", + "id_token": "x" + })) + .unwrap(); + assert!(s.access_token.is_none() && s.refresh_token.is_none()); + } +}