diff --git a/src/api.rs b/src/api.rs index 50271c0..4436ab3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -47,24 +47,61 @@ impl ApiClient { } }; - let api_key_fallback = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); - - // Pre-flight: return the cached JWT if valid, refresh it if - // close to expiry, or mint a new one from the API key. The - // returned string is a JWT — that's what we send on the wire. - let access_token = match crate::jwt::ensure_access_token(&profile_config, api_key_fallback) - { - Ok(t) => t, - Err(e) => { - eprintln!("{}", format!("error: {e}").red()); - eprintln!( - "Run {} to log in, or pass --api-key.", - "hotdata auth".cyan() - ); - std::process::exit(1); + // Auth source precedence: + // + // 1. `HOTDATA_SANDBOX_TOKEN` env var — a `sandbox run` child + // is executing with the parent's credentials scrubbed. + // Refresh in-memory via `HOTDATA_SANDBOX_REFRESH_TOKEN` if + // the JWT is close to expiry; never write to disk (the + // child's FS may not be writable). + // 2. `~/.hotdata/sandbox_session.json` — the user ran + // `hotdata sandbox set ` (or `sandbox new` / `sandbox + // run` in the parent shell). The sandbox JWT is the active + // bearer for *every* command until `sandbox set` (with no + // id) clears the file. + // 3. `~/.hotdata/session.json` + optional api_key fallback — + // normal user-scoped CLI session. + let api_url = profile_config.api_url.to_string(); + let access_token = if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() { + match crate::sandbox_session::refresh_from_env(&api_url) { + Some(t) => t, + None => { + eprintln!("{}", "error: HOTDATA_SANDBOX_TOKEN is empty".red()); + std::process::exit(1); + } + } + } else if crate::sandbox_session::load().is_some() { + match crate::sandbox_session::ensure_access_token(&api_url) { + Some(t) => t, + None => { + eprintln!("{}", "error: sandbox session expired".red()); + eprintln!( + "Run {} to clear it, or {} to re-mint.", + "hotdata sandbox set".cyan(), + "hotdata sandbox set ".cyan(), + ); + std::process::exit(1); + } + } + } else { + let api_key_fallback = profile_config + .api_key + .as_deref() + .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); + + // Pre-flight: return the cached JWT if valid, refresh it if + // close to expiry, or mint a new one from the API key. The + // returned string is a JWT — that's what we send on the wire. + match crate::jwt::ensure_access_token(&profile_config, api_key_fallback) { + Ok(t) => t, + Err(e) => { + eprintln!("{}", format!("error: {e}").red()); + eprintln!( + "Run {} to log in, or pass --api-key.", + "hotdata auth".cyan() + ); + std::process::exit(1); + } } }; diff --git a/src/auth.rs b/src/auth.rs index 928d732..b688b73 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -26,21 +26,37 @@ pub enum AuthStatus { } pub fn check_status(profile_config: &config::ProfileConfig) -> AuthStatus { - let api_key_fallback = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); - - // PKCE-origin sessions don't write an api_key, so absence of a key - // alone isn't "not configured" — only true if there's also no - // cached JWT session to validate. - if api_key_fallback.is_none() && crate::jwt::load_session().is_none() { - return AuthStatus::NotConfigured; - } + // Same precedence as `ApiClient::new`: + // 1. `sandbox run` child via env var + // 2. on-disk sandbox session (sandbox set ) + // 3. user-scoped CLI session / api_key fallback + let api_url = profile_config.api_url.to_string(); + let access_token = if let Some((sandbox_jwt, _)) = + crate::sandbox_session::sandbox_token_in_use() + { + sandbox_jwt + } else if crate::sandbox_session::load().is_some() { + match crate::sandbox_session::ensure_access_token(&api_url) { + Some(t) => t, + None => return AuthStatus::Invalid(401), + } + } else { + let api_key_fallback = profile_config + .api_key + .as_deref() + .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); + + // PKCE-origin sessions don't write an api_key, so absence of a key + // alone isn't "not configured" — only true if there's also no + // cached JWT session to validate. + if api_key_fallback.is_none() && crate::jwt::load_session().is_none() { + return AuthStatus::NotConfigured; + } - let access_token = match crate::jwt::ensure_access_token(profile_config, api_key_fallback) { - Ok(t) => t, - Err(_) => return AuthStatus::Invalid(401), + match crate::jwt::ensure_access_token(profile_config, api_key_fallback) { + Ok(t) => t, + Err(_) => return AuthStatus::Invalid(401), + } }; let url = format!("{}/workspaces", profile_config.api_url); @@ -64,26 +80,50 @@ pub fn status(profile: &str) { } }; - // The credential the CLI is *about to use*. Note: even when an - // override is set, the wire credential is still a JWT (minted on - // demand from the override) — but we report the user-visible source. - let method_label = match profile_config.api_key_source { - ApiKeySource::Flag => "API Key flag", - ApiKeySource::Env => "API Key env", - ApiKeySource::Config => "CLI Session", + // The credential the CLI is *about to use*. Precedence matches + // `ApiClient::new`: env-var sandbox token (sandbox run child) > + // on-disk sandbox session (sandbox set ) > user CLI session. + let env_sandbox = crate::sandbox_session::sandbox_token_in_use(); + let disk_sandbox = if env_sandbox.is_none() { + crate::sandbox_session::load() + } else { + None }; - - // For Flag/Env we mask the api_key the user supplied. For the - // CLI session path we mask the refresh_token — it's stable across - // commands (unlike the 5-min access_token), so the tail stays - // recognizable between runs. - let credential_tail = match profile_config.api_key_source { - ApiKeySource::Flag | ApiKeySource::Env => profile_config - .api_key - .as_deref() - .map(crate::util::mask_credential), - ApiKeySource::Config => crate::jwt::load_session() - .map(|s| crate::util::mask_credential(&s.refresh_token)), + let (method_label, credential_tail) = if let Some((token, sandbox_id)) = &env_sandbox { + let label = match sandbox_id { + Some(id) => format!("Sandbox {id}"), + None => "Sandbox Session".to_string(), + }; + (label, Some(crate::util::mask_credential(token))) + } else if let Some(s) = &disk_sandbox { + // Use the refresh token for the displayed tail — it's stable + // across refreshes (the access token rotates every 3 days), so + // the tail stays recognizable between runs. + let label = if s.sandbox_id.is_empty() { + "Sandbox Session".to_string() + } else { + format!("Sandbox {}", s.sandbox_id) + }; + (label, Some(crate::util::mask_credential(&s.refresh_token))) + } else { + let label = match profile_config.api_key_source { + ApiKeySource::Flag => "API Key flag", + ApiKeySource::Env => "API Key env", + ApiKeySource::Config => "CLI Session", + }; + // For Flag/Env we mask the api_key the user supplied. For + // the CLI session path we mask the refresh_token — it's + // stable across commands (unlike the 5-min access_token), + // so the tail stays recognizable between runs. + let tail = match profile_config.api_key_source { + ApiKeySource::Flag | ApiKeySource::Env => profile_config + .api_key + .as_deref() + .map(crate::util::mask_credential), + ApiKeySource::Config => crate::jwt::load_session() + .map(|s| crate::util::mask_credential(&s.refresh_token)), + }; + (label.to_string(), tail) }; let method_suffix = match credential_tail { Some(tail) => format!(" - {method_label} [{tail}]"), diff --git a/src/main.rs b/src/main.rs index cabf63b..0c6e172 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod queries; mod query; mod results; mod sandbox; +mod sandbox_session; mod skill; mod table; mod tables; @@ -81,7 +82,49 @@ fn resolve_workspace(provided: Option) -> String { } } +// libc::atexit (no extra crate needed — the symbol is linked by default). +// Callbacks registered here fire even when subcommands call +// `std::process::exit`, which Rust's `Drop` would otherwise miss. +unsafe extern "C" { + fn atexit(callback: extern "C" fn()) -> i32; +} + +/// Runs once at process exit. Prints a sandbox footer on stderr when +/// the CLI is running under an on-disk sandbox session (i.e. the user +/// ran `hotdata sandbox set ` to enter it from this shell). Stays +/// silent when the sandbox comes from `HOTDATA_SANDBOX_TOKEN` in the +/// environment: that means we're inside a `sandbox run` child, and +/// the parent already announced the sandbox once at spawn time. +/// Stderr keeps stdout clean for callers parsing JSON/YAML output. +extern "C" fn print_sandbox_footer() { + use crossterm::style::Stylize; + + // Inside a `sandbox run` child — parent printed the banner already. + if sandbox_session::sandbox_token_in_use().is_some() { + return; + } + let Some(session) = sandbox_session::load() else { + return; + }; + if session.sandbox_id.is_empty() { + return; + } + eprintln!( + "{}", + format!( + "current sandbox: {} use 'hotdata sandbox set' to change", + session.sandbox_id + ) + .dark_grey(), + ); +} + fn main() { + // Register before `Cli::parse`, since `--help` / `--version` exit + // from inside the parser. Safety: `atexit` is async-signal-safe; + // the callback only reads env vars / files and writes to stderr. + unsafe { atexit(print_sandbox_footer) }; + dotenvy::dotenv().ok(); let cli = Cli::parse(); diff --git a/src/sandbox.rs b/src/sandbox.rs index 468c989..d0313af 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -1,7 +1,9 @@ use crate::api::ApiClient; use crate::config; +use crate::sandbox_session::{self, SandboxSession}; use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Deserialize, Serialize)] struct Sandbox { @@ -22,6 +24,38 @@ struct DetailResponse { sandbox: Sandbox, } +/// Response shape of `/v1/auth/sandbox` and `/v1/auth/sandbox/`. +#[derive(Deserialize)] +struct SandboxTokenResponse { + token: String, + refresh_token: String, + sandbox_id: String, + expires_in: u64, + refresh_expires_in: u64, +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn persist_sandbox_session(resp: SandboxTokenResponse, workspace_id: &str) { + let now = now_unix(); + let session = SandboxSession { + access_token: resp.token, + refresh_token: resp.refresh_token, + sandbox_id: resp.sandbox_id, + workspace_id: workspace_id.to_string(), + access_expires_at: now + resp.expires_in, + refresh_expires_at: now + resp.refresh_expires_in, + }; + if let Err(e) = sandbox_session::save(&session) { + eprintln!("warning: could not persist sandbox session: {e}"); + } +} + pub fn list(workspace_id: &str, format: &str) { let api = ApiClient::new(Some(workspace_id)); let body: ListResponse = api.get("/sandboxes"); @@ -151,22 +185,27 @@ pub fn new(workspace_id: &str, name: Option<&str>, format: &str) { body["name"] = serde_json::json!(n); } - let resp: DetailResponse = api.post("/sandboxes", &body); - let s = &resp.sandbox; + // POST /auth/sandbox creates the sandbox AND mints a sandbox-scoped + // JWT (+ refresh token) in one round-trip. + let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body); + let sandbox_id = resp.sandbox_id.clone(); + persist_sandbox_session(resp, workspace_id); - // Set as the active sandbox in config - if let Err(e) = config::save_sandbox("default", &s.public_id) { + if let Err(e) = config::save_sandbox("default", &sandbox_id) { eprintln!("warning: could not save sandbox to config: {e}"); } println!("{}", "Sandbox created".green()); match format { - "json" => println!("{}", serde_json::to_string_pretty(s).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(s).unwrap()), + "json" => println!("{}", serde_json::json!({"public_id": sandbox_id})), + "yaml" => print!( + "{}", + serde_yaml::to_string(&serde_json::json!({"public_id": sandbox_id})).unwrap() + ), "table" => { - println!("id: {}", s.public_id); - if !s.name.is_empty() { - println!("name: {}", s.name); + println!("id: {}", sandbox_id); + if let Some(n) = name { + println!("name: {}", n); } } _ => unreachable!(), @@ -219,25 +258,31 @@ pub fn update( pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd: &[String]) { check_sandbox_lock(); - let sid = match sandbox_id { - Some(id) => { - // Verify the sandbox exists - let api = ApiClient::new(Some(workspace_id)); - let path = format!("/sandboxes/{id}"); - let _: DetailResponse = api.get(&path); - id.to_string() - } + let api = ApiClient::new(Some(workspace_id)); + + // Mint (or re-mint, for an existing sandbox) a sandbox-scoped JWT + // by dispatching on grant_type at /auth/sandbox. Either way we + // end up with a fresh bundle persisted to sandbox_session.json + // before we spawn. + let body = match sandbox_id { + Some(id) => serde_json::json!({ + "grant_type": "existing_sandbox", + "sandbox_id": id, + }), None => { - // Create a new sandbox - let api = ApiClient::new(Some(workspace_id)); - let mut body = serde_json::json!({}); + let mut b = serde_json::json!({}); if let Some(n) = name { - body["name"] = serde_json::json!(n); + b["name"] = serde_json::json!(n); } - let resp: DetailResponse = api.post("/sandboxes", &body); - resp.sandbox.public_id + b } }; + let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body); + + let sid = resp.sandbox_id.clone(); + let sandbox_jwt = resp.token.clone(); + let sandbox_refresh = resp.refresh_token.clone(); + persist_sandbox_session(resp, workspace_id); eprintln!("{} {}", "sandbox:".dark_grey(), sid); eprintln!("{} {}", "workspace:".dark_grey(), workspace_id); @@ -246,6 +291,9 @@ pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd .args(&cmd[1..]) .env("HOTDATA_SANDBOX", &sid) .env("HOTDATA_WORKSPACE", workspace_id) + .env("HOTDATA_API_URL", &api.api_url) + .env("HOTDATA_SANDBOX_TOKEN", &sandbox_jwt) + .env("HOTDATA_SANDBOX_REFRESH_TOKEN", &sandbox_refresh) .status(); match status { @@ -261,10 +309,17 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { check_sandbox_lock(); match sandbox_id { Some(id) => { - // Verify the sandbox exists by fetching it + // Mint a sandbox-scoped JWT against this existing id via + // the grant_type=existing_sandbox dispatch. The call + // doubles as an existence + access check (404/403 if the + // user can't reach it). let api = ApiClient::new(Some(workspace_id)); - let path = format!("/sandboxes/{id}"); - let _: DetailResponse = api.get(&path); + let body = serde_json::json!({ + "grant_type": "existing_sandbox", + "sandbox_id": id, + }); + let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body); + persist_sandbox_session(resp, workspace_id); if let Err(e) = config::save_sandbox("default", id) { eprintln!("error saving config: {e}"); @@ -274,7 +329,8 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { println!("id: {}", id); } None => { - // Clear the active sandbox + // Clear the active sandbox + its cached session. + sandbox_session::clear(); if let Err(e) = config::clear_sandbox("default") { eprintln!("error saving config: {e}"); std::process::exit(1); @@ -302,4 +358,5 @@ mod tests { find_sandbox_run_ancestor_inner() ); } + } diff --git a/src/sandbox_session.rs b/src/sandbox_session.rs new file mode 100644 index 0000000..262f40f --- /dev/null +++ b/src/sandbox_session.rs @@ -0,0 +1,380 @@ +//! Persisted sandbox-scoped JWT session. +//! +//! Distinct from the user-scoped session in [`crate::jwt`]: +//! +//! * Minted by `POST /v1/auth/sandbox` (with no body, or +//! `grant_type=existing_sandbox` + `sandbox_id`), not `/o/token/`. +//! * Bound to a single sandbox + workspace; the JWT carries only +//! workspace-read + sandbox-read/write scope. +//! * Refreshed via `POST /v1/auth/sandbox` with +//! `grant_type=refresh_token` — same endpoint as the new-mint path, +//! dispatched by body field (mirrors `POST /o/token/`). The server +//! does **not** rotate the refresh token. The user's own credentials +//! are never involved — possession of the sandbox refresh token is +//! enough. +//! +//! Stored at `~/.hotdata/sandbox_session.json` (mode 0600). + +use crate::config; +use crate::util; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Refresh ahead of expiry to avoid racing it. +const REFRESH_LEEWAY_SECONDS: u64 = 60; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SandboxSession { + pub access_token: String, + pub refresh_token: String, + pub sandbox_id: String, + pub workspace_id: String, + pub access_expires_at: u64, + pub refresh_expires_at: u64, +} + +pub fn session_path() -> Option { + config::config_dir().ok().map(|d| d.join("sandbox_session.json")) +} + +#[allow(dead_code)] // Reserved for parent-side flows that resurrect a session. +pub fn load() -> Option { + let path = session_path()?; + let raw = fs::read_to_string(&path).ok()?; + serde_json::from_str(&raw).ok() +} + +pub fn save(session: &SandboxSession) -> Result<(), String> { + let path = session_path().ok_or_else(|| "no sandbox session path available".to_string())?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?; + } + let json = serde_json::to_string_pretty(session) + .map_err(|e| format!("serialize failed: {e}"))?; + + use std::os::unix::fs::OpenOptionsExt; + let mut f = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&path) + .map_err(|e| format!("open failed: {e}"))?; + f.write_all(json.as_bytes()) + .map_err(|e| format!("write failed: {e}"))?; + Ok(()) +} + +pub fn clear() { + if let Some(path) = session_path() { + let _ = fs::remove_file(path); + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[derive(Deserialize)] +pub(crate) struct MintResponse { + token: String, + refresh_token: String, + sandbox_id: String, + expires_in: u64, + refresh_expires_in: u64, +} + +fn redact(s: &str) -> String { + util::mask_credential(s) +} + +/// Trade a refresh token for a fresh sandbox JWT. The server does +/// **not** rotate the refresh token (matches DOT's +/// ``ROTATE_REFRESH_TOKEN=False``), so the same value is returned on +/// every call. Same endpoint as the new-mint path — +/// ``POST /v1/auth/sandbox`` with ``grant_type=refresh_token`` in the +/// body, mirroring ``POST /o/token/``. +pub fn refresh(api_url: &str, refresh_token: &str) -> Result { + let url = format!("{}/auth/sandbox", api_url.trim_end_matches('/')); + let body = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }); + let body_log = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": redact(refresh_token), + }); + + let client = reqwest::blocking::Client::new(); + let req = client.post(&url).json(&body); + let (status, body_text) = util::send_debug_with_redaction( + &client, + req, + Some(&body_log), + &["token", "refresh_token"], + ) + .map_err(|e| format!("connection error: {e}"))?; + if !status.is_success() { + return Err(format!("sandbox refresh failed: HTTP {status}: {body_text}")); + } + let resp: MintResponse = serde_json::from_str(&body_text) + .map_err(|e| format!("malformed refresh response: {e}"))?; + Ok(session_from_response(resp, /*workspace_id*/ String::new())) +} + +/// Build a [`SandboxSession`] from a mint/refresh response. The mint +/// response itself doesn't include the workspace public_id, so the +/// caller passes it in (the workspace the sandbox was created against +/// is what the JWT's `workspaces` claim restricts the bearer to). For +/// refresh, workspace_id is left blank — the caller fills it in from +/// the prior session, since the sandbox-id ↔ workspace mapping is +/// invariant across refreshes. +pub(crate) fn session_from_response(resp: MintResponse, workspace_id: String) -> SandboxSession { + let now = now_unix(); + SandboxSession { + access_token: resp.token, + refresh_token: resp.refresh_token, + sandbox_id: resp.sandbox_id, + workspace_id, + access_expires_at: now + resp.expires_in, + refresh_expires_at: now + resp.refresh_expires_in, + } +} + +/// Decode a JWT's payload (without verifying the signature) and pull +/// out the named string claim. Returns `None` if the token is +/// unparseable or the claim is missing. +fn jwt_string_claim(token: &str, claim: &str) -> Option { + use base64::Engine; + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1].as_bytes()) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&payload).ok()?; + value.get(claim).and_then(|v| v.as_str()).map(String::from) +} + +/// Decode the `exp` claim out of a JWT without verifying the signature. +/// Returns `None` if the token is unparseable; in that case the caller +/// should treat it as expired (force-refresh or fail). +fn jwt_exp(token: &str) -> Option { + use base64::Engine; + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1].as_bytes()) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&payload).ok()?; + value.get("exp").and_then(|v| v.as_u64()) +} + +/// If `HOTDATA_SANDBOX_TOKEN` is set in the environment, return +/// `(token, sandbox_public_id)` — the sandbox public_id read from the +/// JWT's `sandbox` claim. Returns `None` if no env var is set, or if +/// the token isn't a parseable JWT (in which case we can still use it +/// as a bearer but can't identify the sandbox). +pub fn sandbox_token_in_use() -> Option<(String, Option)> { + let token = std::env::var("HOTDATA_SANDBOX_TOKEN").ok()?; + if token.is_empty() { + return None; + } + let sandbox_id = jwt_string_claim(&token, "sandbox"); + Some((token, sandbox_id)) +} + +/// In-child equivalent of [`ensure_access_token`] that operates on env +/// vars only — used by [`crate::api::ApiClient`] when the parent +/// `sandbox run` already passed in `HOTDATA_SANDBOX_TOKEN` and +/// `HOTDATA_SANDBOX_REFRESH_TOKEN`. The new tokens are *not* persisted +/// to disk: the child may not have write access to the parent's +/// config dir (sandboxed FS), and re-doing the refresh on the next +/// invocation costs one HTTP call. +/// +/// Falls back to the current `HOTDATA_SANDBOX_TOKEN` value if a +/// refresh isn't needed or fails. +pub fn refresh_from_env(api_url: &str) -> Option { + let current = std::env::var("HOTDATA_SANDBOX_TOKEN").ok()?; + let needs_refresh = match jwt_exp(¤t) { + Some(exp) => exp.saturating_sub(REFRESH_LEEWAY_SECONDS) <= now_unix(), + None => true, + }; + if !needs_refresh { + return Some(current); + } + let rt = std::env::var("HOTDATA_SANDBOX_REFRESH_TOKEN").ok()?; + if rt.is_empty() { + return Some(current); + } + match refresh(api_url, &rt) { + Ok(new_session) => Some(new_session.access_token), + Err(_) => Some(current), + } +} + +/// Return the cached sandbox session's access token, refreshing if +/// it's about to expire. Returns `None` if no session is cached, the +/// refresh token is past its TTL, or the refresh call failed. +#[allow(dead_code)] // Reserved for parent-side flows that re-use a cached session. +pub fn ensure_access_token(api_url: &str) -> Option { + let session = load()?; + let now = now_unix(); + + if !session.access_token.is_empty() && now + REFRESH_LEEWAY_SECONDS < session.access_expires_at { + return Some(session.access_token); + } + + if session.refresh_token.is_empty() || now >= session.refresh_expires_at { + return None; + } + + match refresh(api_url, &session.refresh_token) { + Ok(mut new_session) => { + // Carry workspace_id over (refresh response omits it). + new_session.workspace_id = session.workspace_id.clone(); + let tok = new_session.access_token.clone(); + let _ = save(&new_session); + Some(tok) + } + Err(_) => { + clear(); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::test_helpers::with_temp_config_dir; + + fn mk_session(access_offset: i64, refresh_offset: i64) -> SandboxSession { + let now = now_unix() as i64; + SandboxSession { + access_token: "cached".into(), + refresh_token: "cached-refresh".into(), + sandbox_id: "s_abc12345".into(), + workspace_id: "work_xyz".into(), + access_expires_at: (now + access_offset).max(0) as u64, + refresh_expires_at: (now + refresh_offset).max(0) as u64, + } + } + + #[test] + fn round_trip() { + let (_tmp, _guard) = with_temp_config_dir(); + let s = mk_session(3600, 86400); + save(&s).unwrap(); + let loaded = load().unwrap(); + assert_eq!(loaded.access_token, "cached"); + assert_eq!(loaded.sandbox_id, "s_abc12345"); + assert_eq!(loaded.workspace_id, "work_xyz"); + } + + #[test] + fn file_is_mode_0600() { + use std::os::unix::fs::PermissionsExt; + let (_tmp, _guard) = with_temp_config_dir(); + save(&mk_session(60, 60)).unwrap(); + let mode = fs::metadata(session_path().unwrap()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn ensure_returns_cached_when_fresh() { + let (_tmp, _guard) = with_temp_config_dir(); + save(&mk_session(3600, 86400)).unwrap(); + // Unreachable URL — if the code reached the network we'd see an error here. + let tok = ensure_access_token("http://127.0.0.1:1"); + assert_eq!(tok.as_deref(), Some("cached")); + } + + #[test] + fn ensure_returns_none_when_no_session() { + let (_tmp, _guard) = with_temp_config_dir(); + assert!(ensure_access_token("http://127.0.0.1:1").is_none()); + } + + #[test] + fn ensure_returns_none_when_refresh_dead() { + let (_tmp, _guard) = with_temp_config_dir(); + // Access and refresh both expired. + save(&mk_session(-10, -10)).unwrap(); + assert!(ensure_access_token("http://127.0.0.1:1").is_none()); + } + + #[test] + fn refresh_posts_grant_type_to_sandbox_endpoint() { + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/auth/sandbox") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::JsonString( + r#"{"grant_type":"refresh_token","refresh_token":"stable-refresh"}"# + .to_string(), + ), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + // Server does not rotate — same refresh_token comes back. + r#"{"ok":true,"token":"new-jwt","refresh_token":"stable-refresh","sandbox_id":"s_abc12345","expires_in":300,"refresh_expires_in":259200}"#, + ) + .create(); + + let s = refresh(&server.url(), "stable-refresh").unwrap(); + m.assert(); + assert_eq!(s.access_token, "new-jwt"); + assert_eq!(s.refresh_token, "stable-refresh"); + assert_eq!(s.sandbox_id, "s_abc12345"); + } + + #[test] + fn refresh_http_error() { + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/auth/sandbox") + .with_status(401) + .create(); + let err = refresh(&server.url(), "x").unwrap_err(); + m.assert(); + assert!(err.contains("401")); + } + + #[test] + fn ensure_refreshes_and_persists() { + let (_tmp, _guard) = with_temp_config_dir(); + // Access expired but refresh still good. + let mut existing = mk_session(-10, 86400); + existing.workspace_id = "work_xyz".into(); + save(&existing).unwrap(); + + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/auth/sandbox") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"ok":true,"token":"refreshed","refresh_token":"cached-refresh","sandbox_id":"s_abc12345","expires_in":300,"refresh_expires_in":259200}"#, + ) + .create(); + let tok = ensure_access_token(&server.url()); + m.assert(); + assert_eq!(tok.as_deref(), Some("refreshed")); + let after = load().unwrap(); + assert_eq!(after.access_token, "refreshed"); + // No rotation — same refresh_token as before. + assert_eq!(after.refresh_token, "cached-refresh"); + assert_eq!(after.workspace_id, "work_xyz"); + } +} diff --git a/src/util.rs b/src/util.rs index 99dce67..39a7462 100644 --- a/src/util.rs +++ b/src/util.rs @@ -74,10 +74,16 @@ pub fn debug_response_redacted( (status, body) } -/// Mask a credential to its first 4 characters (`XXXX...`), or `***` -/// if the value is too short to safely reveal a head. +/// Mask a credential to its first + last 4 characters +/// (`XXXX...YYYY`), or `***` if it's too short to reveal anything +/// safely. The tail makes it easy to distinguish which token is on +/// the wire (e.g. user JWT vs sandbox-scoped JWT vs opaque API token). pub fn mask_credential(s: &str) -> String { - if s.len() > 4 { + if s.len() >= 12 { + format!("{}...{}", &s[..4], &s[s.len() - 4..]) + } else if s.len() > 4 { + // Short-ish — still better to show head than nothing, but + // don't double up on bytes by showing a tail. format!("{}...", &s[..4]) } else { "***".into() @@ -278,16 +284,34 @@ pub fn format_date(s: &str) -> String { } pub fn api_error(body: String) -> String { - serde_json::from_str::(&body) - .ok() - .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) - .unwrap_or_else(|| { - if body.trim_start().starts_with('<') { - "unexpected server error".to_string() - } else { - body - } - }) + if let Ok(v) = serde_json::from_str::(&body) { + // Two shapes in the wild: + // {"error": {"message": "..."}} — RuntimeDB-style + // {"error": "snake_case_code"} — Django-style (e.g. sandbox endpoints) + if let Some(m) = v["error"]["message"].as_str() { + return m.to_string(); + } + if let Some(code) = v["error"].as_str() { + return humanize_error_code(code); + } + } + if body.trim_start().starts_with('<') { + return "unexpected server error".to_string(); + } + body +} + +/// Turn a snake_case error code into a human-friendly sentence: +/// ``sandbox_not_found`` → ``Sandbox not found``. Cheap heuristic — if +/// a code reads badly after this, the server should be the one to fix +/// it by returning a real message. +fn humanize_error_code(code: &str) -> String { + let spaced = code.replace('_', " "); + let mut chars = spaced.chars(); + match chars.next() { + Some(c) => c.to_uppercase().chain(chars).collect(), + None => String::new(), + } } #[cfg(test)] @@ -296,7 +320,16 @@ mod tests { use serde_json::json; #[test] - fn mask_credential_long() { + fn mask_credential_long_shows_prefix_and_suffix() { + // 12+ chars: show both ends so the user can tell which token + // is on the wire (sandbox JWT vs user JWT vs opaque API token). + assert_eq!(mask_credential("abcdefghijkl"), "abcd...ijkl"); + assert_eq!(mask_credential("eyJhMIDDLEYwxyz"), "eyJh...wxyz"); + } + + #[test] + fn mask_credential_medium_falls_back_to_head_only() { + // Between 5 and 11 chars: showing both ends would overlap. assert_eq!(mask_credential("abcdefgh"), "abcd..."); } @@ -306,6 +339,33 @@ mod tests { assert_eq!(mask_credential(""), "***"); } + #[test] + fn api_error_humanizes_snake_case_code() { + // Django-style flat shape — `sandbox_not_found` should render + // as a readable sentence, not a raw JSON blob. + let body = r#"{"error": "sandbox_not_found"}"#.to_string(); + assert_eq!(api_error(body), "Sandbox not found"); + } + + #[test] + fn api_error_prefers_nested_message_over_code() { + // RuntimeDB-style nested shape — use the human message verbatim. + let body = r#"{"error": {"message": "Query qrun_x not found"}}"#.to_string(); + assert_eq!(api_error(body), "Query qrun_x not found"); + } + + #[test] + fn api_error_falls_through_for_plain_body() { + let body = "raw text body".to_string(); + assert_eq!(api_error(body), "raw text body"); + } + + #[test] + fn api_error_handles_html_body() { + let body = "500".to_string(); + assert_eq!(api_error(body), "unexpected server error"); + } + #[test] fn redact_json_fields_top_level() { let mut v = json!({ @@ -314,8 +374,8 @@ mod tests { "refresh_token": "another-secret" }); redact_json_fields(&mut v, &["access_token", "refresh_token"]); - assert_eq!(v["access_token"], "long..."); - assert_eq!(v["refresh_token"], "anot..."); + assert_eq!(v["access_token"], "long...oken"); + assert_eq!(v["refresh_token"], "anot...cret"); // Non-redacted keys untouched. assert_eq!(v["expires_in"], 300); } @@ -331,8 +391,10 @@ mod tests { } }); redact_json_fields(&mut v, &["access_token"]); + // "secret-1234" is 11 chars — falls into the head-only branch. assert_eq!(v["data"]["access_token"], "secr..."); - assert_eq!(v["data"]["items"][0]["access_token"], "nest..."); + // "nested-secret" is 13 chars — head + tail. + assert_eq!(v["data"]["items"][0]["access_token"], "nest...cret"); } #[test]