//! 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>> { 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> { 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 { let username: String = call.req(0)?; let password_arg: Option = call.opt(1)?; let url = call .get_flag::("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> { 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 { let username_arg: Option = call.get_flag("user")?; let _all_sessions = call.has_flag("all")?; let url = call .get_flag::("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> { 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 { let token_arg: Option = call.get_flag("token")?; let username_arg: Option = call.get_flag("user")?; let url_arg: Option = 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> { 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 { let active_only = call.has_flag("active")?; let username_arg: Option = call.get_flag("user")?; let url = call .get_flag::("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 = 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> { 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 { let mfa_type: String = call.req(0)?; let username = call .get_flag::("user")? .unwrap_or_else(keyring::get_current_username); let url = call .get_flag::("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> { 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 { let code = call .get_flag::("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::("user")? .unwrap_or_else(keyring::get_current_username); let url = call .get_flag::("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); }