UNPKG

avocavo

Version:

Avocavo CLI - Nutrition analysis made simple. Get accurate USDA nutrition data with secure authentication.

520 lines (433 loc) 17.3 kB
const axios = require('axios'); const chalk = require('chalk'); const ora = require('ora'); const open = require('open'); const Conf = require('conf'); // const { SupabaseAuthManager } = require('./auth-supabase'); // Temporarily disabled // Try to load keytar, fall back gracefully if unavailable let keytar; let keytarAvailable = false; try { keytar = require('keytar'); keytarAvailable = true; } catch (error) { console.warn(chalk.yellow('⚠️ Secure storage unavailable, using config file storage')); keytarAvailable = false; } class AuthManager { constructor(baseUrl = 'https://app.avocavo.app') { this.baseUrl = baseUrl.replace(/\/$/, ''); this.serviceName = 'avocavo-nutrition'; this.keytarAvailable = keytarAvailable; // Keep config for non-sensitive metadata this.config = new Conf({ projectName: 'avocavo-nutrition', configName: 'auth' }); // Initialize Supabase auth manager for new OAuth flow - temporarily disabled // this.supabaseAuth = new SupabaseAuthManager(baseUrl); this.supabaseAuth = { isLoggedIn: () => false, getJwtToken: () => null, getUserInfo: () => null, logout: () => {}, getAccessToken: () => null }; // Migrate existing credentials to secure storage this.migrateExistingCredentials(); } isLoggedIn() { // Legacy auth checks only (Supabase temporarily disabled) const apiKey = this.config.get('apiKey'); const loginTime = this.config.get('loginTime'); const sessionData = this.config.get('sessionData'); // Check JWT-based session if (sessionData?.hasJwt && sessionData?.loginTime) { const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); return sessionData.loginTime > thirtyDaysAgo; } // Legacy API key check if (!apiKey || !loginTime) { return false; } const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); return loginTime > thirtyDaysAgo; } async getApiKey() { // Skip Supabase auth (temporarily disabled) // Check if user has selected an API key (new system) const sessionData = this.config.get('sessionData'); if (sessionData?.userInfo?.email) { const currentKey = await this.getApiKeySecurely(sessionData.userInfo.email); if (currentKey) { return currentKey; } } // Fallback to legacy single key for backwards compatibility const legacyKey = this.config.get('apiKey'); if (legacyKey) { return legacyKey; } // Also check currentApiKey (used by storeApiKeySecurely as fallback) const currentApiKey = this.config.get('currentApiKey'); if (currentApiKey) { return currentApiKey; } return null; } getUserInfo() { // Skip Supabase auth (temporarily disabled) // Fallback to legacy const sessionData = this.config.get('sessionData'); return sessionData?.userInfo || this.config.get('userInfo', {}); } async getJwtToken() { const sessionData = this.config.get('sessionData'); if (sessionData?.userInfo?.email) { return await this.getJwtSecurely(sessionData.userInfo.email); } return null; } async login(provider = 'google', useSupabase = true) { // Temporarily disable Supabase OAuth due to backend datetime/NULL field issues // Use reliable legacy OAuth until backend is fixed if (useSupabase) { console.log(chalk.cyan('🔐 Using legacy OAuth (Supabase temporarily disabled due to backend issues)...')); } // Fallback to legacy OAuth (for backward compatibility) console.log(chalk.cyan(`🔐 Starting ${provider} OAuth login...`)); try { // Step 1: Initiate OAuth const spinner = ora('Initiating OAuth login...').start(); const response = await axios.post(`${this.baseUrl}/api/auth/login`, { provider // Removed skip_supabase flag - now using full Supabase auth }); if (!response.data.success) { spinner.fail('Failed to initiate OAuth'); console.error(chalk.red(response.data.error)); return false; } const { session_id, oauth_url } = response.data; spinner.succeed('OAuth session created'); // Step 2: Open browser console.log(chalk.cyan('🌐 Opening browser for authentication...')); console.log(chalk.gray(`If browser doesn't open automatically, visit: ${oauth_url}`)); try { await open(oauth_url); } catch (error) { console.log(chalk.yellow('⚠️ Could not open browser automatically')); console.log(chalk.cyan(`Please manually open: ${oauth_url}`)); } // Step 3: Poll for completion return await this.pollForCompletion(session_id); } catch (error) { console.error(chalk.red(`❌ Login initiation failed: ${error.message}`)); return false; } } async pollForCompletion(sessionId, timeout = 300000, pollInterval = 2000) { const spinner = ora('Waiting for login completion...').start(); const startTime = Date.now(); try { while (Date.now() - startTime < timeout) { try { const response = await axios.get(`${this.baseUrl}/api/auth/status/${sessionId}`); const data = response.data; if (data.status === 'completed' || data.status === 'retrieved') { spinner.succeed('Login completed successfully!'); // Store credentials securely const userEmail = data.user_email || data.user_info?.email; const oauthToken = data.auth_uuid; // This is the OAuth session token // Exchange OAuth token for proper JWT token spinner.start('Exchanging authentication tokens...'); try { const tokenExchangeResponse = await axios.post( `${this.baseUrl}/api/auth/exchange-token`, {}, { headers: { 'Authorization': `Bearer auth_uuid:${oauthToken}`, 'Content-Type': 'application/json' } } ); if (!tokenExchangeResponse.data.success) { spinner.fail('Token exchange failed'); console.error(chalk.red(tokenExchangeResponse.data.error || 'Unknown error')); return false; } const jwtToken = tokenExchangeResponse.data.access_token; if (!jwtToken) { console.error(chalk.red('❌ No JWT token received from token exchange')); return false; } spinner.succeed('Token exchange successful'); // Store JWT token securely for session management await this.storeJwtSecurely(userEmail, jwtToken); // Store session data (JWT-based authentication) const sessionData = { userInfo: { email: userEmail, api_tier: tokenExchangeResponse.data.user?.api_tier || 'free' }, loginTime: Date.now(), provider: data.provider || 'google', hasJwt: true, usesSecureStorage: this.keytarAvailable }; // Store session data this.config.set('sessionData', sessionData); this.config.set('isLoggedIn', true); // Clear any old API key data since we're now JWT-based this.config.delete('apiKey'); this.config.delete('apiKeys'); this.config.delete('activeKey'); console.log(chalk.green(`✅ Logged in as ${userEmail || 'Unknown'}`)); return true; } catch (exchangeError) { spinner.fail('Token exchange failed'); console.error(chalk.red(`Failed to exchange token: ${exchangeError.message}`)); if (exchangeError.response) { console.error(chalk.red(`Server response: ${JSON.stringify(exchangeError.response.data)}`)); } return false; } } else if (data.status === 'failed') { spinner.fail('Login failed'); console.error(chalk.red(data.error || 'Unknown error')); return false; } else if (data.status === 'pending') { // Update spinner text with elapsed time const elapsed = Math.floor((Date.now() - startTime) / 1000); spinner.text = `Waiting for login completion... (${elapsed}s)`; } } catch (error) { if (error.response?.status === 404) { spinner.fail('OAuth session expired'); console.error(chalk.red('Session expired or not found')); return false; } // Continue polling on other errors spinner.text = `Connection error, retrying... (${Math.floor((Date.now() - startTime) / 1000)}s)`; } await new Promise(resolve => setTimeout(resolve, pollInterval)); } spinner.fail('Login timeout'); console.error(chalk.red('Login timed out - please try again')); return false; } catch (error) { spinner.fail('Login polling failed'); console.error(chalk.red(`Polling error: ${error.message}`)); return false; } } async logout() { // Skip Supabase logout (temporarily disabled) // Remove secure storage for all stored keys const keys = this.config.get('apiKeys', {}); for (const keyData of Object.values(keys)) { if (keyData.userInfo?.email) { await this.removeApiKeySecurely(keyData.userInfo.email); } } // Also remove legacy user const userInfo = this.config.get('userInfo', {}); if (userInfo.email) { await this.removeApiKeySecurely(userInfo.email); } this.config.clear(); console.log(chalk.green('✅ Successfully logged out and cleared secure storage')); } getLoginInfo() { return { apiKey: this.config.get('apiKey'), userInfo: this.config.get('userInfo', {}), loginTime: this.config.get('loginTime'), provider: this.config.get('provider') }; } async validateApiKey(apiKey = null) { const keyToValidate = apiKey || await this.getApiKey(); if (!keyToValidate) { return { valid: false, message: 'No API key found' }; } try { const response = await axios.get(`${this.baseUrl}/api/user/`, { headers: { 'X-API-Key': keyToValidate }, timeout: 10000 }); if (response.status === 200) { const account = response.data.account || {}; return { valid: true, message: `Valid - ${account.email || 'Unknown'} (${account.api_tier || 'Unknown'} tier)` }; } else { return { valid: false, message: `Invalid response: HTTP ${response.status}` }; } } catch (error) { if (error.response?.status === 401) { return { valid: false, message: 'Invalid or expired API key' }; } else { return { valid: false, message: `Validation failed: ${error.message}` }; } } } // Key management methods generateKeyId(email, provider) { const timestamp = Date.now(); const emailPart = email ? email.split('@')[0] : 'user'; return `${provider || 'oauth'}-${emailPart}-${timestamp}`.toLowerCase(); } getAllKeys() { return this.config.get('apiKeys', {}); } getActiveKeyId() { return this.config.get('activeKey'); } setActiveKey(keyId) { const keys = this.getAllKeys(); if (!keys[keyId]) { return false; } this.config.set('activeKey', keyId); return true; } async addManualKey(apiKey, nickname) { const keyId = `manual-${nickname.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Date.now()}`; // For manual keys, create a pseudo-email for keychain storage const pseudoEmail = `${nickname.toLowerCase()}@manual.avocavo`; // Store API key securely await this.storeApiKeySecurely(pseudoEmail, apiKey); const keyData = { // Don't store the actual key if using secure storage key: this.keytarAvailable ? null : apiKey, userInfo: { email: pseudoEmail }, loginTime: Date.now(), provider: 'manual', nickname: nickname, usesSecureStorage: this.keytarAvailable }; const keys = this.config.get('apiKeys', {}); keys[keyId] = keyData; this.config.set('apiKeys', keys); return keyId; } async removeKey(keyId) { const keys = this.config.get('apiKeys', {}); if (!keys[keyId]) { return false; } // Remove from secure storage if applicable const keyData = keys[keyId]; if (keyData.userInfo?.email) { await this.removeApiKeySecurely(keyData.userInfo.email); } delete keys[keyId]; this.config.set('apiKeys', keys); // If this was the active key, clear it if (this.config.get('activeKey') === keyId) { this.config.delete('activeKey'); // Set first remaining key as active, if any const remainingKeys = Object.keys(keys); if (remainingKeys.length > 0) { this.config.set('activeKey', remainingKeys[0]); } } return true; } getKeyInfo(keyId) { const keys = this.getAllKeys(); return keys[keyId] || null; } // Secure storage methods async storeJwtSecurely(email, jwtToken) { if (this.keytarAvailable) { try { await keytar.setPassword(this.serviceName, `jwt_${email}`, jwtToken); console.log(chalk.green('✅ Session stored securely in system keychain')); return true; } catch (error) { console.warn(chalk.yellow(`⚠️ Could not store session securely: ${error.message}`)); console.warn(chalk.yellow('⚠️ Falling back to config file storage')); this.config.set('jwtToken', jwtToken); return false; } } else { // SECURITY: Do not store JWT tokens in plaintext configuration files console.error(chalk.red('❌ System keychain unavailable. JWT token cannot be stored securely.')); throw new Error('Secure credential storage is required. Please ensure your system keychain is available.'); } } async storeApiKeySecurely(email, apiKey) { // Keep this method for API key management functionality if (this.keytarAvailable) { try { await keytar.setPassword(this.serviceName, `api_${email}`, apiKey); console.log(chalk.green('✅ API key stored securely in system keychain')); return true; } catch (error) { // SECURITY: Do not store API keys in plaintext as fallback console.error(chalk.red(`❌ Could not store API key securely: ${error.message}`)); throw new Error('Secure credential storage is required. Please ensure your system keychain is available.'); } } else { // SECURITY: Do not store API keys in plaintext configuration files console.error(chalk.red('❌ System keychain unavailable. API key cannot be stored securely.')); throw new Error('Secure credential storage is required. Please ensure your system keychain is available.'); } } async getJwtSecurely(email) { if (this.keytarAvailable) { try { return await keytar.getPassword(this.serviceName, `jwt_${email}`); } catch (error) { // Fallback to config file return this.config.get('jwtToken'); } } else { return this.config.get('jwtToken'); } } async getApiKeySecurely(email) { if (this.keytarAvailable) { try { return await keytar.getPassword(this.serviceName, `api_${email}`); } catch (error) { // Fallback to config file return this.config.get('currentApiKey'); } } else { return this.config.get('currentApiKey'); } } async removeApiKeySecurely(email) { if (this.keytarAvailable) { try { await keytar.deletePassword(this.serviceName, email); } catch (error) { // Continue to config cleanup } } this.config.delete('apiKey'); } async migrateExistingCredentials() { // Check for existing plaintext credentials const legacyApiKey = this.config.get('apiKey'); const userInfo = this.config.get('userInfo', {}); if (legacyApiKey && userInfo.email && this.keytarAvailable) { console.log(chalk.cyan('🔄 Migrating existing credentials to secure storage...')); try { await keytar.setPassword(this.serviceName, userInfo.email, legacyApiKey); this.config.delete('apiKey'); // Remove plaintext key console.log(chalk.green('✅ Credentials migrated successfully to secure storage')); } catch (error) { console.warn(chalk.yellow('⚠️ Could not migrate credentials, keeping existing format')); } } } } module.exports = { AuthManager };