@pure01fx/dsh-openai-codex-auth 0.5.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/lib/index.js ADDED
@@ -0,0 +1,1258 @@
1
+ /** Native OpenAI Codex OAuth login for DeepSeek Harness. */
2
+ import { Service } from '@deepseek-ai/cordis';
3
+ import z from '@deepseek-ai/schemastery';
4
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
5
+ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
6
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
7
+ import { createServer } from 'node:http';
8
+ import { createHash, randomBytes } from 'node:crypto';
9
+ import { readFile, unlink } from 'node:fs/promises';
10
+ import { join, resolve } from 'node:path';
11
+ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
12
+ const AUTH_BASE_URL = 'https://auth.openai.com';
13
+ const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`;
14
+ const TOKEN_URL = `${AUTH_BASE_URL}/oauth/token`;
15
+ const DEVICE_USER_CODE_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/usercode`;
16
+ const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`;
17
+ const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`;
18
+ const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`;
19
+ const BROWSER_REDIRECT_URI = 'http://localhost:1455/auth/callback';
20
+ const BROWSER_CALLBACK_HOSTS = ['127.0.0.1', '::1'];
21
+ const BROWSER_CALLBACK_PORT = 1455;
22
+ const DEVICE_CODE_TIMEOUT_MS = 15 * 60_000;
23
+ const BROWSER_LOGIN_TIMEOUT_MS = 10 * 60_000;
24
+ const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
25
+ const MIN_DEVICE_INTERVAL_MS = 1_000;
26
+ const SLOW_DOWN_INCREMENT_MS = 5_000;
27
+ const DEFAULT_FILENAME = 'openai-codex-auth.json';
28
+ const TOKEN_REF = credentialRef('DSH_OPENAI_CODEX_TOKEN');
29
+ const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
30
+ const MAX_ERROR_BODY_LENGTH = 1_024;
31
+ const MAX_REQUEST_BODY_LENGTH = 8_192;
32
+ class DeviceCodeUnavailableError extends Error {
33
+ code = 'device_code_unavailable';
34
+ }
35
+ class LoginConflictError extends Error {
36
+ }
37
+ class CredentialNotWritableError extends Error {
38
+ }
39
+ function base64Url(value) {
40
+ return value.toString('base64url');
41
+ }
42
+ function messageOf(error) {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+ function accountId(access) {
46
+ const parts = access.split('.');
47
+ if (parts.length !== 3)
48
+ throw new Error('OpenAI returned an invalid access token');
49
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
50
+ const id = payload['https://api.openai.com/auth']?.chatgpt_account_id;
51
+ if (typeof id !== 'string' || id.length === 0)
52
+ throw new Error('OpenAI token has no ChatGPT account id');
53
+ return id;
54
+ }
55
+ function parseCredential(text, filename) {
56
+ const value = JSON.parse(text);
57
+ const credential = value.credential;
58
+ if (value.version !== 1 || credential === undefined
59
+ || typeof credential.access !== 'string' || typeof credential.refresh !== 'string'
60
+ || typeof credential.expires !== 'number' || typeof credential.accountId !== 'string') {
61
+ throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
62
+ }
63
+ return credential;
64
+ }
65
+ async function readCredential(filename) {
66
+ try {
67
+ return parseCredential(await readFile(filename, 'utf8'), filename);
68
+ }
69
+ catch (error) {
70
+ if (error.code === 'ENOENT')
71
+ return undefined;
72
+ throw error;
73
+ }
74
+ }
75
+ async function responseText(response) {
76
+ return (await response.text().catch(() => '')).slice(0, MAX_ERROR_BODY_LENGTH);
77
+ }
78
+ async function readJsonBody(request) {
79
+ const chunks = [];
80
+ let length = 0;
81
+ for await (const chunk of request) {
82
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
83
+ length += buffer.length;
84
+ if (length > MAX_REQUEST_BODY_LENGTH)
85
+ throw new Error('Request body is too large');
86
+ chunks.push(buffer);
87
+ }
88
+ if (length === 0)
89
+ throw new Error('Request body is required');
90
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
91
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
92
+ throw new Error('Request body must be a JSON object');
93
+ return value;
94
+ }
95
+ function parseManualAuthorizationInput(input) {
96
+ const text = input.trim();
97
+ if (text.length === 0)
98
+ throw new Error('Paste the authorization code or complete callback URL');
99
+ if (text.length > 6_000)
100
+ throw new Error('Authorization input is too long');
101
+ const readParams = (params) => {
102
+ const code = params.get('code')?.trim();
103
+ if (!code)
104
+ return undefined;
105
+ const state = params.get('state')?.trim();
106
+ return { code, ...state ? { state } : {} };
107
+ };
108
+ try {
109
+ const url = new URL(text);
110
+ const parsed = readParams(url.searchParams);
111
+ if (parsed !== undefined)
112
+ return parsed;
113
+ }
114
+ catch { }
115
+ if (text.includes('code=')) {
116
+ const parsed = readParams(new URLSearchParams(text.replace(/^[?#]/, '')));
117
+ if (parsed !== undefined)
118
+ return parsed;
119
+ }
120
+ return { code: text };
121
+ }
122
+ async function tokenRequest(body, signal) {
123
+ let response;
124
+ try {
125
+ response = await fetch(TOKEN_URL, {
126
+ method: 'POST',
127
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
128
+ body,
129
+ ...signal === undefined ? {} : { signal },
130
+ });
131
+ }
132
+ catch (error) {
133
+ if (signal?.aborted)
134
+ throw new Error('OpenAI login cancelled');
135
+ throw error;
136
+ }
137
+ if (!response.ok) {
138
+ const text = await responseText(response);
139
+ throw new Error(`OpenAI token request failed (HTTP ${response.status})${text ? `: ${text}` : ''}`);
140
+ }
141
+ const value = await response.json();
142
+ if (value === null || typeof value.access_token !== 'string' || typeof value.refresh_token !== 'string'
143
+ || typeof value.expires_in !== 'number')
144
+ throw new Error('OpenAI token response is incomplete');
145
+ return {
146
+ access: value.access_token,
147
+ refresh: value.refresh_token,
148
+ expires: Date.now() + value.expires_in * 1000,
149
+ accountId: accountId(value.access_token),
150
+ };
151
+ }
152
+ function optionalNumber(value) {
153
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
154
+ }
155
+ function usageWindow(value) {
156
+ if (value === null || typeof value !== 'object')
157
+ return undefined;
158
+ const row = value;
159
+ const usedPercent = optionalNumber(row.used_percent ?? row.usedPercent);
160
+ if (usedPercent === undefined)
161
+ return undefined;
162
+ const windowSeconds = optionalNumber(row.limit_window_seconds ?? row.windowDurationSecs);
163
+ const resetAt = optionalNumber(row.reset_at ?? row.resetsAt);
164
+ return {
165
+ usedPercent: Math.max(0, Math.min(100, usedPercent)),
166
+ ...windowSeconds === undefined ? {} : { windowSeconds },
167
+ ...resetAt === undefined ? {} : { resetAt },
168
+ };
169
+ }
170
+ /** Reduce the OpenAI response to the stable fields displayed by the Web card. */
171
+ export function normalizeUsage(value) {
172
+ const root = value !== null && typeof value === 'object' ? value : {};
173
+ const limits = root.rate_limit !== null && typeof root.rate_limit === 'object'
174
+ ? root.rate_limit
175
+ : root.rateLimits !== null && typeof root.rateLimits === 'object'
176
+ ? root.rateLimits
177
+ : {};
178
+ const credits = root.rate_limit_reset_credits !== null && typeof root.rate_limit_reset_credits === 'object'
179
+ ? root.rate_limit_reset_credits
180
+ : undefined;
181
+ const planType = typeof root.plan_type === 'string'
182
+ ? root.plan_type
183
+ : typeof root.planType === 'string' ? root.planType : undefined;
184
+ const primary = usageWindow(limits.primary_window ?? limits.primary);
185
+ const secondary = usageWindow(limits.secondary_window ?? limits.secondary);
186
+ const limitReached = typeof limits.limit_reached === 'boolean'
187
+ ? limits.limit_reached
188
+ : typeof limits.limitReached === 'boolean' ? limits.limitReached : undefined;
189
+ const resetCredits = optionalNumber(credits?.available_count ?? credits?.availableCount);
190
+ return {
191
+ ...planType === undefined ? {} : { planType },
192
+ ...primary === undefined ? {} : { primary },
193
+ ...secondary === undefined ? {} : { secondary },
194
+ ...limitReached === undefined ? {} : { limitReached },
195
+ ...resetCredits === undefined ? {} : { resetCredits },
196
+ fetchedAt: Date.now(),
197
+ };
198
+ }
199
+ function header(request, name) {
200
+ const value = request.headers[name];
201
+ return typeof value === 'string' ? value : undefined;
202
+ }
203
+ function parseAuthority(authority) {
204
+ if (authority === undefined)
205
+ return undefined;
206
+ try {
207
+ const url = new URL(`http://${authority}`);
208
+ return canonicalAuthority(authority, url) === authority.toLowerCase() ? url : undefined;
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ }
214
+ function isLoopbackHostname(hostname) {
215
+ if (hostname === 'localhost' || hostname === '[::1]')
216
+ return true;
217
+ const parts = hostname.split('.');
218
+ return parts.length === 4 && parts[0] === '127'
219
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255);
220
+ }
221
+ function canonicalAuthority(entry, entryUrl) {
222
+ const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port;
223
+ return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
224
+ }
225
+ function isTrustedAuthority(hostUrl, trustedHosts) {
226
+ return trustedHosts.some((entry) => {
227
+ const entryUrl = parseAuthority(entry);
228
+ if (entryUrl === undefined)
229
+ return false;
230
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
231
+ ? entryUrl.hostname === hostUrl.hostname
232
+ : entryUrl.host === hostUrl.host;
233
+ });
234
+ }
235
+ function isTrustedHost(authority, trustedHosts) {
236
+ const hostUrl = parseAuthority(authority);
237
+ return hostUrl !== undefined
238
+ && (isLoopbackHostname(hostUrl.hostname) || isTrustedAuthority(hostUrl, trustedHosts));
239
+ }
240
+ function isTrustedBrowserRequest(request, trustedHosts) {
241
+ const host = header(request, 'host');
242
+ const hostUrl = parseAuthority(host);
243
+ if (hostUrl === undefined || !isTrustedHost(host, trustedHosts))
244
+ return false;
245
+ const fetchSite = header(request, 'sec-fetch-site');
246
+ if (fetchSite !== undefined && fetchSite !== 'same-origin' && fetchSite !== 'none')
247
+ return false;
248
+ const origin = header(request, 'origin');
249
+ if (origin === undefined)
250
+ return true;
251
+ try {
252
+ return new URL(origin).host === hostUrl.host;
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ }
258
+ function browserCallbackUrl(authority, _fallbackPort) {
259
+ if (authority === undefined)
260
+ return undefined;
261
+ const url = parseAuthority(authority);
262
+ if (url === undefined || (url.hostname !== 'localhost' && url.hostname !== '127.0.0.1'))
263
+ return undefined;
264
+ return BROWSER_REDIRECT_URI;
265
+ }
266
+ function callbackHostMatches(authority, redirectUri) {
267
+ const requestHost = parseAuthority(authority)?.host;
268
+ return requestHost !== undefined && requestHost === new URL(redirectUri).host;
269
+ }
270
+ function isAllowedBrowserProbeOrigin(origin) {
271
+ if (origin === undefined)
272
+ return false;
273
+ try {
274
+ const url = new URL(origin);
275
+ return url.protocol === 'http:' && (url.hostname === '127.0.0.1' || url.hostname === 'localhost');
276
+ }
277
+ catch {
278
+ return false;
279
+ }
280
+ }
281
+ function closeBrowserCallbackServer(server, force = false) {
282
+ return new Promise((resolveClose) => {
283
+ let settled = false;
284
+ const settle = () => {
285
+ if (settled)
286
+ return;
287
+ settled = true;
288
+ server.removeListener('close', settle);
289
+ resolveClose();
290
+ };
291
+ server.once('close', settle);
292
+ try {
293
+ server.close(() => { settle(); });
294
+ if (force)
295
+ server.closeAllConnections();
296
+ }
297
+ catch (error) {
298
+ server.removeListener('close', settle);
299
+ if (error.code !== 'ERR_SERVER_NOT_RUNNING') {
300
+ server.closeAllConnections();
301
+ }
302
+ settle();
303
+ }
304
+ });
305
+ }
306
+ async function closeBrowserCallbackServers(servers, force = false) {
307
+ await Promise.all(servers.map(server => closeBrowserCallbackServer(server, force)));
308
+ }
309
+ function listenBrowserCallbackServer(server, host, signal) {
310
+ return new Promise((resolveListen, rejectListen) => {
311
+ let settled = false;
312
+ let cancelled = false;
313
+ const settle = (error) => {
314
+ if (settled)
315
+ return;
316
+ settled = true;
317
+ server.removeListener('error', onError);
318
+ signal.removeEventListener('abort', onAbort);
319
+ if (error === undefined)
320
+ resolveListen();
321
+ else
322
+ rejectListen(error);
323
+ };
324
+ const onError = (error) => {
325
+ if (cancelled || signal.aborted) {
326
+ settle(new Error('OpenAI login cancelled'));
327
+ return;
328
+ }
329
+ const code = error.code;
330
+ const detail = code === 'EADDRINUSE'
331
+ ? 'Port 1455 is already in use. Close the other Codex login listener and try again.'
332
+ : `Unable to listen on ${host}:1455: ${error.message}`;
333
+ const failure = new Error(detail);
334
+ failure.code = code;
335
+ settle(failure);
336
+ };
337
+ const onAbort = () => {
338
+ cancelled = true;
339
+ if (server.listening) {
340
+ void closeBrowserCallbackServer(server).finally(() => { settle(new Error('OpenAI login cancelled')); });
341
+ }
342
+ };
343
+ if (signal.aborted) {
344
+ settle(new Error('OpenAI login cancelled'));
345
+ return;
346
+ }
347
+ server.once('error', onError);
348
+ signal.addEventListener('abort', onAbort, { once: true });
349
+ server.listen({ port: BROWSER_CALLBACK_PORT, host, ipv6Only: host === '::1' }, () => {
350
+ if (cancelled || signal.aborted) {
351
+ void closeBrowserCallbackServer(server).finally(() => { settle(new Error('OpenAI login cancelled')); });
352
+ }
353
+ else {
354
+ settle();
355
+ }
356
+ });
357
+ });
358
+ }
359
+ async function startDeviceAuthorization(signal) {
360
+ let response;
361
+ try {
362
+ response = await fetch(DEVICE_USER_CODE_URL, {
363
+ method: 'POST',
364
+ headers: { 'content-type': 'application/json' },
365
+ body: JSON.stringify({ client_id: CLIENT_ID }),
366
+ ...signal === undefined ? {} : { signal },
367
+ });
368
+ }
369
+ catch (error) {
370
+ if (signal?.aborted)
371
+ throw new Error('OpenAI login cancelled');
372
+ throw error;
373
+ }
374
+ if (!response.ok) {
375
+ if (response.status === 404) {
376
+ throw new DeviceCodeUnavailableError('OpenAI device-code login is not enabled for this account or workspace. Use browser login instead.');
377
+ }
378
+ const text = await responseText(response);
379
+ throw new Error(`OpenAI device-code request failed (HTTP ${response.status})${text ? `: ${text}` : ''}`);
380
+ }
381
+ const value = await response.json();
382
+ const rawInterval = value?.interval;
383
+ const interval = typeof rawInterval === 'string' && rawInterval.trim() !== ''
384
+ ? Number(rawInterval.trim())
385
+ : rawInterval;
386
+ if (value === null || typeof value.device_auth_id !== 'string' || value.device_auth_id.length === 0
387
+ || typeof value.user_code !== 'string' || value.user_code.length === 0
388
+ || typeof interval !== 'number' || !Number.isFinite(interval) || interval < 0) {
389
+ throw new Error('OpenAI returned an invalid device-code response');
390
+ }
391
+ return {
392
+ deviceAuthId: value.device_auth_id,
393
+ userCode: value.user_code,
394
+ intervalSeconds: interval,
395
+ expiresAt: Date.now() + DEVICE_CODE_TIMEOUT_MS,
396
+ };
397
+ }
398
+ function errorCodeFromText(text) {
399
+ if (!text)
400
+ return undefined;
401
+ try {
402
+ const value = JSON.parse(text);
403
+ const error = value?.error;
404
+ if (typeof error === 'string')
405
+ return error;
406
+ if (error !== null && typeof error === 'object') {
407
+ const code = error.code;
408
+ return typeof code === 'string' ? code : undefined;
409
+ }
410
+ }
411
+ catch {
412
+ return undefined;
413
+ }
414
+ return undefined;
415
+ }
416
+ async function parseDevicePollResponse(response) {
417
+ if (response.ok) {
418
+ const value = await response.json();
419
+ if (value === null || typeof value.authorization_code !== 'string' || value.authorization_code.length === 0
420
+ || typeof value.code_verifier !== 'string' || value.code_verifier.length === 0) {
421
+ return { status: 'failed', message: 'OpenAI returned an invalid device authorization result' };
422
+ }
423
+ return {
424
+ status: 'complete',
425
+ value: { authorizationCode: value.authorization_code, codeVerifier: value.code_verifier },
426
+ };
427
+ }
428
+ const text = await responseText(response);
429
+ const code = errorCodeFromText(text);
430
+ if (code === 'deviceauth_authorization_pending' || code === 'authorization_pending')
431
+ return { status: 'pending' };
432
+ if (code === 'slow_down')
433
+ return { status: 'slow_down' };
434
+ if (code === 'access_denied' || code === 'expired_token' || code === 'authorization_declined') {
435
+ return { status: 'failed', message: `OpenAI device authorization failed: ${code}` };
436
+ }
437
+ if (response.status === 403 || response.status === 404)
438
+ return { status: 'pending' };
439
+ return {
440
+ status: 'failed',
441
+ message: `OpenAI device authorization failed (HTTP ${response.status})${text ? `: ${text}` : ''}`,
442
+ };
443
+ }
444
+ function abortableSleep(ms, signal) {
445
+ return new Promise((resolveSleep, rejectSleep) => {
446
+ if (signal?.aborted) {
447
+ rejectSleep(new Error('OpenAI login cancelled'));
448
+ return;
449
+ }
450
+ const onAbort = () => {
451
+ clearTimeout(timer);
452
+ rejectSleep(new Error('OpenAI login cancelled'));
453
+ };
454
+ const timer = setTimeout(() => {
455
+ signal?.removeEventListener('abort', onAbort);
456
+ resolveSleep();
457
+ }, ms);
458
+ signal?.addEventListener('abort', onAbort, { once: true });
459
+ });
460
+ }
461
+ async function pollDeviceAuthorization(device, signal) {
462
+ let intervalMs = Math.max(MIN_DEVICE_INTERVAL_MS, Math.floor((device.intervalSeconds ?? DEFAULT_DEVICE_INTERVAL_SECONDS) * 1_000));
463
+ while (Date.now() < device.expiresAt) {
464
+ if (signal?.aborted)
465
+ throw new Error('OpenAI login cancelled');
466
+ let response;
467
+ try {
468
+ response = await fetch(DEVICE_TOKEN_URL, {
469
+ method: 'POST',
470
+ headers: { 'content-type': 'application/json' },
471
+ body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }),
472
+ ...signal === undefined ? {} : { signal },
473
+ });
474
+ }
475
+ catch (error) {
476
+ if (signal?.aborted)
477
+ throw new Error('OpenAI login cancelled');
478
+ throw error;
479
+ }
480
+ const result = await parseDevicePollResponse(response);
481
+ if (result.status === 'complete')
482
+ return result.value;
483
+ if (result.status === 'failed')
484
+ throw new Error(result.message ?? 'OpenAI device authorization failed');
485
+ if (result.status === 'slow_down')
486
+ intervalMs += SLOW_DOWN_INCREMENT_MS;
487
+ const remaining = device.expiresAt - Date.now();
488
+ if (remaining <= 0)
489
+ break;
490
+ await abortableSleep(Math.min(intervalMs, remaining), signal);
491
+ }
492
+ throw new Error('OpenAI device-code login timed out');
493
+ }
494
+ export const internals = {
495
+ parseAuthority,
496
+ isLoopbackHostname,
497
+ isTrustedHost,
498
+ isTrustedBrowserRequest,
499
+ browserCallbackUrl,
500
+ callbackHostMatches,
501
+ startDeviceAuthorization,
502
+ parseDevicePollResponse,
503
+ pollDeviceAuthorization,
504
+ };
505
+ /** DSH service providing device-code/browser login, logout, and automatically refreshed bearer tokens. */
506
+ export class OpenAICodexAuth extends Service {
507
+ static Config = z.object({ path: z.string(), dshHome: z.string() });
508
+ static inject = ['credentials', 'webServer', 'webRuntime'];
509
+ filename;
510
+ csrf = base64Url(randomBytes(24));
511
+ usageCache;
512
+ usageError;
513
+ usageRefresh;
514
+ usageGeneration = 0;
515
+ codexTurns = new Map();
516
+ loginFlow;
517
+ startingDevice;
518
+ startingBrowser;
519
+ lastLoginError;
520
+ constructor(ctx, config) {
521
+ super(ctx, 'openaiCodexAuth');
522
+ this.filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), DEFAULT_FILENAME));
523
+ ctx.effect(async () => {
524
+ try {
525
+ const token = await this.bearerToken();
526
+ if (token !== undefined)
527
+ await this.storeCredentialToken(token);
528
+ }
529
+ catch (error) {
530
+ this.lastLoginError = messageOf(error);
531
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
532
+ }
533
+ return () => { };
534
+ }, 'openai-codex-auth: bootstrap credential');
535
+ ctx.on('agent/request', async ({ agent, turn }, next) => {
536
+ const request = await next();
537
+ if (request.provider === 'openai-codex')
538
+ this.markCodexTurn(String(agent.id), turn);
539
+ return request;
540
+ }, { global: true, prepend: true });
541
+ ctx.on('session/event', (session, event) => {
542
+ if (event.type !== 'turn/end' || !this.consumeCodexTurn(String(session.id), event.data.turn))
543
+ return;
544
+ void this.refreshUsage(true);
545
+ }, { global: true });
546
+ ctx.on('agent/disposed', ({ agent }) => {
547
+ this.codexTurns.delete(String(agent.id));
548
+ }, { global: true });
549
+ ctx.effect(() => {
550
+ const timer = setInterval(() => {
551
+ void this.bearerToken()
552
+ .then(token => token === undefined ? undefined : this.storeCredentialToken(token))
553
+ .catch((error) => { this.usageError = messageOf(error); });
554
+ }, 60_000);
555
+ return () => { clearInterval(timer); };
556
+ }, 'openai-codex-auth: refresh timer');
557
+ ctx.effect(() => {
558
+ const disposers = [];
559
+ try {
560
+ const register = (path, handler) => {
561
+ disposers.push(ctx.webServer.register({ kind: 'exact', path, handler }));
562
+ };
563
+ register('/openai-codex/status', (req, res) => this.handleStatus(req, res));
564
+ register('/openai-codex/device/start', (req, res) => this.handleDeviceStart(req, res));
565
+ register('/openai-codex/browser/start', (req, res) => this.handleBrowserStart(req, res));
566
+ register('/openai-codex/browser/prepare', (req, res) => this.handleBrowserPrepare(req, res));
567
+ register('/openai-codex/browser/complete', (req, res) => this.handleBrowserComplete(req, res));
568
+ register('/openai-codex/cancel', (req, res) => this.handleCancel(req, res));
569
+ register('/openai-codex/logout', (req, res) => this.handleLogout(req, res));
570
+ }
571
+ catch (error) {
572
+ for (const dispose of disposers.reverse())
573
+ dispose();
574
+ throw error;
575
+ }
576
+ return async () => {
577
+ const startingDevice = this.startingDevice;
578
+ startingDevice?.abort.abort();
579
+ if (startingDevice !== undefined)
580
+ await startingDevice.promise.catch(() => undefined);
581
+ const startingBrowser = this.startingBrowser;
582
+ startingBrowser?.abort.abort();
583
+ if (startingBrowser !== undefined) {
584
+ await startingBrowser.promise.catch(() => undefined);
585
+ await closeBrowserCallbackServers(startingBrowser.servers, true);
586
+ }
587
+ const flow = this.loginFlow;
588
+ flow?.abort.abort();
589
+ if (flow !== undefined) {
590
+ await flow.completion;
591
+ if (flow.kind === 'browser')
592
+ await closeBrowserCallbackServers(flow.servers, true);
593
+ }
594
+ for (const dispose of disposers.reverse())
595
+ dispose();
596
+ };
597
+ }, 'openai-codex-auth: Web routes');
598
+ }
599
+ markCodexTurn(sessionId, turn) {
600
+ const turns = this.codexTurns.get(sessionId) ?? new Set();
601
+ turns.add(turn);
602
+ this.codexTurns.set(sessionId, turns);
603
+ }
604
+ consumeCodexTurn(sessionId, turn) {
605
+ const turns = this.codexTurns.get(sessionId);
606
+ if (turns === undefined || !turns.delete(turn))
607
+ return false;
608
+ if (turns.size === 0)
609
+ this.codexTurns.delete(sessionId);
610
+ return true;
611
+ }
612
+ async performUsageRefresh(generation) {
613
+ try {
614
+ const credential = await readCredential(this.filename);
615
+ if (credential === undefined) {
616
+ if (this.usageGeneration === generation) {
617
+ this.usageCache = undefined;
618
+ this.usageError = undefined;
619
+ }
620
+ return;
621
+ }
622
+ const usage = await this.fetchUsage(credential);
623
+ if (this.usageGeneration === generation) {
624
+ this.usageCache = usage;
625
+ this.usageError = undefined;
626
+ }
627
+ }
628
+ catch (error) {
629
+ if (this.usageGeneration === generation)
630
+ this.usageError = messageOf(error);
631
+ }
632
+ }
633
+ refreshUsage(queueIfActive = false) {
634
+ const active = this.usageRefresh;
635
+ if (active !== undefined) {
636
+ if (queueIfActive)
637
+ active.queued = true;
638
+ return active.promise;
639
+ }
640
+ const cycle = {
641
+ promise: Promise.resolve(),
642
+ queued: false,
643
+ };
644
+ this.usageRefresh = cycle;
645
+ cycle.promise = (async () => {
646
+ try {
647
+ do {
648
+ cycle.queued = false;
649
+ await this.performUsageRefresh(this.usageGeneration);
650
+ } while (cycle.queued);
651
+ }
652
+ finally {
653
+ if (this.usageRefresh === cycle)
654
+ this.usageRefresh = undefined;
655
+ }
656
+ })();
657
+ return cycle.promise;
658
+ }
659
+ async assertCredentialWritable() {
660
+ const info = await this.ctx.credentials.describe(TOKEN_REF);
661
+ if (!info.writable) {
662
+ throw new CredentialNotWritableError(`DSH_OPENAI_CODEX_TOKEN is supplied by read-only source ${info.source ?? 'unknown'}; remove that override before logging in`);
663
+ }
664
+ }
665
+ async storeCredentialToken(token) {
666
+ await this.assertCredentialWritable();
667
+ await this.ctx.credentials.set(TOKEN_REF, token);
668
+ }
669
+ /** Return a valid bearer token, refreshing and persisting it when near expiry. */
670
+ async bearerToken(signal) {
671
+ return withFileLock(this.filename, async () => {
672
+ const current = await readCredential(this.filename);
673
+ if (current === undefined)
674
+ return undefined;
675
+ if (current.expires > Date.now() + 60_000)
676
+ return current.access;
677
+ await this.assertCredentialWritable();
678
+ const next = await tokenRequest(new URLSearchParams({
679
+ grant_type: 'refresh_token', refresh_token: current.refresh, client_id: CLIENT_ID,
680
+ }), signal);
681
+ if (signal?.aborted)
682
+ throw new Error('OpenAI login cancelled');
683
+ await this.write(next);
684
+ if (signal?.aborted)
685
+ throw new Error('OpenAI login cancelled');
686
+ await this.ctx.credentials.set(TOKEN_REF, next.access);
687
+ return next.access;
688
+ });
689
+ }
690
+ async finishCredential(credential, signal) {
691
+ await this.assertCredentialWritable();
692
+ if (signal.aborted)
693
+ throw new Error('OpenAI login cancelled');
694
+ await withFileLock(this.filename, async () => {
695
+ if (signal.aborted)
696
+ throw new Error('OpenAI login cancelled');
697
+ await this.write(credential);
698
+ });
699
+ this.usageGeneration += 1;
700
+ if (this.usageRefresh !== undefined)
701
+ this.usageRefresh.queued = true;
702
+ this.usageCache = undefined;
703
+ this.usageError = undefined;
704
+ if (signal.aborted)
705
+ throw new Error('OpenAI login cancelled');
706
+ await this.ctx.credentials.set(TOKEN_REF, credential.access);
707
+ this.lastLoginError = undefined;
708
+ }
709
+ async finishAuthorizationCode(code, verifier, redirectUri, signal) {
710
+ const credential = await tokenRequest(new URLSearchParams({
711
+ grant_type: 'authorization_code', client_id: CLIENT_ID, code,
712
+ code_verifier: verifier, redirect_uri: redirectUri,
713
+ }), signal);
714
+ await this.finishCredential(credential, signal);
715
+ }
716
+ settleFlow(flow, work, cleanup) {
717
+ return work
718
+ .then(() => ({ ok: true }))
719
+ .catch((error) => {
720
+ const message = messageOf(error);
721
+ this.lastLoginError = message;
722
+ return { ok: false, error: message };
723
+ })
724
+ .finally(() => {
725
+ cleanup?.();
726
+ if (this.loginFlow === flow)
727
+ this.loginFlow = undefined;
728
+ });
729
+ }
730
+ beginDeviceLogin() {
731
+ if (this.loginFlow?.kind === 'device')
732
+ return Promise.resolve(this.loginFlow);
733
+ if (this.startingDevice !== undefined)
734
+ return this.startingDevice.promise;
735
+ if (this.startingBrowser !== undefined || this.loginFlow !== undefined) {
736
+ return Promise.reject(new LoginConflictError('A browser login is already pending. Cancel it before starting device-code login.'));
737
+ }
738
+ this.lastLoginError = undefined;
739
+ const abort = new AbortController();
740
+ const promise = this.createDeviceLogin(abort);
741
+ const starting = { abort, promise };
742
+ this.startingDevice = starting;
743
+ void promise.then(() => { if (this.startingDevice === starting)
744
+ this.startingDevice = undefined; }, (error) => {
745
+ this.lastLoginError = messageOf(error);
746
+ if (this.startingDevice === starting)
747
+ this.startingDevice = undefined;
748
+ });
749
+ return promise;
750
+ }
751
+ async createDeviceLogin(abort) {
752
+ await this.assertCredentialWritable();
753
+ const device = await startDeviceAuthorization(abort.signal);
754
+ if (abort.signal.aborted)
755
+ throw new Error('OpenAI login cancelled');
756
+ let flow;
757
+ const work = pollDeviceAuthorization(device, abort.signal)
758
+ .then(code => this.finishAuthorizationCode(code.authorizationCode, code.codeVerifier, DEVICE_REDIRECT_URI, abort.signal));
759
+ flow = {
760
+ kind: 'device',
761
+ ...device,
762
+ verificationUri: DEVICE_VERIFICATION_URI,
763
+ abort,
764
+ completion: undefined,
765
+ };
766
+ flow.completion = this.settleFlow(flow, work);
767
+ this.loginFlow = flow;
768
+ return flow;
769
+ }
770
+ beginBrowserLogin(redirectUri) {
771
+ if (this.loginFlow?.kind === 'browser') {
772
+ if (this.loginFlow.redirectUri !== redirectUri) {
773
+ return Promise.reject(new LoginConflictError('A browser login is already pending with another callback. Cancel it before starting a new login.'));
774
+ }
775
+ return Promise.resolve(this.loginFlow);
776
+ }
777
+ if (this.startingBrowser !== undefined)
778
+ return this.startingBrowser.promise;
779
+ if (this.startingDevice !== undefined || this.loginFlow !== undefined) {
780
+ return Promise.reject(new LoginConflictError('A device-code login is already pending. Cancel it before starting browser login.'));
781
+ }
782
+ this.lastLoginError = undefined;
783
+ const abort = new AbortController();
784
+ const probeToken = base64Url(randomBytes(24));
785
+ const probeUrl = `http://127.0.0.1:${BROWSER_CALLBACK_PORT}/openai-codex/probe?token=${encodeURIComponent(probeToken)}`;
786
+ let servers;
787
+ const listener = (req, res) => {
788
+ try {
789
+ const callback = new URL(req.url ?? '/', BROWSER_REDIRECT_URI);
790
+ if (callback.pathname === '/openai-codex/probe') {
791
+ const origin = header(req, 'origin');
792
+ if (callback.searchParams.get('token') !== probeToken || !isAllowedBrowserProbeOrigin(origin)) {
793
+ this.sendJson(res, 403, { ok: false, error: 'Invalid browser callback probe.' });
794
+ return;
795
+ }
796
+ const corsHeaders = {
797
+ 'access-control-allow-origin': origin,
798
+ 'access-control-allow-private-network': 'true',
799
+ vary: 'Origin',
800
+ };
801
+ if (req.method === 'OPTIONS') {
802
+ res.writeHead(204, { ...corsHeaders, 'access-control-allow-methods': 'GET, OPTIONS', 'cache-control': 'no-store' }).end();
803
+ return;
804
+ }
805
+ if (req.method !== 'GET') {
806
+ this.sendJson(res, 405, { ok: false, error: 'GET only' }, { ...corsHeaders, allow: 'GET, OPTIONS' });
807
+ return;
808
+ }
809
+ this.sendJson(res, 200, { ok: true }, corsHeaders);
810
+ return;
811
+ }
812
+ if (callback.pathname !== '/auth/callback') {
813
+ this.sendText(res, 404, 'Not found');
814
+ return;
815
+ }
816
+ void this.handleCallback(req, res).finally(() => {
817
+ const active = this.loginFlow;
818
+ if (active?.kind !== 'browser' || active.servers !== servers) {
819
+ void closeBrowserCallbackServers(servers);
820
+ }
821
+ });
822
+ }
823
+ catch (error) {
824
+ this.sendText(res, 400, `Invalid OpenAI OAuth callback: ${messageOf(error)}`);
825
+ }
826
+ };
827
+ servers = BROWSER_CALLBACK_HOSTS.map(() => createServer(listener));
828
+ const promise = this.createBrowserLogin(redirectUri, probeToken, probeUrl, abort, servers);
829
+ const starting = { abort, servers, promise };
830
+ this.startingBrowser = starting;
831
+ void promise.then(() => { if (this.startingBrowser === starting)
832
+ this.startingBrowser = undefined; }, (error) => {
833
+ void closeBrowserCallbackServers(servers, true);
834
+ this.lastLoginError = messageOf(error);
835
+ if (this.startingBrowser === starting)
836
+ this.startingBrowser = undefined;
837
+ });
838
+ return promise;
839
+ }
840
+ async createBrowserLogin(redirectUri, probeToken, probeUrl, abort, servers) {
841
+ await listenBrowserCallbackServer(servers[0], BROWSER_CALLBACK_HOSTS[0], abort.signal);
842
+ try {
843
+ await listenBrowserCallbackServer(servers[1], BROWSER_CALLBACK_HOSTS[1], abort.signal);
844
+ }
845
+ catch (error) {
846
+ const code = error.code;
847
+ if (code === 'EADDRINUSE' || code === 'EADDRNOTAVAIL' || code === 'EAFNOSUPPORT' || code === 'EPROTONOSUPPORT') {
848
+ servers.splice(1, 1);
849
+ }
850
+ else {
851
+ throw error;
852
+ }
853
+ }
854
+ const verifier = base64Url(randomBytes(32));
855
+ const challenge = base64Url(createHash('sha256').update(verifier).digest());
856
+ const state = randomBytes(16).toString('hex');
857
+ const url = new URL(AUTHORIZE_URL);
858
+ for (const [key, value] of Object.entries({
859
+ response_type: 'code', client_id: CLIENT_ID, redirect_uri: redirectUri,
860
+ scope: 'openid profile email offline_access', code_challenge: challenge,
861
+ code_challenge_method: 'S256', state, id_token_add_organizations: 'true',
862
+ codex_cli_simplified_flow: 'true', originator: 'deepseek-harness',
863
+ }))
864
+ url.searchParams.set(key, value);
865
+ let resolveCode;
866
+ let rejectCode;
867
+ const code = new Promise((resolvePromise, rejectPromise) => {
868
+ resolveCode = resolvePromise;
869
+ rejectCode = rejectPromise;
870
+ });
871
+ const onAbort = () => { rejectCode(new Error('OpenAI login cancelled')); };
872
+ abort.signal.addEventListener('abort', onAbort, { once: true });
873
+ const expiresAt = Date.now() + BROWSER_LOGIN_TIMEOUT_MS;
874
+ const timeout = setTimeout(() => { abort.abort(); }, BROWSER_LOGIN_TIMEOUT_MS);
875
+ let flow;
876
+ flow = {
877
+ kind: 'browser',
878
+ url: url.toString(),
879
+ redirectUri,
880
+ state,
881
+ probeToken,
882
+ probeUrl,
883
+ expiresAt,
884
+ abort,
885
+ servers,
886
+ resolveCode,
887
+ rejectCode,
888
+ timeout,
889
+ completion: undefined,
890
+ };
891
+ const work = code.then(authorizationCode => this.finishAuthorizationCode(authorizationCode, verifier, redirectUri, abort.signal));
892
+ flow.completion = this.settleFlow(flow, work, () => {
893
+ clearTimeout(timeout);
894
+ abort.signal.removeEventListener('abort', onAbort);
895
+ void closeBrowserCallbackServers(servers);
896
+ });
897
+ this.loginFlow = flow;
898
+ return flow;
899
+ }
900
+ async cancelLogin(clearError) {
901
+ const startingDevice = this.startingDevice;
902
+ if (startingDevice !== undefined) {
903
+ startingDevice.abort.abort();
904
+ await startingDevice.promise.catch(() => undefined);
905
+ }
906
+ const startingBrowser = this.startingBrowser;
907
+ if (startingBrowser !== undefined) {
908
+ startingBrowser.abort.abort();
909
+ await startingBrowser.promise.catch(() => undefined);
910
+ await closeBrowserCallbackServers(startingBrowser.servers);
911
+ }
912
+ const flow = this.loginFlow;
913
+ if (flow !== undefined) {
914
+ flow.abort.abort();
915
+ await flow.completion;
916
+ if (flow.kind === 'browser')
917
+ await closeBrowserCallbackServers(flow.servers);
918
+ }
919
+ if (clearError)
920
+ this.lastLoginError = undefined;
921
+ }
922
+ async logout() {
923
+ await this.cancelLogin(true);
924
+ await withFileLock(this.filename, async () => {
925
+ try {
926
+ await unlink(this.filename);
927
+ }
928
+ catch (error) {
929
+ if (error.code !== 'ENOENT')
930
+ throw error;
931
+ }
932
+ });
933
+ let unsetError;
934
+ try {
935
+ await this.ctx.credentials.unset(TOKEN_REF);
936
+ }
937
+ catch (error) {
938
+ unsetError = error;
939
+ }
940
+ this.usageGeneration += 1;
941
+ if (this.usageRefresh !== undefined)
942
+ this.usageRefresh.queued = false;
943
+ this.usageCache = undefined;
944
+ this.usageError = undefined;
945
+ this.lastLoginError = undefined;
946
+ if (unsetError !== undefined) {
947
+ throw new Error(`Local OpenAI credential was removed, but DSH_OPENAI_CODEX_TOKEN could not be unset: ${messageOf(unsetError)}`);
948
+ }
949
+ }
950
+ async status(refresh, callbackUrl) {
951
+ let credential;
952
+ let credentialError;
953
+ try {
954
+ credential = await readCredential(this.filename);
955
+ }
956
+ catch (error) {
957
+ credentialError = messageOf(error);
958
+ }
959
+ if (credential !== undefined) {
960
+ try {
961
+ await this.bearerToken();
962
+ credential = await readCredential(this.filename) ?? credential;
963
+ }
964
+ catch (error) {
965
+ this.usageError = messageOf(error);
966
+ }
967
+ if (refresh)
968
+ await this.refreshUsage(true);
969
+ else if (this.usageRefresh !== undefined)
970
+ await this.usageRefresh.promise;
971
+ }
972
+ const flow = this.loginFlow;
973
+ const startingMethod = this.startingDevice !== undefined
974
+ ? 'device'
975
+ : this.startingBrowser !== undefined ? 'browser' : undefined;
976
+ const device = flow?.kind === 'device'
977
+ ? { userCode: flow.userCode, verificationUri: flow.verificationUri, expiresAt: flow.expiresAt }
978
+ : undefined;
979
+ const browser = flow?.kind === 'browser'
980
+ ? { authorizationUrl: flow.url, probeUrl: flow.probeUrl, expiresAt: flow.expiresAt }
981
+ : undefined;
982
+ return {
983
+ loggedIn: credential !== undefined,
984
+ loginPending: startingMethod !== undefined || flow !== undefined,
985
+ ...startingMethod !== undefined ? { loginMethod: startingMethod } : flow === undefined ? {} : { loginMethod: flow.kind },
986
+ ...this.lastLoginError === undefined ? {} : { loginError: this.lastLoginError },
987
+ ...credentialError === undefined ? {} : { credentialError },
988
+ ...callbackUrl === undefined ? {} : { browserCallbackUrl: callbackUrl },
989
+ ...device === undefined ? {} : { device },
990
+ ...browser === undefined ? {} : { browser },
991
+ ...credential === undefined ? {} : {
992
+ accountId: credential.accountId,
993
+ expiresAt: credential.expires,
994
+ ...this.usageCache === undefined ? {} : { usage: this.usageCache },
995
+ ...this.usageError === undefined ? {} : { usageError: this.usageError },
996
+ },
997
+ csrf: this.csrf,
998
+ };
999
+ }
1000
+ async fetchUsage(credential) {
1001
+ const access = await this.bearerToken();
1002
+ if (access === undefined)
1003
+ throw new Error('OpenAI login is missing');
1004
+ const response = await fetch(USAGE_URL, {
1005
+ headers: {
1006
+ accept: 'application/json',
1007
+ authorization: `Bearer ${access}`,
1008
+ 'chatgpt-account-id': credential.accountId,
1009
+ 'user-agent': 'dsh-openai-codex-auth/0.5.0',
1010
+ },
1011
+ });
1012
+ if (!response.ok)
1013
+ throw new Error(`Codex usage request failed (HTTP ${response.status})`);
1014
+ return normalizeUsage(await response.json());
1015
+ }
1016
+ write(credential) {
1017
+ return writeFileAtomic(this.filename, `${JSON.stringify({ version: 1, credential }, null, 2)}\n`, {
1018
+ mode: 0o600, dirMode: 0o700,
1019
+ });
1020
+ }
1021
+ sendJson(res, status, value, extraHeaders = {}) {
1022
+ res.writeHead(status, {
1023
+ 'cache-control': 'no-store',
1024
+ 'content-type': 'application/json; charset=utf-8',
1025
+ ...extraHeaders,
1026
+ }).end(JSON.stringify(value));
1027
+ }
1028
+ sendText(res, status, text, extraHeaders = {}) {
1029
+ res.writeHead(status, {
1030
+ 'cache-control': 'no-store',
1031
+ 'content-type': 'text/plain; charset=utf-8',
1032
+ 'x-content-type-options': 'nosniff',
1033
+ ...extraHeaders,
1034
+ }).end(text);
1035
+ }
1036
+ trustedManagementRequest(req, res) {
1037
+ if (isTrustedBrowserRequest(req, this.ctx.webRuntime.trustedHosts))
1038
+ return true;
1039
+ this.sendJson(res, 403, { error: 'Untrusted browser request.' });
1040
+ return false;
1041
+ }
1042
+ requireCsrf(req, res) {
1043
+ if (req.headers['x-dsh-csrf'] === this.csrf)
1044
+ return true;
1045
+ this.sendJson(res, 403, { error: 'Invalid CSRF token.' });
1046
+ return false;
1047
+ }
1048
+ async handleStatus(req, res) {
1049
+ if (!this.trustedManagementRequest(req, res))
1050
+ return;
1051
+ if (req.method !== 'GET') {
1052
+ this.sendJson(res, 405, { error: 'GET only' }, { allow: 'GET' });
1053
+ return;
1054
+ }
1055
+ try {
1056
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
1057
+ const callback = browserCallbackUrl(header(req, 'host'), this.ctx.webServer.port);
1058
+ this.sendJson(res, 200, await this.status(url.searchParams.get('refresh') === '1', callback));
1059
+ }
1060
+ catch (error) {
1061
+ this.sendJson(res, 500, { error: messageOf(error) });
1062
+ }
1063
+ }
1064
+ async handleDeviceStart(req, res) {
1065
+ if (!this.trustedManagementRequest(req, res))
1066
+ return;
1067
+ if (req.method !== 'POST') {
1068
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
1069
+ return;
1070
+ }
1071
+ if (!this.requireCsrf(req, res))
1072
+ return;
1073
+ try {
1074
+ const flow = await this.beginDeviceLogin();
1075
+ this.sendJson(res, 200, {
1076
+ userCode: flow.userCode,
1077
+ verificationUri: flow.verificationUri,
1078
+ expiresAt: flow.expiresAt,
1079
+ });
1080
+ }
1081
+ catch (error) {
1082
+ const unavailable = error instanceof DeviceCodeUnavailableError;
1083
+ const conflict = error instanceof LoginConflictError || error instanceof CredentialNotWritableError;
1084
+ this.sendJson(res, unavailable || conflict ? 409 : 502, {
1085
+ error: messageOf(error),
1086
+ ...unavailable ? { code: error.code } : {},
1087
+ });
1088
+ }
1089
+ }
1090
+ async handleBrowserStart(req, res) {
1091
+ if (!this.trustedManagementRequest(req, res))
1092
+ return;
1093
+ if (req.method !== 'GET') {
1094
+ this.sendJson(res, 405, { error: 'GET only' }, { allow: 'GET' });
1095
+ return;
1096
+ }
1097
+ try {
1098
+ const redirectUri = browserCallbackUrl(header(req, 'host'), this.ctx.webServer.port);
1099
+ if (redirectUri === undefined) {
1100
+ this.sendJson(res, 400, { error: 'Browser OAuth requires an HTTP 127.0.0.1 or localhost entry URL. Use device-code login instead.' });
1101
+ return;
1102
+ }
1103
+ await this.assertCredentialWritable();
1104
+ const flow = await this.beginBrowserLogin(redirectUri);
1105
+ res.writeHead(302, { location: flow.url, 'cache-control': 'no-store' }).end();
1106
+ }
1107
+ catch (error) {
1108
+ this.sendJson(res, 409, { error: messageOf(error) });
1109
+ }
1110
+ }
1111
+ async handleBrowserPrepare(req, res) {
1112
+ if (!this.trustedManagementRequest(req, res))
1113
+ return;
1114
+ if (req.method !== 'POST') {
1115
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
1116
+ return;
1117
+ }
1118
+ if (!this.requireCsrf(req, res))
1119
+ return;
1120
+ try {
1121
+ const redirectUri = browserCallbackUrl(header(req, 'host'), this.ctx.webServer.port);
1122
+ if (redirectUri === undefined) {
1123
+ this.sendJson(res, 400, { error: 'Browser OAuth requires an HTTP 127.0.0.1 or localhost entry URL. Use device-code login instead.' });
1124
+ return;
1125
+ }
1126
+ await this.assertCredentialWritable();
1127
+ const flow = await this.beginBrowserLogin(redirectUri);
1128
+ this.sendJson(res, 200, {
1129
+ authorizationUrl: flow.url,
1130
+ probeUrl: flow.probeUrl,
1131
+ expiresAt: flow.expiresAt,
1132
+ });
1133
+ }
1134
+ catch (error) {
1135
+ const conflict = error instanceof LoginConflictError || error instanceof CredentialNotWritableError;
1136
+ this.sendJson(res, conflict ? 409 : 502, { error: messageOf(error) });
1137
+ }
1138
+ }
1139
+ async handleBrowserComplete(req, res) {
1140
+ if (!this.trustedManagementRequest(req, res))
1141
+ return;
1142
+ if (req.method !== 'POST') {
1143
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
1144
+ return;
1145
+ }
1146
+ if (!this.requireCsrf(req, res))
1147
+ return;
1148
+ try {
1149
+ const flow = this.loginFlow;
1150
+ if (flow?.kind !== 'browser') {
1151
+ this.sendJson(res, 409, { error: 'No OpenAI browser login is pending.' });
1152
+ return;
1153
+ }
1154
+ const body = await readJsonBody(req);
1155
+ if (typeof body.input !== 'string') {
1156
+ this.sendJson(res, 400, { error: 'The input field must contain an authorization code or callback URL.' });
1157
+ return;
1158
+ }
1159
+ const parsed = parseManualAuthorizationInput(body.input);
1160
+ if (parsed.state !== undefined && parsed.state !== flow.state) {
1161
+ this.sendJson(res, 400, { error: 'The pasted callback state does not match this login.' });
1162
+ return;
1163
+ }
1164
+ flow.resolveCode(parsed.code);
1165
+ const result = await flow.completion;
1166
+ if (result.ok)
1167
+ this.sendJson(res, 200, { ok: true });
1168
+ else
1169
+ this.sendJson(res, 502, { error: result.error });
1170
+ }
1171
+ catch (error) {
1172
+ this.sendJson(res, 400, { error: messageOf(error) });
1173
+ }
1174
+ }
1175
+ async handleCallback(req, res) {
1176
+ if (req.method !== 'GET') {
1177
+ this.sendText(res, 405, 'GET only', { allow: 'GET' });
1178
+ return;
1179
+ }
1180
+ try {
1181
+ const flow = this.loginFlow;
1182
+ if (flow?.kind !== 'browser') {
1183
+ this.sendText(res, 400, 'No OpenAI browser login is pending.');
1184
+ return;
1185
+ }
1186
+ if (!callbackHostMatches(header(req, 'host'), flow.redirectUri)) {
1187
+ this.sendText(res, 400, 'Invalid OpenAI OAuth callback host.');
1188
+ return;
1189
+ }
1190
+ const url = new URL(req.url ?? '', flow.redirectUri);
1191
+ if (url.searchParams.get('state') !== flow.state) {
1192
+ this.sendText(res, 400, 'Invalid OpenAI OAuth callback state.');
1193
+ return;
1194
+ }
1195
+ const oauthError = url.searchParams.get('error');
1196
+ if (oauthError !== null) {
1197
+ const description = (url.searchParams.get('error_description') ?? oauthError).slice(0, 500);
1198
+ flow.rejectCode(new Error(`OpenAI login failed: ${description}`));
1199
+ const result = await flow.completion;
1200
+ this.sendText(res, 400, result.ok ? 'OpenAI login cancelled.' : result.error);
1201
+ return;
1202
+ }
1203
+ const code = url.searchParams.get('code');
1204
+ if (code === null || code.length === 0) {
1205
+ flow.rejectCode(new Error('Missing authorization code'));
1206
+ const result = await flow.completion;
1207
+ this.sendText(res, 400, result.ok ? 'Missing authorization code.' : result.error);
1208
+ return;
1209
+ }
1210
+ flow.resolveCode(code);
1211
+ const result = await flow.completion;
1212
+ if (result.ok) {
1213
+ this.sendText(res, 200, 'OpenAI login complete. You may close this window.');
1214
+ }
1215
+ else {
1216
+ this.sendText(res, 502, `OpenAI login failed: ${result.error}`);
1217
+ }
1218
+ }
1219
+ catch (error) {
1220
+ this.sendText(res, 400, `Invalid OpenAI OAuth callback: ${messageOf(error)}`);
1221
+ }
1222
+ }
1223
+ async handleCancel(req, res) {
1224
+ if (!this.trustedManagementRequest(req, res))
1225
+ return;
1226
+ if (req.method !== 'POST') {
1227
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
1228
+ return;
1229
+ }
1230
+ if (!this.requireCsrf(req, res))
1231
+ return;
1232
+ try {
1233
+ await this.cancelLogin(true);
1234
+ this.sendJson(res, 200, { ok: true });
1235
+ }
1236
+ catch (error) {
1237
+ this.sendJson(res, 500, { error: messageOf(error) });
1238
+ }
1239
+ }
1240
+ async handleLogout(req, res) {
1241
+ if (!this.trustedManagementRequest(req, res))
1242
+ return;
1243
+ if (req.method !== 'POST') {
1244
+ this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
1245
+ return;
1246
+ }
1247
+ if (!this.requireCsrf(req, res))
1248
+ return;
1249
+ try {
1250
+ await this.logout();
1251
+ this.sendJson(res, 200, { ok: true });
1252
+ }
1253
+ catch (error) {
1254
+ this.sendJson(res, 500, { error: messageOf(error) });
1255
+ }
1256
+ }
1257
+ }
1258
+ export default OpenAICodexAuth;