Jesús Pérez 4b92aa764a
Some checks failed
Build and Test / Validate Setup (push) Has been cancelled
Build and Test / Build (darwin-amd64) (push) Has been cancelled
Build and Test / Build (darwin-arm64) (push) Has been cancelled
Build and Test / Build (linux-amd64) (push) Has been cancelled
Build and Test / Build (windows-amd64) (push) Has been cancelled
Build and Test / Build (linux-arm64) (push) Has been cancelled
Build and Test / Security Audit (push) Has been cancelled
Build and Test / Package Results (push) Has been cancelled
Build and Test / Quality Gate (push) Has been cancelled
implements a production-ready bootstrap installer with comprehensive error handling, version-agnostic archive extraction, and clear user messaging. All improvements follow DRY principles using symlink-based architecture for single-source-of-truth maintenance
2025-12-11 22:04:54 +00:00

662 lines
22 KiB
Rust

//! Nushell plugin for provisioning authentication (JWT, MFA).
//!
//! This plugin provides authentication commands for the provisioning platform:
//! - `auth login` - Authenticate with JWT
//! - `auth logout` - Revoke authentication session
//! - `auth verify` - Verify token validity
//! - `auth sessions` - List active sessions
//! - `auth mfa enroll` - Enroll in MFA
//! - `auth mfa verify` - Verify MFA code
use nu_plugin::{
serve_plugin, EngineInterface, EvaluatedCall, MsgPackSerializer, Plugin, PluginCommand,
SimplePluginCommand,
};
use nu_protocol::{record, Category, Example, LabeledError, Signature, SyntaxShape, Type, Value};
pub mod auth;
pub mod error;
mod helpers;
pub mod keyring;
#[cfg(test)]
mod tests;
use crate::auth::DEFAULT_CONTROL_CENTER_URL;
/// Nushell plugin for provisioning authentication (JWT, MFA).
#[derive(Debug)]
pub struct AuthPlugin;
impl Plugin for AuthPlugin {
/// Returns the plugin version from Cargo.toml.
fn version(&self) -> String {
env!("CARGO_PKG_VERSION").into()
}
/// Returns the list of commands provided by this plugin.
fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin = Self>>> {
vec![
Box::new(Login),
Box::new(Logout),
Box::new(Verify),
Box::new(Sessions),
Box::new(MfaEnroll),
Box::new(MfaVerify),
]
}
}
// =============================================================================
// Login Command
// =============================================================================
/// Login command - Authenticate with the provisioning platform.
#[derive(Debug)]
pub struct Login;
impl SimplePluginCommand for Login {
type Plugin = AuthPlugin;
fn name(&self) -> &str {
"auth login"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Nothing, Type::Record(vec![].into()))
.required("username", SyntaxShape::String, "Username for login")
.optional(
"password",
SyntaxShape::String,
"Password (will prompt if omitted)",
)
.named(
"url",
SyntaxShape::String,
"Control center URL (default: http://localhost:8081)",
None,
)
.switch("save", "Save credentials to secure keyring", None)
.category(Category::Custom("provisioning".into()))
}
fn description(&self) -> &str {
"Login to provisioning platform with JWT authentication"
}
fn examples(&self) -> Vec<Example<'_>> {
vec![
Example {
example: "auth login admin",
description: "Login as admin (password prompt)",
result: None,
},
Example {
example: "auth login admin mypassword",
description: "Login with password in command",
result: None,
},
Example {
example: "auth login admin --url http://control.example.com:8081",
description: "Login to custom control center URL",
result: None,
},
Example {
example: "auth login admin --save",
description: "Login and save credentials to keyring",
result: None,
},
]
}
fn run(
&self,
_plugin: &AuthPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let username: String = call.req(0)?;
let password_arg: Option<String> = call.opt(1)?;
let url = call
.get_flag::<String>("url")?
.unwrap_or_else(|| DEFAULT_CONTROL_CENTER_URL.to_string());
let save_token = call.has_flag("save")?;
// Get password (from arg or prompt)
let password = if let Some(pwd) = password_arg {
pwd
} else {
helpers::prompt_password("Password: ")
.map_err(|e| LabeledError::new(e.to_string()))?
};
// Send login request
let token_response = helpers::send_login_request(&url, &username, &password)
.map_err(|e| LabeledError::new(e.to_string()))?;
// Store tokens in keyring if requested
if save_token {
keyring::store_tokens(
&username,
&token_response.access_token,
&token_response.refresh_token,
)
.map_err(|e| LabeledError::new(e.to_string()))?;
}
// Return success response
Ok(Value::record(
record! {
"success" => Value::bool(true, call.head),
"user" => Value::record(record! {
"id" => Value::string(&token_response.user.id, call.head),
"username" => Value::string(&token_response.user.username, call.head),
"email" => Value::string(&token_response.user.email, call.head),
"roles" => Value::list(
token_response.user.roles.iter()
.map(|r| Value::string(r, call.head))
.collect(),
call.head
),
}, call.head),
"expires_in" => Value::int(token_response.expires_in, call.head),
"token_saved" => Value::bool(save_token, call.head),
},
call.head,
))
}
}
// =============================================================================
// Logout Command
// =============================================================================
/// Logout command - Remove authentication session.
#[derive(Debug)]
pub struct Logout;
impl SimplePluginCommand for Logout {
type Plugin = AuthPlugin;
fn name(&self) -> &str {
"auth logout"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Nothing, Type::Record(vec![].into()))
.named(
"user",
SyntaxShape::String,
"Username (defaults to current user)",
Some('u'),
)
.named("url", SyntaxShape::String, "Control Center URL", None)
.switch("all", "Logout from all active sessions", Some('a'))
.category(Category::Custom("provisioning".into()))
}
fn description(&self) -> &str {
"Logout from provisioning platform"
}
fn examples(&self) -> Vec<Example<'_>> {
vec![
Example {
example: "auth logout",
description: "Logout from current session",
result: None,
},
Example {
example: "auth logout --all",
description: "Logout from all active sessions",
result: None,
},
]
}
fn run(
&self,
_plugin: &AuthPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let username_arg: Option<String> = call.get_flag("user")?;
let _all_sessions = call.has_flag("all")?;
let url = call
.get_flag::<String>("url")?
.unwrap_or_else(|| DEFAULT_CONTROL_CENTER_URL.to_string());
// Get username (from flag or current user)
let username = username_arg.unwrap_or_else(keyring::get_current_username);
// Get access token
let access_token = keyring::get_access_token(&username)
.map_err(|e| LabeledError::new(e.to_string()))?;
// Send logout request
helpers::send_logout_request(&url, &access_token)
.map_err(|e| LabeledError::new(e.to_string()))?;
// Remove tokens from keyring
keyring::remove_tokens(&username)
.map_err(|e| LabeledError::new(e.to_string()))?;
Ok(Value::record(
record! {
"success" => Value::bool(true, call.head),
"message" => Value::string("Logged out successfully", call.head),
"user" => Value::string(&username, call.head),
},
call.head,
))
}
}
// =============================================================================
// Verify Command
// =============================================================================
/// Verify token command - Check authentication status.
#[derive(Debug)]
pub struct Verify;
impl SimplePluginCommand for Verify {
type Plugin = AuthPlugin;
fn name(&self) -> &str {
"auth verify"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Nothing, Type::Record(vec![].into()))
.named(
"token",
SyntaxShape::String,
"Token to verify (uses stored token if omitted)",
None,
)
.named(
"user",
SyntaxShape::String,
"Username for stored token lookup",
Some('u'),
)
.named(
"url",
SyntaxShape::String,
"Control Center URL for remote verification",
None,
)
.switch("local", "Verify locally without contacting server", Some('l'))
.category(Category::Custom("provisioning".into()))
}
fn description(&self) -> &str {
"Verify current authentication token"
}
fn examples(&self) -> Vec<Example<'_>> {
vec![
Example {
example: "auth verify",
description: "Verify stored authentication token",
result: None,
},
Example {
example: "auth verify --token eyJhbGc...",
description: "Verify specific token",
result: None,
},
Example {
example: "auth verify --local",
description: "Verify token locally (no server contact)",
result: None,
},
]
}
fn run(
&self,
_plugin: &AuthPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let token_arg: Option<String> = call.get_flag("token")?;
let username_arg: Option<String> = call.get_flag("user")?;
let url_arg: Option<String> = call.get_flag("url")?;
let local_only = call.has_flag("local")?;
// Get token (from arg or keyring)
let token = if let Some(t) = token_arg {
t
} else {
let username = username_arg.unwrap_or_else(keyring::get_current_username);
keyring::get_access_token(&username)
.map_err(|e| LabeledError::new(e.to_string()))?
};
if local_only {
// Local verification (no network)
let result = auth::verify_token_local(&token)
.map_err(|e| LabeledError::new(e.to_string()))?;
Ok(Value::record(
record! {
"valid" => Value::bool(result.valid, call.head),
"verification_type" => Value::string("local", call.head),
"expires_in" => result.expires_in
.map(|s| Value::int(s, call.head))
.unwrap_or(Value::nothing(call.head)),
"user_id" => result.claims.as_ref()
.map(|c| Value::string(&c.sub, call.head))
.unwrap_or(Value::nothing(call.head)),
"username" => result.claims.as_ref()
.map(|c| Value::string(&c.username, call.head))
.unwrap_or(Value::nothing(call.head)),
"roles" => result.claims.as_ref()
.map(|c| Value::list(
c.roles.iter().map(|r| Value::string(r, call.head)).collect(),
call.head
))
.unwrap_or(Value::nothing(call.head)),
"error" => result.error
.map(|e| Value::string(&e, call.head))
.unwrap_or(Value::nothing(call.head)),
},
call.head,
))
} else {
// Remote verification
let url = url_arg.unwrap_or_else(|| DEFAULT_CONTROL_CENTER_URL.to_string());
let result = helpers::verify_token(&url, &token)
.map_err(|e| LabeledError::new(e.to_string()))?;
Ok(helpers::verify_response_to_value(&result, call.head))
}
}
}
// =============================================================================
// Sessions Command
// =============================================================================
/// Sessions command - List active authentication sessions.
#[derive(Debug)]
pub struct Sessions;
impl SimplePluginCommand for Sessions {
type Plugin = AuthPlugin;
fn name(&self) -> &str {
"auth sessions"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(
Type::Nothing,
Type::List(Box::new(Type::Record(vec![].into()))),
)
.switch("active", "Show only active sessions", None)
.named(
"user",
SyntaxShape::String,
"Username for token lookup",
Some('u'),
)
.named("url", SyntaxShape::String, "Control Center URL", None)
.category(Category::Custom("provisioning".into()))
}
fn description(&self) -> &str {
"List active authentication sessions"
}
fn examples(&self) -> Vec<Example<'_>> {
vec![
Example {
example: "auth sessions",
description: "List all sessions",
result: None,
},
Example {
example: "auth sessions --active",
description: "List only active sessions",
result: None,
},
]
}
fn run(
&self,
_plugin: &AuthPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let active_only = call.has_flag("active")?;
let username_arg: Option<String> = call.get_flag("user")?;
let url = call
.get_flag::<String>("url")?
.unwrap_or_else(|| DEFAULT_CONTROL_CENTER_URL.to_string());
// Get username and access token
let username = username_arg.unwrap_or_else(keyring::get_current_username);
let access_token = keyring::get_access_token(&username)
.map_err(|e| LabeledError::new(e.to_string()))?;
// List sessions from server
let sessions = helpers::list_sessions(&url, &access_token, active_only)
.map_err(|e| LabeledError::new(e.to_string()))?;
let session_values: Vec<Value> = sessions
.iter()
.map(|s| helpers::session_info_to_value(s, call.head))
.collect();
Ok(Value::list(session_values, call.head))
}
}
// =============================================================================
// MFA Enroll Command
// =============================================================================
/// MFA Enrollment command.
#[derive(Debug)]
pub struct MfaEnroll;
impl SimplePluginCommand for MfaEnroll {
type Plugin = AuthPlugin;
fn name(&self) -> &str {
"auth mfa enroll"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Nothing, Type::Record(vec![].into()))
.required("type", SyntaxShape::String, "MFA type: totp or webauthn")
.named("user", SyntaxShape::String, "Username", Some('u'))
.named("url", SyntaxShape::String, "Control Center URL", None)
.category(Category::Custom("provisioning".into()))
}
fn description(&self) -> &str {
"Enroll in MFA (TOTP or WebAuthn)"
}
fn examples(&self) -> Vec<Example<'_>> {
vec![
Example {
example: "auth mfa enroll totp",
description: "Enroll TOTP (Google Authenticator, Authy)",
result: None,
},
Example {
example: "auth mfa enroll webauthn",
description: "Enroll WebAuthn (YubiKey, Touch ID)",
result: None,
},
Example {
example: "auth mfa enroll totp --user alice",
description: "Enroll TOTP for specific user",
result: None,
},
]
}
fn run(
&self,
_plugin: &AuthPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let mfa_type: String = call.req(0)?;
let username = call
.get_flag::<String>("user")?
.unwrap_or_else(keyring::get_current_username);
let url = call
.get_flag::<String>("url")?
.unwrap_or_else(|| DEFAULT_CONTROL_CENTER_URL.to_string());
// Validate MFA type
if mfa_type != "totp" && mfa_type != "webauthn" {
return Err(LabeledError::new(format!(
"Invalid MFA type '{}'. Use 'totp' or 'webauthn'",
mfa_type
)));
}
// Get access token
let access_token = keyring::get_access_token(&username)
.map_err(|e| LabeledError::new(e.to_string()))?;
// Send enrollment request
let response = helpers::send_mfa_enroll_request(&url, &access_token, &mfa_type)
.map_err(|e| LabeledError::new(e.to_string()))?;
// Display QR code if TOTP
if mfa_type == "totp" {
helpers::display_qr_code(&response.qr_code_uri)
.map_err(|e| LabeledError::new(e.to_string()))?;
}
Ok(Value::record(
record! {
"success" => Value::bool(true, call.head),
"mfa_type" => Value::string(&mfa_type, call.head),
"secret" => Value::string(&response.secret, call.head),
"backup_codes" => Value::list(
response.backup_codes.iter()
.map(|c| Value::string(c, call.head))
.collect(),
call.head
),
},
call.head,
))
}
}
// =============================================================================
// MFA Verify Command
// =============================================================================
/// MFA Verify command.
#[derive(Debug)]
pub struct MfaVerify;
impl SimplePluginCommand for MfaVerify {
type Plugin = AuthPlugin;
fn name(&self) -> &str {
"auth mfa verify"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Nothing, Type::Record(vec![].into()))
.named("code", SyntaxShape::String, "6-digit TOTP code", Some('c'))
.named("user", SyntaxShape::String, "Username", Some('u'))
.named("url", SyntaxShape::String, "Control Center URL", None)
.category(Category::Custom("provisioning".into()))
}
fn description(&self) -> &str {
"Verify MFA code"
}
fn examples(&self) -> Vec<Example<'_>> {
vec![
Example {
example: "auth mfa verify --code 123456",
description: "Verify TOTP code",
result: None,
},
Example {
example: "auth mfa verify --code 123456 --user alice",
description: "Verify TOTP code for specific user",
result: None,
},
]
}
fn run(
&self,
_plugin: &AuthPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let code = call
.get_flag::<String>("code")?
.ok_or_else(|| LabeledError::new("--code is required"))?;
// Validate code format
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
return Err(LabeledError::new(
"Code must be a 6-digit number",
));
}
let username = call
.get_flag::<String>("user")?
.unwrap_or_else(keyring::get_current_username);
let url = call
.get_flag::<String>("url")?
.unwrap_or_else(|| DEFAULT_CONTROL_CENTER_URL.to_string());
// Get access token
let access_token = keyring::get_access_token(&username)
.map_err(|e| LabeledError::new(e.to_string()))?;
// Verify code
let valid = helpers::send_mfa_verify_request(&url, &access_token, &code)
.map_err(|e| LabeledError::new(e.to_string()))?;
Ok(Value::record(
record! {
"valid" => Value::bool(valid, call.head),
"message" => Value::string(
if valid { "MFA verified successfully" } else { "Invalid code" },
call.head
),
},
call.head,
))
}
}
/// Entry point for the plugin binary.
fn main() {
serve_plugin(&AuthPlugin, MsgPackSerializer);
}