oc-codex-multi-account 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +321 -0
  2. package/dist/auth-sync.d.ts +3 -0
  3. package/dist/auth-sync.d.ts.map +1 -0
  4. package/dist/auth-sync.js +105 -0
  5. package/dist/auth-sync.js.map +1 -0
  6. package/dist/auth.d.ts +15 -0
  7. package/dist/auth.d.ts.map +1 -0
  8. package/dist/auth.js +236 -0
  9. package/dist/auth.js.map +1 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +160 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/codex-auth.d.ts +28 -0
  15. package/dist/codex-auth.d.ts.map +1 -0
  16. package/dist/codex-auth.js +174 -0
  17. package/dist/codex-auth.js.map +1 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +730 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/limits-refresh.d.ts +9 -0
  23. package/dist/limits-refresh.d.ts.map +1 -0
  24. package/dist/limits-refresh.js +48 -0
  25. package/dist/limits-refresh.js.map +1 -0
  26. package/dist/logger.d.ts +6 -0
  27. package/dist/logger.d.ts.map +1 -0
  28. package/dist/logger.js +52 -0
  29. package/dist/logger.js.map +1 -0
  30. package/dist/models.d.ts +7 -0
  31. package/dist/models.d.ts.map +1 -0
  32. package/dist/models.js +121 -0
  33. package/dist/models.js.map +1 -0
  34. package/dist/probe-limits.d.ts +10 -0
  35. package/dist/probe-limits.d.ts.map +1 -0
  36. package/dist/probe-limits.js +160 -0
  37. package/dist/probe-limits.js.map +1 -0
  38. package/dist/rate-limits.d.ts +6 -0
  39. package/dist/rate-limits.d.ts.map +1 -0
  40. package/dist/rate-limits.js +117 -0
  41. package/dist/rate-limits.js.map +1 -0
  42. package/dist/refresh-queue.d.ts +18 -0
  43. package/dist/refresh-queue.d.ts.map +1 -0
  44. package/dist/refresh-queue.js +78 -0
  45. package/dist/refresh-queue.js.map +1 -0
  46. package/dist/rotation.d.ts +20 -0
  47. package/dist/rotation.d.ts.map +1 -0
  48. package/dist/rotation.js +273 -0
  49. package/dist/rotation.js.map +1 -0
  50. package/dist/sessions-limits.d.ts +11 -0
  51. package/dist/sessions-limits.d.ts.map +1 -0
  52. package/dist/sessions-limits.js +123 -0
  53. package/dist/sessions-limits.js.map +1 -0
  54. package/dist/store.d.ts +23 -0
  55. package/dist/store.d.ts.map +1 -0
  56. package/dist/store.js +339 -0
  57. package/dist/store.js.map +1 -0
  58. package/dist/systemd.d.ts +10 -0
  59. package/dist/systemd.d.ts.map +1 -0
  60. package/dist/systemd.js +53 -0
  61. package/dist/systemd.js.map +1 -0
  62. package/dist/types.d.ts +98 -0
  63. package/dist/types.d.ts.map +1 -0
  64. package/dist/types.js +12 -0
  65. package/dist/types.js.map +1 -0
  66. package/dist/web.d.ts +6 -0
  67. package/dist/web.d.ts.map +1 -0
  68. package/dist/web.js +1857 -0
  69. package/dist/web.js.map +1 -0
  70. package/package.json +53 -0
package/README.md ADDED
@@ -0,0 +1,321 @@
1
+ # oc-codex-multi-account
2
+
3
+ [![npm version](https://img.shields.io/npm/v/oc-codex-multi-account)](https://www.npmjs.com/package/oc-codex-multi-account)
4
+
5
+ Multi-account OAuth rotation for OpenAI Codex with sticky threshold switching.
6
+
7
+ > **Based on [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) by [@nummanali](https://x.com/nummanali)**. Forked and modified to add multi-account rotation support.
8
+
9
+ ## Patched Build (Codex Backend Compatible)
10
+
11
+ This fork patches the plugin to talk to **ChatGPT Codex backend** (`chatgpt.com/backend-api`) with the same headers and request shape as the official Codex OAuth plugin.
12
+
13
+ **Install from npm (recommended):**
14
+
15
+ ```bash
16
+ bun add oc-codex-multi-account --cwd ~/.config/opencode
17
+ ```
18
+
19
+ Then set the plugin entry in `~/.config/opencode/opencode.json`:
20
+
21
+ ```json
22
+ {
23
+ "plugin": ["oc-codex-multi-account@latest"]
24
+ }
25
+ ```
26
+
27
+ If you already installed an older build, re-run the GitHub install command above to override it.
28
+
29
+ ## Installation
30
+
31
+ ### Via npm (Recommended)
32
+
33
+ Add to your `~/.config/opencode/opencode.json`:
34
+
35
+ ```json
36
+ {
37
+ "plugin": ["oc-codex-multi-account@latest"]
38
+ }
39
+ ```
40
+
41
+ OpenCode will auto-install on first run.
42
+
43
+ ### Manual Install
44
+
45
+ If auto-install fails, install manually:
46
+
47
+ ```bash
48
+ bun add oc-codex-multi-account --cwd ~/.config/opencode
49
+ ```
50
+
51
+ ### From Source
52
+
53
+ ```bash
54
+ git clone https://github.com/gaboe/oc-codex-multi-account.git
55
+ cd oc-codex-multi-account
56
+ bun install
57
+ bun run build
58
+ bun link
59
+ ```
60
+
61
+ ## Add Your Accounts
62
+
63
+ ```bash
64
+ # Add each account (opens browser for OAuth)
65
+ opencode-multi-auth add personal
66
+ opencode-multi-auth add work
67
+ opencode-multi-auth add backup
68
+
69
+ # Each command opens your browser - log in with a different ChatGPT account each time
70
+ ```
71
+
72
+ ## Verify Setup
73
+
74
+ ```bash
75
+ opencode-multi-auth status
76
+ ```
77
+
78
+ Output:
79
+ ```
80
+ [multi-auth] Account Status
81
+
82
+ Strategy: round-robin
83
+ Accounts: 3
84
+ Active: personal
85
+
86
+ personal (active)
87
+ Email: you@personal.com
88
+ Uses: 12
89
+ Token expires: 12/25/2025, 3:00:00 PM
90
+
91
+ work
92
+ Email: you@work.com
93
+ Uses: 10
94
+ Token expires: 12/25/2025, 3:00:00 PM
95
+
96
+ backup
97
+ Email: you@backup.com
98
+ Uses: 8
99
+ Token expires: 12/25/2025, 3:00:00 PM
100
+ ```
101
+
102
+ ## Web Dashboard (Local Only)
103
+
104
+ Launch the local dashboard:
105
+
106
+ ```bash
107
+ opencode-multi-auth web --port 3434 --host 127.0.0.1
108
+ ```
109
+
110
+ Or from the repo:
111
+
112
+ ```bash
113
+ npm run web
114
+ ```
115
+
116
+ Open `http://127.0.0.1:3434` to manage Codex CLI tokens from `~/.codex/auth.json`:
117
+ - Sync current auth.json token into your local list
118
+ - See which token is active on the device
119
+ - Switch auth.json to a stored token
120
+ - Refresh OAuth tokens (per-token or all)
121
+ - Refresh 5-hour and weekly limits manually (probe-run per alias)
122
+ - Search/filter by alias/email/tags/notes
123
+ - Sort by remaining limits, expiry, or alias; recommended token badge
124
+ - Tag and annotate tokens (notes)
125
+ - Queue-based refresh with progress + stop
126
+ - Limit history sparklines and trend rate
127
+ - Built-in log view
128
+
129
+ The dashboard watches `~/.codex/auth.json` and will add new tokens as you log in via Codex CLI.
130
+
131
+ Limit refresh runs `codex exec` in a per-alias sandbox (`~/.codex-multi/<alias>`) so you can
132
+ update limits for any stored token without switching the active device token.
133
+
134
+ ### Optional Store Encryption
135
+
136
+ Set `CODEX_SOFT_STORE_PASSPHRASE` to encrypt `~/.config/opencode-multi-auth/accounts.json` at rest:
137
+
138
+ ```bash
139
+ export CODEX_SOFT_STORE_PASSPHRASE="your-passphrase"
140
+ ```
141
+
142
+ If the store is encrypted and the passphrase is missing, the UI will show a locked status and refuse to overwrite.
143
+
144
+ ### Systemd Autostart (user service)
145
+
146
+ Install and enable the user service:
147
+
148
+ ```bash
149
+ opencode-multi-auth service install --port 3434 --host 127.0.0.1
150
+ ```
151
+
152
+ Check status or disable:
153
+
154
+ ```bash
155
+ opencode-multi-auth service status
156
+ opencode-multi-auth service disable
157
+ ```
158
+
159
+ ### Logs
160
+
161
+ The dashboard writes logs to `~/.config/opencode-multi-auth/logs/codex-soft.log` by default.
162
+ Override with `CODEX_SOFT_LOG_PATH` if you want a custom path.
163
+
164
+ ## Configure OpenCode
165
+
166
+ Add to your `~/.config/opencode/opencode.json`:
167
+
168
+ ```json
169
+ {
170
+ "plugin": ["oc-codex-multi-account@latest"]
171
+ }
172
+ ```
173
+
174
+ Or with other plugins:
175
+
176
+ ```json
177
+ {
178
+ "plugin": [
179
+ "oh-my-opencode",
180
+ "oc-codex-multi-account@latest"
181
+ ]
182
+ }
183
+ ```
184
+
185
+
186
+ ## Background Notifications (macOS)
187
+
188
+
189
+ ### iPhone notifications via ntfy (click to open session)
190
+
191
+ If you want push notifications on iOS (with a clickable link to the OpenCode web session), use `ntfy`.
192
+
193
+ 1) Install the **ntfy** app on iPhone and subscribe to a topic.
194
+
195
+ 2) Set these env vars on the Mac where OpenCode runs:
196
+
197
+ - `OPENCODE_MULTI_AUTH_NOTIFY_NTFY_URL`
198
+ Example: `https://ntfy.sh/<your-topic>` (or your self-hosted ntfy URL)
199
+ - `OPENCODE_MULTI_AUTH_NOTIFY_UI_BASE_URL`
200
+ Base URL of your OpenCode web UI reachable from iPhone.
201
+ Example (Tailscale): `http://100.x.y.z:4096`
202
+ - Optional: `OPENCODE_MULTI_AUTH_NOTIFY_NTFY_TOKEN` (Bearer token)
203
+
204
+ The plugin sends notifications for:
205
+
206
+ - `session.idle` (finished): priority `3`
207
+ - `session.status` with `retry`: priority `4`
208
+ - `session.error`: priority `5`
209
+
210
+ When possible, the notification body includes `Project` + session `Title`, plus the `sessionID`.
211
+ It also attaches a `Click:` URL like `<base>/session/<sessionID>` so tapping the push opens the session.
212
+
213
+ This plugin can send a **macOS notification + sound** when a session finishes work.
214
+ It listens for OpenCode events (`session.status` and `session.idle`).
215
+
216
+ Defaults:
217
+ - Enabled by default
218
+ - Sound: `/System/Library/Sounds/Glass.aiff`
219
+
220
+ Environment variables:
221
+ - `OPENCODE_MULTI_AUTH_NOTIFY=0` disables notifications
222
+ - `OPENCODE_MULTI_AUTH_NOTIFY_SOUND=/path/to/sound.aiff` overrides the sound
223
+ - `OPENCODE_MULTI_AUTH_NOTIFY_MAC_OPEN=0` disables click-to-open on macOS (when available)
224
+
225
+ Clickable macOS notifications require `terminal-notifier` (optional). If installed, clicking the banner opens the session URL.
226
+
227
+ If OpenCode seems to only make progress when the window is focused, macOS may be throttling it.
228
+ Try disabling App Nap for OpenCode.app (Finder -> Get Info -> Prevent App Nap),
229
+ or run the server from a terminal under `caffeinate`.
230
+
231
+ ## Codex Latest Model Mapping
232
+
233
+ OpenCode may not list the newest Codex model yet (it keeps an internal allowlist).
234
+ This plugin can still use the newest model by **mapping** the selected Codex model
235
+ to the latest backend model on ChatGPT.
236
+
237
+ Default behavior:
238
+ - If you select `openai/gpt-5.2-codex` (or `openai/gpt-5-codex`), the plugin will send requests as `gpt-5.3-codex`.
239
+
240
+ Environment variables:
241
+ - `OPENCODE_MULTI_AUTH_PREFER_CODEX_LATEST=0` disables the mapping (use exact model).
242
+ - `OPENCODE_MULTI_AUTH_CODEX_LATEST_MODEL=gpt-5.3-codex` overrides the target model.
243
+ - `OPENCODE_MULTI_AUTH_DEBUG=1` prints mapping logs like: `model map: gpt-5.2-codex -> gpt-5.3-codex`.
244
+
245
+ ## Troubleshooting
246
+
247
+ ### BunInstallFailedError (DependencyLoop)
248
+
249
+ If OpenCode fails to boot with:
250
+
251
+ ```
252
+ BunInstallFailedError
253
+ { "pkg": "oc-codex-multi-account", "version": "latest" }
254
+ ```
255
+
256
+ It usually means an older `@a3fckx/opencode-multi-auth` dependency is still present.
257
+
258
+ Fix:
259
+
260
+ 1) Remove the old dependency from `~/.config/opencode/package.json`:
261
+
262
+ ```json
263
+ {
264
+ "dependencies": {
265
+ "@a3fckx/opencode-multi-auth": "^1.0.4"
266
+ }
267
+ }
268
+ ```
269
+
270
+ 2) Reinstall:
271
+
272
+ ```bash
273
+ bun add oc-codex-multi-account --cwd ~/.config/opencode
274
+ ```
275
+
276
+ Optional fallback: use a file path plugin entry if installs are blocked:
277
+
278
+ ```json
279
+ {
280
+ "plugin": [
281
+ "file:///Users/<you>/.config/opencode/node_modules/oc-codex-multi-account/dist/index.js"
282
+ ]
283
+ }
284
+ ```
285
+
286
+ ## How It Works
287
+
288
+ | Feature | Behavior |
289
+ |---------|----------|
290
+ | **Rotation** | Round-robin across all accounts per API call |
291
+ | **Rate Limits** | Auto-skips rate-limited account for 5 min, uses next |
292
+ | **Token Refresh** | Auto-refreshes tokens before expiry |
293
+ | **Models** | Auto-discovers GPT-5.x models from OpenAI API |
294
+ | **Storage** | `~/.config/opencode-multi-auth/accounts.json` |
295
+
296
+ ## CLI Commands
297
+
298
+ | Command | Description |
299
+ |---------|-------------|
300
+ | `add <alias>` | Add new account via OAuth (opens browser) |
301
+ | `remove <alias>` | Remove an account |
302
+ | `list` | List all configured accounts |
303
+ | `status` | Detailed status with usage counts |
304
+ | `path` | Show config file location |
305
+ | `web` | Launch local Codex auth.json dashboard |
306
+ | `service` | Install/disable systemd user service |
307
+ | `help` | Show help message |
308
+
309
+ ## Requirements
310
+
311
+ - ChatGPT Plus/Pro subscription(s)
312
+ - OpenCode CLI
313
+
314
+ ## Credits
315
+
316
+ - Original OAuth implementation: [numman-ali/opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth)
317
+ - Multi-account rotation: [@a3fckx](https://github.com/a3fckx)
318
+
319
+ ## License
320
+
321
+ MIT
@@ -0,0 +1,3 @@
1
+ import type { Auth } from '@opencode-ai/sdk';
2
+ export declare function syncAuthFromOpenCode(getAuth: () => Promise<Auth>): Promise<void>;
3
+ //# sourceMappingURL=auth-sync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-sync.d.ts","sourceRoot":"","sources":["../src/auth-sync.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAkD5C,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDtF"}
@@ -0,0 +1,105 @@
1
+ import { addAccount, loadStore, updateAccount } from './store.js';
2
+ import { decodeJwtPayload, getAccountIdFromClaims, getEmailFromClaims } from './codex-auth.js';
3
+ const OPENAI_ISSUER = 'https://auth.openai.com';
4
+ const AUTH_SYNC_COOLDOWN_MS = 10_000;
5
+ let lastSyncedAccess = null;
6
+ let lastSyncAt = 0;
7
+ async function fetchEmail(accessToken) {
8
+ try {
9
+ const res = await fetch(`${OPENAI_ISSUER}/userinfo`, {
10
+ headers: { Authorization: `Bearer ${accessToken}` }
11
+ });
12
+ if (!res.ok)
13
+ return undefined;
14
+ const user = (await res.json());
15
+ return user.email;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ function findAccountAliasByToken(access, refresh) {
22
+ const store = loadStore();
23
+ for (const account of Object.values(store.accounts)) {
24
+ if (account.accessToken === access)
25
+ return account.alias;
26
+ if (refresh && account.refreshToken === refresh)
27
+ return account.alias;
28
+ }
29
+ return null;
30
+ }
31
+ function findAccountAliasByEmail(email, store) {
32
+ for (const account of Object.values(store.accounts)) {
33
+ if (account.email && account.email === email)
34
+ return account.alias;
35
+ }
36
+ return null;
37
+ }
38
+ function buildAlias(email, existingAliases) {
39
+ const base = email ? email.split('@')[0] : 'account';
40
+ let candidate = base || 'account';
41
+ let suffix = 1;
42
+ while (existingAliases.has(candidate)) {
43
+ candidate = `${base}-${suffix}`;
44
+ suffix += 1;
45
+ }
46
+ return candidate;
47
+ }
48
+ export async function syncAuthFromOpenCode(getAuth) {
49
+ const now = Date.now();
50
+ if (now - lastSyncAt < AUTH_SYNC_COOLDOWN_MS)
51
+ return;
52
+ lastSyncAt = now;
53
+ let auth = null;
54
+ try {
55
+ auth = await getAuth();
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ if (!auth || auth.type !== 'oauth')
61
+ return;
62
+ if (!auth.access)
63
+ return;
64
+ if (auth.access === lastSyncedAccess)
65
+ return;
66
+ lastSyncedAccess = auth.access;
67
+ const existingAlias = findAccountAliasByToken(auth.access, auth.refresh);
68
+ const accessClaims = decodeJwtPayload(auth.access);
69
+ const derivedEmail = getEmailFromClaims(accessClaims);
70
+ const derivedAccountId = getAccountIdFromClaims(accessClaims);
71
+ if (existingAlias) {
72
+ updateAccount(existingAlias, {
73
+ accessToken: auth.access,
74
+ refreshToken: auth.refresh,
75
+ expiresAt: auth.expires,
76
+ email: derivedEmail,
77
+ accountId: derivedAccountId
78
+ });
79
+ return;
80
+ }
81
+ const store = loadStore();
82
+ const email = (await fetchEmail(auth.access)) || derivedEmail;
83
+ if (email) {
84
+ const existingByEmail = findAccountAliasByEmail(email, store);
85
+ if (existingByEmail) {
86
+ updateAccount(existingByEmail, {
87
+ accessToken: auth.access,
88
+ refreshToken: auth.refresh,
89
+ expiresAt: auth.expires,
90
+ email
91
+ });
92
+ return;
93
+ }
94
+ }
95
+ const alias = buildAlias(email, new Set(Object.keys(store.accounts)));
96
+ addAccount(alias, {
97
+ accessToken: auth.access,
98
+ refreshToken: auth.refresh,
99
+ expiresAt: auth.expires,
100
+ email,
101
+ accountId: derivedAccountId,
102
+ source: 'opencode'
103
+ });
104
+ }
105
+ //# sourceMappingURL=auth-sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-sync.js","sourceRoot":"","sources":["../src/auth-sync.ts"],"names":[],"mappings":"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"}
package/dist/auth.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { AccountCredentials } from './types.js';
2
+ interface AuthorizationFlow {
3
+ pkce: {
4
+ verifier: string;
5
+ challenge: string;
6
+ };
7
+ state: string;
8
+ url: string;
9
+ }
10
+ export declare function createAuthorizationFlow(): Promise<AuthorizationFlow>;
11
+ export declare function loginAccount(alias: string, flow?: AuthorizationFlow): Promise<AccountCredentials>;
12
+ export declare function refreshToken(alias: string): Promise<AccountCredentials | null>;
13
+ export declare function ensureValidToken(alias: string): Promise<string | null>;
14
+ export {};
15
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAsBpD,UAAU,iBAAiB;IACzB,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;IAC7C,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAiB1E;AAED,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,iBAAiB,GACvB,OAAO,CAAC,kBAAkB,CAAC,CA8I7B;AAED,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CA+DpF;AAED,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAe5E"}
package/dist/auth.js ADDED
@@ -0,0 +1,236 @@
1
+ import { generatePKCE } from '@openauthjs/openauth/pkce';
2
+ import { randomBytes } from 'node:crypto';
3
+ import * as http from 'http';
4
+ import * as url from 'url';
5
+ import * as path from 'node:path';
6
+ import * as os from 'node:os';
7
+ import { addAccount, updateAccount, loadStore } from './store.js';
8
+ import { clearAuthInvalid } from './rotation.js';
9
+ import { decodeJwtPayload, getAccountIdFromClaims, getEmailFromClaims, getExpiryFromClaims } from './codex-auth.js';
10
+ // OpenAI OAuth endpoints (same as official Codex CLI)
11
+ const OPENAI_ISSUER = 'https://auth.openai.com';
12
+ const AUTHORIZE_URL = `${OPENAI_ISSUER}/oauth/authorize`;
13
+ const TOKEN_URL = `${OPENAI_ISSUER}/oauth/token`;
14
+ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
15
+ const REDIRECT_PORT = 1455;
16
+ const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/auth/callback`;
17
+ const SCOPES = ['openid', 'profile', 'email', 'offline_access'];
18
+ const FLOW_FILE_DIR = path.join(os.homedir(), '.config', 'opencode-multi-auth');
19
+ const FLOW_FILE = path.join(FLOW_FILE_DIR, 'pending-flow.json');
20
+ export async function createAuthorizationFlow() {
21
+ const pkce = await generatePKCE();
22
+ const state = randomBytes(16).toString('hex');
23
+ const authUrl = new URL(AUTHORIZE_URL);
24
+ authUrl.searchParams.set('client_id', CLIENT_ID);
25
+ authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
26
+ authUrl.searchParams.set('response_type', 'code');
27
+ authUrl.searchParams.set('scope', SCOPES.join(' '));
28
+ authUrl.searchParams.set('code_challenge', pkce.challenge);
29
+ authUrl.searchParams.set('code_challenge_method', 'S256');
30
+ authUrl.searchParams.set('state', state);
31
+ authUrl.searchParams.set('audience', 'https://api.openai.com/v1');
32
+ authUrl.searchParams.set('id_token_add_organizations', 'true');
33
+ authUrl.searchParams.set('codex_cli_simplified_flow', 'true');
34
+ authUrl.searchParams.set('originator', 'codex_cli_rs');
35
+ return { pkce, state, url: authUrl.toString() };
36
+ }
37
+ export async function loginAccount(alias, flow) {
38
+ const activeFlow = flow ?? await createAuthorizationFlow();
39
+ const { pkce, state } = activeFlow;
40
+ return new Promise((resolve, reject) => {
41
+ let server = null;
42
+ const cleanup = () => {
43
+ if (server) {
44
+ server.close();
45
+ server = null;
46
+ }
47
+ };
48
+ server = http.createServer(async (req, res) => {
49
+ if (!req.url?.startsWith('/auth/callback')) {
50
+ res.writeHead(404);
51
+ res.end('Not found');
52
+ return;
53
+ }
54
+ const parsedUrl = url.parse(req.url, true);
55
+ const code = parsedUrl.query.code;
56
+ const returnedState = parsedUrl.query.state;
57
+ if (!code) {
58
+ res.writeHead(400);
59
+ res.end('No authorization code received');
60
+ cleanup();
61
+ reject(new Error('No authorization code'));
62
+ return;
63
+ }
64
+ if (returnedState && returnedState !== state) {
65
+ res.writeHead(400);
66
+ res.end('Invalid state');
67
+ cleanup();
68
+ reject(new Error('Invalid state'));
69
+ return;
70
+ }
71
+ try {
72
+ // Exchange code for tokens
73
+ const tokenRes = await fetch(TOKEN_URL, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
+ body: new URLSearchParams({
77
+ grant_type: 'authorization_code',
78
+ client_id: CLIENT_ID,
79
+ code,
80
+ code_verifier: pkce.verifier,
81
+ redirect_uri: REDIRECT_URI
82
+ })
83
+ });
84
+ if (!tokenRes.ok) {
85
+ throw new Error(`Token exchange failed: ${tokenRes.status}`);
86
+ }
87
+ const tokens = (await tokenRes.json());
88
+ if (!tokens.refresh_token) {
89
+ throw new Error('Token exchange did not return a refresh_token');
90
+ }
91
+ const now = Date.now();
92
+ const accessClaims = decodeJwtPayload(tokens.access_token);
93
+ const idClaims = tokens.id_token ? decodeJwtPayload(tokens.id_token) : null;
94
+ const expiresAt = getExpiryFromClaims(accessClaims) || getExpiryFromClaims(idClaims) || now + tokens.expires_in * 1000;
95
+ let email = getEmailFromClaims(idClaims) || getEmailFromClaims(accessClaims);
96
+ try {
97
+ const userRes = await fetch(`${OPENAI_ISSUER}/userinfo`, {
98
+ headers: { Authorization: `Bearer ${tokens.access_token}` }
99
+ });
100
+ if (userRes.ok) {
101
+ const user = (await userRes.json());
102
+ email = user.email || email;
103
+ }
104
+ }
105
+ catch {
106
+ /* user info fetch is non-critical */
107
+ }
108
+ const accountId = getAccountIdFromClaims(idClaims) ||
109
+ getAccountIdFromClaims(accessClaims);
110
+ const store = addAccount(alias, {
111
+ accessToken: tokens.access_token,
112
+ refreshToken: tokens.refresh_token,
113
+ idToken: tokens.id_token,
114
+ accountId,
115
+ expiresAt,
116
+ email,
117
+ lastRefresh: new Date(now).toISOString(),
118
+ lastSeenAt: now,
119
+ source: 'opencode',
120
+ authInvalid: false,
121
+ authInvalidatedAt: undefined
122
+ });
123
+ const account = store.accounts[alias];
124
+ res.writeHead(200, { 'Content-Type': 'text/html' });
125
+ res.end(`
126
+ <html>
127
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
128
+ <h1>Account "${alias}" authenticated!</h1>
129
+ <p>${email || 'Unknown email'}</p>
130
+ <p>You can close this window.</p>
131
+ </body>
132
+ </html>
133
+ `);
134
+ cleanup();
135
+ resolve(account);
136
+ }
137
+ catch (err) {
138
+ res.writeHead(500);
139
+ res.end('Authentication failed');
140
+ cleanup();
141
+ reject(err);
142
+ }
143
+ });
144
+ server.listen(REDIRECT_PORT, () => {
145
+ console.log(`\n[multi-auth] Login for account "${alias}"`);
146
+ console.log(`[multi-auth] Open this URL in your browser:\n`);
147
+ console.log(` ${activeFlow.url}\n`);
148
+ console.log(`[multi-auth] Waiting for callback on port ${REDIRECT_PORT}...`);
149
+ });
150
+ server.on('error', (err) => {
151
+ if (err.code === 'EADDRINUSE') {
152
+ reject(new Error(`Port ${REDIRECT_PORT} is in use. Stop Codex CLI if running.`));
153
+ }
154
+ else {
155
+ reject(err);
156
+ }
157
+ });
158
+ // Timeout after 5 minutes
159
+ setTimeout(() => {
160
+ cleanup();
161
+ reject(new Error('Login timeout - no callback received'));
162
+ }, 5 * 60 * 1000);
163
+ });
164
+ }
165
+ export async function refreshToken(alias) {
166
+ const store = loadStore();
167
+ const account = store.accounts[alias];
168
+ if (!account?.refreshToken) {
169
+ console.error(`[multi-auth] No refresh token for ${alias}`);
170
+ return null;
171
+ }
172
+ try {
173
+ const tokenRes = await fetch(TOKEN_URL, {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
176
+ body: new URLSearchParams({
177
+ grant_type: 'refresh_token',
178
+ client_id: CLIENT_ID,
179
+ refresh_token: account.refreshToken
180
+ })
181
+ });
182
+ if (!tokenRes.ok) {
183
+ console.error(`[multi-auth] Refresh failed for ${alias}: ${tokenRes.status}`);
184
+ // If the refresh token is invalid/expired, mark this account invalid so
185
+ // rotation can keep working without repeatedly selecting a broken account.
186
+ if (tokenRes.status === 401 || tokenRes.status === 403) {
187
+ try {
188
+ updateAccount(alias, {
189
+ authInvalid: true,
190
+ authInvalidatedAt: Date.now()
191
+ });
192
+ }
193
+ catch {
194
+ // ignore
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+ const tokens = (await tokenRes.json());
200
+ const accessClaims = decodeJwtPayload(tokens.access_token);
201
+ const idClaims = tokens.id_token ? decodeJwtPayload(tokens.id_token) : null;
202
+ const expiresAt = getExpiryFromClaims(accessClaims) || getExpiryFromClaims(idClaims) || Date.now() + tokens.expires_in * 1000;
203
+ const updates = {
204
+ accessToken: tokens.access_token,
205
+ refreshToken: tokens.refresh_token || account.refreshToken,
206
+ expiresAt,
207
+ lastRefresh: new Date().toISOString(),
208
+ idToken: tokens.id_token || account.idToken,
209
+ accountId: getAccountIdFromClaims(idClaims) ||
210
+ getAccountIdFromClaims(accessClaims) ||
211
+ account.accountId
212
+ };
213
+ const updatedStore = updateAccount(alias, updates);
214
+ clearAuthInvalid(alias);
215
+ return updatedStore.accounts[alias];
216
+ }
217
+ catch (err) {
218
+ console.error(`[multi-auth] Refresh error for ${alias}:`, err);
219
+ return null;
220
+ }
221
+ }
222
+ export async function ensureValidToken(alias) {
223
+ const store = loadStore();
224
+ const account = store.accounts[alias];
225
+ if (!account)
226
+ return null;
227
+ // Refresh if expiring within 5 minutes
228
+ const bufferMs = 5 * 60 * 1000;
229
+ if (account.expiresAt < Date.now() + bufferMs) {
230
+ console.log(`[multi-auth] Refreshing token for ${alias}`);
231
+ const refreshed = await refreshToken(alias);
232
+ return refreshed?.accessToken || null;
233
+ }
234
+ return account.accessToken;
235
+ }
236
+ //# sourceMappingURL=auth.js.map