use anyhow::Result;
use clap::{Subcommand, ValueEnum};
use colored::Colorize;
use raps_kernel::prompts;
use serde::Serialize;
use crate::commands::tracked::tracked_op;
use crate::output::OutputFormat;
use raps_kernel::auth::AuthClient;
use raps_kernel::storage::{StorageBackend, TokenStorage};
const AVAILABLE_SCOPES: &[(&str, &str)] = &[
("data:read", "Read data (hubs, projects, folders, items)"),
("data:write", "Write data (create/update items)"),
("data:create", "Create new data"),
("data:search", "Search for data"),
("bucket:create", "Create OSS buckets"),
("bucket:read", "Read OSS buckets"),
("bucket:update", "Update OSS buckets"),
("bucket:delete", "Delete OSS buckets"),
("account:read", "Read account information"),
("account:write", "Write account information"),
("user:read", "Read user profile"),
("user:write", "Write user profile"),
("user-profile:read", "Read user profile (OpenID Connect)"),
("viewables:read", "Read viewable content"),
("code:all", "Design Automation (all engines)"),
("openid", "OpenID Connect identity"),
];
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum LoginPreset {
All,
Viewer,
Editor,
Storage,
Automation,
Admin,
}
impl LoginPreset {
fn scopes(&self) -> Vec<&'static str> {
match self {
LoginPreset::All => AVAILABLE_SCOPES.iter().map(|(s, _)| *s).collect(),
LoginPreset::Viewer => vec![
"data:read",
"data:search",
"bucket:read",
"account:read",
"user:read",
"viewables:read",
],
LoginPreset::Editor => vec![
"data:read",
"data:write",
"data:create",
"data:search",
"bucket:read",
"bucket:create",
"bucket:update",
"account:read",
"user:read",
"viewables:read",
],
LoginPreset::Storage => vec![
"data:read",
"data:write",
"data:create",
"bucket:create",
"bucket:read",
"bucket:update",
"bucket:delete",
],
LoginPreset::Automation => vec![
"code:all",
"data:read",
"data:write",
"data:create",
"bucket:read",
"bucket:create",
],
LoginPreset::Admin => vec![
"account:read",
"account:write",
"user:read",
"user:write",
"data:read",
],
}
}
}
const DEFAULT_SCOPES: &[&str] = &[
"data:read",
"data:write",
"data:create",
"data:search",
"bucket:create",
"bucket:read",
"bucket:update",
"bucket:delete",
"account:read",
"account:write",
"user:read",
"viewables:read",
"code:all",
];
#[derive(Debug, Subcommand)]
pub enum AuthCommands {
Test,
Login {
#[arg(short, long, conflicts_with = "preset")]
default: bool,
#[arg(short = 'p', long, value_enum, conflicts_with = "default")]
preset: Option<LoginPreset>,
#[arg(long)]
device: bool,
#[arg(long)]
token: Option<String>,
#[arg(long)]
refresh_token: Option<String>,
#[arg(long, default_value = "3600")]
expires_in: u64,
},
Logout,
Status,
Whoami,
Inspect {
#[arg(long)]
warn_expiry_seconds: Option<u64>,
},
}
impl AuthCommands {
pub async fn execute(
self,
auth_client: &AuthClient,
output_format: OutputFormat,
) -> Result<()> {
match self {
AuthCommands::Test => test_auth(auth_client, output_format).await,
AuthCommands::Login {
default,
preset,
device,
token,
refresh_token,
expires_in,
} => {
login(
auth_client,
default,
preset,
device,
token,
refresh_token,
expires_in,
output_format,
)
.await
}
AuthCommands::Logout => logout(auth_client, output_format).await,
AuthCommands::Status => status(auth_client, output_format).await,
AuthCommands::Whoami => whoami(auth_client, output_format).await,
AuthCommands::Inspect {
warn_expiry_seconds,
} => inspect_token(auth_client, warn_expiry_seconds, output_format).await,
}
}
}
#[derive(Serialize)]
struct TestAuthOutput {
success: bool,
client_id: String,
base_url: String,
}
async fn test_auth(auth_client: &AuthClient, output_format: OutputFormat) -> Result<()> {
if output_format.supports_colors() {
println!("{}", "Testing 2-legged authentication...".dimmed());
}
auth_client.test_auth().await?;
let output = TestAuthOutput {
success: true,
client_id: mask_string(&auth_client.config().client_id),
base_url: auth_client.config().base_url.clone(),
};
match output_format {
OutputFormat::Table => {
println!("{} 2-legged authentication successful!", "✓".green().bold());
println!(" {} {}", "Client ID:".bold(), output.client_id);
println!(" {} {}", "Base URL:".bold(), output.base_url);
}
_ => {
output_format.write(&output)?;
}
}
Ok(())
}
#[derive(Serialize)]
struct LoginOutput {
success: bool,
access_token: String,
refresh_token_stored: bool,
scopes: Vec<String>,
}
#[allow(clippy::too_many_arguments)]
async fn login(
auth_client: &AuthClient,
use_defaults: bool,
preset: Option<LoginPreset>,
device: bool,
token: Option<String>,
refresh_token: Option<String>,
expires_in: u64,
output_format: OutputFormat,
) -> Result<()> {
if auth_client.is_logged_in().await {
let msg = "Already logged in. Use 'raps auth logout' to logout first.";
match output_format {
OutputFormat::Table => println!("{}", msg.yellow()),
_ => output_format.write_message(msg)?,
}
return Ok(());
}
if let Some(access_token) = token {
eprintln!(
"{}",
"WARNING: Using token-based login. Tokens should be kept secure!"
.yellow()
.bold()
);
eprintln!(
"{}",
" This is intended for CI/CD environments. Never commit tokens to version control."
.dimmed()
);
let scopes: Vec<String> = if let Some(p) = preset {
p.scopes().iter().map(|s| s.to_string()).collect()
} else {
DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect()
};
let stored = auth_client
.login_with_token(access_token, refresh_token, expires_in, scopes)
.await?;
let output = LoginOutput {
success: true,
access_token: mask_string(&stored.access_token),
refresh_token_stored: stored.refresh_token.is_some(),
scopes: stored.scopes.clone(),
};
match output_format {
OutputFormat::Table => {
println!("\n{} Login successful!", "✓".green().bold());
println!(" {} {}", "Access Token:".bold(), output.access_token);
if output.refresh_token_stored {
println!(" {} {}", "Refresh Token:".bold(), "stored".green());
}
println!(" {} {:?}", "Scopes:".bold(), output.scopes);
}
_ => {
output_format.write(&output)?;
}
}
return Ok(());
}
let scopes: Vec<&str> = if let Some(p) = preset {
p.scopes()
} else if use_defaults || raps_kernel::interactive::is_non_interactive() {
DEFAULT_SCOPES.to_vec()
} else {
let scope_labels: Vec<String> = AVAILABLE_SCOPES
.iter()
.map(|(scope, desc)| format!("{} - {}", scope, desc))
.collect();
let selections = prompts::multi_select("Select OAuth scopes", &scope_labels)?;
if selections.is_empty() {
anyhow::bail!("At least one scope must be selected");
}
selections.iter().map(|&i| AVAILABLE_SCOPES[i].0).collect()
};
if output_format.supports_colors() {
println!("{}", "Starting 3-legged OAuth login...".dimmed());
println!(" {} {:?}", "Scopes:".bold(), scopes);
}
let device = if !device && raps_kernel::interactive::is_headless() {
eprintln!(
"{}",
"Headless environment detected (no browser available). \
Switching to device code flow automatically."
.yellow()
);
eprintln!(
"{}",
" Tip: use 'raps auth login --device' to skip this detection.".dimmed()
);
true
} else {
device
};
let token = if device {
auth_client.login_device(&scopes).await?
} else {
auth_client.login(&scopes).await?
};
let output = LoginOutput {
success: true,
access_token: mask_string(&token.access_token),
refresh_token_stored: token.refresh_token.is_some(),
scopes: token.scopes.clone(),
};
match output_format {
OutputFormat::Table => {
println!("\n{} Login successful!", "✓".green().bold());
println!(" {} {}", "Access Token:".bold(), output.access_token);
if output.refresh_token_stored {
println!(" {} {}", "Refresh Token:".bold(), "stored".green());
}
println!(" {} {:?}", "Scopes:".bold(), output.scopes);
}
_ => {
output_format.write(&output)?;
}
}
Ok(())
}
#[derive(Serialize)]
struct LogoutOutput {
success: bool,
message: String,
}
async fn logout(auth_client: &AuthClient, output_format: OutputFormat) -> Result<()> {
if !auth_client.is_logged_in().await {
let msg = "Not currently logged in.";
match output_format {
OutputFormat::Table => println!("{}", msg.yellow()),
_ => {
let output = LogoutOutput {
success: false,
message: msg.to_string(),
};
output_format.write(&output)?;
}
}
return Ok(());
}
auth_client.logout().await?;
let output = LogoutOutput {
success: true,
message: "Logged out successfully. Stored tokens cleared.".to_string(),
};
match output_format {
OutputFormat::Table => {
println!("{} {}", "✓".green().bold(), output.message);
}
_ => {
output_format.write(&output)?;
}
}
Ok(())
}
#[derive(Serialize)]
struct StatusOutput {
two_legged: TwoLeggedStatus,
three_legged: ThreeLeggedStatus,
}
#[derive(Serialize)]
struct TwoLeggedStatus {
available: bool,
}
#[derive(Serialize)]
struct ThreeLeggedStatus {
logged_in: bool,
token: Option<String>,
expires_at: Option<i64>,
expires_in_seconds: Option<i64>,
}
async fn status(auth_client: &AuthClient, output_format: OutputFormat) -> Result<()> {
let two_legged_available = auth_client.test_auth().await.is_ok();
let three_legged_logged_in = auth_client.is_logged_in().await;
let token = if three_legged_logged_in {
auth_client
.get_3leg_token()
.await
.ok()
.map(|t| mask_string(&t))
} else {
None
};
let expires_at = auth_client.get_token_expiry().await;
let expires_in_seconds = expires_at.map(|exp| {
let now = chrono::Utc::now().timestamp();
(exp - now).max(0)
});
let output = StatusOutput {
two_legged: TwoLeggedStatus {
available: two_legged_available,
},
three_legged: ThreeLeggedStatus {
logged_in: three_legged_logged_in,
token,
expires_at,
expires_in_seconds,
},
};
match output_format {
OutputFormat::Table => {
println!("{}", "Authentication Status".bold());
println!("{}", "-".repeat(40));
print!(" {} ", "2-legged (Client Credentials):".bold());
if output.two_legged.available {
println!("{}", "Available".green());
} else {
println!("{}", "Not configured".red());
}
print!(" {} ", "3-legged (User Login):".bold());
if output.three_legged.logged_in {
println!("{}", "Logged in".green());
if let Some(ref token) = output.three_legged.token {
println!(" {} {}", "Token:".dimmed(), token);
}
if let Some(expires_in) = output.three_legged.expires_in_seconds {
if expires_in > 0 {
let hours = expires_in / 3600;
let minutes = (expires_in % 3600) / 60;
println!(" {} {}h {}m", "Expires in:".dimmed(), hours, minutes);
} else {
println!(" {} {}", "Status:".dimmed(), "Expired".red());
}
}
} else {
println!("{}", "Not logged in".yellow());
println!(" {}", "Run 'raps auth login' to authenticate".dimmed());
}
println!("{}", "-".repeat(40));
}
_ => {
output_format.write(&output)?;
}
}
Ok(())
}
#[derive(Serialize)]
struct WhoamiOutput {
name: Option<String>,
email: Option<String>,
email_verified: Option<bool>,
username: Option<String>,
aps_id: String,
profile_url: Option<String>,
}
async fn whoami(auth_client: &AuthClient, output_format: OutputFormat) -> Result<()> {
if !auth_client.is_logged_in().await {
let msg = "Not logged in. Please run 'raps auth login' first.";
match output_format {
OutputFormat::Table => println!("{}", msg.yellow()),
_ => output_format.write_message(msg)?,
}
return Ok(());
}
let user = tracked_op("Fetching user profile", output_format, || {
auth_client.get_user_info()
})
.await?;
let output = WhoamiOutput {
name: user.name.clone(),
email: user.email.clone(),
email_verified: user.email_verified,
username: user.preferred_username.clone(),
aps_id: user.sub.clone(),
profile_url: user.profile.clone(),
};
match output_format {
OutputFormat::Table => {
println!("\n{}", "User Profile".bold());
println!("{}", "-".repeat(50));
if let Some(ref name) = output.name {
println!(" {} {}", "Name:".bold(), name.cyan());
}
if let Some(ref email) = output.email {
let verified = if output.email_verified.unwrap_or(false) {
" (verified)".green().to_string()
} else {
"".to_string()
};
println!(" {} {}{}", "Email:".bold(), email, verified);
}
if let Some(ref username) = output.username {
println!(" {} {}", "Username:".bold(), username);
}
println!(" {} {}", "APS ID:".bold(), output.aps_id.dimmed());
if let Some(ref profile) = output.profile_url {
println!(" {} {}", "Profile URL:".bold(), profile.dimmed());
}
println!("{}", "-".repeat(50));
}
_ => {
output_format.write(&output)?;
}
}
Ok(())
}
fn mask_string(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= 8 {
"*".repeat(chars.len())
} else {
let prefix: String = chars[..4].iter().collect();
let suffix: String = chars[chars.len() - 4..].iter().collect();
format!("{}...{}", prefix, suffix)
}
}
#[derive(Serialize)]
struct InspectOutput {
authenticated: bool,
token_type: Option<String>,
expires_in_seconds: Option<i64>,
expires_at: Option<String>,
scopes: Option<Vec<String>>,
is_expiring_soon: bool,
warning: Option<String>,
}
async fn inspect_token(
_auth_client: &AuthClient,
warn_expiry_seconds: Option<u64>,
output_format: OutputFormat,
) -> Result<()> {
let backend = StorageBackend::from_env();
let storage = TokenStorage::new(backend);
let token_data = storage.load()?;
let output = if let Some(data) = token_data {
let now = chrono::Utc::now().timestamp();
let expires_at = data.expires_at;
let expires_in = expires_at - now;
let scopes: Vec<String> = data.scopes.clone();
let warn_threshold = warn_expiry_seconds.unwrap_or(300).min(86400) as i64; let is_expiring_soon = expires_in > 0 && expires_in < warn_threshold;
let is_expired = expires_in <= 0;
let warning = if is_expired {
Some("Token has expired!".to_string())
} else if is_expiring_soon {
Some(format!("Token expires in {} seconds", expires_in))
} else {
None
};
InspectOutput {
authenticated: !is_expired,
token_type: Some(if data.access_token.starts_with("ey") {
"JWT".to_string()
} else {
"Opaque".to_string()
}),
expires_in_seconds: Some(expires_in),
expires_at: Some(
chrono::DateTime::from_timestamp(expires_at, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| "Unknown".to_string()),
),
scopes: Some(scopes),
is_expiring_soon: is_expiring_soon || is_expired,
warning,
}
} else {
InspectOutput {
authenticated: false,
token_type: None,
expires_in_seconds: None,
expires_at: None,
scopes: None,
is_expiring_soon: true,
warning: Some("No token found. Run 'raps auth login' first.".to_string()),
}
};
match output_format {
OutputFormat::Table => {
println!("\n{}", "Token Inspection".bold());
println!("{}", "-".repeat(60));
if output.authenticated {
println!(" {} {}", "Authenticated:".bold(), "Yes".green());
} else {
println!(" {} {}", "Authenticated:".bold(), "No".red());
}
if let Some(ref token_type) = output.token_type {
println!(" {} {}", "Token type:".bold(), token_type);
}
if let Some(expires_in) = output.expires_in_seconds {
let color = if expires_in <= 0 {
"Expired".red().to_string()
} else if expires_in < 300 {
format!("{} seconds", expires_in).yellow().to_string()
} else {
format!(
"{} seconds ({:.1} hours)",
expires_in,
expires_in as f64 / 3600.0
)
.to_string()
};
println!(" {} {}", "Expires in:".bold(), color);
}
if let Some(ref expires_at) = output.expires_at {
println!(" {} {}", "Expires at:".bold(), expires_at.dimmed());
}
if let Some(ref scopes) = output.scopes {
println!(" {} {}", "Scopes:".bold(), scopes.len());
for scope in scopes {
println!(" {} {}", "-".cyan(), scope);
}
}
if let Some(ref warning) = output.warning {
println!("\n {} {}", "!".yellow().bold(), warning.yellow());
}
println!("{}", "-".repeat(60));
}
_ => {
output_format.write(&output)?;
}
}
if warn_expiry_seconds.is_some() && output.is_expiring_soon {
raps_kernel::error::ExitCode::AuthFailure.exit();
}
Ok(())
}