From 2e3aa68b80d832658bf19bdf4ec47ea953af75a5 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 22 Jun 2026 22:11:28 -0400 Subject: [PATCH 1/2] fix(auth): th api/admin honor the user JWT, not just the M2M session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `require_authed()` (used by every `th api` + `th admin` command) loaded only `SmoothApiClient::from_disk()`, whose `CredentialsStore` is hard-wired to the M2M file (`~/.smooth/auth/smooai.json`). So a user who ran `th auth login` (which writes `smooai-user.json` and is honored by `th config`) still got "run `th api login`" from `th admin` — even though the platform API accepts user JWTs (`assertMachineTokenAuthorizedForOrg` returns early for Supabase auth). Prefer a valid user session first (load `smooai-user.json` via `CredentialsStore::at`, build the client with it), falling back to the M2M session otherwise. An expired user JWT isn't Supabase-refreshed here — `is_authenticated()` is false, so it falls through to M2M / the re-login prompt (no regression; the M2M path is unchanged and verified working). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/smooth-cli/src/smooai/mod.rs | 33 +++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/smooth-cli/src/smooai/mod.rs b/crates/smooth-cli/src/smooai/mod.rs index 71833207..45127402 100644 --- a/crates/smooth-cli/src/smooai/mod.rs +++ b/crates/smooth-cli/src/smooai/mod.rs @@ -37,9 +37,21 @@ use smooth_api_client::{CredentialsStore, SmoothApiClient}; /// session re-mints transparently — the user doesn't see the /// expiry unless their stored M2M credentials were rotated. pub async fn require_authed() -> Result { + // Prefer a valid USER session (`~/.smooth/auth/smooai-user.json`, written by + // `th auth login` and used by `th config`) so `th api`/`th admin` honor a + // logged-in user — the platform API accepts user JWTs + // (`assertMachineTokenAuthorizedForOrg` passes Supabase auth). Fall back to + // the M2M session (`smooai.json`) otherwise. `SmoothApiClient::from_disk`'s + // own store is hard-wired to the M2M file, hence the explicit user-store load + // here. (An EXPIRED user JWT isn't Supabase-refreshed here — it falls through + // to M2M / a re-login prompt.) + if let Some(client) = try_user_session().await { + return Ok(client); + } + let client = SmoothApiClient::from_disk().context("load credentials")?; if client.credentials().is_none() { - anyhow::bail!("not logged in — run `th api login` first"); + anyhow::bail!("not logged in — run `th auth login` (user) or `th api login` (M2M) first"); } // Try to refresh if expired. ensure_fresh_token is a no-op when // the token is still valid or when no client_credentials are on @@ -47,13 +59,30 @@ pub async fn require_authed() -> Result { client.ensure_fresh_token().await.ok(); if !client.is_authenticated() { anyhow::bail!( - "session expired and no stored client credentials to auto-refresh — run `th api login` again \ + "session expired and no stored client credentials to auto-refresh — run `th auth login` again \ (or set SMOOAI_CONFIG_CLIENT_ID + SMOOAI_CONFIG_CLIENT_SECRET so the next call refreshes silently)" ); } Ok(client) } +/// Build an authed client from the user JWT at `~/.smooth/auth/smooai-user.json`, +/// or `None` if it's absent/unreadable/expired so the caller falls back to M2M. +async fn try_user_session() -> Option { + let home = std::env::var_os("HOME")?; + let path = std::path::Path::new(&home).join(".smooth").join("auth").join("smooai-user.json"); + if !path.exists() { + return None; + } + let store = CredentialsStore::at(&path); + let creds = store.load().ok()??; + let client = SmoothApiClient::new(smooth_api_client::base_url(), Some(creds), store).ok()?; + // No-op for a user JWT (no client_secret to re-mint), but harmless. + client.ensure_fresh_token().await.ok(); + // is_authenticated() is false for an expired session → fall back to M2M. + client.is_authenticated().then_some(client) +} + /// Resolve the active org id. Delegates to /// [`crate::active_org::resolve`] so every `th api` subcommand reads /// from the same source `th config` and `th auth whoami` do. From 1b453ed2600e3e8b972ba9886885e484214e69a6 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 22 Jun 2026 22:32:46 -0400 Subject: [PATCH 2/2] fix(auth): resolve the user JWT from the active profile, not just legacy path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of this fix hardcoded `~/.smooth/auth/smooai-user.json`, but `th auth login` writes the session under the active profile (`~/.config/smooth/auth/profiles//smooai-user.json`, where is in `.../auth/active` or `$SMOOAI_PROFILE`). So `th admin`/`th api` still fell through to M2M and 401'd on config-schema writes (which the server forbids for M2M). `try_user_session` now walks a candidate list — `$SMOOAI_USER_AUTH_FILE` → active profile → legacy flat path — and uses the first with a valid (non-expired) session. Verified: with M2M + the legacy file moved aside (only the active-profile JWT present), `th admin` authenticates and succeeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/smooth-cli/src/smooai/mod.rs | 67 +++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/crates/smooth-cli/src/smooai/mod.rs b/crates/smooth-cli/src/smooai/mod.rs index 45127402..2de4faa4 100644 --- a/crates/smooth-cli/src/smooai/mod.rs +++ b/crates/smooth-cli/src/smooai/mod.rs @@ -69,18 +69,61 @@ pub async fn require_authed() -> Result { /// Build an authed client from the user JWT at `~/.smooth/auth/smooai-user.json`, /// or `None` if it's absent/unreadable/expired so the caller falls back to M2M. async fn try_user_session() -> Option { - let home = std::env::var_os("HOME")?; - let path = std::path::Path::new(&home).join(".smooth").join("auth").join("smooai-user.json"); - if !path.exists() { - return None; - } - let store = CredentialsStore::at(&path); - let creds = store.load().ok()??; - let client = SmoothApiClient::new(smooth_api_client::base_url(), Some(creds), store).ok()?; - // No-op for a user JWT (no client_secret to re-mint), but harmless. - client.ensure_fresh_token().await.ok(); - // is_authenticated() is false for an expired session → fall back to M2M. - client.is_authenticated().then_some(client) + for path in user_jwt_candidates() { + if !path.exists() { + continue; + } + let store = CredentialsStore::at(&path); + let Ok(Some(creds)) = store.load() else { continue }; + let Ok(client) = SmoothApiClient::new(smooth_api_client::base_url(), Some(creds), store) else { + continue; + }; + // No-op for a user JWT (no client_secret to re-mint), but harmless. + client.ensure_fresh_token().await.ok(); + // is_authenticated() is false for an expired session → try the next candidate. + if client.is_authenticated() { + return Some(client); + } + } + None +} + +/// Candidate paths for the user JWT, in priority order. `th auth login` writes the +/// session under the active profile (`~/.config/smooth/auth/profiles//`), +/// while older builds + `SMOOAI_USER_AUTH_FILE` use the flat legacy path — so we +/// try them all and use the first that holds a valid session. +/// 1. `$SMOOAI_USER_AUTH_FILE` (explicit override) +/// 2. active profile: `$XDG_CONFIG_HOME|~/.config`/smooth/auth/profiles//smooai-user.json, +/// where = `$SMOOAI_PROFILE` or the name in `.../auth/active` +/// 3. legacy `~/.smooth/auth/smooai-user.json` +fn user_jwt_candidates() -> Vec { + use std::path::PathBuf; + let mut paths = Vec::new(); + + if let Some(explicit) = std::env::var_os("SMOOAI_USER_AUTH_FILE") { + paths.push(PathBuf::from(explicit)); + } + + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))); + if let Some(cfg) = config_home { + let auth = cfg.join("smooth").join("auth"); + let profile = std::env::var("SMOOAI_PROFILE") + .ok() + .or_else(|| std::fs::read_to_string(auth.join("active")).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(name) = profile { + paths.push(auth.join("profiles").join(name).join("smooai-user.json")); + } + } + + if let Some(home) = std::env::var_os("HOME") { + paths.push(PathBuf::from(home).join(".smooth").join("auth").join("smooai-user.json")); + } + + paths } /// Resolve the active org id. Delegates to