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