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/LICENSE +21 -0
- package/README.md +206 -0
- package/docs/ACCOUNTS.md +202 -0
- package/docs/API.md +274 -0
- package/docs/ARCHITECTURE.md +133 -0
- package/docs/CLAUDE_INTEGRATION.md +163 -0
- package/docs/OAUTH.md +201 -0
- package/docs/OPENCLAW.md +338 -0
- package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
- package/package.json +44 -0
- package/public/css/style.css +791 -0
- package/public/index.html +783 -0
- package/public/js/app.js +511 -0
- package/src/account-manager.js +483 -0
- package/src/claude-config.js +143 -0
- package/src/cli/accounts.js +413 -0
- package/src/cli/index.js +66 -0
- package/src/direct-api.js +123 -0
- package/src/format-converter.js +331 -0
- package/src/index.js +41 -0
- package/src/kilo-api.js +68 -0
- package/src/kilo-format-converter.js +270 -0
- package/src/kilo-streamer.js +198 -0
- package/src/model-api.js +189 -0
- package/src/oauth.js +554 -0
- package/src/response-streamer.js +329 -0
- package/src/routes/api-routes.js +1035 -0
- package/src/server-settings.js +48 -0
- package/src/server.js +30 -0
- package/src/utils/logger.js +156 -0
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
|
+
};
|