opencode-copilot-account-switcher 0.14.27 → 0.14.29
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 +16 -0
- package/dist/providers/codex-menu-adapter.d.ts +2 -2
- package/dist/providers/codex-menu-adapter.js +61 -14
- package/dist/upstream/codex-loader-adapter.d.ts +30 -0
- package/dist/upstream/codex-loader-adapter.js +25 -0
- package/dist/upstream/codex-plugin.snapshot.d.ts +19 -0
- package/dist/upstream/codex-plugin.snapshot.js +555 -19
- package/package.json +1 -1
- package/dist/codex-oauth.d.ts +0 -64
- package/dist/codex-oauth.js +0 -344
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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,52 @@ export function createCodexMenuAdapter(inputDeps) {
|
|
|
243
272
|
return true;
|
|
244
273
|
},
|
|
245
274
|
authorizeNewAccount: async () => {
|
|
246
|
-
const
|
|
247
|
-
|
|
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
|
+
if (pending.url) {
|
|
291
|
+
console.log(`Go to: ${pending.url}`);
|
|
292
|
+
}
|
|
293
|
+
if (pending.instructions) {
|
|
294
|
+
console.log(pending.instructions);
|
|
295
|
+
}
|
|
296
|
+
const result = await pending.callback();
|
|
297
|
+
if (result.type !== "success" || (!result.refresh && !result.access))
|
|
248
298
|
return undefined;
|
|
249
|
-
const refresh =
|
|
250
|
-
const access =
|
|
299
|
+
const refresh = result.refresh ?? result.access;
|
|
300
|
+
const access = result.access ?? result.refresh;
|
|
251
301
|
await inputDeps.client.auth.set({
|
|
252
302
|
path: { id: "openai" },
|
|
253
303
|
body: {
|
|
254
304
|
type: "oauth",
|
|
255
305
|
refresh,
|
|
256
306
|
access,
|
|
257
|
-
expires:
|
|
258
|
-
accountId:
|
|
307
|
+
expires: result.expires,
|
|
308
|
+
accountId: result.accountId,
|
|
259
309
|
},
|
|
260
310
|
});
|
|
261
311
|
return {
|
|
262
312
|
name: pickName({
|
|
263
|
-
accountId:
|
|
264
|
-
email: oauth.email,
|
|
313
|
+
accountId: result.accountId,
|
|
265
314
|
fallback: `openai-${now()}`,
|
|
266
315
|
}),
|
|
267
316
|
providerId: "openai",
|
|
268
|
-
workspaceName: oauth.workspaceName,
|
|
269
317
|
refresh,
|
|
270
318
|
access,
|
|
271
|
-
expires:
|
|
272
|
-
accountId:
|
|
273
|
-
email: oauth.email,
|
|
319
|
+
expires: result.expires,
|
|
320
|
+
accountId: result.accountId,
|
|
274
321
|
source: "manual",
|
|
275
322
|
addedAt: now(),
|
|
276
323
|
};
|
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
package/dist/codex-oauth.d.ts
DELETED
|
@@ -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 {};
|
package/dist/codex-oauth.js
DELETED
|
@@ -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
|
-
}
|