avocavo 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +131 -0
- package/bin/avocavo.js +701 -0
- package/lib/api.js +258 -0
- package/lib/auth-supabase.js +718 -0
- package/lib/auth.js +484 -0
- package/lib/formatters.js +256 -0
- package/lib/keys.js +263 -0
- package/package.json +73 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
const { createClient } = require('@supabase/supabase-js');
|
|
2
|
+
const axios = require('axios');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const ora = require('ora');
|
|
5
|
+
const open = require('open');
|
|
6
|
+
const Conf = require('conf');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const url = require('url');
|
|
9
|
+
const readline = require('readline');
|
|
10
|
+
|
|
11
|
+
// Try to load keytar, fall back gracefully if unavailable
|
|
12
|
+
let keytar;
|
|
13
|
+
let keytarAvailable = false;
|
|
14
|
+
try {
|
|
15
|
+
keytar = require('keytar');
|
|
16
|
+
keytarAvailable = true;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.warn(chalk.yellow('ā ļø Secure storage unavailable, using config file storage'));
|
|
19
|
+
keytarAvailable = false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class SupabaseAuthManager {
|
|
23
|
+
constructor(baseUrl = 'https://app.avocavo.app') {
|
|
24
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
25
|
+
this.serviceName = 'avocavo-nutrition';
|
|
26
|
+
this.keytarAvailable = keytarAvailable;
|
|
27
|
+
|
|
28
|
+
// Keep config for non-sensitive metadata
|
|
29
|
+
this.config = new Conf({
|
|
30
|
+
projectName: 'avocavo-nutrition',
|
|
31
|
+
configName: 'auth'
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Initialize Supabase client - get config from backend
|
|
35
|
+
this.supabaseConfig = null;
|
|
36
|
+
this.supabase = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async initializeSupabase() {
|
|
40
|
+
if (this.supabase) return true;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const spinner = ora('Getting Supabase configuration...').start();
|
|
44
|
+
const response = await axios.get(`${this.baseUrl}/api/auth/supabase-config`, { timeout: 10000 });
|
|
45
|
+
|
|
46
|
+
if (!response.data.success) {
|
|
47
|
+
spinner.fail('Failed to get Supabase configuration');
|
|
48
|
+
console.error(chalk.red(`ā ${response.data.error}`));
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.supabaseConfig = response.data.config;
|
|
53
|
+
this.supabase = createClient(this.supabaseConfig.url, this.supabaseConfig.anon_key);
|
|
54
|
+
|
|
55
|
+
spinner.succeed('Supabase configuration loaded');
|
|
56
|
+
return true;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(chalk.red(`ā Failed to initialize Supabase: ${error.message}`));
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async login(provider = 'google') {
|
|
64
|
+
console.log(chalk.cyan(`š Starting ${provider} OAuth login with Supabase...`));
|
|
65
|
+
|
|
66
|
+
// Initialize Supabase client first
|
|
67
|
+
const initialized = await this.initializeSupabase();
|
|
68
|
+
if (!initialized) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Create a temporary local server to handle the OAuth callback
|
|
74
|
+
const server = await this.createCallbackServer();
|
|
75
|
+
const callbackUrl = `http://localhost:${server.port}/callback`;
|
|
76
|
+
|
|
77
|
+
console.log(chalk.cyan('š Opening browser for authentication...'));
|
|
78
|
+
|
|
79
|
+
// Start OAuth flow with Supabase
|
|
80
|
+
// console.log(chalk.blue(`š OAuth callback URL: ${callbackUrl}`)); // Debug only
|
|
81
|
+
const { data, error } = await this.supabase.auth.signInWithOAuth({
|
|
82
|
+
provider,
|
|
83
|
+
options: {
|
|
84
|
+
redirectTo: callbackUrl,
|
|
85
|
+
queryParams: {
|
|
86
|
+
access_type: 'offline',
|
|
87
|
+
prompt: 'consent',
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// console.log(chalk.blue(`š OAuth URL: ${data?.url || 'No URL returned'}`)); // Debug only
|
|
93
|
+
|
|
94
|
+
if (error) {
|
|
95
|
+
console.error(chalk.red(`ā OAuth initiation failed: ${error.message}`));
|
|
96
|
+
server.close();
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (data.url) {
|
|
101
|
+
try {
|
|
102
|
+
await open(data.url);
|
|
103
|
+
} catch (openError) {
|
|
104
|
+
console.log(chalk.yellow('ā ļø Could not open browser automatically'));
|
|
105
|
+
console.log(chalk.cyan(`Please manually open: ${data.url}`));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Wait for the callback with shorter timeout
|
|
110
|
+
const authResult = await Promise.race([
|
|
111
|
+
server.waitForCallback(),
|
|
112
|
+
new Promise((resolve) => {
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
resolve({ success: false, error: 'timeout', needsManualToken: true });
|
|
115
|
+
}, 10000); // 10 second timeout
|
|
116
|
+
})
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
// Server should already be closed by successful callback, but ensure it's closed
|
|
120
|
+
try {
|
|
121
|
+
server.close();
|
|
122
|
+
} catch (e) {
|
|
123
|
+
// Server already closed
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (authResult.success) {
|
|
127
|
+
console.log(chalk.green(`ā
Login successful! Welcome ${authResult.user.email}`));
|
|
128
|
+
|
|
129
|
+
// Store session data
|
|
130
|
+
await this.storeSession(authResult.session);
|
|
131
|
+
return true;
|
|
132
|
+
} else if (authResult.needsManualToken) {
|
|
133
|
+
console.log(chalk.yellow('\nā° Timeout waiting for callback. Trying manual token input...'));
|
|
134
|
+
return await this.handleManualTokenInput();
|
|
135
|
+
} else {
|
|
136
|
+
console.error(chalk.red(`ā Login failed: ${authResult.error}`));
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error(chalk.red(`ā Login error: ${error.message}`));
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async createCallbackServer() {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const server = http.createServer();
|
|
149
|
+
let callbackPromise;
|
|
150
|
+
let callbackResolve;
|
|
151
|
+
let callbackReject;
|
|
152
|
+
|
|
153
|
+
// Create a promise that will be resolved when we get the callback
|
|
154
|
+
const waitForCallback = () => {
|
|
155
|
+
if (!callbackPromise) {
|
|
156
|
+
callbackPromise = new Promise((resolve, reject) => {
|
|
157
|
+
callbackResolve = resolve;
|
|
158
|
+
callbackReject = reject;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return callbackPromise;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
server.on('request', async (req, res) => {
|
|
165
|
+
const parsedUrl = url.parse(req.url, true);
|
|
166
|
+
// console.log(chalk.blue(`š Callback received: ${req.url}`)); // Debug only
|
|
167
|
+
|
|
168
|
+
if (parsedUrl.pathname === '/callback') {
|
|
169
|
+
const { code, error, error_description, access_token } = parsedUrl.query;
|
|
170
|
+
// console.log(chalk.blue(`š OAuth callback - code: ${code ? 'present' : 'missing'}, token: ${access_token ? 'present' : 'missing'}, error: ${error || 'none'}`)); // Debug only
|
|
171
|
+
|
|
172
|
+
// Send response to browser
|
|
173
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
174
|
+
|
|
175
|
+
// If no query params, send HTML that can extract fragment tokens
|
|
176
|
+
if (!code && !access_token && !error) {
|
|
177
|
+
res.end(`
|
|
178
|
+
<html>
|
|
179
|
+
<head><title>OAuth Callback</title></head>
|
|
180
|
+
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
|
|
181
|
+
<h2>š Processing Authentication...</h2>
|
|
182
|
+
<p>Please wait while we complete your login.</p>
|
|
183
|
+
<script>
|
|
184
|
+
// Extract token from URL fragment and send to server
|
|
185
|
+
const fragment = window.location.hash.substring(1);
|
|
186
|
+
const params = new URLSearchParams(fragment);
|
|
187
|
+
const access_token = params.get('access_token');
|
|
188
|
+
const error = params.get('error');
|
|
189
|
+
|
|
190
|
+
if (access_token) {
|
|
191
|
+
// Send token to server as query parameter
|
|
192
|
+
fetch('/callback?access_token=' + encodeURIComponent(access_token))
|
|
193
|
+
.then(() => {
|
|
194
|
+
document.body.innerHTML = '<h2 style="color: green;">ā
Authentication Successful!</h2><p>You can close this window and return to the terminal.</p>';
|
|
195
|
+
})
|
|
196
|
+
.catch(err => {
|
|
197
|
+
document.body.innerHTML = '<h2 style="color: red;">ā Authentication Failed</h2><p>Could not process token.</p>';
|
|
198
|
+
});
|
|
199
|
+
} else if (error) {
|
|
200
|
+
fetch('/callback?error=' + encodeURIComponent(error))
|
|
201
|
+
.then(() => {
|
|
202
|
+
document.body.innerHTML = '<h2 style="color: red;">ā Authentication Failed</h2><p>' + error + '</p>';
|
|
203
|
+
});
|
|
204
|
+
} else {
|
|
205
|
+
document.body.innerHTML = '<h2 style="color: orange;">ā ļø No Authentication Data</h2><p>No token or error found in URL.</p>';
|
|
206
|
+
}
|
|
207
|
+
</script>
|
|
208
|
+
</body>
|
|
209
|
+
</html>
|
|
210
|
+
`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (error) {
|
|
215
|
+
res.end(`
|
|
216
|
+
<html>
|
|
217
|
+
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
|
|
218
|
+
<h2 style="color: red;">ā Authentication Failed</h2>
|
|
219
|
+
<p>${error_description || error}</p>
|
|
220
|
+
<p>You can close this window.</p>
|
|
221
|
+
</body>
|
|
222
|
+
</html>
|
|
223
|
+
`);
|
|
224
|
+
callbackResolve({ success: false, error: error_description || error });
|
|
225
|
+
} else if (access_token) {
|
|
226
|
+
res.end(`
|
|
227
|
+
<html>
|
|
228
|
+
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
|
|
229
|
+
<h2 style="color: green;">ā
Authentication Successful!</h2>
|
|
230
|
+
<p>Token received. You can close this window and return to the terminal.</p>
|
|
231
|
+
</body>
|
|
232
|
+
</html>
|
|
233
|
+
`);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
// Use the access token directly to get user info
|
|
237
|
+
// console.log(chalk.blue('š Using access token from URL fragment...')); // Debug only
|
|
238
|
+
const tempSupabase = createClient(this.supabaseConfig.url, this.supabaseConfig.anon_key);
|
|
239
|
+
const { data: user, error } = await tempSupabase.auth.getUser(access_token);
|
|
240
|
+
|
|
241
|
+
if (error || !user) {
|
|
242
|
+
// console.log(chalk.red(`š Token verification failed: ${error?.message || 'Could not verify user'}`)); // Debug only
|
|
243
|
+
callbackResolve({ success: false, error: error?.message || 'Could not verify user' });
|
|
244
|
+
} else {
|
|
245
|
+
console.log(chalk.green('š Token verification successful!'));
|
|
246
|
+
|
|
247
|
+
// Create a session-like object
|
|
248
|
+
const mockSession = {
|
|
249
|
+
access_token: access_token,
|
|
250
|
+
user: user.user,
|
|
251
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600 // 1 hour from now
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Close server immediately on success
|
|
255
|
+
setTimeout(() => server.close(), 100);
|
|
256
|
+
callbackResolve({ success: true, session: mockSession, user: user.user });
|
|
257
|
+
}
|
|
258
|
+
} catch (tokenError) {
|
|
259
|
+
console.log(chalk.red(`š Token processing error: ${tokenError.message}`));
|
|
260
|
+
callbackResolve({ success: false, error: tokenError.message });
|
|
261
|
+
}
|
|
262
|
+
} else if (code) {
|
|
263
|
+
res.end(`
|
|
264
|
+
<html>
|
|
265
|
+
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
|
|
266
|
+
<h2 style="color: green;">ā
Authentication Successful!</h2>
|
|
267
|
+
<p>You can close this window and return to the terminal.</p>
|
|
268
|
+
</body>
|
|
269
|
+
</html>
|
|
270
|
+
`);
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
// Exchange code for session using Supabase
|
|
274
|
+
console.log(chalk.blue('š Exchanging code for session...'));
|
|
275
|
+
const { data, error } = await this.supabase.auth.exchangeCodeForSession(code);
|
|
276
|
+
|
|
277
|
+
if (error) {
|
|
278
|
+
console.log(chalk.red(`š Token exchange failed: ${error.message}`));
|
|
279
|
+
callbackResolve({ success: false, error: error.message });
|
|
280
|
+
} else {
|
|
281
|
+
console.log(chalk.green('š Token exchange successful!'));
|
|
282
|
+
// Close server immediately on success
|
|
283
|
+
setTimeout(() => server.close(), 100);
|
|
284
|
+
callbackResolve({ success: true, session: data.session, user: data.user });
|
|
285
|
+
}
|
|
286
|
+
} catch (exchangeError) {
|
|
287
|
+
console.log(chalk.red(`š Token exchange error: ${exchangeError.message}`));
|
|
288
|
+
callbackResolve({ success: false, error: exchangeError.message });
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
res.end(`
|
|
292
|
+
<html>
|
|
293
|
+
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
|
|
294
|
+
<h2 style="color: orange;">ā ļø Incomplete Authentication</h2>
|
|
295
|
+
<p>No authorization code received.</p>
|
|
296
|
+
</body>
|
|
297
|
+
</html>
|
|
298
|
+
`);
|
|
299
|
+
callbackResolve({ success: false, error: 'No authorization code received' });
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
res.writeHead(404);
|
|
303
|
+
res.end('Not found');
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
server.listen(0, 'localhost', () => {
|
|
308
|
+
const port = server.address().port;
|
|
309
|
+
// console.log(chalk.blue(`š Callback server started on http://localhost:${port}/callback`)); // Debug only
|
|
310
|
+
resolve({
|
|
311
|
+
server,
|
|
312
|
+
port,
|
|
313
|
+
close: () => server.close(),
|
|
314
|
+
waitForCallback
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
server.on('error', reject);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async storeSession(session) {
|
|
323
|
+
const sessionData = {
|
|
324
|
+
userInfo: {
|
|
325
|
+
email: session.user.email,
|
|
326
|
+
id: session.user.id
|
|
327
|
+
},
|
|
328
|
+
loginTime: Date.now(),
|
|
329
|
+
provider: 'supabase-oauth',
|
|
330
|
+
hasSupabaseSession: true,
|
|
331
|
+
usesSecureStorage: this.keytarAvailable
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// Store session data in config
|
|
335
|
+
this.config.set('sessionData', sessionData);
|
|
336
|
+
this.config.set('isLoggedIn', true);
|
|
337
|
+
|
|
338
|
+
// Store JWT token securely
|
|
339
|
+
if (session.access_token) {
|
|
340
|
+
await this.storeJwtSecurely(session.user.email, session.access_token);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Store refresh token securely
|
|
344
|
+
if (session.refresh_token) {
|
|
345
|
+
await this.storeRefreshTokenSecurely(session.user.email, session.refresh_token);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async storeJwtSecurely(email, jwtToken) {
|
|
350
|
+
if (this.keytarAvailable) {
|
|
351
|
+
try {
|
|
352
|
+
await keytar.setPassword(this.serviceName, `jwt_${email}`, jwtToken);
|
|
353
|
+
return true;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
console.warn(chalk.yellow(`ā ļø Could not store JWT securely: ${error.message}`));
|
|
356
|
+
this.config.set('jwtToken', jwtToken);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
this.config.set('jwtToken', jwtToken);
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async storeRefreshTokenSecurely(email, refreshToken) {
|
|
366
|
+
if (this.keytarAvailable) {
|
|
367
|
+
try {
|
|
368
|
+
await keytar.setPassword(this.serviceName, `refresh_${email}`, refreshToken);
|
|
369
|
+
return true;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.warn(chalk.yellow(`ā ļø Could not store refresh token securely: ${error.message}`));
|
|
372
|
+
this.config.set('refreshToken', refreshToken);
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
this.config.set('refreshToken', refreshToken);
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async getJwtSecurely(email) {
|
|
382
|
+
if (this.keytarAvailable) {
|
|
383
|
+
try {
|
|
384
|
+
return await keytar.getPassword(this.serviceName, `jwt_${email}`);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
return this.config.get('jwtToken');
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
return this.config.get('jwtToken');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async getRefreshTokenSecurely(email) {
|
|
394
|
+
if (this.keytarAvailable) {
|
|
395
|
+
try {
|
|
396
|
+
return await keytar.getPassword(this.serviceName, `refresh_${email}`);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
return this.config.get('refreshToken');
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
return this.config.get('refreshToken');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
isLoggedIn() {
|
|
406
|
+
return this.config.get('isLoggedIn', false);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
getUserInfo() {
|
|
410
|
+
const sessionData = this.config.get('sessionData');
|
|
411
|
+
return sessionData?.userInfo || {};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async getApiKey() {
|
|
415
|
+
// For Supabase auth, we use JWT tokens instead of API keys
|
|
416
|
+
const sessionData = this.config.get('sessionData');
|
|
417
|
+
if (sessionData?.userInfo?.email) {
|
|
418
|
+
return await this.getJwtSecurely(sessionData.userInfo.email);
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async getJwtToken() {
|
|
424
|
+
const sessionData = this.config.get('sessionData');
|
|
425
|
+
if (sessionData?.userInfo?.email) {
|
|
426
|
+
return await this.getJwtSecurely(sessionData.userInfo.email);
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async getSelectedApiKey() {
|
|
432
|
+
// Get the selected/current API key (not JWT token)
|
|
433
|
+
const sessionData = this.config.get('sessionData');
|
|
434
|
+
if (sessionData?.userInfo?.email) {
|
|
435
|
+
if (this.keytarAvailable) {
|
|
436
|
+
try {
|
|
437
|
+
const key = await keytar.getPassword(this.serviceName, `api_${sessionData.userInfo.email}`);
|
|
438
|
+
if (key) {
|
|
439
|
+
if (process.env.AVOCAVO_DEBUG) {
|
|
440
|
+
console.log(chalk.green('[DEBUG] ā
API key retrieved from OS keystore'));
|
|
441
|
+
}
|
|
442
|
+
return key;
|
|
443
|
+
} else {
|
|
444
|
+
if (process.env.AVOCAVO_DEBUG) {
|
|
445
|
+
console.log(chalk.yellow('[DEBUG] ā ļø No key in keystore, checking config'));
|
|
446
|
+
}
|
|
447
|
+
// Fallback to encrypted config if keytar returns null
|
|
448
|
+
const encrypted = this.config.get('currentApiKey_encrypted');
|
|
449
|
+
if (encrypted) {
|
|
450
|
+
if (process.env.AVOCAVO_DEBUG) {
|
|
451
|
+
console.log(chalk.yellow('[DEBUG] š Using config file storage'));
|
|
452
|
+
}
|
|
453
|
+
// Decrypt the API key
|
|
454
|
+
return Buffer.from(encrypted, 'base64').toString('utf8');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (process.env.AVOCAVO_DEBUG) {
|
|
459
|
+
console.log(chalk.red(`[DEBUG] ā Keytar error: ${error.message}`));
|
|
460
|
+
}
|
|
461
|
+
// Fallback to encrypted config
|
|
462
|
+
const encrypted = this.config.get('currentApiKey_encrypted');
|
|
463
|
+
if (encrypted) {
|
|
464
|
+
return Buffer.from(encrypted, 'base64').toString('utf8');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
if (process.env.AVOCAVO_DEBUG) {
|
|
469
|
+
console.log(chalk.red('[DEBUG] ā Keytar not available'));
|
|
470
|
+
}
|
|
471
|
+
// No keytar, use encrypted config
|
|
472
|
+
const encrypted = this.config.get('currentApiKey_encrypted');
|
|
473
|
+
if (encrypted) {
|
|
474
|
+
return Buffer.from(encrypted, 'base64').toString('utf8');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async logout() {
|
|
482
|
+
// Sign out from Supabase (if client is initialized)
|
|
483
|
+
try {
|
|
484
|
+
if (this.supabase) {
|
|
485
|
+
await this.supabase.auth.signOut();
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
console.warn(chalk.yellow(`ā ļø Could not sign out from Supabase: ${error.message}`));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Remove stored credentials
|
|
492
|
+
const sessionData = this.config.get('sessionData');
|
|
493
|
+
if (sessionData?.userInfo?.email) {
|
|
494
|
+
const email = sessionData.userInfo.email;
|
|
495
|
+
|
|
496
|
+
if (this.keytarAvailable) {
|
|
497
|
+
try {
|
|
498
|
+
await keytar.deletePassword(this.serviceName, `jwt_${email}`);
|
|
499
|
+
await keytar.deletePassword(this.serviceName, `refresh_${email}`);
|
|
500
|
+
await keytar.deletePassword(this.serviceName, `api_${email}`); // Delete API key too
|
|
501
|
+
} catch (error) {
|
|
502
|
+
// Continue cleanup
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
this.config.clear();
|
|
508
|
+
console.log(chalk.green('ā
Successfully logged out'));
|
|
509
|
+
|
|
510
|
+
// DEBUG: Log stack trace to find duplicate logout
|
|
511
|
+
if (process.env.AVOCAVO_DEBUG) {
|
|
512
|
+
console.log('Logout called from:', new Error().stack);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// API key management methods - these will call the backend with JWT auth
|
|
517
|
+
async getJwtAuthHeaders() {
|
|
518
|
+
const jwtToken = await this.getJwtToken();
|
|
519
|
+
if (!jwtToken) {
|
|
520
|
+
throw new Error('Not logged in. Please login first.');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
'Authorization': `Bearer ${jwtToken}`,
|
|
525
|
+
'Content-Type': 'application/json'
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async listApiKeys() {
|
|
530
|
+
try {
|
|
531
|
+
const headers = await this.getJwtAuthHeaders();
|
|
532
|
+
const response = await axios.get(`${this.baseUrl}/api/keys`, { headers, timeout: 30000 });
|
|
533
|
+
return response.data;
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (error.response?.status === 401) {
|
|
536
|
+
throw new Error('Session expired. Please login again.');
|
|
537
|
+
}
|
|
538
|
+
throw new Error(`Failed to list API keys: ${error.message}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async createApiKey(name = "CLI Key", description = null, environment = "development") {
|
|
543
|
+
try {
|
|
544
|
+
const headers = await this.getJwtAuthHeaders();
|
|
545
|
+
const data = {
|
|
546
|
+
key_name: name,
|
|
547
|
+
description: description || "Created via Supabase CLI",
|
|
548
|
+
environment: environment
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const response = await axios.post(`${this.baseUrl}/api/keys`, data, { headers, timeout: 30000 });
|
|
552
|
+
|
|
553
|
+
// Auto-select the newly created key
|
|
554
|
+
if (response.data.success && response.data.key) {
|
|
555
|
+
const newKey = response.data.key;
|
|
556
|
+
console.log(chalk.cyan(`š Auto-selecting your new API key: ${newKey.key_name}`));
|
|
557
|
+
await this.storeApiKeySecurely(this.config.get('sessionData')?.userInfo?.email, newKey.api_key);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return response.data;
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (error.response?.status === 401) {
|
|
563
|
+
throw new Error('Session expired. Please login again.');
|
|
564
|
+
}
|
|
565
|
+
throw new Error(`Failed to create API key: ${error.message}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async switchApiKey(keyId) {
|
|
570
|
+
try {
|
|
571
|
+
const headers = await this.getJwtAuthHeaders();
|
|
572
|
+
const response = await axios.post(`${this.baseUrl}/api/keys/${keyId}/reveal`, {}, { headers, timeout: 30000 });
|
|
573
|
+
|
|
574
|
+
if (response.data.success) {
|
|
575
|
+
const fullApiKey = response.data.api_key;
|
|
576
|
+
const keyName = response.data.key_name;
|
|
577
|
+
|
|
578
|
+
// Store the selected API key
|
|
579
|
+
const sessionData = this.config.get('sessionData');
|
|
580
|
+
if (sessionData?.userInfo?.email) {
|
|
581
|
+
await this.storeApiKeySecurely(sessionData.userInfo.email, fullApiKey);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
console.log(chalk.green(`ā
Switched to API key: ${keyName}`));
|
|
585
|
+
return fullApiKey;
|
|
586
|
+
} else {
|
|
587
|
+
throw new Error(response.data.error || 'Failed to reveal API key');
|
|
588
|
+
}
|
|
589
|
+
} catch (error) {
|
|
590
|
+
throw new Error(`Failed to switch API key: ${error.message}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async autoSelectSingleKey() {
|
|
595
|
+
try {
|
|
596
|
+
const keysList = await this.listApiKeys();
|
|
597
|
+
if (keysList.keys && keysList.keys.length === 1) {
|
|
598
|
+
const singleKey = keysList.keys[0];
|
|
599
|
+
console.log(chalk.cyan(`š Auto-selecting your only API key: ${singleKey.key_name}`));
|
|
600
|
+
return await this.switchApiKey(singleKey.id);
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async refreshApiKeyLimits(keyId) {
|
|
609
|
+
try {
|
|
610
|
+
const headers = await this.getJwtAuthHeaders();
|
|
611
|
+
const response = await axios.post(`${this.baseUrl}/api/keys/${keyId}/refresh-limits`, {}, { headers, timeout: 30000 });
|
|
612
|
+
|
|
613
|
+
if (response.data.success) {
|
|
614
|
+
console.log(chalk.green(`ā
Updated limits: ${response.data.old_limit} ā ${response.data.new_limit}`));
|
|
615
|
+
return response.data;
|
|
616
|
+
} else {
|
|
617
|
+
throw new Error(response.data.error || 'Failed to refresh limits');
|
|
618
|
+
}
|
|
619
|
+
} catch (error) {
|
|
620
|
+
throw new Error(`Failed to refresh API key limits: ${error.message}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async storeApiKeySecurely(email, apiKey) {
|
|
625
|
+
if (this.keytarAvailable) {
|
|
626
|
+
try {
|
|
627
|
+
await keytar.setPassword(this.serviceName, `api_${email}`, apiKey);
|
|
628
|
+
// Clear any plain text version
|
|
629
|
+
this.config.delete('currentApiKey');
|
|
630
|
+
this.config.delete('currentApiKey_encrypted');
|
|
631
|
+
this.config.delete('insecureStorage');
|
|
632
|
+
|
|
633
|
+
// Debug logging
|
|
634
|
+
if (process.env.AVOCAVO_DEBUG) {
|
|
635
|
+
console.log(chalk.green('[DEBUG] ā
API key stored in OS keystore'));
|
|
636
|
+
}
|
|
637
|
+
return true;
|
|
638
|
+
} catch (error) {
|
|
639
|
+
if (process.env.AVOCAVO_DEBUG) {
|
|
640
|
+
console.log(chalk.red(`[DEBUG] ā Keytar failed: ${error.message}`));
|
|
641
|
+
}
|
|
642
|
+
// Encrypt API key before storing (basic obfuscation)
|
|
643
|
+
const encrypted = Buffer.from(apiKey).toString('base64');
|
|
644
|
+
this.config.set('currentApiKey_encrypted', encrypted);
|
|
645
|
+
this.config.set('insecureStorage', true);
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
if (process.env.AVOCAVO_DEBUG) {
|
|
650
|
+
console.log(chalk.red('[DEBUG] ā Keytar not available'));
|
|
651
|
+
}
|
|
652
|
+
// Encrypt API key before storing (basic obfuscation)
|
|
653
|
+
const encrypted = Buffer.from(apiKey).toString('base64');
|
|
654
|
+
this.config.set('currentApiKey_encrypted', encrypted);
|
|
655
|
+
this.config.set('insecureStorage', true);
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async handleManualTokenInput() {
|
|
661
|
+
console.log(chalk.cyan('\nš§ Manual Token Input'));
|
|
662
|
+
console.log(chalk.yellow('If you were redirected to nutrition.avocavo.app after logging in:'));
|
|
663
|
+
console.log(chalk.yellow('1. Look at the URL in your browser'));
|
|
664
|
+
console.log(chalk.yellow('2. Find the part that says #access_token='));
|
|
665
|
+
console.log(chalk.yellow('3. Copy ONLY the token part (after #access_token= and before &)'));
|
|
666
|
+
console.log(chalk.yellow('4. Paste it below:'));
|
|
667
|
+
|
|
668
|
+
const rl = readline.createInterface({
|
|
669
|
+
input: process.stdin,
|
|
670
|
+
output: process.stdout
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
return new Promise((resolve) => {
|
|
674
|
+
rl.question(chalk.blue('\nPaste your access token here: '), async (token) => {
|
|
675
|
+
rl.close();
|
|
676
|
+
|
|
677
|
+
if (!token || token.trim().length === 0) {
|
|
678
|
+
console.log(chalk.red('ā No token provided'));
|
|
679
|
+
resolve(false);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
token = token.trim();
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
// Verify token by getting user info from Supabase
|
|
687
|
+
const tempSupabase = createClient(this.supabaseConfig.url, this.supabaseConfig.anon_key);
|
|
688
|
+
const { data: user, error } = await tempSupabase.auth.getUser(token);
|
|
689
|
+
|
|
690
|
+
if (error || !user) {
|
|
691
|
+
console.log(chalk.red(`ā Invalid token: ${error?.message || 'Could not verify user'}`));
|
|
692
|
+
resolve(false);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Create a session-like object
|
|
697
|
+
const mockSession = {
|
|
698
|
+
access_token: token,
|
|
699
|
+
user: user.user,
|
|
700
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600 // 1 hour from now
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
console.log(chalk.green(`ā
Token verified! Welcome ${user.user.email}`));
|
|
704
|
+
|
|
705
|
+
// Store session data
|
|
706
|
+
await this.storeSession(mockSession);
|
|
707
|
+
resolve(true);
|
|
708
|
+
|
|
709
|
+
} catch (error) {
|
|
710
|
+
console.log(chalk.red(`ā Token verification failed: ${error.message}`));
|
|
711
|
+
resolve(false);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
module.exports = { SupabaseAuthManager };
|