Files
foxhunt/bin/fxt/src/commands/auth.rs
jgrusewski 078771bf95 feat(fxt): auto-refresh tokens and FXT_PASSWORD env var for non-interactive login
- Auto-refresh: before non-auth commands, if the access token is expired
  but a refresh token exists, silently regenerate tokens. Prints
  "Token refreshed." to stderr so users know what happened.
- FXT_PASSWORD: login reads password from this env var when set,
  skipping the interactive prompt. Safer than a --password flag
  (env vars don't appear in ps output or shell history).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:34:34 +01:00

179 lines
5.9 KiB
Rust

//! `fxt auth` -- authentication and session management.
use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::Colorize;
use serde::Serialize;
use crate::auth::token_manager::{AuthTokenManager, FileTokenStorage};
use crate::auth::LoginClient;
use crate::grpc::FoxhuntClient;
use crate::output::{self, HumanReadable, OutputFormat};
#[derive(Parser, Debug)]
pub struct AuthCommand {
#[command(subcommand)]
action: AuthAction,
}
#[derive(Subcommand, Debug)]
enum AuthAction {
/// Login with username and password
Login {
/// Username
#[arg(long)]
username: Option<String>,
},
/// Logout and clear stored tokens
Logout,
/// Show current authentication status
Status,
}
// ── Result types ──────────────────────────────────────────────────────
#[derive(Serialize)]
struct LoginResult {
success: bool,
message: String,
}
impl HumanReadable for LoginResult {
fn print_human(&self) {
if self.success {
println!("{} {}", "Authenticated.".green().bold(), self.message);
} else {
println!("{} {}", "Login failed.".red().bold(), self.message);
}
}
}
#[derive(Serialize)]
struct LogoutResult {
success: bool,
message: String,
}
impl HumanReadable for LogoutResult {
fn print_human(&self) {
if self.success {
println!("{} {}", "Logged out.".green().bold(), self.message);
} else {
println!("{} {}", "Logout failed.".red().bold(), self.message);
}
}
}
#[derive(Serialize)]
struct AuthStatusResult {
authenticated: bool,
has_refresh_token: bool,
expires_in: Option<String>,
}
impl HumanReadable for AuthStatusResult {
fn print_human(&self) {
println!("{}", "Authentication Status".cyan().bold());
if self.authenticated {
println!(" Status: {}", "authenticated".green());
if let Some(ref exp) = self.expires_in {
println!(" Token expires in: {exp}");
}
} else {
println!(" Status: {}", "not authenticated".red());
if self.has_refresh_token {
println!(
" {}",
"Refresh token found -- run `fxt auth login` to re-authenticate.".yellow()
);
} else {
println!(" Run `fxt auth login` to authenticate.");
}
}
}
}
// ── Execute ───────────────────────────────────────────────────────────
impl AuthCommand {
pub async fn execute(&self, client: &FoxhuntClient, format: OutputFormat) -> Result<()> {
match &self.action {
AuthAction::Login { username } => {
let storage = FileTokenStorage::new()?;
let manager = AuthTokenManager::new(storage);
let login_client = LoginClient::new(client.channel());
if let Some(user) = username {
// Password: FXT_PASSWORD env var > interactive prompt
// (never accept --password flag — visible in ps/history)
let pw = if let Ok(env_pw) = std::env::var("FXT_PASSWORD") {
env_pw
} else {
print!("Password: ");
std::io::Write::flush(&mut std::io::stdout())?;
rpassword::read_password().map_err(|e| anyhow::anyhow!("{e}"))?
};
login_client
.login_with_credentials(user, &pw, &manager)
.await?;
let result = LoginResult {
success: true,
message: format!("Logged in as {user}"),
};
output::render(&result, format)?;
} else {
// Interactive login flow
login_client.interactive_login(&manager).await?;
let result = LoginResult {
success: true,
message: "Interactive login complete.".to_owned(),
};
output::render(&result, format)?;
}
}
AuthAction::Logout => {
let storage = FileTokenStorage::new()?;
let manager = AuthTokenManager::new(storage);
manager.clear_tokens().await?;
let result = LogoutResult {
success: true,
message: "All tokens cleared.".to_owned(),
};
output::render(&result, format)?;
}
AuthAction::Status => {
let storage = FileTokenStorage::new()?;
let manager = AuthTokenManager::new(storage);
let has_token = manager.has_valid_token().await;
let has_refresh = manager.get_refresh_token().await?.is_some();
#[allow(clippy::integer_division)]
let expires_in = manager.time_until_expiry().await.map(|d| {
let secs = d.as_secs();
if secs >= 3600 {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
} else if secs >= 60 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{secs}s")
}
});
let result = AuthStatusResult {
authenticated: has_token,
has_refresh_token: has_refresh,
expires_in,
};
output::render(&result, format)?;
}
}
Ok(())
}
}