avocavo
Version:
Avocavo CLI - Nutrition analysis made simple. Get accurate USDA nutrition data with secure authentication.
774 lines (680 loc) • 28.2 kB
JavaScript
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 };