UNPKG

avocavo

Version:

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

774 lines (680 loc) 28.2 kB
const { createClient } = require('@supabase/supabase-js'); const axios = require('axios'); const chalk = require('chalk'); const ora = require('ora'); const open = require('open'); const Conf = require('conf'); const http = require('http'); const url = require('url'); const readline = require('readline'); const crypto = require('crypto'); // Encryption utilities for insecure storage fallback const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; const KEY_LENGTH = 32; const IV_LENGTH = 16; const TAG_LENGTH = 16; function deriveKey(password) { // Use a consistent salt based on the machine const salt = crypto.createHash('sha256').update(process.platform + require('os').homedir()).digest(); return crypto.pbkdf2Sync(password, salt, 100000, KEY_LENGTH, 'sha256'); } function encryptApiKey(apiKey) { try { // Use machine-specific key derivation const key = deriveKey('avocavo-cli-encryption-key'); const iv = crypto.randomBytes(IV_LENGTH); const cipher = crypto.createCipherGCM(ENCRYPTION_ALGORITHM, key, iv); let encrypted = cipher.update(apiKey, 'utf8', 'hex'); encrypted += cipher.final('hex'); const tag = cipher.getAuthTag(); // Combine iv + tag + encrypted data return iv.toString('hex') + tag.toString('hex') + encrypted; } catch (error) { console.error(chalk.red('❌ Encryption failed. Refusing to store credentials insecurely.')); throw new Error('Cannot store credentials securely. Please ensure keytar is available.'); } } function decryptApiKey(encryptedData) { try { const key = deriveKey('avocavo-cli-encryption-key'); // Extract iv, tag, and encrypted data const iv = Buffer.from(encryptedData.slice(0, IV_LENGTH * 2), 'hex'); const tag = Buffer.from(encryptedData.slice(IV_LENGTH * 2, (IV_LENGTH + TAG_LENGTH) * 2), 'hex'); const encrypted = encryptedData.slice((IV_LENGTH + TAG_LENGTH) * 2); const decipher = crypto.createDecipherGCM(ENCRYPTION_ALGORITHM, key, iv); decipher.setAuthTag(tag); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } catch (error) { console.error(chalk.red('❌ Decryption failed. Credentials may be corrupted.')); throw new Error('Cannot decrypt stored credentials'); } } // 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 SupabaseAuthManager { 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 client - get config from backend this.supabaseConfig = null; this.supabase = null; } async initializeSupabase() { if (this.supabase) return true; try { const spinner = ora('Getting Supabase configuration...').start(); const response = await axios.get(`${this.baseUrl}/api/auth/supabase-config`, { timeout: 10000 }); if (!response.data.success) { spinner.fail('Failed to get Supabase configuration'); console.error(chalk.red(`❌ ${response.data.error}`)); return false; } this.supabaseConfig = response.data.config; this.supabase = createClient(this.supabaseConfig.url, this.supabaseConfig.anon_key); spinner.succeed('Supabase configuration loaded'); return true; } catch (error) { console.error(chalk.red(`❌ Failed to initialize Supabase: ${error.message}`)); return false; } } async login(provider = 'google') { console.log(chalk.cyan(`🔐 Starting ${provider} OAuth login with Supabase...`)); // Initialize Supabase client first const initialized = await this.initializeSupabase(); if (!initialized) { return false; } try { // Create a temporary local server to handle the OAuth callback const server = await this.createCallbackServer(); const callbackUrl = `http://localhost:${server.port}/callback`; console.log(chalk.cyan('🌐 Opening browser for authentication...')); // Start OAuth flow with Supabase // console.log(chalk.blue(`🔍 OAuth callback URL: ${callbackUrl}`)); // Debug only const { data, error } = await this.supabase.auth.signInWithOAuth({ provider, options: { redirectTo: callbackUrl, queryParams: { access_type: 'offline', prompt: 'consent', } } }); // console.log(chalk.blue(`🔍 OAuth URL: ${data?.url || 'No URL returned'}`)); // Debug only if (error) { console.error(chalk.red(`❌ OAuth initiation failed: ${error.message}`)); server.close(); return false; } if (data.url) { try { await open(data.url); } catch (openError) { console.log(chalk.yellow('⚠️ Could not open browser automatically')); console.log(chalk.cyan(`Please manually open: ${data.url}`)); } } // Wait for the callback with shorter timeout const authResult = await Promise.race([ server.waitForCallback(), new Promise((resolve) => { setTimeout(() => { resolve({ success: false, error: 'timeout', needsManualToken: true }); }, 10000); // 10 second timeout }) ]); // Server should already be closed by successful callback, but ensure it's closed try { server.close(); } catch (e) { // Server already closed } if (authResult.success) { console.log(chalk.green(`✅ Login successful! Welcome ${authResult.user.email}`)); // Store session data await this.storeSession(authResult.session); return true; } else if (authResult.needsManualToken) { console.log(chalk.yellow('\n⏰ Timeout waiting for callback. Trying manual token input...')); return await this.handleManualTokenInput(); } else { console.error(chalk.red(`❌ Login failed: ${authResult.error}`)); return false; } } catch (error) { console.error(chalk.red(`❌ Login error: ${error.message}`)); return false; } } async createCallbackServer() { return new Promise((resolve, reject) => { const server = http.createServer(); let callbackPromise; let callbackResolve; let callbackReject; // Create a promise that will be resolved when we get the callback const waitForCallback = () => { if (!callbackPromise) { callbackPromise = new Promise((resolve, reject) => { callbackResolve = resolve; callbackReject = reject; }); } return callbackPromise; }; server.on('request', async (req, res) => { const parsedUrl = url.parse(req.url, true); // console.log(chalk.blue(`🔍 Callback received: ${req.url}`)); // Debug only if (parsedUrl.pathname === '/callback') { const { code, error, error_description, access_token } = parsedUrl.query; // console.log(chalk.blue(`🔍 OAuth callback - code: ${code ? 'present' : 'missing'}, token: ${access_token ? 'present' : 'missing'}, error: ${error || 'none'}`)); // Debug only // Send response to browser res.writeHead(200, { 'Content-Type': 'text/html' }); // If no query params, send HTML that can extract fragment tokens if (!code && !access_token && !error) { res.end(` <html> <head><title>OAuth Callback</title></head> <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;"> <h2>🔐 Processing Authentication...</h2> <p>Please wait while we complete your login.</p> <script> // Extract token from URL fragment and send to server const fragment = window.location.hash.substring(1); const params = new URLSearchParams(fragment); const access_token = params.get('access_token'); const error = params.get('error'); if (access_token) { // Send token to server as query parameter fetch('/callback?access_token=' + encodeURIComponent(access_token)) .then(() => { document.body.innerHTML = '<h2 style="color: green;">✅ Authentication Successful!</h2><p>You can close this window and return to the terminal.</p>'; }) .catch(err => { document.body.innerHTML = '<h2 style="color: red;">❌ Authentication Failed</h2><p>Could not process token.</p>'; }); } else if (error) { fetch('/callback?error=' + encodeURIComponent(error)) .then(() => { document.body.innerHTML = '<h2 style="color: red;">❌ Authentication Failed</h2><p>' + error + '</p>'; }); } else { document.body.innerHTML = '<h2 style="color: orange;">⚠️ No Authentication Data</h2><p>No token or error found in URL.</p>'; } </script> </body> </html> `); return; } if (error) { res.end(` <html> <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;"> <h2 style="color: red;">❌ Authentication Failed</h2> <p>${error_description || error}</p> <p>You can close this window.</p> </body> </html> `); callbackResolve({ success: false, error: error_description || error }); } else if (access_token) { res.end(` <html> <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;"> <h2 style="color: green;">✅ Authentication Successful!</h2> <p>Token received. You can close this window and return to the terminal.</p> </body> </html> `); try { // Use the access token directly to get user info // console.log(chalk.blue('🔍 Using access token from URL fragment...')); // Debug only const tempSupabase = createClient(this.supabaseConfig.url, this.supabaseConfig.anon_key); const { data: user, error } = await tempSupabase.auth.getUser(access_token); if (error || !user) { // console.log(chalk.red(`🔍 Token verification failed: ${error?.message || 'Could not verify user'}`)); // Debug only callbackResolve({ success: false, error: error?.message || 'Could not verify user' }); } else { console.log(chalk.green('🔍 Token verification successful!')); // Create a session-like object const mockSession = { access_token: access_token, user: user.user, expires_at: Math.floor(Date.now() / 1000) + 3600 // 1 hour from now }; // Close server immediately on success setTimeout(() => server.close(), 100); callbackResolve({ success: true, session: mockSession, user: user.user }); } } catch (tokenError) { console.log(chalk.red(`🔍 Token processing error: ${tokenError.message}`)); callbackResolve({ success: false, error: tokenError.message }); } } else if (code) { res.end(` <html> <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;"> <h2 style="color: green;">✅ Authentication Successful!</h2> <p>You can close this window and return to the terminal.</p> </body> </html> `); try { // Exchange code for session using Supabase console.log(chalk.blue('🔍 Exchanging code for session...')); const { data, error } = await this.supabase.auth.exchangeCodeForSession(code); if (error) { console.log(chalk.red(`🔍 Token exchange failed: ${error.message}`)); callbackResolve({ success: false, error: error.message }); } else { console.log(chalk.green('🔍 Token exchange successful!')); // Close server immediately on success setTimeout(() => server.close(), 100); callbackResolve({ success: true, session: data.session, user: data.user }); } } catch (exchangeError) { console.log(chalk.red(`🔍 Token exchange error: ${exchangeError.message}`)); callbackResolve({ success: false, error: exchangeError.message }); } } else { res.end(` <html> <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;"> <h2 style="color: orange;">⚠️ Incomplete Authentication</h2> <p>No authorization code received.</p> </body> </html> `); callbackResolve({ success: false, error: 'No authorization code received' }); } } else { res.writeHead(404); res.end('Not found'); } }); server.listen(0, 'localhost', () => { const port = server.address().port; // console.log(chalk.blue(`🔍 Callback server started on http://localhost:${port}/callback`)); // Debug only resolve({ server, port, close: () => server.close(), waitForCallback }); }); server.on('error', reject); }); } async storeSession(session) { const sessionData = { userInfo: { email: session.user.email, id: session.user.id }, loginTime: Date.now(), provider: 'supabase-oauth', hasSupabaseSession: true, usesSecureStorage: this.keytarAvailable }; // Store session data in config this.config.set('sessionData', sessionData); this.config.set('isLoggedIn', true); // Store JWT token securely if (session.access_token) { await this.storeJwtSecurely(session.user.email, session.access_token); } // Store refresh token securely if (session.refresh_token) { await this.storeRefreshTokenSecurely(session.user.email, session.refresh_token); } } async storeJwtSecurely(email, jwtToken) { if (this.keytarAvailable) { try { await keytar.setPassword(this.serviceName, `jwt_${email}`, jwtToken); return true; } catch (error) { console.warn(chalk.yellow(`⚠️ Could not store JWT securely: ${error.message}`)); this.config.set('jwtToken', jwtToken); return false; } } else { this.config.set('jwtToken', jwtToken); return false; } } async storeRefreshTokenSecurely(email, refreshToken) { if (this.keytarAvailable) { try { await keytar.setPassword(this.serviceName, `refresh_${email}`, refreshToken); return true; } catch (error) { console.warn(chalk.yellow(`⚠️ Could not store refresh token securely: ${error.message}`)); this.config.set('refreshToken', refreshToken); return false; } } else { this.config.set('refreshToken', refreshToken); return false; } } async getJwtSecurely(email) { if (this.keytarAvailable) { try { return await keytar.getPassword(this.serviceName, `jwt_${email}`); } catch (error) { return this.config.get('jwtToken'); } } else { return this.config.get('jwtToken'); } } async getRefreshTokenSecurely(email) { if (this.keytarAvailable) { try { return await keytar.getPassword(this.serviceName, `refresh_${email}`); } catch (error) { return this.config.get('refreshToken'); } } else { return this.config.get('refreshToken'); } } isLoggedIn() { return this.config.get('isLoggedIn', false); } getUserInfo() { const sessionData = this.config.get('sessionData'); return sessionData?.userInfo || {}; } async getApiKey() { // For Supabase auth, we use JWT tokens instead of API keys const sessionData = this.config.get('sessionData'); if (sessionData?.userInfo?.email) { return await this.getJwtSecurely(sessionData.userInfo.email); } return null; } async getJwtToken() { const sessionData = this.config.get('sessionData'); if (sessionData?.userInfo?.email) { return await this.getJwtSecurely(sessionData.userInfo.email); } return null; } async getSelectedApiKey() { // Get the selected/current API key (not JWT token) const sessionData = this.config.get('sessionData'); if (sessionData?.userInfo?.email) { if (this.keytarAvailable) { try { const key = await keytar.getPassword(this.serviceName, `api_${sessionData.userInfo.email}`); if (key) { if (process.env.AVOCAVO_DEBUG) { console.log(chalk.green('[DEBUG] ✅ API key retrieved from OS keystore')); } return key; } else { if (process.env.AVOCAVO_DEBUG) { console.log(chalk.yellow('[DEBUG] ⚠️ No key in keystore, checking config')); } // Fallback to encrypted config if keytar returns null const encrypted = this.config.get('currentApiKey_encrypted'); if (encrypted) { if (process.env.AVOCAVO_DEBUG) { console.log(chalk.yellow('[DEBUG] 📁 Using config file storage')); } // Decrypt the API key return decryptApiKey(encrypted); } } } catch (error) { if (process.env.AVOCAVO_DEBUG) { console.log(chalk.red(`[DEBUG] ❌ Keytar error: ${error.message}`)); } // Fallback to encrypted config const encrypted = this.config.get('currentApiKey_encrypted'); if (encrypted) { return decryptApiKey(encrypted); } } } else { if (process.env.AVOCAVO_DEBUG) { console.log(chalk.red('[DEBUG] ❌ Keytar not available')); } // No keytar, use encrypted config const encrypted = this.config.get('currentApiKey_encrypted'); if (encrypted) { return decryptApiKey(encrypted); } } } return null; } async logout() { // Sign out from Supabase (if client is initialized) try { if (this.supabase) { await this.supabase.auth.signOut(); } } catch (error) { console.warn(chalk.yellow(`⚠️ Could not sign out from Supabase: ${error.message}`)); } // Remove stored credentials const sessionData = this.config.get('sessionData'); if (sessionData?.userInfo?.email) { const email = sessionData.userInfo.email; if (this.keytarAvailable) { try { await keytar.deletePassword(this.serviceName, `jwt_${email}`); await keytar.deletePassword(this.serviceName, `refresh_${email}`); await keytar.deletePassword(this.serviceName, `api_${email}`); // Delete API key too } catch (error) { // Continue cleanup } } } this.config.clear(); console.log(chalk.green('✅ Successfully logged out')); // DEBUG: Log stack trace to find duplicate logout if (process.env.AVOCAVO_DEBUG) { console.log('Logout called from:', new Error().stack); } } // API key management methods - these will call the backend with JWT auth async getJwtAuthHeaders() { const jwtToken = await this.getJwtToken(); if (!jwtToken) { throw new Error('Not logged in. Please login first.'); } return { 'Authorization': `Bearer ${jwtToken}`, 'Content-Type': 'application/json' }; } async listApiKeys() { try { const headers = await this.getJwtAuthHeaders(); const response = await axios.get(`${this.baseUrl}/api/keys`, { headers, timeout: 30000 }); return response.data; } catch (error) { if (error.response?.status === 401) { throw new Error('Session expired. Please login again.'); } throw new Error(`Failed to list API keys: ${error.message}`); } } async createApiKey(name = "CLI Key", description = null, environment = "development") { try { const headers = await this.getJwtAuthHeaders(); const data = { key_name: name, description: description || "Created via Supabase CLI", environment: environment }; const response = await axios.post(`${this.baseUrl}/api/keys`, data, { headers, timeout: 30000 }); // Auto-select the newly created key if (response.data.success && response.data.key) { const newKey = response.data.key; console.log(chalk.cyan(`🔄 Auto-selecting your new API key: ${newKey.key_name}`)); await this.storeApiKeySecurely(this.config.get('sessionData')?.userInfo?.email, newKey.api_key); } return response.data; } catch (error) { if (error.response?.status === 401) { throw new Error('Session expired. Please login again.'); } throw new Error(`Failed to create API key: ${error.message}`); } } async switchApiKey(keyId) { try { const headers = await this.getJwtAuthHeaders(); const response = await axios.post(`${this.baseUrl}/api/keys/${keyId}/reveal`, {}, { headers, timeout: 30000 }); if (response.data.success) { const fullApiKey = response.data.api_key; const keyName = response.data.key_name; // Store the selected API key const sessionData = this.config.get('sessionData'); if (sessionData?.userInfo?.email) { await this.storeApiKeySecurely(sessionData.userInfo.email, fullApiKey); } console.log(chalk.green(`✅ Switched to API key: ${keyName}`)); return fullApiKey; } else { throw new Error(response.data.error || 'Failed to reveal API key'); } } catch (error) { throw new Error(`Failed to switch API key: ${error.message}`); } } async autoSelectSingleKey() { try { const keysList = await this.listApiKeys(); if (keysList.keys && keysList.keys.length === 1) { const singleKey = keysList.keys[0]; console.log(chalk.cyan(`🔄 Auto-selecting your only API key: ${singleKey.key_name}`)); return await this.switchApiKey(singleKey.id); } return null; } catch (error) { return null; } } async refreshApiKeyLimits(keyId) { try { const headers = await this.getJwtAuthHeaders(); const response = await axios.post(`${this.baseUrl}/api/keys/${keyId}/refresh-limits`, {}, { headers, timeout: 30000 }); if (response.data.success) { console.log(chalk.green(`✅ Updated limits: ${response.data.old_limit} → ${response.data.new_limit}`)); return response.data; } else { throw new Error(response.data.error || 'Failed to refresh limits'); } } catch (error) { throw new Error(`Failed to refresh API key limits: ${error.message}`); } } async storeApiKeySecurely(email, apiKey) { if (this.keytarAvailable) { try { await keytar.setPassword(this.serviceName, `api_${email}`, apiKey); // Clear any plain text version this.config.delete('currentApiKey'); this.config.delete('currentApiKey_encrypted'); this.config.delete('insecureStorage'); // Debug logging if (process.env.AVOCAVO_DEBUG) { console.log(chalk.green('[DEBUG] ✅ API key stored in OS keystore')); } return true; } catch (error) { if (process.env.AVOCAVO_DEBUG) { console.log(chalk.red(`[DEBUG] ❌ Keytar failed: ${error.message}`)); } // Use proper encryption for API key storage const encrypted = encryptApiKey(apiKey); this.config.set('currentApiKey_encrypted', encrypted); this.config.set('insecureStorage', true); console.warn(chalk.yellow('⚠️ Credentials stored with local encryption (keychain unavailable)')); return false; } } else { if (process.env.AVOCAVO_DEBUG) { console.log(chalk.red('[DEBUG] ❌ Keytar not available')); } // Use proper encryption for API key storage const encrypted = encryptApiKey(apiKey); this.config.set('currentApiKey_encrypted', encrypted); this.config.set('insecureStorage', true); console.warn(chalk.yellow('⚠️ Credentials stored with local encryption (keychain unavailable)')); return false; } } async handleManualTokenInput() { console.log(chalk.cyan('\n🔧 Manual Token Input')); console.log(chalk.yellow('If you were redirected to nutrition.avocavo.app after logging in:')); console.log(chalk.yellow('1. Look at the URL in your browser')); console.log(chalk.yellow('2. Find the part that says #access_token=')); console.log(chalk.yellow('3. Copy ONLY the token part (after #access_token= and before &)')); console.log(chalk.yellow('4. Paste it below:')); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(chalk.blue('\nPaste your access token here: '), async (token) => { rl.close(); if (!token || token.trim().length === 0) { console.log(chalk.red('❌ No token provided')); resolve(false); return; } token = token.trim(); try { // Verify token by getting user info from Supabase const tempSupabase = createClient(this.supabaseConfig.url, this.supabaseConfig.anon_key); const { data: user, error } = await tempSupabase.auth.getUser(token); if (error || !user) { console.log(chalk.red(`❌ Invalid token: ${error?.message || 'Could not verify user'}`)); resolve(false); return; } // Create a session-like object const mockSession = { access_token: token, user: user.user, expires_at: Math.floor(Date.now() / 1000) + 3600 // 1 hour from now }; console.log(chalk.green(`✅ Token verified! Welcome ${user.user.email}`)); // Store session data await this.storeSession(mockSession); resolve(true); } catch (error) { console.log(chalk.red(`❌ Token verification failed: ${error.message}`)); resolve(false); } }); }); } } module.exports = { SupabaseAuthManager };