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
662 lines
22 KiB
Rust
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);
|
|
}
|