opencode-copilot-account-switcher 0.14.27 → 0.14.28

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/dist/plugin.js CHANGED
@@ -10,6 +10,7 @@ import { brokerStartupDiagnosticsPath, ensureWechatStateLayout } from "./wechat/
10
10
  import { createCodexMenuAdapter } from "./providers/codex-menu-adapter.js";
11
11
  import { createCopilotMenuAdapter } from "./providers/copilot-menu-adapter.js";
12
12
  import { createProviderRegistry } from "./providers/registry.js";
13
+ import { loadOfficialCodexAuthMethods } from "./upstream/codex-loader-adapter.js";
13
14
  import { isTTY } from "./ui/ansi.js";
14
15
  import { showMenu } from "./ui/menu.js";
15
16
  import { select, selectMany } from "./ui/select.js";
@@ -73,6 +74,14 @@ function toSharedRuntimeAction(action) {
73
74
  return { type: "provider", name: "toggle-experimental-slash-commands" };
74
75
  if (action.type === "toggle-network-retry")
75
76
  return { type: "provider", name: "toggle-network-retry" };
77
+ if (action.type === "toggle-wechat-notifications")
78
+ return { type: "provider", name: "toggle-wechat-notifications" };
79
+ if (action.type === "toggle-wechat-question-notify")
80
+ return { type: "provider", name: "toggle-wechat-question-notify" };
81
+ if (action.type === "toggle-wechat-permission-notify")
82
+ return { type: "provider", name: "toggle-wechat-permission-notify" };
83
+ if (action.type === "toggle-wechat-session-error-notify")
84
+ return { type: "provider", name: "toggle-wechat-session-error-notify" };
76
85
  if (action.type === "wechat-bind")
77
86
  return { type: "provider", name: "wechat-bind" };
78
87
  if (action.type === "wechat-rebind")
@@ -386,6 +395,13 @@ async function createAccountSwitcherPlugin(input, provider) {
386
395
  }
387
396
  const adapter = createCodexMenuAdapter({
388
397
  client: codexClient,
398
+ loadOfficialCodexAuthMethods: () => loadOfficialCodexAuthMethods({
399
+ client: {
400
+ auth: {
401
+ set: async (value) => client.auth.set(value),
402
+ },
403
+ },
404
+ }),
389
405
  readCommonSettings: readCommonSettingsStore,
390
406
  writeCommonSettings: async (settings) => {
391
407
  await writeCommonSettingsStore(settings);
@@ -1,5 +1,5 @@
1
1
  import { type CodexStatusFetcherResult } from "../codex-status-fetcher.js";
2
- import { type CodexOAuthAccount } from "../codex-oauth.js";
2
+ import { type OfficialCodexAuthMethod } from "../upstream/codex-loader-adapter.js";
3
3
  import { type CodexAccountEntry, type CodexStoreFile } from "../codex-store.js";
4
4
  import type { ProviderMenuAdapter } from "../menu-runtime.js";
5
5
  import { type AccountEntry } from "../store.js";
@@ -42,7 +42,7 @@ type AdapterDependencies = {
42
42
  };
43
43
  accountId?: string;
44
44
  }) => Promise<CodexStatusFetcherResult>;
45
- runCodexOAuth?: () => Promise<CodexOAuthAccount | undefined>;
45
+ loadOfficialCodexAuthMethods?: () => Promise<OfficialCodexAuthMethod[]>;
46
46
  readCommonSettings?: () => Promise<CommonSettingsStore>;
47
47
  writeCommonSettings?: (settings: CommonSettingsStore, meta?: WriteMeta) => Promise<void>;
48
48
  };
@@ -1,12 +1,34 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
3
  import { fetchCodexStatus } from "../codex-status-fetcher.js";
4
- import { runCodexOAuth } from "../codex-oauth.js";
4
+ import { loadOfficialCodexAuthMethods, } from "../upstream/codex-loader-adapter.js";
5
5
  import { getActiveCodexAccount, readCodexStore, writeCodexStore, } from "../codex-store.js";
6
6
  import { recoverInvalidCodexAccount } from "../codex-invalid-account.js";
7
7
  import { readAuth } from "../store.js";
8
8
  import { readCommonSettingsStore, writeCommonSettingsStore, } from "../common-settings-store.js";
9
9
  import { applyCommonSettingsAction } from "../common-settings-actions.js";
10
+ function pickOfficialOauthMethodByKind(methods, kind) {
11
+ return methods.find((method) => {
12
+ if (method.type !== "oauth")
13
+ return false;
14
+ if (typeof method.authorize !== "function")
15
+ return false;
16
+ const label = method.label.toLowerCase();
17
+ if (kind === "browser")
18
+ return label.includes("browser");
19
+ return label.includes("headless") || label.includes("device");
20
+ });
21
+ }
22
+ function parseOfficialOauthSelection(raw) {
23
+ const value = raw.trim().toLowerCase();
24
+ if (!value)
25
+ return "cancel";
26
+ if (value === "1" || value === "browser" || value === "b")
27
+ return "browser";
28
+ if (value === "2" || value === "headless" || value === "h" || value === "device")
29
+ return "headless";
30
+ return undefined;
31
+ }
10
32
  function pickName(input) {
11
33
  const accountId = input.accountId?.trim();
12
34
  if (accountId)
@@ -73,7 +95,14 @@ export function createCodexMenuAdapter(inputDeps) {
73
95
  });
74
96
  const loadAuth = inputDeps.readAuthEntries ?? readAuth;
75
97
  const fetchStatus = inputDeps.fetchStatus ?? ((input) => fetchCodexStatus(input));
76
- const authorizeOpenAIOAuth = inputDeps.runCodexOAuth ?? runCodexOAuth;
98
+ const loadOfficialMethods = inputDeps.loadOfficialCodexAuthMethods
99
+ ?? (() => loadOfficialCodexAuthMethods({
100
+ client: {
101
+ auth: {
102
+ set: async (value) => inputDeps.client.auth.set(value),
103
+ },
104
+ },
105
+ }));
77
106
  const readCommonSettings = inputDeps.readCommonSettings ?? readCommonSettingsStore;
78
107
  const writeCommonSettings = async (settings, meta) => {
79
108
  if (inputDeps.writeCommonSettings) {
@@ -243,34 +272,46 @@ export function createCodexMenuAdapter(inputDeps) {
243
272
  return true;
244
273
  },
245
274
  authorizeNewAccount: async () => {
246
- const oauth = await authorizeOpenAIOAuth();
247
- if (!oauth || (!oauth.refresh && !oauth.access))
275
+ const methods = await loadOfficialMethods();
276
+ const browserMethod = pickOfficialOauthMethodByKind(methods, "browser");
277
+ const headlessMethod = pickOfficialOauthMethodByKind(methods, "headless");
278
+ const selectedKey = parseOfficialOauthSelection(await prompt("Choose Codex auth method (1/browser/b, 2/headless/h/device, Enter to cancel): "));
279
+ if (selectedKey === "cancel" || !selectedKey)
280
+ return undefined;
281
+ const selectedMethod = selectedKey === "browser" ? browserMethod : headlessMethod;
282
+ if (!selectedMethod || typeof selectedMethod.authorize !== "function")
283
+ return undefined;
284
+ const pending = await selectedMethod.authorize();
285
+ if (pending.method && pending.method !== "auto") {
286
+ throw new Error(`Unsupported official Codex auth method: ${pending.method}`);
287
+ }
288
+ if (typeof pending.callback !== "function")
289
+ return undefined;
290
+ const result = await pending.callback();
291
+ if (result.type !== "success" || (!result.refresh && !result.access))
248
292
  return undefined;
249
- const refresh = oauth.refresh ?? oauth.access;
250
- const access = oauth.access ?? oauth.refresh;
293
+ const refresh = result.refresh ?? result.access;
294
+ const access = result.access ?? result.refresh;
251
295
  await inputDeps.client.auth.set({
252
296
  path: { id: "openai" },
253
297
  body: {
254
298
  type: "oauth",
255
299
  refresh,
256
300
  access,
257
- expires: oauth.expires,
258
- accountId: oauth.accountId,
301
+ expires: result.expires,
302
+ accountId: result.accountId,
259
303
  },
260
304
  });
261
305
  return {
262
306
  name: pickName({
263
- accountId: oauth.accountId,
264
- email: oauth.email,
307
+ accountId: result.accountId,
265
308
  fallback: `openai-${now()}`,
266
309
  }),
267
310
  providerId: "openai",
268
- workspaceName: oauth.workspaceName,
269
311
  refresh,
270
312
  access,
271
- expires: oauth.expires,
272
- accountId: oauth.accountId,
273
- email: oauth.email,
313
+ expires: result.expires,
314
+ accountId: result.accountId,
274
315
  source: "manual",
275
316
  addedAt: now(),
276
317
  };
@@ -36,6 +36,35 @@ export type OfficialCodexChatHeadersHook = (input: {
36
36
  }, output: {
37
37
  headers: Record<string, string>;
38
38
  }) => Promise<void>;
39
+ type OfficialCodexAuthResult = {
40
+ type: "success";
41
+ refresh: string;
42
+ access: string;
43
+ expires: number;
44
+ accountId?: string;
45
+ } | {
46
+ type: "failed";
47
+ };
48
+ type OfficialCodexAuthorizePending = {
49
+ url: string;
50
+ instructions?: string;
51
+ method?: string;
52
+ callback?: () => Promise<OfficialCodexAuthResult>;
53
+ };
54
+ export type OfficialCodexAuthMethod = {
55
+ label: string;
56
+ type: string;
57
+ authorize?: () => Promise<OfficialCodexAuthorizePending>;
58
+ };
59
+ export declare function loadOfficialCodexAuthMethods(input?: {
60
+ client?: {
61
+ auth?: {
62
+ set?: (value: unknown) => Promise<unknown>;
63
+ };
64
+ };
65
+ baseFetch?: typeof fetch;
66
+ version?: string;
67
+ }): Promise<OfficialCodexAuthMethod[]>;
39
68
  export declare function loadOfficialCodexConfig(input: {
40
69
  getAuth: () => Promise<CodexAuthState | undefined>;
41
70
  provider?: CodexProviderConfig;
@@ -67,3 +96,4 @@ export declare function loadOfficialCodexChatHeaders(input?: {
67
96
  baseFetch?: typeof fetch;
68
97
  version?: string;
69
98
  }): Promise<OfficialCodexChatHeadersHook>;
99
+ export {};
@@ -13,6 +13,31 @@ async function loadOfficialHooks(input) {
13
13
  return hooks;
14
14
  });
15
15
  }
16
+ export async function loadOfficialCodexAuthMethods(input = {}) {
17
+ const hooks = await loadOfficialHooks(input);
18
+ const methods = hooks.auth?.methods;
19
+ if (!Array.isArray(methods)) {
20
+ return [];
21
+ }
22
+ return methods.map((method) => {
23
+ if (typeof method.authorize !== "function") {
24
+ return method;
25
+ }
26
+ return {
27
+ ...method,
28
+ authorize: async () => {
29
+ const pending = await runWithOfficialBridge(input, () => method.authorize());
30
+ if (!pending || typeof pending.callback !== "function") {
31
+ return pending;
32
+ }
33
+ return {
34
+ ...pending,
35
+ callback: () => runWithOfficialBridge(input, () => pending.callback()),
36
+ };
37
+ },
38
+ };
39
+ });
40
+ }
16
41
  export async function loadOfficialCodexConfig(input) {
17
42
  const hooks = await loadOfficialHooks({
18
43
  client: input.client,
@@ -9,5 +9,24 @@ declare const officialCodexExportBridge: {
9
9
  version?: string;
10
10
  } | undefined, fn: () => Promise<any>): Promise<any>;
11
11
  };
12
+ export interface IdTokenClaims {
13
+ chatgpt_account_id?: string;
14
+ organizations?: Array<{
15
+ id: string;
16
+ }>;
17
+ email?: string;
18
+ "https://api.openai.com/auth"?: {
19
+ chatgpt_account_id?: string;
20
+ };
21
+ }
22
+ export declare function parseJwtClaims(token: string): IdTokenClaims | undefined;
23
+ export declare function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined;
24
+ export declare function extractAccountId(tokens: TokenResponse): string | undefined;
25
+ interface TokenResponse {
26
+ id_token: string;
27
+ access_token: string;
28
+ refresh_token: string;
29
+ expires_in?: number;
30
+ }
12
31
  export declare function CodexAuthPlugin(input: PluginInput): Promise<Hooks>;
13
32
  export { officialCodexExportBridge };
@@ -1,5 +1,6 @@
1
1
  // @ts-nocheck
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { createServer } from "node:http";
3
4
  import os from "node:os";
4
5
  const officialCodexExportBridgeStorage = new AsyncLocalStorage();
5
6
  const officialCodexExportBridge = {
@@ -26,8 +27,384 @@ const OAUTH_DUMMY_KEY = "official-codex-oauth";
26
27
  function fetch(request, init) {
27
28
  return (officialCodexExportBridgeStorage.getStore()?.fetchImpl ?? officialCodexExportBridge.fetchImpl)(request, init);
28
29
  }
30
+ function sleep(ms) {
31
+ return Bun.sleep(ms);
32
+ }
33
+ const Bun = {
34
+ serve(options) {
35
+ let closed = false;
36
+ let listening = false;
37
+ let closePromise;
38
+ let resolveReady;
39
+ let rejectReady;
40
+ const ready = new Promise((resolve, reject) => {
41
+ resolveReady = resolve;
42
+ rejectReady = reject;
43
+ });
44
+ const server = createServer((req, res) => {
45
+ const chunks = [];
46
+ req.on("data", (chunk) => {
47
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
48
+ });
49
+ req.on("error", (error) => {
50
+ res.statusCode = 500;
51
+ res.end(String(error));
52
+ });
53
+ req.on("end", () => {
54
+ const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
55
+ const request = new Request("http://127.0.0.1:" + options.port + (req.url ?? "/"), {
56
+ method: req.method,
57
+ headers: req.headers,
58
+ body,
59
+ duplex: "half",
60
+ });
61
+ Promise.resolve(options.fetch(request))
62
+ .then(async (response) => {
63
+ res.statusCode = response.status;
64
+ response.headers.forEach((value, key) => res.setHeader(key, value));
65
+ const payload = Buffer.from(await response.arrayBuffer());
66
+ res.end(payload);
67
+ })
68
+ .catch((error) => {
69
+ res.statusCode = 500;
70
+ res.end(String(error));
71
+ });
72
+ });
73
+ });
74
+ server.once("listening", () => {
75
+ listening = true;
76
+ resolveReady?.();
77
+ resolveReady = undefined;
78
+ rejectReady = undefined;
79
+ });
80
+ server.once("error", (error) => {
81
+ if (!listening) {
82
+ rejectReady?.(error);
83
+ resolveReady = undefined;
84
+ rejectReady = undefined;
85
+ }
86
+ });
87
+ server.listen(options.port);
88
+ return {
89
+ ready,
90
+ stop() {
91
+ if (closePromise)
92
+ return closePromise;
93
+ closePromise = new Promise((resolve, reject) => {
94
+ if (closed) {
95
+ resolve();
96
+ return;
97
+ }
98
+ server.close((error) => {
99
+ if (error) {
100
+ reject(error);
101
+ return;
102
+ }
103
+ closed = true;
104
+ resolve();
105
+ });
106
+ });
107
+ return closePromise;
108
+ },
109
+ };
110
+ },
111
+ sleep(ms) {
112
+ return new Promise((resolve) => setTimeout(resolve, ms));
113
+ },
114
+ };
115
+ const Log = {
116
+ create() {
117
+ return {
118
+ info() { },
119
+ warn() { },
120
+ error() { },
121
+ };
122
+ },
123
+ };
29
124
  /* LOCAL_SHIMS_END */
125
+ const log = Log.create({ service: "plugin.codex" });
126
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
127
+ const ISSUER = "https://auth.openai.com";
30
128
  const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
129
+ const OAUTH_PORT = 1455;
130
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
131
+ async function generatePKCE() {
132
+ const verifier = generateRandomString(43);
133
+ const encoder = new TextEncoder();
134
+ const data = encoder.encode(verifier);
135
+ const hash = await crypto.subtle.digest("SHA-256", data);
136
+ const challenge = base64UrlEncode(hash);
137
+ return { verifier, challenge };
138
+ }
139
+ function generateRandomString(length) {
140
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
141
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
142
+ return Array.from(bytes)
143
+ .map((b) => chars[b % chars.length])
144
+ .join("");
145
+ }
146
+ function base64UrlEncode(buffer) {
147
+ const bytes = new Uint8Array(buffer);
148
+ const binary = String.fromCharCode(...bytes);
149
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
150
+ }
151
+ function generateState() {
152
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer);
153
+ }
154
+ export function parseJwtClaims(token) {
155
+ const parts = token.split(".");
156
+ if (parts.length !== 3)
157
+ return undefined;
158
+ try {
159
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString());
160
+ }
161
+ catch {
162
+ return undefined;
163
+ }
164
+ }
165
+ export function extractAccountIdFromClaims(claims) {
166
+ return (claims.chatgpt_account_id ||
167
+ claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
168
+ claims.organizations?.[0]?.id);
169
+ }
170
+ export function extractAccountId(tokens) {
171
+ if (tokens.id_token) {
172
+ const claims = parseJwtClaims(tokens.id_token);
173
+ const accountId = claims && extractAccountIdFromClaims(claims);
174
+ if (accountId)
175
+ return accountId;
176
+ }
177
+ if (tokens.access_token) {
178
+ const claims = parseJwtClaims(tokens.access_token);
179
+ return claims ? extractAccountIdFromClaims(claims) : undefined;
180
+ }
181
+ return undefined;
182
+ }
183
+ function buildAuthorizeUrl(redirectUri, pkce, state) {
184
+ const params = new URLSearchParams({
185
+ response_type: "code",
186
+ client_id: CLIENT_ID,
187
+ redirect_uri: redirectUri,
188
+ scope: "openid profile email offline_access",
189
+ code_challenge: pkce.challenge,
190
+ code_challenge_method: "S256",
191
+ id_token_add_organizations: "true",
192
+ codex_cli_simplified_flow: "true",
193
+ state,
194
+ originator: "opencode",
195
+ });
196
+ return `${ISSUER}/oauth/authorize?${params.toString()}`;
197
+ }
198
+ async function exchangeCodeForTokens(code, redirectUri, pkce) {
199
+ const response = await fetch(`${ISSUER}/oauth/token`, {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
202
+ body: new URLSearchParams({
203
+ grant_type: "authorization_code",
204
+ code,
205
+ redirect_uri: redirectUri,
206
+ client_id: CLIENT_ID,
207
+ code_verifier: pkce.verifier,
208
+ }).toString(),
209
+ });
210
+ if (!response.ok) {
211
+ throw new Error(`Token exchange failed: ${response.status}`);
212
+ }
213
+ return response.json();
214
+ }
215
+ async function refreshAccessToken(refreshToken) {
216
+ const response = await fetch(`${ISSUER}/oauth/token`, {
217
+ method: "POST",
218
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
219
+ body: new URLSearchParams({
220
+ grant_type: "refresh_token",
221
+ refresh_token: refreshToken,
222
+ client_id: CLIENT_ID,
223
+ }).toString(),
224
+ });
225
+ if (!response.ok) {
226
+ throw new Error(`Token refresh failed: ${response.status}`);
227
+ }
228
+ return response.json();
229
+ }
230
+ const HTML_SUCCESS = `<!doctype html>
231
+ <html>
232
+ <head>
233
+ <title>OpenCode - Codex Authorization Successful</title>
234
+ <style>
235
+ body {
236
+ font-family:
237
+ system-ui,
238
+ -apple-system,
239
+ sans-serif;
240
+ display: flex;
241
+ justify-content: center;
242
+ align-items: center;
243
+ height: 100vh;
244
+ margin: 0;
245
+ background: #131010;
246
+ color: #f1ecec;
247
+ }
248
+ .container {
249
+ text-align: center;
250
+ padding: 2rem;
251
+ }
252
+ h1 {
253
+ color: #f1ecec;
254
+ margin-bottom: 1rem;
255
+ }
256
+ p {
257
+ color: #b7b1b1;
258
+ }
259
+ </style>
260
+ </head>
261
+ <body>
262
+ <div class="container">
263
+ <h1>Authorization Successful</h1>
264
+ <p>You can close this window and return to OpenCode.</p>
265
+ </div>
266
+ <script>
267
+ setTimeout(() => window.close(), 2000)
268
+ </script>
269
+ </body>
270
+ </html>`;
271
+ const HTML_ERROR = (error) => `<!doctype html>
272
+ <html>
273
+ <head>
274
+ <title>OpenCode - Codex Authorization Failed</title>
275
+ <style>
276
+ body {
277
+ font-family:
278
+ system-ui,
279
+ -apple-system,
280
+ sans-serif;
281
+ display: flex;
282
+ justify-content: center;
283
+ align-items: center;
284
+ height: 100vh;
285
+ margin: 0;
286
+ background: #131010;
287
+ color: #f1ecec;
288
+ }
289
+ .container {
290
+ text-align: center;
291
+ padding: 2rem;
292
+ }
293
+ h1 {
294
+ color: #fc533a;
295
+ margin-bottom: 1rem;
296
+ }
297
+ p {
298
+ color: #b7b1b1;
299
+ }
300
+ .error {
301
+ color: #ff917b;
302
+ font-family: monospace;
303
+ margin-top: 1rem;
304
+ padding: 1rem;
305
+ background: #3c140d;
306
+ border-radius: 0.5rem;
307
+ }
308
+ </style>
309
+ </head>
310
+ <body>
311
+ <div class="container">
312
+ <h1>Authorization Failed</h1>
313
+ <p>An error occurred during authorization.</p>
314
+ <div class="error">${error}</div>
315
+ </div>
316
+ </body>
317
+ </html>`;
318
+ let oauthServer;
319
+ let pendingOAuth;
320
+ async function startOAuthServer() {
321
+ if (oauthServer) {
322
+ return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` };
323
+ }
324
+ oauthServer = Bun.serve({
325
+ port: OAUTH_PORT,
326
+ fetch(req) {
327
+ const url = new URL(req.url);
328
+ if (url.pathname === "/auth/callback") {
329
+ const code = url.searchParams.get("code");
330
+ const state = url.searchParams.get("state");
331
+ const error = url.searchParams.get("error");
332
+ const errorDescription = url.searchParams.get("error_description");
333
+ if (error) {
334
+ const errorMsg = errorDescription || error;
335
+ pendingOAuth?.reject(new Error(errorMsg));
336
+ pendingOAuth = undefined;
337
+ return new Response(HTML_ERROR(errorMsg), {
338
+ headers: { "Content-Type": "text/html" },
339
+ });
340
+ }
341
+ if (!code) {
342
+ const errorMsg = "Missing authorization code";
343
+ pendingOAuth?.reject(new Error(errorMsg));
344
+ pendingOAuth = undefined;
345
+ return new Response(HTML_ERROR(errorMsg), {
346
+ status: 400,
347
+ headers: { "Content-Type": "text/html" },
348
+ });
349
+ }
350
+ if (!pendingOAuth || state !== pendingOAuth.state) {
351
+ const errorMsg = "Invalid state - potential CSRF attack";
352
+ pendingOAuth?.reject(new Error(errorMsg));
353
+ pendingOAuth = undefined;
354
+ return new Response(HTML_ERROR(errorMsg), {
355
+ status: 400,
356
+ headers: { "Content-Type": "text/html" },
357
+ });
358
+ }
359
+ const current = pendingOAuth;
360
+ pendingOAuth = undefined;
361
+ exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
362
+ .then((tokens) => current.resolve(tokens))
363
+ .catch((err) => current.reject(err));
364
+ return new Response(HTML_SUCCESS, {
365
+ headers: { "Content-Type": "text/html" },
366
+ });
367
+ }
368
+ if (url.pathname === "/cancel") {
369
+ pendingOAuth?.reject(new Error("Login cancelled"));
370
+ pendingOAuth = undefined;
371
+ return new Response("Login cancelled", { status: 200 });
372
+ }
373
+ return new Response("Not found", { status: 404 });
374
+ },
375
+ });
376
+ log.info("codex oauth server started", { port: OAUTH_PORT });
377
+ return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` };
378
+ }
379
+ function stopOAuthServer() {
380
+ if (oauthServer) {
381
+ oauthServer.stop();
382
+ oauthServer = undefined;
383
+ log.info("codex oauth server stopped");
384
+ }
385
+ }
386
+ function waitForOAuthCallback(pkce, state) {
387
+ return new Promise((resolve, reject) => {
388
+ const timeout = setTimeout(() => {
389
+ if (pendingOAuth) {
390
+ pendingOAuth = undefined;
391
+ reject(new Error("OAuth callback timeout - authorization took too long"));
392
+ }
393
+ }, 5 * 60 * 1000); // 5 minute timeout
394
+ pendingOAuth = {
395
+ pkce,
396
+ state,
397
+ resolve: (tokens) => {
398
+ clearTimeout(timeout);
399
+ resolve(tokens);
400
+ },
401
+ reject: (error) => {
402
+ clearTimeout(timeout);
403
+ reject(error);
404
+ },
405
+ };
406
+ });
407
+ }
31
408
  export async function CodexAuthPlugin(input) {
32
409
  return {
33
410
  auth: {
@@ -36,28 +413,98 @@ export async function CodexAuthPlugin(input) {
36
413
  const auth = await getAuth();
37
414
  if (auth.type !== "oauth")
38
415
  return {};
39
- if (provider?.models) {
40
- for (const model of Object.values(provider.models)) {
41
- model.cost = {
42
- input: 0,
43
- output: 0,
44
- cache: { read: 0, write: 0 },
45
- };
46
- }
416
+ // Filter models to only allowed Codex models for OAuth
417
+ const allowedModels = new Set([
418
+ "gpt-5.1-codex",
419
+ "gpt-5.1-codex-max",
420
+ "gpt-5.1-codex-mini",
421
+ "gpt-5.2",
422
+ "gpt-5.2-codex",
423
+ "gpt-5.3-codex",
424
+ "gpt-5.4",
425
+ "gpt-5.4-mini",
426
+ ]);
427
+ for (const modelId of Object.keys(provider.models)) {
428
+ if (modelId.includes("codex"))
429
+ continue;
430
+ if (allowedModels.has(modelId))
431
+ continue;
432
+ delete provider.models[modelId];
433
+ }
434
+ // Zero out costs for Codex (included with ChatGPT subscription)
435
+ for (const model of Object.values(provider.models)) {
436
+ model.cost = {
437
+ input: 0,
438
+ output: 0,
439
+ cache: { read: 0, write: 0 },
440
+ };
47
441
  }
48
442
  return {
49
443
  apiKey: OAUTH_DUMMY_KEY,
50
444
  async fetch(requestInput, init) {
445
+ // Remove dummy API key authorization header
446
+ if (init?.headers) {
447
+ if (init.headers instanceof Headers) {
448
+ init.headers.delete("authorization");
449
+ init.headers.delete("Authorization");
450
+ }
451
+ else if (Array.isArray(init.headers)) {
452
+ init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization");
453
+ }
454
+ else {
455
+ delete init.headers["authorization"];
456
+ delete init.headers["Authorization"];
457
+ }
458
+ }
51
459
  const currentAuth = await getAuth();
52
460
  if (currentAuth.type !== "oauth")
53
461
  return fetch(requestInput, init);
54
- const headers = new Headers(init?.headers);
55
- headers.delete("authorization");
56
- headers.delete("Authorization");
462
+ // Cast to include accountId field
463
+ const authWithAccount = currentAuth;
464
+ // Check if token needs refresh
465
+ if (!currentAuth.access || currentAuth.expires < Date.now()) {
466
+ log.info("refreshing codex access token");
467
+ const tokens = await refreshAccessToken(currentAuth.refresh);
468
+ const newAccountId = extractAccountId(tokens) || authWithAccount.accountId;
469
+ await input.client.auth.set({
470
+ path: { id: "openai" },
471
+ body: {
472
+ type: "oauth",
473
+ refresh: tokens.refresh_token,
474
+ access: tokens.access_token,
475
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
476
+ ...(newAccountId && { accountId: newAccountId }),
477
+ },
478
+ });
479
+ currentAuth.access = tokens.access_token;
480
+ authWithAccount.accountId = newAccountId;
481
+ }
482
+ // Build headers
483
+ const headers = new Headers();
484
+ if (init?.headers) {
485
+ if (init.headers instanceof Headers) {
486
+ init.headers.forEach((value, key) => headers.set(key, value));
487
+ }
488
+ else if (Array.isArray(init.headers)) {
489
+ for (const [key, value] of init.headers) {
490
+ if (value !== undefined)
491
+ headers.set(key, String(value));
492
+ }
493
+ }
494
+ else {
495
+ for (const [key, value] of Object.entries(init.headers)) {
496
+ if (value !== undefined)
497
+ headers.set(key, String(value));
498
+ }
499
+ }
500
+ }
501
+ // Set authorization header with access token
57
502
  headers.set("authorization", `Bearer ${currentAuth.access}`);
58
- if (currentAuth.accountId) {
59
- headers.set("ChatGPT-Account-Id", currentAuth.accountId);
503
+ // Set ChatGPT-Account-Id header for organization subscriptions
504
+ if (authWithAccount.accountId) {
505
+ headers.set("ChatGPT-Account-Id", authWithAccount.accountId);
60
506
  }
507
+ // Rewrite URL to Codex endpoint
61
508
  const parsed = requestInput instanceof URL
62
509
  ? requestInput
63
510
  : new URL(typeof requestInput === "string" ? requestInput : requestInput.url);
@@ -75,12 +522,101 @@ export async function CodexAuthPlugin(input) {
75
522
  {
76
523
  label: "ChatGPT Pro/Plus (browser)",
77
524
  type: "oauth",
78
- authorize: async () => ({
79
- url: "",
80
- instructions: "",
81
- method: "auto",
82
- callback: async () => ({ type: "failed" }),
83
- }),
525
+ authorize: async () => {
526
+ const { redirectUri } = await startOAuthServer();
527
+ const pkce = await generatePKCE();
528
+ const state = generateState();
529
+ const authUrl = buildAuthorizeUrl(redirectUri, pkce, state);
530
+ const callbackPromise = waitForOAuthCallback(pkce, state);
531
+ return {
532
+ url: authUrl,
533
+ instructions: "Complete authorization in your browser. This window will close automatically.",
534
+ method: "auto",
535
+ callback: async () => {
536
+ const tokens = await callbackPromise;
537
+ stopOAuthServer();
538
+ const accountId = extractAccountId(tokens);
539
+ return {
540
+ type: "success",
541
+ refresh: tokens.refresh_token,
542
+ access: tokens.access_token,
543
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
544
+ accountId,
545
+ };
546
+ },
547
+ };
548
+ },
549
+ },
550
+ {
551
+ label: "ChatGPT Pro/Plus (headless)",
552
+ type: "oauth",
553
+ authorize: async () => {
554
+ const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
555
+ method: "POST",
556
+ headers: {
557
+ "Content-Type": "application/json",
558
+ "User-Agent": `opencode/${Installation.VERSION}`,
559
+ },
560
+ body: JSON.stringify({ client_id: CLIENT_ID }),
561
+ });
562
+ if (!deviceResponse.ok)
563
+ throw new Error("Failed to initiate device authorization");
564
+ const deviceData = (await deviceResponse.json());
565
+ const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000;
566
+ return {
567
+ url: `${ISSUER}/codex/device`,
568
+ instructions: `Enter code: ${deviceData.user_code}`,
569
+ method: "auto",
570
+ async callback() {
571
+ while (true) {
572
+ const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
573
+ method: "POST",
574
+ headers: {
575
+ "Content-Type": "application/json",
576
+ "User-Agent": `opencode/${Installation.VERSION}`,
577
+ },
578
+ body: JSON.stringify({
579
+ device_auth_id: deviceData.device_auth_id,
580
+ user_code: deviceData.user_code,
581
+ }),
582
+ });
583
+ if (response.ok) {
584
+ const data = (await response.json());
585
+ const tokenResponse = await fetch(`${ISSUER}/oauth/token`, {
586
+ method: "POST",
587
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
588
+ body: new URLSearchParams({
589
+ grant_type: "authorization_code",
590
+ code: data.authorization_code,
591
+ redirect_uri: `${ISSUER}/deviceauth/callback`,
592
+ client_id: CLIENT_ID,
593
+ code_verifier: data.code_verifier,
594
+ }).toString(),
595
+ });
596
+ if (!tokenResponse.ok) {
597
+ throw new Error(`Token exchange failed: ${tokenResponse.status}`);
598
+ }
599
+ const tokens = await tokenResponse.json();
600
+ return {
601
+ type: "success",
602
+ refresh: tokens.refresh_token,
603
+ access: tokens.access_token,
604
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
605
+ accountId: extractAccountId(tokens),
606
+ };
607
+ }
608
+ if (response.status !== 403 && response.status !== 404) {
609
+ return { type: "failed" };
610
+ }
611
+ await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS);
612
+ }
613
+ },
614
+ };
615
+ },
616
+ },
617
+ {
618
+ label: "Manually enter API Key",
619
+ type: "api",
84
620
  },
85
621
  ],
86
622
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.14.27",
3
+ "version": "0.14.28",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -1,64 +0,0 @@
1
- export type TokenResponse = {
2
- id_token?: string;
3
- access_token?: string;
4
- refresh_token?: string;
5
- expires_in?: number;
6
- };
7
- export type IdTokenClaims = {
8
- chatgpt_account_id?: string;
9
- organizations?: Array<{
10
- id?: string;
11
- name?: string;
12
- display_name?: string;
13
- workspace_name?: string;
14
- slug?: string;
15
- }>;
16
- organization?: {
17
- id?: string;
18
- name?: string;
19
- display_name?: string;
20
- workspace_name?: string;
21
- slug?: string;
22
- };
23
- workspace?: {
24
- id?: string;
25
- name?: string;
26
- display_name?: string;
27
- workspace_name?: string;
28
- slug?: string;
29
- };
30
- workspace_name?: string;
31
- email?: string;
32
- "https://api.openai.com/auth"?: {
33
- chatgpt_account_id?: string;
34
- workspace_name?: string;
35
- workspace_id?: string;
36
- organization_id?: string;
37
- };
38
- };
39
- export type CodexOAuthAccount = {
40
- refresh?: string;
41
- access?: string;
42
- expires?: number;
43
- accountId?: string;
44
- email?: string;
45
- workspaceName?: string;
46
- };
47
- type OAuthMode = "browser" | "headless";
48
- type RunCodexOAuthInput = {
49
- now?: () => number;
50
- timeoutMs?: number;
51
- fetchImpl?: typeof globalThis.fetch;
52
- selectMode?: () => Promise<OAuthMode | undefined>;
53
- runBrowserAuth?: () => Promise<TokenResponse>;
54
- runDeviceAuth?: () => Promise<TokenResponse>;
55
- openUrl?: (url: string) => Promise<void>;
56
- log?: (message: string) => void;
57
- };
58
- export declare function parseJwtClaims(token: string): IdTokenClaims | undefined;
59
- export declare function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined;
60
- export declare function extractAccountId(tokens: TokenResponse): string | undefined;
61
- export declare function extractWorkspaceNameFromClaims(claims: IdTokenClaims): string | undefined;
62
- export declare function extractWorkspaceName(tokens: TokenResponse): string | undefined;
63
- export declare function runCodexOAuth(input?: RunCodexOAuthInput): Promise<CodexOAuthAccount | undefined>;
64
- export {};
@@ -1,344 +0,0 @@
1
- import { randomBytes, createHash } from "node:crypto";
2
- import { createServer } from "node:http";
3
- import { spawn } from "node:child_process";
4
- import os from "node:os";
5
- import { createInterface } from "node:readline/promises";
6
- import { stdin as input, stdout as output, platform } from "node:process";
7
- const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
8
- const ISSUER = "https://auth.openai.com";
9
- const OAUTH_PORT = 1455;
10
- const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
11
- const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
12
- const USER_AGENT = `opencode-copilot-account-switcher (${platform} ${os.release()}; ${os.arch()})`;
13
- const HTML_SUCCESS = `<!doctype html>
14
- <html>
15
- <head>
16
- <title>Codex Authorization Successful</title>
17
- </head>
18
- <body>
19
- <h1>Authorization Successful</h1>
20
- <p>You can close this window and return to OpenCode.</p>
21
- <script>
22
- setTimeout(() => window.close(), 2000)
23
- </script>
24
- </body>
25
- </html>`;
26
- const htmlError = (message) => `<!doctype html>
27
- <html>
28
- <head>
29
- <title>Codex Authorization Failed</title>
30
- </head>
31
- <body>
32
- <h1>Authorization Failed</h1>
33
- <p>${message}</p>
34
- </body>
35
- </html>`;
36
- function base64UrlEncode(input) {
37
- const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
38
- return Buffer.from(bytes)
39
- .toString("base64")
40
- .replace(/\+/g, "-")
41
- .replace(/\//g, "_")
42
- .replace(/=+$/g, "");
43
- }
44
- function generateRandomString(length) {
45
- const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
46
- const bytes = randomBytes(length);
47
- return Array.from(bytes, (byte) => chars[byte % chars.length]).join("");
48
- }
49
- async function generatePKCE() {
50
- const verifier = generateRandomString(43);
51
- const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest());
52
- return { verifier, challenge };
53
- }
54
- function generateState() {
55
- return base64UrlEncode(randomBytes(32));
56
- }
57
- export function parseJwtClaims(token) {
58
- const parts = token.split(".");
59
- if (parts.length !== 3)
60
- return undefined;
61
- try {
62
- return JSON.parse(Buffer.from(parts[1], "base64url").toString());
63
- }
64
- catch {
65
- return undefined;
66
- }
67
- }
68
- export function extractAccountIdFromClaims(claims) {
69
- return (claims.chatgpt_account_id
70
- || claims["https://api.openai.com/auth"]?.chatgpt_account_id
71
- || claims.organizations?.[0]?.id);
72
- }
73
- export function extractAccountId(tokens) {
74
- if (tokens.id_token) {
75
- const claims = parseJwtClaims(tokens.id_token);
76
- const accountId = claims && extractAccountIdFromClaims(claims);
77
- if (accountId)
78
- return accountId;
79
- }
80
- if (!tokens.access_token)
81
- return undefined;
82
- const claims = parseJwtClaims(tokens.access_token);
83
- return claims ? extractAccountIdFromClaims(claims) : undefined;
84
- }
85
- function pickWorkspaceLikeLabel(input) {
86
- if (!input)
87
- return undefined;
88
- return input.workspace_name ?? input.display_name ?? input.name ?? input.slug ?? input.id;
89
- }
90
- export function extractWorkspaceNameFromClaims(claims) {
91
- return (claims.workspace_name
92
- || claims["https://api.openai.com/auth"]?.workspace_name
93
- || claims["https://api.openai.com/auth"]?.workspace_id
94
- || claims["https://api.openai.com/auth"]?.organization_id
95
- || pickWorkspaceLikeLabel(claims.workspace)
96
- || pickWorkspaceLikeLabel(claims.organization)
97
- || pickWorkspaceLikeLabel(claims.organizations?.[0]));
98
- }
99
- export function extractWorkspaceName(tokens) {
100
- if (tokens.id_token) {
101
- const claims = parseJwtClaims(tokens.id_token);
102
- const workspaceName = claims && extractWorkspaceNameFromClaims(claims);
103
- if (workspaceName)
104
- return workspaceName;
105
- }
106
- if (!tokens.access_token)
107
- return undefined;
108
- const claims = parseJwtClaims(tokens.access_token);
109
- return claims ? extractWorkspaceNameFromClaims(claims) : undefined;
110
- }
111
- function extractEmail(tokens) {
112
- if (tokens.id_token) {
113
- const claims = parseJwtClaims(tokens.id_token);
114
- if (claims?.email)
115
- return claims.email;
116
- }
117
- if (!tokens.access_token)
118
- return undefined;
119
- return parseJwtClaims(tokens.access_token)?.email;
120
- }
121
- function buildAuthorizeUrl(redirectUri, pkce, state) {
122
- const params = new URLSearchParams({
123
- response_type: "code",
124
- client_id: CLIENT_ID,
125
- redirect_uri: redirectUri,
126
- scope: "openid profile email offline_access",
127
- code_challenge: pkce.challenge,
128
- code_challenge_method: "S256",
129
- id_token_add_organizations: "true",
130
- codex_cli_simplified_flow: "true",
131
- state,
132
- originator: "opencode",
133
- });
134
- return `${ISSUER}/oauth/authorize?${params.toString()}`;
135
- }
136
- async function promptText(message) {
137
- const rl = createInterface({ input, output });
138
- try {
139
- return (await rl.question(message)).trim();
140
- }
141
- finally {
142
- rl.close();
143
- }
144
- }
145
- async function selectModeDefault() {
146
- const value = (await promptText("OpenAI/Codex login mode ([1] browser, [2] headless, Enter to cancel): ")).toLowerCase();
147
- if (!value)
148
- return undefined;
149
- if (value === "1" || value === "browser" || value === "b")
150
- return "browser";
151
- if (value === "2" || value === "headless" || value === "h" || value === "device")
152
- return "headless";
153
- return undefined;
154
- }
155
- async function openUrlDefault(url) {
156
- if (process.platform === "win32") {
157
- await new Promise((resolve, reject) => {
158
- const child = spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
159
- child.on("error", reject);
160
- child.on("exit", (code) => {
161
- if (code && code !== 0)
162
- reject(new Error(`failed to open browser: ${code}`));
163
- else
164
- resolve();
165
- });
166
- });
167
- return;
168
- }
169
- const command = process.platform === "darwin" ? "open" : "xdg-open";
170
- await new Promise((resolve, reject) => {
171
- const child = spawn(command, [url], { stdio: "ignore" });
172
- child.on("error", reject);
173
- child.on("exit", (code) => {
174
- if (code && code !== 0)
175
- reject(new Error(`failed to open browser: ${code}`));
176
- else
177
- resolve();
178
- });
179
- });
180
- }
181
- async function exchangeCodeForTokens(input) {
182
- const response = await input.fetchImpl(`${ISSUER}/oauth/token`, {
183
- method: "POST",
184
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
185
- body: new URLSearchParams({
186
- grant_type: "authorization_code",
187
- code: input.code,
188
- redirect_uri: input.redirectUri,
189
- client_id: CLIENT_ID,
190
- code_verifier: input.verifier,
191
- }).toString(),
192
- });
193
- if (!response.ok) {
194
- throw new Error(`Token exchange failed: ${response.status}`);
195
- }
196
- return response.json();
197
- }
198
- async function runBrowserAuthDefault(input) {
199
- const pkce = await generatePKCE();
200
- const state = generateState();
201
- const redirectUri = `http://localhost:${OAUTH_PORT}/auth/callback`;
202
- const authUrl = buildAuthorizeUrl(redirectUri, pkce, state);
203
- const tokens = await new Promise((resolve, reject) => {
204
- let closed = false;
205
- const finish = (handler) => {
206
- if (closed)
207
- return;
208
- closed = true;
209
- clearTimeout(timeout);
210
- void server.close(() => handler());
211
- };
212
- const respond = (res, status, body) => {
213
- res.statusCode = status;
214
- res.setHeader("Content-Type", "text/html");
215
- res.end(body);
216
- };
217
- const server = createServer((req, res) => {
218
- const url = new URL(req.url ?? "/", redirectUri);
219
- if (url.pathname !== "/auth/callback") {
220
- respond(res, 404, htmlError("Not found"));
221
- return;
222
- }
223
- const code = url.searchParams.get("code");
224
- const returnedState = url.searchParams.get("state");
225
- const error = url.searchParams.get("error");
226
- const errorDescription = url.searchParams.get("error_description");
227
- if (error) {
228
- const message = errorDescription || error;
229
- respond(res, 400, htmlError(message));
230
- finish(() => reject(new Error(message)));
231
- return;
232
- }
233
- if (!code) {
234
- respond(res, 400, htmlError("Missing authorization code"));
235
- finish(() => reject(new Error("Missing authorization code")));
236
- return;
237
- }
238
- if (returnedState !== state) {
239
- respond(res, 400, htmlError("Invalid state - potential CSRF attack"));
240
- finish(() => reject(new Error("Invalid state - potential CSRF attack")));
241
- return;
242
- }
243
- respond(res, 200, HTML_SUCCESS);
244
- void exchangeCodeForTokens({
245
- code,
246
- redirectUri,
247
- verifier: pkce.verifier,
248
- fetchImpl: input.fetchImpl,
249
- }).then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error instanceof Error ? error : new Error(String(error)))));
250
- });
251
- server.on("error", reject);
252
- server.listen(OAUTH_PORT, async () => {
253
- try {
254
- input.log("Opening browser for OpenAI/Codex authorization...");
255
- await input.openUrl(authUrl);
256
- }
257
- catch (error) {
258
- finish(() => reject(error instanceof Error ? error : new Error(String(error))));
259
- }
260
- });
261
- const timeout = setTimeout(() => {
262
- finish(() => reject(new Error("OAuth callback timeout - authorization took too long")));
263
- }, input.timeoutMs);
264
- });
265
- return tokens;
266
- }
267
- async function runDeviceAuthDefault(input) {
268
- const deadline = Date.now() + input.timeoutMs;
269
- const deviceResponse = await input.fetchImpl(`${ISSUER}/api/accounts/deviceauth/usercode`, {
270
- method: "POST",
271
- headers: {
272
- "Content-Type": "application/json",
273
- "User-Agent": USER_AGENT,
274
- },
275
- body: JSON.stringify({ client_id: CLIENT_ID }),
276
- });
277
- if (!deviceResponse.ok)
278
- throw new Error("Failed to initiate device authorization");
279
- const deviceData = await deviceResponse.json();
280
- const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000;
281
- input.log(`Open ${ISSUER}/codex/device and enter code: ${deviceData.user_code}`);
282
- while (true) {
283
- if (Date.now() >= deadline) {
284
- throw new Error("Device authorization timeout - authorization took too long");
285
- }
286
- const response = await input.fetchImpl(`${ISSUER}/api/accounts/deviceauth/token`, {
287
- method: "POST",
288
- headers: {
289
- "Content-Type": "application/json",
290
- "User-Agent": USER_AGENT,
291
- },
292
- body: JSON.stringify({
293
- device_auth_id: deviceData.device_auth_id,
294
- user_code: deviceData.user_code,
295
- }),
296
- });
297
- if (response.ok) {
298
- const data = await response.json();
299
- return exchangeCodeForTokens({
300
- code: data.authorization_code,
301
- redirectUri: `${ISSUER}/deviceauth/callback`,
302
- verifier: data.code_verifier,
303
- fetchImpl: input.fetchImpl,
304
- });
305
- }
306
- if (response.status !== 403 && response.status !== 404) {
307
- throw new Error(`Device authorization failed: ${response.status}`);
308
- }
309
- if (Date.now() + interval + OAUTH_POLLING_SAFETY_MARGIN_MS >= deadline) {
310
- throw new Error("Device authorization timeout - authorization took too long");
311
- }
312
- await new Promise((resolve) => setTimeout(resolve, interval + OAUTH_POLLING_SAFETY_MARGIN_MS));
313
- }
314
- }
315
- function normalizeTokens(tokens, now) {
316
- const refresh = tokens.refresh_token;
317
- const access = tokens.access_token;
318
- if (!refresh && !access)
319
- return undefined;
320
- const workspaceName = extractWorkspaceName(tokens);
321
- return {
322
- refresh,
323
- access,
324
- expires: now() + (tokens.expires_in ?? 3600) * 1000,
325
- accountId: extractAccountId(tokens),
326
- email: extractEmail(tokens),
327
- ...(workspaceName ? { workspaceName } : {}),
328
- };
329
- }
330
- export async function runCodexOAuth(input = {}) {
331
- const now = input.now ?? Date.now;
332
- const timeoutMs = input.timeoutMs ?? OAUTH_TIMEOUT_MS;
333
- const fetchImpl = input.fetchImpl ?? globalThis.fetch;
334
- const selectMode = input.selectMode ?? selectModeDefault;
335
- const openUrl = input.openUrl ?? openUrlDefault;
336
- const log = input.log ?? console.log;
337
- const mode = await selectMode();
338
- if (!mode)
339
- return undefined;
340
- const runBrowserAuth = input.runBrowserAuth ?? (() => runBrowserAuthDefault({ fetchImpl, openUrl, log, timeoutMs }));
341
- const runDeviceAuth = input.runDeviceAuth ?? (() => runDeviceAuthDefault({ fetchImpl, log, timeoutMs }));
342
- const tokens = mode === "headless" ? await runDeviceAuth() : await runBrowserAuth();
343
- return normalizeTokens(tokens, now);
344
- }