codex-claude-proxy 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/src/oauth.js ADDED
@@ -0,0 +1,554 @@
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
+ callbackPath: '/auth/callback'
23
+ };
24
+
25
+ // Store PKCE verifiers temporarily (in production, use proper session storage)
26
+ const pkceStore = new Map();
27
+
28
+ /**
29
+ * Generate PKCE code verifier and challenge
30
+ * @returns {{verifier: string, challenge: string}}
31
+ */
32
+ function generatePKCE() {
33
+ const verifier = crypto.randomBytes(32).toString('base64url');
34
+ const challenge = crypto
35
+ .createHash('sha256')
36
+ .update(verifier)
37
+ .digest('base64url');
38
+ return { verifier, challenge };
39
+ }
40
+
41
+ /**
42
+ * Generate random state for CSRF protection
43
+ * @returns {string}
44
+ */
45
+ function generateState() {
46
+ return crypto.randomBytes(16).toString('hex');
47
+ }
48
+
49
+ /**
50
+ * Decode JWT token without verification (for extracting claims)
51
+ * @param {string} token - JWT token
52
+ * @returns {object} Decoded payload
53
+ */
54
+ function decodeJWT(token) {
55
+ try {
56
+ const parts = token.split('.');
57
+ if (parts.length !== 3) return null;
58
+ const payload = Buffer.from(parts[1], 'base64').toString('utf8');
59
+ return JSON.parse(payload);
60
+ } catch (e) {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Extract account info from access token
67
+ * @param {string} accessToken - JWT access token
68
+ * @returns {{accountId: string, planType: string, userId: string, email: string}}
69
+ */
70
+ function extractAccountInfo(accessToken) {
71
+ const payload = decodeJWT(accessToken);
72
+ if (!payload) return null;
73
+
74
+ const authInfo = payload['https://api.openai.com/auth'] || {};
75
+ const profileInfo = payload['https://api.openai.com/profile'] || {};
76
+
77
+ return {
78
+ accountId: authInfo.chatgpt_account_id || null,
79
+ planType: authInfo.chatgpt_plan_type || 'free',
80
+ userId: authInfo.chatgpt_user_id || payload.sub || null,
81
+ email: profileInfo.email || payload.email || null,
82
+ expiresAt: payload.exp ? payload.exp * 1000 : null
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Get authorization URL for OAuth flow
88
+ * @param {string} verifier - PKCE code verifier
89
+ * @param {string} state - CSRF state
90
+ * @param {number} port - Callback server port
91
+ * @returns {string} Authorization URL
92
+ */
93
+ function getAuthorizationUrl(verifier, state, port) {
94
+ const { challenge } = generatePKCEFromVerifier(verifier);
95
+ const redirectUri = `http://localhost:${port}${OAUTH_CONFIG.callbackPath}`;
96
+
97
+ pkceStore.set(state, { verifier, port, createdAt: Date.now() });
98
+
99
+ // Clean up old entries
100
+ for (const [key, value] of pkceStore.entries()) {
101
+ if (Date.now() - value.createdAt > 5 * 60 * 1000) {
102
+ pkceStore.delete(key);
103
+ }
104
+ }
105
+
106
+ const params = new URLSearchParams({
107
+ response_type: 'code',
108
+ client_id: OAUTH_CONFIG.clientId,
109
+ redirect_uri: redirectUri,
110
+ scope: OAUTH_CONFIG.scopes.join(' '),
111
+ code_challenge: challenge,
112
+ code_challenge_method: 'S256',
113
+ state: state,
114
+ id_token_add_organizations: 'true',
115
+ codex_cli_simplified_flow: 'true',
116
+ originator: 'codex_cli_rs',
117
+ prompt: 'login', // Force login screen for multi-account support
118
+ max_age: '0' // Force re-authentication
119
+ });
120
+
121
+ const url = `${OAUTH_CONFIG.authUrl}?${params.toString()}`;
122
+ console.log(`[OAuth] Generated Authorization URL: ${url}`);
123
+ return url;
124
+ }
125
+
126
+ /**
127
+ * Modern Success/Error templates for better UX
128
+ */
129
+ function getSuccessHtml(message) {
130
+ return `
131
+ <!DOCTYPE html>
132
+ <html>
133
+ <head>
134
+ <meta charset="UTF-8">
135
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
136
+ <title>Authentication Successful</title>
137
+ <style>
138
+ 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; }
139
+ .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; }
140
+ .icon { font-size: 4rem; margin-bottom: 1.5rem; display: block; }
141
+ h1 { margin: 0 0 1rem; color: #10b981; font-weight: 700; }
142
+ p { color: #94a3b8; line-height: 1.6; font-size: 1.1rem; }
143
+ .footer { margin-top: 2rem; font-size: 0.9rem; color: #64748b; }
144
+ </style>
145
+ </head>
146
+ <body>
147
+ <div class="card">
148
+ <span class="icon">✅</span>
149
+ <h1>Success!</h1>
150
+ <p>\${message}</p>
151
+ <div class="footer">You can close this window and return to the app.</div>
152
+ </div>
153
+ <script>
154
+ if (window.opener) {
155
+ window.opener.postMessage({ type: 'oauth-success' }, '*');
156
+ }
157
+ setTimeout(() => window.close(), 3000);
158
+ </script>
159
+ </body>
160
+ </html>
161
+ `;
162
+ }
163
+
164
+ function getErrorHtml(error) {
165
+ return `
166
+ <!DOCTYPE html>
167
+ <html>
168
+ <head>
169
+ <meta charset="UTF-8">
170
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
171
+ <title>Authentication Failed</title>
172
+ <style>
173
+ 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; }
174
+ .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; }
175
+ .icon { font-size: 4rem; margin-bottom: 1.5rem; display: block; }
176
+ h1 { margin: 0 0 1rem; color: #ef4444; font-weight: 700; }
177
+ p { color: #94a3b8; line-height: 1.6; font-size: 1.1rem; }
178
+ </style>
179
+ </head>
180
+ <body>
181
+ <div class="card">
182
+ <span class="icon">❌</span>
183
+ <h1>Failed</h1>
184
+ <p>Authentication could not be completed.</p>
185
+ <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;">
186
+ \${error}
187
+ </div>
188
+ <p style="margin-top: 1.5rem; font-size: 0.9rem;">Please close this window and try again.</p>
189
+ </div>
190
+ </body>
191
+ </html>
192
+ `;
193
+ }
194
+
195
+ function getLogoutThenAuthUrl(verifier, state, port) {
196
+ const authUrl = getAuthorizationUrl(verifier, state, port);
197
+ // Note: auth.openai.com/logout doesn't always support 'continue' reliably for all users
198
+ // prompt=login in getAuthorizationUrl is the preferred way now.
199
+ return authUrl;
200
+ }
201
+
202
+ /**
203
+ * Generate challenge from verifier
204
+ * @param {string} verifier - PKCE code verifier
205
+ * @returns {{challenge: string}}
206
+ */
207
+ function generatePKCEFromVerifier(verifier) {
208
+ const challenge = crypto
209
+ .createHash('sha256')
210
+ .update(verifier)
211
+ .digest('base64url');
212
+ return { challenge };
213
+ }
214
+
215
+ /**
216
+ * Get stored PKCE data for a state
217
+ * @param {string} state - OAuth state
218
+ * @returns {{verifier: string, port: number}|null}
219
+ */
220
+ function getPKCEData(state) {
221
+ return pkceStore.get(state) || null;
222
+ }
223
+
224
+ /**
225
+ * Start local callback server
226
+ * @param {number} port - Port to listen on
227
+ * @param {string} expectedState - Expected state for validation
228
+ * @param {number} timeoutMs - Timeout in milliseconds
229
+ * @returns {{promise: Promise<string>, server: http.Server}}
230
+ */
231
+ function startCallbackServer(port, expectedState, timeoutMs = 120000) {
232
+ let server = null;
233
+ let timeoutId = null;
234
+ let resolvePromise, rejectPromise;
235
+
236
+ const promise = new Promise((resolve, reject) => {
237
+ resolvePromise = resolve;
238
+ rejectPromise = reject;
239
+
240
+ server = http.createServer((req, res) => {
241
+ const url = new URL(req.url, `http://localhost:${port}`);
242
+ console.log(`[OAuth] Received request: ${req.method} ${req.url}`);
243
+
244
+ // Handle both /auth/callback and /success (often seen in simplified flow)
245
+ if (url.pathname !== OAUTH_CONFIG.callbackPath && url.pathname !== '/success') {
246
+ res.writeHead(404);
247
+ res.end('Not found');
248
+ return;
249
+ }
250
+
251
+ const code = url.searchParams.get('code');
252
+ const state = url.searchParams.get('state');
253
+ const error = url.searchParams.get('error');
254
+ const idToken = url.searchParams.get('id_token');
255
+
256
+ if (error) {
257
+ console.error(`[OAuth] Error in callback: \${error}`);
258
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
259
+ res.end(getErrorHtml(error));
260
+ server.close();
261
+ rejectPromise(new Error(`OAuth error: \${error}`));
262
+ return;
263
+ }
264
+
265
+ // Capture code but don't close until we show a success page
266
+ if (code) {
267
+ console.log('[OAuth] Got authorization code');
268
+
269
+ // Success!
270
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
271
+ res.end(getSuccessHtml('Authentication Successful! You can close this window.'));
272
+
273
+ // Delay closing slightly so the browser can finish loading the page
274
+ setTimeout(() => {
275
+ server.close();
276
+ clearTimeout(timeoutId);
277
+ resolvePromise(code);
278
+ }, 1000);
279
+ return;
280
+ }
281
+
282
+ // Handle /success redirect that might happen after or instead of callback
283
+ if (url.pathname === '/success' || idToken) {
284
+ console.log('[OAuth] At success page');
285
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
286
+ res.end(getSuccessHtml('Login Successful!'));
287
+
288
+ // If we don't have a code yet, we can't resolve.
289
+ // But if this is a follow-up redirect, we might already have resolved.
290
+ if (idToken && !code) {
291
+ console.log('[OAuth] Got id_token in success page, but no code yet.');
292
+ }
293
+ return;
294
+ }
295
+
296
+ res.writeHead(400);
297
+ res.end('Waiting for authorization code...');
298
+ });
299
+
300
+ // Success case is handled via global getSuccessHtml now
301
+
302
+ server.on('error', (err) => {
303
+ clearTimeout(timeoutId);
304
+ rejectPromise(new Error(`Failed to start callback server: ${err.message}`));
305
+ });
306
+
307
+ server.listen(port, () => {
308
+ console.log(`[OAuth] Callback server listening on http://localhost:${port}`);
309
+ });
310
+
311
+ timeoutId = setTimeout(() => {
312
+ server.close();
313
+ rejectPromise(new Error('OAuth callback timeout'));
314
+ }, timeoutMs);
315
+ });
316
+
317
+ return { promise, server };
318
+ }
319
+
320
+ /**
321
+ * Exchange authorization code for tokens
322
+ * @param {string} code - Authorization code
323
+ * @param {string} verifier - PKCE code verifier
324
+ * @param {number} port - Callback port used
325
+ * @returns {Promise<{accessToken: string, refreshToken: string, idToken: string, expiresIn: number}>}
326
+ */
327
+ async function exchangeCodeForTokens(code, verifier, port) {
328
+ const redirectUri = `http://localhost:${port}${OAUTH_CONFIG.callbackPath}`;
329
+
330
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
331
+ method: 'POST',
332
+ headers: {
333
+ 'Content-Type': 'application/x-www-form-urlencoded'
334
+ },
335
+ body: new URLSearchParams({
336
+ grant_type: 'authorization_code',
337
+ code: code,
338
+ redirect_uri: redirectUri,
339
+ client_id: OAUTH_CONFIG.clientId,
340
+ code_verifier: verifier
341
+ })
342
+ });
343
+
344
+ if (!response.ok) {
345
+ const error = await response.text();
346
+ throw new Error(`Token exchange failed: ${response.status} - ${error}`);
347
+ }
348
+
349
+ const tokens = await response.json();
350
+
351
+ if (!tokens.access_token) {
352
+ throw new Error('No access token in response');
353
+ }
354
+
355
+ return {
356
+ accessToken: tokens.access_token,
357
+ refreshToken: tokens.refresh_token,
358
+ idToken: tokens.id_token,
359
+ expiresIn: tokens.expires_in
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Refresh access token using refresh token
365
+ * @param {string} refreshToken - OAuth refresh token
366
+ * @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>}
367
+ */
368
+ async function refreshAccessToken(refreshToken) {
369
+ const response = await fetch(OAUTH_CONFIG.tokenUrl, {
370
+ method: 'POST',
371
+ headers: {
372
+ 'Content-Type': 'application/x-www-form-urlencoded'
373
+ },
374
+ body: new URLSearchParams({
375
+ grant_type: 'refresh_token',
376
+ refresh_token: refreshToken,
377
+ client_id: OAUTH_CONFIG.clientId
378
+ })
379
+ });
380
+
381
+ if (!response.ok) {
382
+ const error = await response.text();
383
+ throw new Error(`Token refresh failed: ${response.status} - ${error}`);
384
+ }
385
+
386
+ const tokens = await response.json();
387
+
388
+ return {
389
+ accessToken: tokens.access_token,
390
+ refreshToken: tokens.refresh_token || refreshToken,
391
+ idToken: tokens.id_token,
392
+ expiresIn: tokens.expires_in
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Open URL in default browser
398
+ * @param {string} url - URL to open
399
+ */
400
+ async function openBrowser(url) {
401
+ const platform = process.platform;
402
+
403
+ try {
404
+ if (platform === 'darwin') {
405
+ await execAsync(`open "${url}"`);
406
+ } else if (platform === 'win32') {
407
+ await execAsync(`start "" "${url}"`);
408
+ } else {
409
+ await execAsync(`xdg-open "${url}"`);
410
+ }
411
+ } catch (e) {
412
+ console.log(`[OAuth] Could not open browser automatically. Please visit:\n${url}`);
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Complete OAuth flow - returns full account info
418
+ * @param {number} [customPort] - Optional custom port for callback
419
+ * @returns {Promise<{email: string, accountId: string, planType: string, accessToken: string, refreshToken: string}>}
420
+ */
421
+ async function performOAuthFlow(customPort) {
422
+ const port = customPort || OAUTH_CONFIG.callbackPort;
423
+ const { verifier } = generatePKCE();
424
+ const state = generateState();
425
+
426
+ // Get authorization URL
427
+ const authUrl = getAuthorizationUrl(verifier, state, port);
428
+
429
+ // Start callback server
430
+ const { promise: callbackPromise, server } = startCallbackServer(port, state);
431
+
432
+ console.log(`\n[OAuth] Starting authentication flow...`);
433
+ console.log(`[OAuth] Callback URL: http://localhost:${port}${OAUTH_CONFIG.callbackPath}`);
434
+
435
+ // Open browser
436
+ await openBrowser(authUrl);
437
+
438
+ console.log(`\n[OAuth] Waiting for authentication...`);
439
+ console.log(`[OAuth] If browser didn't open, visit:\n${authUrl}\n`);
440
+
441
+ // Wait for callback
442
+ const code = await callbackPromise;
443
+ console.log(`[OAuth] Received authorization code`);
444
+
445
+ // Exchange code for tokens
446
+ console.log(`[OAuth] Exchanging code for tokens...`);
447
+ const tokens = await exchangeCodeForTokens(code, verifier, port);
448
+ console.log(`[OAuth] Token exchange successful`);
449
+
450
+ // Extract account info from access token
451
+ const accountInfo = extractAccountInfo(tokens.accessToken);
452
+
453
+ return {
454
+ email: accountInfo?.email || 'unknown',
455
+ accountId: accountInfo?.accountId,
456
+ planType: accountInfo?.planType || 'free',
457
+ accessToken: tokens.accessToken,
458
+ refreshToken: tokens.refreshToken,
459
+ idToken: tokens.idToken,
460
+ expiresAt: accountInfo?.expiresAt || (Date.now() + tokens.expiresIn * 1000)
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Handle OAuth callback from web flow
466
+ * @param {string} code - Authorization code
467
+ * @param {string} state - OAuth state
468
+ * @returns {Promise<{email: string, accountId: string, planType: string, accessToken: string, refreshToken: string}>}
469
+ */
470
+ async function handleOAuthCallback(code, state) {
471
+ const pkceData = getPKCEData(state);
472
+ if (!pkceData) {
473
+ throw new Error('Invalid or expired OAuth state');
474
+ }
475
+
476
+ const tokens = await exchangeCodeForTokens(code, pkceData.verifier, pkceData.port);
477
+ const accountInfo = extractAccountInfo(tokens.accessToken);
478
+
479
+ // Clean up
480
+ pkceStore.delete(state);
481
+
482
+ return {
483
+ email: accountInfo?.email || 'unknown',
484
+ accountId: accountInfo?.accountId,
485
+ planType: accountInfo?.planType || 'free',
486
+ accessToken: tokens.accessToken,
487
+ refreshToken: tokens.refreshToken,
488
+ idToken: tokens.idToken,
489
+ expiresAt: accountInfo?.expiresAt || (Date.now() + tokens.expiresIn * 1000)
490
+ };
491
+ }
492
+
493
+ export function extractCodeFromInput(input) {
494
+ if (!input || typeof input !== 'string') {
495
+ throw new Error('No input provided');
496
+ }
497
+
498
+ const trimmed = input.trim();
499
+
500
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
501
+ try {
502
+ const url = new URL(trimmed);
503
+ const code = url.searchParams.get('code');
504
+ const state = url.searchParams.get('state');
505
+ const error = url.searchParams.get('error');
506
+
507
+ if (error) {
508
+ throw new Error(`OAuth error: ${error}`);
509
+ }
510
+
511
+ if (!code) {
512
+ throw new Error('No authorization code found in URL');
513
+ }
514
+
515
+ return { code, state };
516
+ } catch (e) {
517
+ if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) {
518
+ throw e;
519
+ }
520
+ throw new Error('Invalid URL format');
521
+ }
522
+ }
523
+
524
+ if (trimmed.length < 10) {
525
+ throw new Error('Input is too short to be a valid authorization code');
526
+ }
527
+
528
+ return { code: trimmed, state: null };
529
+ }
530
+
531
+ export {
532
+ OAUTH_CONFIG,
533
+ generatePKCE,
534
+ generateState,
535
+ decodeJWT,
536
+ extractAccountInfo,
537
+ getAuthorizationUrl,
538
+ getLogoutThenAuthUrl,
539
+ startCallbackServer,
540
+ exchangeCodeForTokens,
541
+ refreshAccessToken,
542
+ openBrowser,
543
+ performOAuthFlow,
544
+ handleOAuthCallback,
545
+ getPKCEData
546
+ };
547
+
548
+ export default {
549
+ performOAuthFlow,
550
+ handleOAuthCallback,
551
+ refreshAccessToken,
552
+ extractAccountInfo,
553
+ extractCodeFromInput
554
+ };