@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.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/cli.js +118 -0
- package/docs/ACCOUNTS.md +202 -0
- package/docs/API.md +289 -0
- package/docs/ARCHITECTURE.md +129 -0
- package/docs/CLAUDE_INTEGRATION.md +163 -0
- package/docs/OAUTH.md +85 -0
- package/docs/OPENCLAW.md +34 -0
- package/docs/legal.md +11 -0
- package/images/dashboard-screenshot.png +0 -0
- package/images/demo-screenshot.png +0 -0
- package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
- package/package.json +61 -0
- package/public/css/style.css +1502 -0
- package/public/index.html +827 -0
- package/public/js/app.js +601 -0
- package/src/account-manager.js +528 -0
- package/src/account-rotation/index.js +93 -0
- package/src/account-rotation/rate-limits.js +293 -0
- package/src/account-rotation/strategies/base-strategy.js +48 -0
- package/src/account-rotation/strategies/index.js +31 -0
- package/src/account-rotation/strategies/round-robin-strategy.js +42 -0
- package/src/account-rotation/strategies/sticky-strategy.js +97 -0
- package/src/claude-config.js +153 -0
- package/src/cli/accounts.js +557 -0
- package/src/direct-api.js +164 -0
- package/src/format-converter.js +420 -0
- package/src/index.js +46 -0
- package/src/kilo-api.js +68 -0
- package/src/kilo-format-converter.js +285 -0
- package/src/kilo-models.js +103 -0
- package/src/kilo-streamer.js +243 -0
- package/src/middleware/credentials.js +116 -0
- package/src/middleware/sse.js +96 -0
- package/src/model-api.js +189 -0
- package/src/model-mapper.js +157 -0
- package/src/oauth.js +666 -0
- package/src/response-streamer.js +409 -0
- package/src/routes/accounts-route.js +332 -0
- package/src/routes/api-routes.js +98 -0
- package/src/routes/chat-route.js +229 -0
- package/src/routes/claude-config-route.js +121 -0
- package/src/routes/logs-route.js +43 -0
- package/src/routes/messages-route.js +203 -0
- package/src/routes/models-route.js +119 -0
- package/src/routes/settings-route.js +143 -0
- package/src/security.js +142 -0
- package/src/server-settings.js +56 -0
- package/src/server.js +58 -0
- package/src/signature-cache.js +106 -0
- package/src/thinking-utils.js +312 -0
- 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, '<')
|
|
131
|
+
.replace(/>/g, '>')
|
|
132
|
+
.replace(/"/g, '"')
|
|
133
|
+
.replace(/'/g, ''');
|
|
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
|
+
};
|