Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions crates/smooth-cli/src/smooai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,95 @@ 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<SmoothApiClient> {
// 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
// disk to re-exchange with.
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<SmoothApiClient> {
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/<name>/`),
/// 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/<active>/smooai-user.json,
/// where <active> = `$SMOOAI_PROFILE` or the name in `.../auth/active`
/// 3. legacy `~/.smooth/auth/smooai-user.json`
fn user_jwt_candidates() -> Vec<std::path::PathBuf> {
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
/// [`crate::active_org::resolve`] so every `th api` subcommand reads
/// from the same source `th config` and `th auth whoami` do.
Expand Down
Loading