@pikoloo/codex-proxy 1.0.6

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -0
  3. package/bin/cli.js +118 -0
  4. package/docs/ACCOUNTS.md +202 -0
  5. package/docs/API.md +289 -0
  6. package/docs/ARCHITECTURE.md +129 -0
  7. package/docs/CLAUDE_INTEGRATION.md +163 -0
  8. package/docs/OAUTH.md +85 -0
  9. package/docs/OPENCLAW.md +34 -0
  10. package/docs/legal.md +11 -0
  11. package/images/dashboard-screenshot.png +0 -0
  12. package/images/demo-screenshot.png +0 -0
  13. package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
  14. package/package.json +61 -0
  15. package/public/css/style.css +1502 -0
  16. package/public/index.html +827 -0
  17. package/public/js/app.js +601 -0
  18. package/src/account-manager.js +528 -0
  19. package/src/account-rotation/index.js +93 -0
  20. package/src/account-rotation/rate-limits.js +293 -0
  21. package/src/account-rotation/strategies/base-strategy.js +48 -0
  22. package/src/account-rotation/strategies/index.js +31 -0
  23. package/src/account-rotation/strategies/round-robin-strategy.js +42 -0
  24. package/src/account-rotation/strategies/sticky-strategy.js +97 -0
  25. package/src/claude-config.js +153 -0
  26. package/src/cli/accounts.js +557 -0
  27. package/src/direct-api.js +164 -0
  28. package/src/format-converter.js +420 -0
  29. package/src/index.js +46 -0
  30. package/src/kilo-api.js +68 -0
  31. package/src/kilo-format-converter.js +285 -0
  32. package/src/kilo-models.js +103 -0
  33. package/src/kilo-streamer.js +243 -0
  34. package/src/middleware/credentials.js +116 -0
  35. package/src/middleware/sse.js +96 -0
  36. package/src/model-api.js +189 -0
  37. package/src/model-mapper.js +157 -0
  38. package/src/oauth.js +666 -0
  39. package/src/response-streamer.js +409 -0
  40. package/src/routes/accounts-route.js +332 -0
  41. package/src/routes/api-routes.js +98 -0
  42. package/src/routes/chat-route.js +229 -0
  43. package/src/routes/claude-config-route.js +121 -0
  44. package/src/routes/logs-route.js +43 -0
  45. package/src/routes/messages-route.js +203 -0
  46. package/src/routes/models-route.js +119 -0
  47. package/src/routes/settings-route.js +143 -0
  48. package/src/security.js +142 -0
  49. package/src/server-settings.js +56 -0
  50. package/src/server.js +58 -0
  51. package/src/signature-cache.js +106 -0
  52. package/src/thinking-utils.js +312 -0
  53. package/src/utils/logger.js +156 -0
package/src/oauth.js ADDED
@@ -0,0 +1,666 @@
1
+ /**
2
+ * OpenAI/ChatGPT OAuth Module
3
+ * Handles OAuth 2.0 with PKCE for ChatGPT authentication
4
+ */
5
+
6
+ import crypto from 'crypto';
7
+ import http from 'http';
8
+ import { exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ // OpenAI OAuth Configuration (from Codex app)
14
+ const OAUTH_CONFIG = {
15
+ clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
16
+ authUrl: 'https://auth.openai.com/oauth/authorize',
17
+ tokenUrl: 'https://auth.openai.com/oauth/token',
18
+ logoutUrl: 'https://auth.openai.com/logout',
19
+ userInfoUrl: 'https://api.openai.com/v1/me',
20
+ scopes: ['openid', 'profile', 'email', 'offline_access'],
21
+ callbackPort: 1455,
22
+ callbackFallbackPorts: [1456, 1457, 1458, 1459, 1460],
23
+ callbackPath: '/auth/callback'
24
+ };
25
+
26
+ // Store PKCE verifiers temporarily (in production, use proper session storage)
27
+ const pkceStore = new Map();
28
+
29
+ /**
30
+ * Generate PKCE code verifier and challenge
31
+ * @returns {{verifier: string, challenge: string}}
32
+ */
33
+ function generatePKCE() {
34
+ const verifier = crypto.randomBytes(32).toString('base64url');
35
+ const challenge = crypto
36
+ .createHash('sha256')
37
+ .update(verifier)
38
+ .digest('base64url');
39
+ return { verifier, challenge };
40
+ }
41
+
42
+ /**
43
+ * Generate random state for CSRF protection
44
+ * @returns {string}
45
+ */
46
+ function generateState() {
47
+ return crypto.randomBytes(16).toString('hex');
48
+ }
49
+
50
+ /**
51
+ * Decode JWT token without verification (for extracting claims)
52
+ * @param {string} token - JWT token
53
+ * @returns {object} Decoded payload
54
+ */
55
+ function decodeJWT(token) {
56
+ try {
57
+ const parts = token.split('.');
58
+ if (parts.length !== 3) return null;
59
+ const payload = Buffer.from(parts[1], 'base64').toString('utf8');
60
+ return JSON.parse(payload);
61
+ } catch (e) {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Extract account info from access token
68
+ * @param {string} accessToken - JWT access token
69
+ * @returns {{accountId: string, planType: string, userId: string, email: string}}
70
+ */
71
+ function extractAccountInfo(accessToken) {
72
+ const payload = decodeJWT(accessToken);
73
+ if (!payload) return null;
74
+
75
+ const authInfo = payload['https://api.openai.com/auth'] || {};
76
+ const profileInfo = payload['https://api.openai.com/profile'] || {};
77
+
78
+ return {
79
+ accountId: authInfo.chatgpt_account_id || null,
80
+ planType: authInfo.chatgpt_plan_type || 'free',
81
+ userId: authInfo.chatgpt_user_id || payload.sub || null,
82
+ email: profileInfo.email || payload.email || null,
83
+ expiresAt: payload.exp ? payload.exp * 1000 : null
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Get authorization URL for OAuth flow
89
+ * @param {string} verifier - PKCE code verifier
90
+ * @param {string} state - CSRF state
91
+ * @param {number} port - Callback server port
92
+ * @returns {string} Authorization URL
93
+ */
94
+ function getAuthorizationUrl(verifier, state, port) {
95
+ const { challenge } = generatePKCEFromVerifier(verifier);
96
+ const redirectUri = `http://localhost:${port}${OAUTH_CONFIG.callbackPath}`;
97
+
98
+ pkceStore.set(state, { verifier, port, createdAt: Date.now() });
99
+
100
+ // Clean up old entries
101
+ for (const [key, value] of pkceStore.entries()) {
102
+ if (Date.now() - value.createdAt > 5 * 60 * 1000) {
103
+ pkceStore.delete(key);
104
+ }
105
+ }
106
+
107
+ const params = new URLSearchParams({
108
+ response_type: 'code',
109
+ client_id: OAUTH_CONFIG.clientId,
110
+ redirect_uri: redirectUri,
111
+ scope: OAUTH_CONFIG.scopes.join(' '),
112
+ code_challenge: challenge,
113
+ code_challenge_method: 'S256',
114
+ state: state,
115
+ id_token_add_organizations: 'true',
116
+ codex_cli_simplified_flow: 'true',
117
+ originator: 'codex_cli_rs',
118
+ prompt: 'login', // Force login screen for multi-account support
119
+ max_age: '0' // Force re-authentication
120
+ });
121
+
122
+ const url = `${OAUTH_CONFIG.authUrl}?${params.toString()}`;
123
+ console.log(`[OAuth] Generated Authorization URL: ${url}`);
124
+ return url;
125
+ }
126
+
127
+ function escapeHtml(value) {
128
+ return String(value)
129
+ .replace(/&/g, '&')
130
+ .replace(/</g, '&lt;')
131
+ .replace(/>/g, '&gt;')
132
+ .replace(/"/g, '&quot;')
133
+ .replace(/'/g, '&#39;');
134
+ }
135
+
136
+ /**
137
+ * Modern Success/Error templates for better UX
138
+ */
139
+ function getSuccessHtml(message) {
140
+ return `
141
+ <!DOCTYPE html>
142
+ <html>
143
+ <head>
144
+ <meta charset="UTF-8">
145
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
146
+ <title>Authentication Successful</title>
147
+ <style>
148
+ body { font-family: 'Inter', system-ui, -apple-system, sans-serif; background: #0f172a; color: #f8fafc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
149
+ .card { background: #1e293b; padding: 3rem; border-radius: 1rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); text-align: center; max-width: 400px; border: 1px solid #334155; }
150
+ .icon { font-size: 4rem; margin-bottom: 1.5rem; display: block; }
151
+ h1 { margin: 0 0 1rem; color: #10b981; font-weight: 700; }
152
+ p { color: #94a3b8; line-height: 1.6; font-size: 1.1rem; }
153
+ .footer { margin-top: 2rem; font-size: 0.9rem; color: #64748b; }
154
+ </style>
155
+ </head>
156
+ <body>
157
+ <div class="card">
158
+ <span class="icon">✅</span>
159
+ <h1>Success!</h1>
160
+ <p>${escapeHtml(message)}</p>
161
+ <div class="footer">You can close this window and return to the app.</div>
162
+ </div>
163
+ <script>
164
+ if (window.opener) {
165
+ window.opener.postMessage({ type: 'oauth-success' }, '*');
166
+ }
167
+ setTimeout(() => window.close(), 3000);
168
+ </script>
169
+ </body>
170
+ </html>
171
+ `;
172
+ }
173
+
174
+ function getErrorHtml(error) {
175
+ return `
176
+ <!DOCTYPE html>
177
+ <html>
178
+ <head>
179
+ <meta charset="UTF-8">
180
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
181
+ <title>Authentication Failed</title>
182
+ <style>
183
+ body { font-family: 'Inter', system-ui, -apple-system, sans-serif; background: #0f172a; color: #f8fafc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
184
+ .card { background: #1e293b; padding: 3rem; border-radius: 1rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); text-align: center; max-width: 400px; border: 1px solid #334155; }
185
+ .icon { font-size: 4rem; margin-bottom: 1.5rem; display: block; }
186
+ h1 { margin: 0 0 1rem; color: #ef4444; font-weight: 700; }
187
+ p { color: #94a3b8; line-height: 1.6; font-size: 1.1rem; }
188
+ </style>
189
+ </head>
190
+ <body>
191
+ <div class="card">
192
+ <span class="icon">❌</span>
193
+ <h1>Failed</h1>
194
+ <p>Authentication could not be completed.</p>
195
+ <div style="background: rgba(239, 68, 68, 0.1); padding: 1rem; border-radius: 0.5rem; color: #fca5a5; margin-top: 1rem; font-family: monospace; font-size: 0.9rem;">
196
+ ${escapeHtml(error)}
197
+ </div>
198
+ <p style="margin-top: 1.5rem; font-size: 0.9rem;">Please close this window and try again.</p>
199
+ </div>
200
+ </body>
201
+ </html>
202
+ `;
203
+ }
204
+
205
+ function getLogoutThenAuthUrl(verifier, state, port) {
206
+ const authUrl = getAuthorizationUrl(verifier, state, port);
207
+ // Note: auth.openai.com/logout doesn't always support 'continue' reliably for all users
208
+ // prompt=login in getAuthorizationUrl is the preferred way now.
209
+ return authUrl;
210
+ }
211
+
212
+ /**
213
+ * Generate challenge from verifier
214
+ * @param {string} verifier - PKCE code verifier
215
+ * @returns {{challenge: string}}
216
+ */
217
+ function generatePKCEFromVerifier(verifier) {
218
+ const challenge = crypto
219
+ .createHash('sha256')
220
+ .update(verifier)
221
+ .digest('base64url');
222
+ return { challenge };
223
+ }
224
+
225
+ /**
226
+ * Get stored PKCE data for a state
227
+ * @param {string} state - OAuth state
228
+ * @returns {{verifier: string, port: number}|null}
229
+ */
230
+ function getPKCEData(state) {
231
+ return pkceStore.get(state) || null;
232
+ }
233
+
234
+ /**
235
+ * Attempt to bind server to a specific port
236
+ * @param {http.Server} server - HTTP server instance
237
+ * @param {number} port - Port to bind to
238
+ * @param {string} host - Host to bind to
239
+ * @returns {Promise<number>} Resolves with port on success, rejects on error
240
+ */
241
+ function tryBindPort(server, port, host = '127.0.0.1') {
242
+ return new Promise((resolve, reject) => {
243
+ const onError = (err) => {
244
+ server.removeListener('listening', onSuccess);
245
+ reject(err);
246
+ };
247
+ const onSuccess = () => {
248
+ server.removeListener('error', onError);
249
+ resolve(server.address()?.port || port);
250
+ };
251
+ server.once('error', onError);
252
+ server.once('listening', onSuccess);
253
+ server.listen(port, host);
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Start local callback server with port fallback and abort support
259
+ * @param {string} expectedState - Expected state for validation
260
+ * @param {number} timeoutMs - Timeout in milliseconds
261
+ * @param {{host?: string, port?: number}} [options]
262
+ * @returns {{promise: Promise<{code: string, state: string}>, ready: Promise<number>, abort: Function, getPort: Function}}
263
+ */
264
+ function startCallbackServer(expectedState, timeoutMs = 120000, options = {}) {
265
+ let server = null;
266
+ let timeoutId = null;
267
+ let isAborted = false;
268
+ let isSettled = false;
269
+ let actualPort = options.port ?? OAUTH_CONFIG.callbackPort;
270
+ const host = options.host || process.env.OAUTH_CALLBACK_HOST || '127.0.0.1';
271
+
272
+ let readyResolve;
273
+ let readyReject;
274
+ const ready = new Promise((resolve, reject) => {
275
+ readyResolve = resolve;
276
+ readyReject = reject;
277
+ });
278
+
279
+ function settleReject(reject, error) {
280
+ if (isSettled) return;
281
+ isSettled = true;
282
+ if (timeoutId) clearTimeout(timeoutId);
283
+ if (server) {
284
+ try { server.close(); } catch { /* ignore */ }
285
+ }
286
+ reject(error);
287
+ }
288
+
289
+ const promise = new Promise(async (resolve, reject) => {
290
+ const requestedPort = options.port ?? OAUTH_CONFIG.callbackPort;
291
+ const portsToTry = requestedPort === OAUTH_CONFIG.callbackPort
292
+ ? [requestedPort, ...(OAUTH_CONFIG.callbackFallbackPorts || [])]
293
+ : [requestedPort];
294
+ const errors = [];
295
+
296
+ server = http.createServer((req, res) => {
297
+ const url = new URL(req.url, `http://${host === '0.0.0.0' ? 'localhost' : host}:${actualPort}`);
298
+ console.log(`[OAuth] Received request: ${req.method} ${req.url}`);
299
+
300
+ if (url.pathname !== OAUTH_CONFIG.callbackPath && url.pathname !== '/success') {
301
+ res.writeHead(404);
302
+ res.end('Not found');
303
+ return;
304
+ }
305
+
306
+ const code = url.searchParams.get('code');
307
+ const state = url.searchParams.get('state');
308
+ const error = url.searchParams.get('error');
309
+ const port = Number(url.port);
310
+ const idToken = url.searchParams.get('id_token');
311
+
312
+ if (error) {
313
+ console.error(`[OAuth] Error in callback: ${error}`);
314
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
315
+ res.end(getErrorHtml(error));
316
+ settleReject(reject, new Error(`OAuth error: ${error}`));
317
+ return;
318
+ }
319
+
320
+ if (code) {
321
+ if (!state || state !== expectedState) {
322
+ console.error('[OAuth] Invalid OAuth state in callback');
323
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
324
+ res.end(getErrorHtml('Invalid OAuth state'));
325
+ settleReject(reject, new Error('Invalid OAuth state'));
326
+ return;
327
+ }
328
+
329
+ console.log('[OAuth] Got authorization code');
330
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
331
+ res.end(getSuccessHtml('Authentication Successful! You can close this window.'));
332
+
333
+ setTimeout(() => {
334
+ if (isSettled) return;
335
+ isSettled = true;
336
+ server.close();
337
+ clearTimeout(timeoutId);
338
+ resolve({ code, state });
339
+ }, 1000);
340
+ return;
341
+ }
342
+
343
+ if (url.pathname === '/success' || idToken) {
344
+ console.log('[OAuth] At success page');
345
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
346
+ res.end(getSuccessHtml('Login Successful!'));
347
+ return;
348
+ }
349
+
350
+ res.writeHead(400);
351
+ res.end('Waiting for authorization code...');
352
+ });
353
+
354
+ // Try ports with fallback logic (Windows EACCES fix)
355
+ let boundSuccessfully = false;
356
+ for (const port of portsToTry) {
357
+ try {
358
+ actualPort = await tryBindPort(server, port, host);
359
+ boundSuccessfully = true;
360
+
361
+ if (requestedPort === 0) {
362
+ console.log(`[OAuth] Callback server listening on ${host}:${actualPort}`);
363
+ } else if (port !== OAUTH_CONFIG.callbackPort) {
364
+ console.log(`[OAuth] Primary port ${OAUTH_CONFIG.callbackPort} unavailable, using fallback port ${actualPort}`);
365
+ } else {
366
+ console.log(`[OAuth] Callback server listening on ${host}:${port}`);
367
+ }
368
+ readyResolve(actualPort);
369
+ break;
370
+ } catch (err) {
371
+ const errMsg = err.code === 'EACCES'
372
+ ? `Permission denied on port ${port}`
373
+ : err.code === 'EADDRINUSE'
374
+ ? `Port ${port} already in use`
375
+ : `Failed to bind port ${port}: ${err.message}`;
376
+ errors.push(errMsg);
377
+ console.log(`[OAuth] ${errMsg}`);
378
+ }
379
+ }
380
+
381
+ if (!boundSuccessfully) {
382
+ const isWindows = process.platform === 'win32';
383
+ let errorMsg = `Failed to start OAuth callback server.\nTried ports: ${portsToTry.join(', ')}\n\nErrors:\n${errors.join('\n')}`;
384
+
385
+ if (isWindows) {
386
+ errorMsg += `\n
387
+ ================== WINDOWS TROUBLESHOOTING ==================
388
+ The default port range may be reserved by Hyper-V/WSL2/Docker.
389
+
390
+ Option 1: Use a custom port
391
+ Set OAUTH_CALLBACK_PORT=3456 in your environment
392
+
393
+ Option 2: Reset Windows NAT (run as Administrator)
394
+ net stop winnat && net start winnat
395
+
396
+ Option 3: Check reserved port ranges
397
+ netsh interface ipv4 show excludedportrange protocol=tcp
398
+ ==============================================================`;
399
+ } else {
400
+ errorMsg += `\n\nTry setting a custom port via environment variable.`;
401
+ }
402
+
403
+ readyReject(new Error(errorMsg));
404
+ settleReject(reject, new Error(errorMsg));
405
+ return;
406
+ }
407
+
408
+ timeoutId = setTimeout(() => {
409
+ if (!isAborted) {
410
+ settleReject(reject, new Error('OAuth callback timeout - no response received'));
411
+ }
412
+ }, timeoutMs);
413
+ });
414
+
415
+ const abort = () => {
416
+ if (isAborted) return;
417
+ isAborted = true;
418
+ if (timeoutId) {
419
+ clearTimeout(timeoutId);
420
+ }
421
+ if (server) {
422
+ server.close();
423
+ console.log('[OAuth] Callback server aborted (manual completion)');
424
+ }
425
+ };
426
+
427
+ const getPort = () => actualPort;
428
+
429
+ return { promise, ready, abort, getPort };
430
+ }
431
+
432
+ /**
433
+ * Exchange authorization code for tokens
434
+ * @param {string} code - Authorization code
435
+ * @param {string} verifier - PKCE code verifier
436
+ * @param {number} port - Callback port used
437
+ * @returns {Promise<{accessToken: string, refreshToken: string, idToken: string, expiresIn: number}>}
438
+ */
439
+ async function exchangeCodeForTokens(code, verifier, port) {
440
+ const callbackPort = port || OAUTH_CONFIG.callbackPort;
441
+ const redirectUri = `http://localhost:${callbackPort}${OAUTH_CONFIG.callbackPath}`;
442
+
443
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
444
+ method: 'POST',
445
+ headers: {
446
+ 'Content-Type': 'application/x-www-form-urlencoded'
447
+ },
448
+ body: new URLSearchParams({
449
+ grant_type: 'authorization_code',
450
+ code: code,
451
+ redirect_uri: redirectUri,
452
+ client_id: OAUTH_CONFIG.clientId,
453
+ code_verifier: verifier
454
+ })
455
+ });
456
+
457
+ if (!response.ok) {
458
+ const error = await response.text();
459
+ throw new Error(`Token exchange failed: ${response.status} - ${error}`);
460
+ }
461
+
462
+ const tokens = await response.json();
463
+
464
+ if (!tokens.access_token) {
465
+ throw new Error('No access token in response');
466
+ }
467
+
468
+ return {
469
+ accessToken: tokens.access_token,
470
+ refreshToken: tokens.refresh_token,
471
+ idToken: tokens.id_token,
472
+ expiresIn: tokens.expires_in
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Refresh access token using refresh token
478
+ * @param {string} refreshToken - OAuth refresh token
479
+ * @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>}
480
+ */
481
+ async function refreshAccessToken(refreshToken) {
482
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
483
+ method: 'POST',
484
+ headers: {
485
+ 'Content-Type': 'application/x-www-form-urlencoded'
486
+ },
487
+ body: new URLSearchParams({
488
+ grant_type: 'refresh_token',
489
+ refresh_token: refreshToken,
490
+ client_id: OAUTH_CONFIG.clientId
491
+ })
492
+ });
493
+
494
+ if (!response.ok) {
495
+ const error = await response.text();
496
+ throw new Error(`Token refresh failed: ${response.status} - ${error}`);
497
+ }
498
+
499
+ const tokens = await response.json();
500
+
501
+ return {
502
+ accessToken: tokens.access_token,
503
+ refreshToken: tokens.refresh_token || refreshToken,
504
+ idToken: tokens.id_token,
505
+ expiresIn: tokens.expires_in
506
+ };
507
+ }
508
+
509
+ /**
510
+ * Open URL in default browser
511
+ * @param {string} url - URL to open
512
+ */
513
+ async function openBrowser(url) {
514
+ const platform = process.platform;
515
+
516
+ try {
517
+ if (platform === 'darwin') {
518
+ await execAsync(`open "${url}"`);
519
+ } else if (platform === 'win32') {
520
+ await execAsync(`start "" "${url}"`);
521
+ } else {
522
+ await execAsync(`xdg-open "${url}"`);
523
+ }
524
+ } catch (e) {
525
+ console.log(`[OAuth] Could not open browser automatically. Please visit:\n${url}`);
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Complete OAuth flow - returns full account info
531
+ * @param {number} [customPort] - Optional custom port for callback
532
+ * @returns {Promise<{email: string, accountId: string, planType: string, accessToken: string, refreshToken: string}>}
533
+ */
534
+ async function performOAuthFlow(customPort) {
535
+ const port = customPort || OAUTH_CONFIG.callbackPort;
536
+ const { verifier } = generatePKCE();
537
+ const state = generateState();
538
+
539
+ const callback = startCallbackServer(state, 120000, { port });
540
+ const actualPort = await callback.ready;
541
+
542
+ const authUrl = getAuthorizationUrl(verifier, state, actualPort);
543
+
544
+ console.log(`\n[OAuth] Starting authentication flow...`);
545
+ console.log(`[OAuth] Callback URL: http://localhost:${actualPort}${OAUTH_CONFIG.callbackPath}`);
546
+
547
+ // Open browser
548
+ await openBrowser(authUrl);
549
+
550
+ console.log(`\n[OAuth] Waiting for authentication...`);
551
+ console.log(`[OAuth] If browser didn't open, visit:\n${authUrl}\n`);
552
+
553
+ // Wait for callback
554
+ const { code } = await callback.promise;
555
+ console.log(`[OAuth] Received authorization code`);
556
+
557
+ // Exchange code for tokens
558
+ console.log(`[OAuth] Exchanging code for tokens...`);
559
+ const tokens = await exchangeCodeForTokens(code, verifier, actualPort);
560
+ console.log(`[OAuth] Token exchange successful`);
561
+
562
+ // Extract account info from access token
563
+ const accountInfo = extractAccountInfo(tokens.accessToken);
564
+
565
+ return {
566
+ email: accountInfo?.email || 'unknown',
567
+ accountId: accountInfo?.accountId,
568
+ planType: accountInfo?.planType || 'free',
569
+ accessToken: tokens.accessToken,
570
+ refreshToken: tokens.refreshToken,
571
+ idToken: tokens.idToken,
572
+ expiresAt: accountInfo?.expiresAt || (Date.now() + tokens.expiresIn * 1000)
573
+ };
574
+ }
575
+
576
+ /**
577
+ * Handle OAuth callback from web flow
578
+ * @param {string} code - Authorization code
579
+ * @param {string} state - OAuth state
580
+ * @returns {Promise<{email: string, accountId: string, planType: string, accessToken: string, refreshToken: string}>}
581
+ */
582
+ async function handleOAuthCallback(code, state) {
583
+ const pkceData = getPKCEData(state);
584
+ if (!pkceData) {
585
+ throw new Error('Invalid or expired OAuth state');
586
+ }
587
+
588
+ const tokens = await exchangeCodeForTokens(code, pkceData.verifier, pkceData.port);
589
+ const accountInfo = extractAccountInfo(tokens.accessToken);
590
+
591
+ // Clean up
592
+ pkceStore.delete(state);
593
+
594
+ return {
595
+ email: accountInfo?.email || 'unknown',
596
+ accountId: accountInfo?.accountId,
597
+ planType: accountInfo?.planType || 'free',
598
+ accessToken: tokens.accessToken,
599
+ refreshToken: tokens.refreshToken,
600
+ idToken: tokens.idToken,
601
+ expiresAt: accountInfo?.expiresAt || (Date.now() + tokens.expiresIn * 1000)
602
+ };
603
+ }
604
+
605
+ export function extractCodeFromInput(input) {
606
+ if (!input || typeof input !== 'string') {
607
+ throw new Error('No input provided');
608
+ }
609
+
610
+ const trimmed = input.trim();
611
+
612
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
613
+ try {
614
+ const url = new URL(trimmed);
615
+ const code = url.searchParams.get('code');
616
+ const state = url.searchParams.get('state');
617
+ const error = url.searchParams.get('error');
618
+
619
+ if (error) {
620
+ throw new Error(`OAuth error: ${error}`);
621
+ }
622
+
623
+ if (!code) {
624
+ throw new Error('No authorization code found in URL');
625
+ }
626
+
627
+ return { code, state, port: Number.isInteger(port) && port > 0 ? port : null };
628
+ } catch (e) {
629
+ if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) {
630
+ throw e;
631
+ }
632
+ throw new Error('Invalid URL format');
633
+ }
634
+ }
635
+
636
+ if (trimmed.length < 10) {
637
+ throw new Error('Input is too short to be a valid authorization code');
638
+ }
639
+
640
+ return { code: trimmed, state: null, port: null };
641
+ }
642
+
643
+ export {
644
+ OAUTH_CONFIG,
645
+ generatePKCE,
646
+ generateState,
647
+ decodeJWT,
648
+ extractAccountInfo,
649
+ getAuthorizationUrl,
650
+ getLogoutThenAuthUrl,
651
+ startCallbackServer,
652
+ exchangeCodeForTokens,
653
+ refreshAccessToken,
654
+ openBrowser,
655
+ performOAuthFlow,
656
+ handleOAuthCallback,
657
+ getPKCEData
658
+ };
659
+
660
+ export default {
661
+ performOAuthFlow,
662
+ handleOAuthCallback,
663
+ refreshAccessToken,
664
+ extractAccountInfo,
665
+ extractCodeFromInput
666
+ };