@lanonasis/cli
Version:
Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security
962 lines (961 loc) • 41 kB
JavaScript
import chalk from 'chalk';
import inquirer from 'inquirer';
import ora from 'ora';
import open from 'open';
import crypto from 'crypto';
import http from 'http';
import url from 'url';
import axios from 'axios';
import { apiClient } from '../utils/api.js';
import { CLIConfig } from '../utils/config.js';
// Color scheme
const colors = {
primary: chalk.blue.bold,
success: chalk.green,
warning: chalk.yellow,
error: chalk.red,
info: chalk.cyan,
accent: chalk.magenta,
muted: chalk.gray,
highlight: chalk.white.bold
};
// Helper function to handle authentication delays
async function handleAuthDelay(config) {
if (config.shouldDelayAuth()) {
const delayMs = config.getAuthDelayMs();
const failureCount = config.getFailureCount();
const lastFailure = config.getLastAuthFailure();
console.log();
console.log(chalk.yellow(`⚠️ Multiple authentication failures detected (${failureCount} attempts)`));
if (lastFailure) {
const lastFailureDate = new Date(lastFailure);
console.log(chalk.gray(`Last failure: ${lastFailureDate.toLocaleString()}`));
}
console.log(chalk.yellow(`Waiting ${Math.round(delayMs / 1000)} seconds before retry...`));
console.log(chalk.gray('This delay helps prevent account lockouts and reduces server load.'));
// Show countdown
const spinner = ora(`Waiting ${Math.round(delayMs / 1000)} seconds...`).start();
await new Promise(resolve => setTimeout(resolve, delayMs));
spinner.succeed('Ready to retry authentication');
console.log();
}
}
// Enhanced authentication failure handler
async function handleAuthenticationFailure(error, config, authMethod = 'jwt') {
// Increment failure count
await config.incrementFailureCount();
const failureCount = config.getFailureCount();
// Determine error type and provide specific guidance
const errorType = categorizeAuthError(error);
console.log();
console.log(chalk.red('✖ Authentication failed'));
switch (errorType) {
case 'invalid_credentials':
console.log(chalk.red('Invalid credentials provided'));
if (authMethod === 'vendor_key') {
console.log(chalk.gray('• Verify the vendor key matches the value shown in your dashboard'));
console.log(chalk.gray('• Confirm the key is active and has not been revoked'));
console.log(chalk.gray('• Ensure you copied the entire key without extra spaces'));
}
else {
console.log(chalk.gray('• Double-check your email and password'));
console.log(chalk.gray('• Passwords are case-sensitive'));
console.log(chalk.gray('• Consider resetting your password if needed'));
}
break;
case 'network_error':
console.log(chalk.red('Network connection failed'));
console.log(chalk.gray('• Check your internet connection'));
console.log(chalk.gray('• Verify you can access https://auth.lanonasis.com'));
console.log(chalk.gray('• Try again in a few moments'));
if (failureCount >= 2) {
console.log(chalk.gray('• Consider using a different network if issues persist'));
}
break;
case 'server_error':
console.log(chalk.red('Server temporarily unavailable'));
console.log(chalk.gray('• The authentication service may be experiencing issues'));
console.log(chalk.gray('• Please try again in a few minutes'));
console.log(chalk.gray('• Check https://status.lanonasis.com for service status'));
break;
case 'rate_limited':
console.log(chalk.red('Too many authentication attempts'));
console.log(chalk.gray('• Please wait before trying again'));
console.log(chalk.gray('• Rate limiting helps protect your account'));
console.log(chalk.gray('• Consider using a vendor key for automated access'));
break;
case 'expired_token':
console.log(chalk.red('Authentication token has expired'));
console.log(chalk.gray('• Please log in again to refresh your session'));
console.log(chalk.gray('• Consider using a vendor key for longer-term access'));
await config.clearInvalidCredentials();
break;
default:
console.log(chalk.red(`Unexpected error: ${sanitizeErrorMessage(error.message || 'Unknown error')}`));
console.log(chalk.gray('• Please try again'));
console.log(chalk.gray('• If the problem persists, contact support'));
}
// Progressive guidance for repeated failures
if (failureCount >= 3) {
console.log();
console.log(chalk.yellow('💡 Multiple failures detected. Recovery options:'));
if (authMethod === 'vendor_key') {
console.log(chalk.cyan('• Generate a new vendor key from your dashboard'));
console.log(chalk.cyan('• Try: lanonasis auth logout && lanonasis auth login'));
console.log(chalk.cyan('• Switch to browser login: lanonasis auth login (choose Browser Login)'));
}
else {
console.log(chalk.cyan('• Reset your password if you\'re unsure'));
console.log(chalk.cyan('• Try vendor key authentication instead'));
console.log(chalk.cyan('• Clear stored config: lanonasis auth logout'));
}
if (failureCount >= 5) {
console.log(chalk.yellow('• Consider contacting support if issues persist'));
console.log(chalk.gray('• Include error details and your email address'));
}
}
}
// Categorize authentication errors for specific handling
function categorizeAuthError(error) {
if (!error)
return 'unknown';
// Check HTTP status codes
if (error.response?.status) {
const status = error.response.status;
switch (status) {
case 401:
// Check if it's specifically an expired token
if (error.response.data?.error?.includes('expired') || error.response.data?.message?.includes('expired')) {
return 'expired_token';
}
return 'invalid_credentials';
case 403:
return 'invalid_credentials';
case 429:
return 'rate_limited';
case 500:
case 502:
case 503:
case 504:
return 'server_error';
}
}
// Check error codes for network issues
if (error.code) {
switch (error.code) {
case 'ECONNREFUSED':
case 'ENOTFOUND':
case 'ECONNRESET':
case 'ETIMEDOUT':
case 'ENETUNREACH':
return 'network_error';
}
}
// Check error messages
const message = error.message?.toLowerCase() || '';
if (message.includes('network') || message.includes('connection') || message.includes('timeout')) {
return 'network_error';
}
if (message.includes('invalid') || message.includes('unauthorized') || message.includes('forbidden')) {
return 'invalid_credentials';
}
if (message.includes('expired')) {
return 'expired_token';
}
if (message.includes('rate limit') || message.includes('too many')) {
return 'rate_limited';
}
return 'unknown';
}
// ============================================
// OAuth2 PKCE Helper Functions
// ============================================
/**
* Generate PKCE code verifier and challenge for OAuth2
*/
function generatePKCE() {
// Generate random verifier (43-128 chars, base64url)
const verifier = crypto.randomBytes(32).toString('base64url');
// Generate challenge: base64url(sha256(verifier))
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
/**
* Sanitize error messages to prevent command injection
*/
function sanitizeErrorMessage(message) {
if (typeof message !== 'string')
return 'Unknown error';
// Remove potential command injection characters
return message
.replace(/[;&|`$()]/g, '') // Remove shell metacharacters
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // Remove script tags
.replace(/javascript:/gi, '') // Remove javascript: URLs
.trim();
}
/**
* Start local HTTP server to catch OAuth2 callback
*/
function createCallbackServer(port = 8888) {
return new Promise((resolve, reject) => {
// Sanitize HTML to prevent XSS
function sanitizeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
const server = http.createServer((req, res) => {
// Set security headers
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === '/callback') {
const { code, state, error, error_description } = parsedUrl.query;
// Send response to browser
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<html>
<head><title>Authentication Failed</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>❌ Authentication Failed</h1>
<p>${sanitizeHtml(String(error_description || error))}</p>
<p style="color: gray;">You can close this window.</p>
</body>
</html>
`);
reject(new Error(`OAuth error: ${error_description || error}`));
}
else if (code) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<head><title>Authentication Successful</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>✅ Authentication Successful</h1>
<p>You can close this window and return to the CLI.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
resolve({ code: code, state: state });
}
else {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Invalid callback');
reject(new Error('No authorization code received'));
}
// Close server after handling request
server.close();
}
});
server.listen(port, () => {
console.log(chalk.gray(` Local callback server listening on port ${port}`));
});
// Timeout after 5 minutes
setTimeout(() => {
server.close();
reject(new Error('Authentication timeout - please try again'));
}, 300000);
});
}
/**
* Exchange authorization code for OAuth2 tokens
*/
async function exchangeCodeForTokens(code, verifier, authBase, redirectUri) {
const tokenEndpoint = `${authBase}/oauth/token`;
try {
// Use axios directly to have full control over error handling
const response = await axios.post(tokenEndpoint, {
grant_type: 'authorization_code',
code,
code_verifier: verifier,
client_id: 'lanonasis-cli',
redirect_uri: redirectUri
}, {
headers: {
'Content-Type': 'application/json',
}
});
return response.data;
}
catch (error) {
// Extract detailed error information from axios error response
if (error.response) {
const errorData = error.response.data || {};
const status = error.response.status;
const errorMessage = errorData.error_description || errorData.error || error.message || `Request failed with status code ${status}`;
const details = errorData.details;
const enhancedError = new Error(errorMessage);
enhancedError.response = error.response;
enhancedError.status = status;
enhancedError.details = details;
enhancedError.errorData = errorData;
throw enhancedError;
}
// If it's not an axios error, just rethrow
throw error;
}
}
/**
* Refresh OAuth2 access token using refresh token
* @internal Used for token refresh flows
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function refreshOAuth2Token(config) {
const refreshToken = config.get('refresh_token');
if (!refreshToken) {
return false;
}
try {
const authBase = config.getDiscoveredApiUrl();
const response = await apiClient.post(`${authBase}/oauth/token`, {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'lanonasis-cli'
});
await config.setToken(response.access_token);
if (response.refresh_token) {
await config.set('refresh_token', response.refresh_token);
}
await config.set('token_expires_at', Date.now() + (response.expires_in * 1000));
return true;
}
catch {
console.error(chalk.yellow('⚠️ Token refresh failed, please re-authenticate'));
return false;
}
}
export async function diagnoseCommand() {
const config = new CLIConfig();
await config.init();
console.log(chalk.blue.bold('🔍 Authentication Diagnostic'));
console.log(colors.info('━'.repeat(50)));
console.log();
const diagnostics = {
configExists: false,
hasCredentials: false,
credentialType: 'none',
credentialsValid: false,
tokenExpired: false,
authFailures: 0,
lastFailure: null,
endpointsReachable: false,
serviceDiscovery: false,
deviceId: null
};
// Step 1: Check if config exists
console.log(chalk.cyan('1. Configuration File'));
try {
const configExists = await config.exists();
diagnostics.configExists = configExists;
if (configExists) {
console.log(chalk.green(' ✓ Config file exists at'), config.getConfigPath());
}
else {
console.log(chalk.red(' ✖ Config file not found at'), config.getConfigPath());
console.log(chalk.gray(' → Run: lanonasis auth login'));
}
}
catch (error) {
console.log(chalk.red(' ✖ Error checking config:'), error instanceof Error ? error.message : 'Unknown error');
}
// Step 2: Check stored credentials
console.log(chalk.cyan('\n2. Stored Credentials'));
const token = config.getToken();
const vendorKey = await config.getVendorKeyAsync();
const authMethod = config.get('authMethod');
if (vendorKey) {
diagnostics.hasCredentials = true;
diagnostics.credentialType = 'vendor_key';
console.log(chalk.green(' ✓ Vendor key found'));
// Validate vendor key presence
const formatValidation = config.validateVendorKeyFormat(vendorKey);
if (formatValidation !== true) {
console.log(chalk.red(` ✖ Vendor key issue: ${formatValidation}`));
}
}
else if (token) {
diagnostics.hasCredentials = true;
diagnostics.credentialType = authMethod === 'oauth' ? 'oauth' : 'jwt';
console.log(chalk.green(` ✓ ${diagnostics.credentialType.toUpperCase()} token found`));
// Check token expiry
try {
const isAuth = await config.isAuthenticated();
if (!isAuth) {
diagnostics.tokenExpired = true;
console.log(chalk.red(' ✖ Token is expired'));
}
else {
console.log(chalk.green(' ✓ Token is not expired'));
}
}
catch (error) {
console.log(chalk.yellow(' ⚠ Could not validate token expiry'));
if (process.env.CLI_VERBOSE === 'true' && error instanceof Error) {
console.log(chalk.gray(` ${error.message}`));
}
}
}
else {
console.log(chalk.red(' ✖ No credentials found'));
console.log(chalk.gray(' → Run: lanonasis auth login'));
}
// Step 3: Check authentication failures
console.log(chalk.cyan('\n3. Authentication History'));
diagnostics.authFailures = config.getFailureCount();
diagnostics.lastFailure = config.getLastAuthFailure() ?? null;
if (diagnostics.authFailures === 0) {
console.log(chalk.green(' ✓ No recent authentication failures'));
}
else {
console.log(chalk.yellow(` ⚠ ${diagnostics.authFailures} recent authentication failures`));
if (diagnostics.lastFailure) {
const lastFailureDate = new Date(diagnostics.lastFailure);
console.log(chalk.gray(` Last failure: ${lastFailureDate.toLocaleString()}`));
}
if (config.shouldDelayAuth()) {
const delayMs = config.getAuthDelayMs();
console.log(chalk.yellow(` ⚠ Authentication delay active: ${Math.round(delayMs / 1000)}s`));
}
}
// Step 4: Test credential validation against server
console.log(chalk.cyan('\n4. Server Validation'));
if (diagnostics.hasCredentials) {
const spinner = ora('Testing credentials against server...').start();
try {
const isValid = await config.validateStoredCredentials();
diagnostics.credentialsValid = isValid;
if (isValid) {
spinner.succeed('Credentials are valid');
console.log(chalk.green(' ✓ Server authentication successful'));
}
else {
spinner.fail('Credentials are invalid');
console.log(chalk.red(' ✖ Server rejected credentials'));
console.log(chalk.gray(' → Try: lanonasis auth login'));
}
}
catch (error) {
spinner.fail('Server validation failed');
console.log(chalk.red(' ✖ Could not validate with server:'));
console.log(chalk.gray(` ${error instanceof Error ? error.message : 'Unknown error'}`));
}
}
else {
console.log(chalk.gray(' - Skipped (no credentials to validate)'));
}
// Step 5: Test endpoint connectivity
console.log(chalk.cyan('\n5. Endpoint Connectivity'));
const spinner2 = ora('Testing authentication endpoints...').start();
try {
await config.discoverServices();
diagnostics.serviceDiscovery = true;
const services = config.get('discoveredServices');
if (services) {
spinner2.succeed('Authentication endpoints reachable');
console.log(chalk.green(' ✓ Service discovery successful'));
console.log(chalk.gray(` Auth endpoint: ${services.auth_base}`));
diagnostics.endpointsReachable = true;
}
else {
spinner2.warn('Using fallback endpoints');
console.log(chalk.yellow(' ⚠ Service discovery failed, using fallbacks'));
diagnostics.endpointsReachable = true; // Fallbacks still work
}
}
catch (error) {
spinner2.fail('Endpoint connectivity failed');
console.log(chalk.red(' ✖ Cannot reach authentication endpoints'));
console.log(chalk.gray(` ${error instanceof Error ? error.message : 'Unknown error'}`));
console.log(chalk.gray(' → Check internet connection'));
}
// Step 6: Device identification
console.log(chalk.cyan('\n6. Device Information'));
try {
const deviceId = await config.getDeviceId();
diagnostics.deviceId = deviceId;
console.log(chalk.green(' ✓ Device ID:'), chalk.gray(deviceId));
}
catch (error) {
console.log(chalk.yellow(' ⚠ Could not get device ID'));
if (process.env.CLI_VERBOSE === 'true' && error instanceof Error) {
console.log(chalk.gray(` ${error.message}`));
}
}
// Summary and recommendations
console.log(chalk.blue.bold('\n📋 Diagnostic Summary'));
console.log(colors.info('━'.repeat(50)));
const issues = [];
const recommendations = [];
if (!diagnostics.configExists) {
issues.push('No configuration file found');
recommendations.push('Run: lanonasis auth login');
}
if (!diagnostics.hasCredentials) {
issues.push('No authentication credentials stored');
recommendations.push('Run: lanonasis auth login --vendor-key <your-key>');
}
if (diagnostics.hasCredentials && !diagnostics.credentialsValid) {
issues.push('Stored credentials are invalid');
recommendations.push('Run: lanonasis auth logout && lanonasis auth login');
}
if (diagnostics.tokenExpired) {
issues.push('Authentication token has expired');
recommendations.push('Run: lanonasis auth login');
}
if (diagnostics.authFailures >= 3) {
issues.push(`Multiple authentication failures (${diagnostics.authFailures})`);
recommendations.push('Wait for delay period, then try: lanonasis auth login');
}
if (!diagnostics.endpointsReachable) {
issues.push('Cannot reach authentication endpoints');
recommendations.push('Check internet connection and firewall settings');
}
if (issues.length === 0) {
console.log(chalk.green('✅ All authentication checks passed!'));
console.log(chalk.cyan(' Your authentication is working correctly.'));
}
else {
console.log(chalk.red(`❌ Found ${issues.length} issue(s):`));
issues.forEach(issue => {
console.log(chalk.red(` • ${issue}`));
});
console.log(chalk.yellow('\n💡 Recommended actions:'));
recommendations.forEach(rec => {
console.log(chalk.cyan(` • ${rec}`));
});
}
// Additional troubleshooting info
if (diagnostics.authFailures > 0 || !diagnostics.credentialsValid) {
console.log(chalk.gray('\n🔧 Additional troubleshooting:'));
console.log(chalk.gray(' • Verify the vendor key matches the value shown in your dashboard'));
console.log(chalk.gray(' • Check if your key is active in the dashboard'));
console.log(chalk.gray(' • Try browser authentication: lanonasis auth login (choose Browser Login)'));
console.log(chalk.gray(' • Contact support if issues persist'));
}
}
export async function loginCommand(options) {
const config = new CLIConfig();
await config.init();
console.log(chalk.blue.bold('🔐 Onasis-Core Golden Contract Authentication'));
console.log(colors.info('━'.repeat(50)));
console.log();
// Debug: Check options
if (process.env.CLI_VERBOSE === 'true') {
console.log('Debug - Login options:', {
hasEmail: !!options.email,
hasPassword: !!options.password,
hasVendorKey: !!options.vendorKey
});
}
// Enhanced authentication flow - check for vendor key first
if (options.vendorKey) {
await handleVendorKeyAuth(options.vendorKey, config);
return;
}
// Check for email/password for direct credentials flow
if (options.email && options.password) {
await handleCredentialsFlow(options, config);
return;
}
// Show authentication options
const authChoice = await inquirer.prompt([
{
type: 'select',
name: 'method',
message: 'Choose authentication method:',
choices: [
{
name: '🔑 Vendor Key (Recommended for API access)',
value: 'vendor'
},
{
name: '🌐 Browser Login (Get token from web page)',
value: 'oauth'
},
{
name: '⚙️ Username/Password (Direct credentials)',
value: 'credentials'
}
]
}
]);
switch (authChoice.method) {
case 'vendor':
await handleVendorKeyFlow(config);
break;
case 'oauth':
await handleOAuthFlow(config);
break;
case 'credentials':
await handleCredentialsFlow(options, config);
break;
}
}
async function handleVendorKeyAuth(vendorKey, config) {
// Check for authentication delay before attempting
await handleAuthDelay(config);
const spinner = ora('Validating vendor key...').start();
try {
await config.setVendorKey(vendorKey);
// Explicitly set authMethod to vendor_key when user does explicit vendor key auth
// This overrides any previous OAuth authMethod
await config.set('authMethod', 'vendor_key');
await config.save();
// Test the vendor key with a health check
await apiClient.get('/health');
spinner.succeed('Vendor key authentication successful');
console.log();
console.log(chalk.green('✓ Authenticated with vendor key'));
console.log(colors.info('Ready to use Onasis-Core services'));
}
catch (error) {
spinner.fail('Vendor key validation failed');
// Use enhanced error handling
await handleAuthenticationFailure(error, config, 'vendor_key');
process.exit(1);
}
}
async function handleVendorKeyFlow(config) {
console.log();
console.log(chalk.yellow('🔑 Vendor Key Authentication'));
console.log(chalk.gray('Vendor keys provide secure API access for automation and integrations.'));
console.log();
// Enhanced guidance for obtaining vendor keys
console.log(chalk.cyan('📋 How to get your vendor key:'));
console.log(chalk.gray('1. Visit your Lanonasis dashboard at https://dashboard.lanonasis.com'));
console.log(chalk.gray('2. Navigate to Settings → API Keys'));
console.log(chalk.gray('3. Click "Generate New Key" and copy the full key value'));
console.log();
const { vendorKey } = await inquirer.prompt([
{
type: 'password',
name: 'vendorKey',
message: 'Enter your vendor key:',
mask: '*',
validate: (input) => {
return config.validateVendorKeyFormat(input);
}
}
]);
await handleVendorKeyAuth(vendorKey, config);
}
function isHeadlessEnvironment() {
if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY)
return true;
if (!process.stdout.isTTY)
return true;
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
return true;
return false;
}
async function handleOAuthFlow(config) {
console.log();
console.log(chalk.yellow('🌐 Browser-Based OAuth2 Authentication'));
console.log(chalk.gray('Secure authentication using OAuth2 with PKCE'));
console.log();
const headless = isHeadlessEnvironment();
if (headless) {
console.log(chalk.yellow('⚠️ Headless/remote environment detected — skipping browser flow'));
console.log(chalk.gray(' Use the manual token paste path below instead.'));
console.log();
}
const { openBrowser } = headless
? { openBrowser: false }
: await inquirer.prompt([
{
type: 'confirm',
name: 'openBrowser',
message: 'Open browser for OAuth2 authentication?',
default: true
}
]);
if (!openBrowser) {
// Fallback: manual token paste for headless/remote environments
console.log();
console.log(chalk.cyan('📋 Manual Token Authentication'));
console.log(chalk.gray('Open this URL in any browser to authenticate:'));
const authBase = config.getDiscoveredApiUrl();
console.log(chalk.white(` ${authBase}/auth/cli-login`));
console.log();
console.log(chalk.gray('After logging in, expand "Headless/Remote?" to copy the token, then paste it below.'));
console.log();
const { token } = await inquirer.prompt([
{
type: 'password',
name: 'token',
message: 'Paste token:',
mask: '*',
validate: (input) => input.trim().length > 0 || 'Token is required',
}
]);
const trimmed = token.trim();
await config.setToken(trimmed);
await config.set('authMethod', 'jwt');
console.log();
console.log(chalk.green('✓ Token saved successfully'));
console.log(colors.info('You can now use all Lanonasis services'));
process.exit(0);
}
try {
// Generate PKCE challenge
const pkce = generatePKCE();
console.log(chalk.gray(' ✓ Generated PKCE challenge'));
// Start local callback server
const callbackPort = 8888;
const callbackPromise = createCallbackServer(callbackPort);
console.log(chalk.gray(` ✓ Started local callback server on port ${callbackPort}`));
// Build OAuth2 authorization URL
const authBase = config.getDiscoveredApiUrl();
const redirectUri = `http://localhost:${callbackPort}/callback`;
const authUrl = new URL(`${authBase}/oauth/authorize`);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'lanonasis-cli');
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('scope', 'read write offline_access');
authUrl.searchParams.set('code_challenge', pkce.challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', crypto.randomBytes(16).toString('hex'));
console.log();
console.log(colors.info('Opening browser for authentication...'));
await open(authUrl.toString());
console.log(colors.info('Waiting for authentication in browser...'));
console.log(colors.muted(`If browser doesn't open, visit: ${authUrl.toString()}`));
console.log();
// Wait for callback
const spinner = ora('Waiting for authorization...').start();
const { code } = await callbackPromise;
spinner.succeed('Authorization code received');
// Exchange code for tokens
spinner.text = 'Exchanging code for access tokens...';
spinner.start();
// Debug logging in verbose mode
if (process.env.CLI_VERBOSE === 'true') {
console.log(chalk.dim(` Code length: ${code.length}`));
console.log(chalk.dim(` Verifier length: ${pkce.verifier.length}`));
console.log(chalk.dim(` Redirect URI: ${redirectUri}`));
console.log(chalk.dim(` Token endpoint: ${authBase}/oauth/token`));
}
const tokens = await exchangeCodeForTokens(code, pkce.verifier, authBase, redirectUri);
spinner.succeed('Access tokens received');
// Store OAuth tokens - these are already valid auth-gateway tokens from /oauth/token
// No need for additional exchange since /oauth/token returns auth-gateway's own tokens
await config.setToken(tokens.access_token);
await config.set('refresh_token', tokens.refresh_token);
await config.set('token_expires_at', Date.now() + (tokens.expires_in * 1000));
await config.set('authMethod', 'oauth');
spinner.text = 'Configuring unified access...';
spinner.start();
spinner.succeed('Unified authentication configured');
console.log();
console.log(chalk.green('✓ OAuth2 authentication successful'));
console.log(colors.info('You can now use all Lanonasis services'));
console.log(chalk.gray('✓ MCP, API, and CLI access configured'));
process.exit(0);
}
catch (error) {
console.error(chalk.red('✖ OAuth2 authentication failed'));
// Display detailed error information
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(chalk.gray(` ${errorMessage}`));
// Show validation details if available
if (error.details) {
console.error(chalk.yellow('\n Validation errors:'));
for (const [field, messages] of Object.entries(error.details)) {
const msgArray = Array.isArray(messages) ? messages : [messages];
msgArray.forEach((msg) => {
console.error(chalk.gray(` • ${field}: ${msg}`));
});
}
}
// Show error data if available
if (error.errorData) {
const errorData = error.errorData;
if (errorData.error) {
console.error(chalk.yellow(`\n Error: ${errorData.error}`));
}
if (errorData.error_description) {
console.error(chalk.gray(` ${errorData.error_description}`));
}
// Show details if not already shown above
if (!error.details && errorData.details) {
console.error(chalk.yellow('\n Details:'));
console.error(chalk.gray(JSON.stringify(errorData.details, null, 2)));
}
}
// Show full error response in verbose mode
if (process.env.CLI_VERBOSE === 'true') {
if (error.response?.data) {
console.error(chalk.dim('\n Full error response:'));
console.error(chalk.dim(JSON.stringify(error.response.data, null, 2)));
}
if (error.response?.config) {
console.error(chalk.dim('\n Request config:'));
console.error(chalk.dim(JSON.stringify({
url: error.response.config.url,
method: error.response.config.method,
data: error.response.config.data
}, null, 2)));
}
}
process.exit(1);
}
}
async function handleCredentialsFlow(options, config) {
console.log();
console.log(chalk.yellow('⚙️ Username/Password Authentication'));
console.log();
// Check for authentication delay before attempting
await handleAuthDelay(config);
let { email, password } = options;
// Get credentials if not provided
if (!email || !password) {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'email',
message: 'Email:',
default: email,
validate: (input) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(input) || 'Please enter a valid email address';
}
},
{
type: 'password',
name: 'password',
message: 'Password:',
mask: '*',
validate: (input) => input.length > 0 || 'Password is required'
}
]);
email = answers.email;
password = answers.password;
}
const spinner = ora('Authenticating...').start();
try {
const response = await apiClient.login(email, password);
if (process.env.CLI_VERBOSE === 'true') {
console.log(chalk.dim(' Login response:'), JSON.stringify(response, null, 2));
}
// The auth-gateway login endpoint already returns the correct token format
// No need to exchange - this token works with all services (MCP, API, CLI)
const authToken = response.token || response.access_token;
if (!authToken) {
throw new Error('No token received from login response');
}
if (process.env.CLI_VERBOSE === 'true') {
console.log(chalk.dim(` JWT received (length: ${authToken.length})`));
}
const refreshToken = response.refresh_token;
const expiresIn = response.expires_in;
// Store JWT token for API authentication
await config.setToken(authToken);
if (typeof refreshToken === 'string' && refreshToken.length > 0) {
config.set('refresh_token', refreshToken);
}
if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) {
config.set('token_expires_at', Date.now() + (expiresIn * 1000));
}
await config.setAndSave('authMethod', 'jwt');
spinner.succeed('Login successful');
console.log();
console.log(chalk.green('✓ Authenticated successfully'));
console.log(`Welcome, ${response.user.email}!`);
if (response.user.role) {
console.log(`Role: ${response.user.role}`);
}
console.log(chalk.gray('✓ API and MCP access configured'));
console.log(chalk.dim('Your JWT token works with all services: API, MCP, CLI.'));
}
catch (error) {
spinner.fail('Login failed');
// Use enhanced error handling
await handleAuthenticationFailure(error, config, 'jwt');
// For 401 errors, offer registration option
const errorResponse = error && typeof error === 'object' && 'response' in error ? error.response : null;
if (errorResponse && typeof errorResponse === 'object' && 'status' in errorResponse && errorResponse.status === 401) {
console.log();
const answer = await inquirer.prompt([
{
type: 'confirm',
name: 'register',
message: 'Would you like to create a new account?',
default: false
}
]);
if (answer.register) {
await registerFlow(email);
return; // Don't exit if registration succeeds
}
}
process.exit(1);
}
}
async function registerFlow(defaultEmail) {
console.log();
console.log(chalk.blue.bold('📝 Create New Account'));
console.log();
const answers = await inquirer.prompt([
{
type: 'input',
name: 'email',
message: 'Email:',
default: defaultEmail,
validate: (input) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(input) || 'Please enter a valid email address';
}
},
{
type: 'password',
name: 'password',
message: 'Password (min 8 characters):',
mask: '*',
validate: (input) => input.length >= 8 || 'Password must be at least 8 characters'
},
{
type: 'password',
name: 'confirmPassword',
message: 'Confirm password:',
mask: '*',
validate: (input, answers) => {
return input === answers?.password || 'Passwords do not match';
}
},
{
type: 'input',
name: 'organizationName',
message: 'Organization name (optional):',
default: ''
}
]);
const spinner = ora('Creating account...').start();
try {
const response = await apiClient.register(answers.email, answers.password, answers.organizationName || undefined);
const config = new CLIConfig();
await config.setToken(response.token);
spinner.succeed('Account created successfully');
console.log();
console.log(chalk.green('✓ Account created and authenticated'));
console.log(`Welcome to MaaS, ${response.user.email}!`);
if (answers.organizationName) {
console.log(`Organization: ${answers.organizationName}`);
}
console.log(`Plan: ${response.user.plan || 'free'}`);
}
catch (error) {
spinner.fail('Registration failed');
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(chalk.red('✖ Registration failed:'), errorMessage);
process.exit(1);
}
}