- 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>
179 lines
5.9 KiB
Rust
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(())
|
|
}
|
|
}
|