icebear commited on
Commit
c0825ff
Β·
unverified Β·
2 Parent(s): 1e8f1355dd5107

Merge pull request #4 from icebear0828/feat/native-oauth

Browse files

feat: native OAuth PKCE login with localhost:1455 redirect

config/default.yaml CHANGED
@@ -19,9 +19,12 @@ auth:
19
  refresh_margin_seconds: 300
20
  rotation_strategy: "least_used"
21
  rate_limit_backoff_seconds: 60
 
 
 
22
 
23
  server:
24
- host: "0.0.0.0"
25
  port: 8080
26
  proxy_api_key: null
27
 
 
19
  refresh_margin_seconds: 300
20
  rotation_strategy: "least_used"
21
  rate_limit_backoff_seconds: 60
22
+ oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann"
23
+ oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize"
24
+ oauth_token_endpoint: "https://auth.openai.com/oauth/token"
25
 
26
  server:
27
+ host: "::"
28
  port: 8080
29
  proxy_api_key: null
30
 
public/dashboard.html CHANGED
@@ -233,6 +233,78 @@
233
  font-size: 0.8rem;
234
  margin-top: 0.5rem;
235
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  .spinner {
237
  display: inline-block;
238
  width: 14px;
@@ -266,9 +338,33 @@
266
  <h2>Accounts</h2>
267
  <div id="accountList" class="loading">Loading accounts...</div>
268
  <div class="add-account-section">
269
- <button class="btn-login" id="loginBtn" onclick="startLogin()">Add Account via ChatGPT Login</button>
270
- <div class="login-info" id="loginInfo" style="display:none"></div>
271
- <div class="login-error" id="loginError" style="display:none"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  </div>
273
  </div>
274
 
@@ -331,6 +427,19 @@ Loading...
331
  <script>
332
  let authData = null;
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  function statusClass(status) {
335
  const map = {
336
  active: 'status-ok',
@@ -403,98 +512,6 @@ Loading...
403
  container.innerHTML = html;
404
  }
405
 
406
- let loginPolling = false;
407
- let knownAccountIds = new Set();
408
-
409
- async function startLogin() {
410
- const btn = document.getElementById('loginBtn');
411
- const infoEl = document.getElementById('loginInfo');
412
- const errorEl = document.getElementById('loginError');
413
-
414
- btn.disabled = true;
415
- btn.innerHTML = '<span class="spinner"></span> Connecting to Codex CLI...';
416
- infoEl.style.display = 'none';
417
- errorEl.style.display = 'none';
418
-
419
- // Snapshot current account IDs so we can detect the new one
420
- try {
421
- const resp = await fetch('/auth/accounts');
422
- const data = await resp.json();
423
- knownAccountIds = new Set((data.accounts || []).map(a => a.id));
424
- } catch {}
425
-
426
- try {
427
- const resp = await fetch('/auth/accounts/login');
428
- const data = await resp.json();
429
-
430
- if (data.error) {
431
- errorEl.textContent = data.error;
432
- errorEl.style.display = 'block';
433
- btn.disabled = false;
434
- btn.innerHTML = 'Add Account via ChatGPT Login';
435
- return;
436
- }
437
-
438
- if (data.authUrl) {
439
- window.open(data.authUrl, '_blank');
440
- btn.innerHTML = '<span class="spinner"></span> Waiting for login...';
441
- infoEl.textContent = 'A new tab has been opened. Complete the ChatGPT login there.';
442
- infoEl.style.display = 'block';
443
- startLoginPolling();
444
- }
445
- } catch (err) {
446
- errorEl.textContent = 'Network error: ' + err.message;
447
- errorEl.style.display = 'block';
448
- btn.disabled = false;
449
- btn.innerHTML = 'Add Account via ChatGPT Login';
450
- }
451
- }
452
-
453
- function startLoginPolling() {
454
- if (loginPolling) return;
455
- loginPolling = true;
456
-
457
- const poll = async () => {
458
- if (!loginPolling) return;
459
- try {
460
- const resp = await fetch('/auth/accounts');
461
- const data = await resp.json();
462
- const accounts = data.accounts || [];
463
- const newAccount = accounts.find(a => !knownAccountIds.has(a.id));
464
-
465
- if (newAccount) {
466
- loginPolling = false;
467
- const btn = document.getElementById('loginBtn');
468
- const infoEl = document.getElementById('loginInfo');
469
- btn.disabled = false;
470
- btn.innerHTML = 'Add Account via ChatGPT Login';
471
- infoEl.textContent = 'Account added: ' + (newAccount.email || newAccount.id);
472
- infoEl.style.display = 'block';
473
- await loadAccounts();
474
- await loadStatus();
475
- setTimeout(() => { infoEl.style.display = 'none'; }, 4000);
476
- return;
477
- }
478
- } catch {}
479
- if (loginPolling) setTimeout(poll, 2000);
480
- };
481
-
482
- poll();
483
-
484
- // Timeout after 5 minutes
485
- setTimeout(() => {
486
- if (loginPolling) {
487
- loginPolling = false;
488
- const btn = document.getElementById('loginBtn');
489
- btn.disabled = false;
490
- btn.innerHTML = 'Add Account via ChatGPT Login';
491
- document.getElementById('loginInfo').style.display = 'none';
492
- document.getElementById('loginError').textContent = 'Login timed out. Please try again.';
493
- document.getElementById('loginError').style.display = 'block';
494
- }
495
- }, 5 * 60 * 1000);
496
- }
497
-
498
  async function deleteAccount(id) {
499
  if (!confirm('Remove this account?')) return;
500
 
@@ -644,6 +661,210 @@ for await (const chunk of stream) {
644
  window.location.href = '/';
645
  }
646
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  loadStatus();
648
  loadAccounts();
649
  </script>
 
233
  font-size: 0.8rem;
234
  margin-top: 0.5rem;
235
  }
236
+ .paste-section {
237
+ display: none;
238
+ margin-top: 1rem;
239
+ background: #0d1117;
240
+ border: 1px solid #30363d;
241
+ border-radius: 8px;
242
+ padding: 1rem;
243
+ }
244
+ .paste-section label {
245
+ display: block;
246
+ font-size: 0.82rem;
247
+ color: #8b949e;
248
+ margin-bottom: 0.5rem;
249
+ }
250
+ .paste-section textarea {
251
+ width: 100%;
252
+ padding: 8px;
253
+ background: #161b22;
254
+ border: 1px solid #30363d;
255
+ border-radius: 6px;
256
+ color: #c9d1d9;
257
+ font-family: monospace;
258
+ font-size: 0.8rem;
259
+ resize: vertical;
260
+ min-height: 60px;
261
+ margin-bottom: 0.5rem;
262
+ }
263
+ .paste-section textarea:focus {
264
+ outline: none;
265
+ border-color: #58a6ff;
266
+ }
267
+ .paste-section .hint {
268
+ font-size: 0.75rem;
269
+ color: #484f58;
270
+ margin-bottom: 0.75rem;
271
+ line-height: 1.4;
272
+ }
273
+ .btn-device {
274
+ display: inline-flex;
275
+ align-items: center;
276
+ gap: 0.5rem;
277
+ padding: 8px 16px;
278
+ background: #21262d;
279
+ border: 1px solid #30363d;
280
+ border-radius: 8px;
281
+ color: #c9d1d9;
282
+ cursor: pointer;
283
+ font-size: 0.85rem;
284
+ font-weight: 500;
285
+ margin-top: 0.5rem;
286
+ }
287
+ .btn-device:hover { background: #30363d; }
288
+ .btn-device:disabled { opacity: 0.5; cursor: not-allowed; }
289
+ .device-code-panel {
290
+ background: #0d1117;
291
+ border: 1px solid #30363d;
292
+ border-radius: 8px;
293
+ padding: 1.25rem;
294
+ text-align: center;
295
+ margin-top: 0.75rem;
296
+ display: none;
297
+ }
298
+ .device-code-panel .code-display {
299
+ font-family: monospace;
300
+ font-size: 1.6rem;
301
+ font-weight: 700;
302
+ color: #58a6ff;
303
+ letter-spacing: 0.1em;
304
+ margin: 0.5rem 0;
305
+ }
306
+ .device-code-panel a { color: #3fb950; font-size: 0.85rem; }
307
+ .device-code-panel .wait-text { color: #8b949e; font-size: 0.8rem; margin-top: 0.5rem; }
308
  .spinner {
309
  display: inline-block;
310
  width: 14px;
 
338
  <h2>Accounts</h2>
339
  <div id="accountList" class="loading">Loading accounts...</div>
340
  <div class="add-account-section">
341
+ <button class="btn-login" id="addAccountBtn" onclick="startAddAccount()">Add Account via ChatGPT Login</button>
342
+ <div class="login-info" id="addInfo" style="display:none"></div>
343
+ <div class="login-error" id="addError" style="display:none"></div>
344
+ <div class="paste-section" id="addPasteSection">
345
+ <div class="hint">
346
+ If the popup shows an error or you're on a different machine,
347
+ copy the full URL from the popup's address bar and paste it here.
348
+ </div>
349
+ <label>Paste the callback URL</label>
350
+ <textarea id="addCallbackInput" placeholder="http://localhost:54321/auth/callback?code=...&state=..."></textarea>
351
+ <button class="btn-login" id="addRelayBtn" onclick="submitAddRelay()" style="font-size:0.8rem;padding:6px 14px">Submit</button>
352
+ </div>
353
+
354
+ <div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.75rem">
355
+ <button class="btn-device" id="dashDeviceBtn" onclick="dashStartDeviceCode()">Device Code Login</button>
356
+ <button class="btn-device" id="dashCliBtn" onclick="dashImportCli()">Import CLI Token</button>
357
+ </div>
358
+ <div class="device-code-panel" id="dashDevicePanel">
359
+ <div style="color:#8b949e;font-size:0.82rem">Enter this code at:</div>
360
+ <a id="dashVerifyLink" href="#" target="_blank" rel="noopener"></a>
361
+ <div class="code-display" id="dashUserCode"></div>
362
+ <div class="wait-text" id="dashDeviceStatus"><span class="spinner"></span> Waiting for authorization...</div>
363
+ <div class="login-error" id="dashDeviceError" style="display:none"></div>
364
+ <div class="login-info" id="dashDeviceSuccess" style="display:none"></div>
365
+ </div>
366
+ <div class="login-error" id="dashCliError" style="display:none"></div>
367
+ <div class="login-info" id="dashCliSuccess" style="display:none"></div>
368
  </div>
369
  </div>
370
 
 
427
  <script>
428
  let authData = null;
429
 
430
+ // Listen for postMessage from OAuth callback popup (cross-port communication)
431
+ window.addEventListener('message', async (event) => {
432
+ if (event.data?.type === 'oauth-callback-success') {
433
+ if (addPollTimer) clearInterval(addPollTimer);
434
+ document.getElementById('addPasteSection').style.display = 'none';
435
+ const infoEl = document.getElementById('addInfo');
436
+ infoEl.textContent = 'Account added successfully!';
437
+ infoEl.style.display = 'block';
438
+ await loadAccounts();
439
+ await loadStatus();
440
+ }
441
+ });
442
+
443
  function statusClass(status) {
444
  const map = {
445
  active: 'status-ok',
 
512
  container.innerHTML = html;
513
  }
514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  async function deleteAccount(id) {
516
  if (!confirm('Remove this account?')) return;
517
 
 
661
  window.location.href = '/';
662
  }
663
 
664
+ let addPollTimer = null;
665
+
666
+ async function startAddAccount() {
667
+ const btn = document.getElementById('addAccountBtn');
668
+ const infoEl = document.getElementById('addInfo');
669
+ const errEl = document.getElementById('addError');
670
+ infoEl.style.display = 'none';
671
+ errEl.style.display = 'none';
672
+ btn.disabled = true;
673
+ btn.textContent = 'Opening login...';
674
+
675
+ try {
676
+ const resp = await fetch('/auth/login-start', { method: 'POST' });
677
+ const data = await resp.json();
678
+
679
+ if (!resp.ok || !data.authUrl) {
680
+ throw new Error(data.error || 'Failed to start login');
681
+ }
682
+
683
+ window.open(data.authUrl, 'oauth_add', 'width=600,height=700,scrollbars=yes');
684
+
685
+ document.getElementById('addPasteSection').style.display = 'block';
686
+ btn.textContent = 'Add Account via ChatGPT Login';
687
+ btn.disabled = false;
688
+
689
+ // Poll for new account (callback server handles same-machine)
690
+ if (addPollTimer) clearInterval(addPollTimer);
691
+ const prevCount = (await fetch('/auth/accounts').then(r => r.json())).accounts?.length || 0;
692
+ addPollTimer = setInterval(async () => {
693
+ try {
694
+ const r = await fetch('/auth/accounts');
695
+ const d = await r.json();
696
+ if ((d.accounts?.length || 0) > prevCount) {
697
+ clearInterval(addPollTimer);
698
+ document.getElementById('addPasteSection').style.display = 'none';
699
+ infoEl.textContent = 'Account added successfully!';
700
+ infoEl.style.display = 'block';
701
+ await loadAccounts();
702
+ await loadStatus();
703
+ }
704
+ } catch {}
705
+ }, 2000);
706
+ setTimeout(() => { if (addPollTimer) clearInterval(addPollTimer); }, 5 * 60 * 1000);
707
+
708
+ } catch (err) {
709
+ btn.textContent = 'Add Account via ChatGPT Login';
710
+ btn.disabled = false;
711
+ errEl.textContent = err.message;
712
+ errEl.style.display = 'block';
713
+ }
714
+ }
715
+
716
+ async function submitAddRelay() {
717
+ const callbackUrl = document.getElementById('addCallbackInput').value.trim();
718
+ const infoEl = document.getElementById('addInfo');
719
+ const errEl = document.getElementById('addError');
720
+ infoEl.style.display = 'none';
721
+ errEl.style.display = 'none';
722
+
723
+ if (!callbackUrl) {
724
+ errEl.textContent = 'Please paste the callback URL';
725
+ errEl.style.display = 'block';
726
+ return;
727
+ }
728
+
729
+ const btn = document.getElementById('addRelayBtn');
730
+ btn.disabled = true;
731
+ btn.textContent = 'Exchanging...';
732
+
733
+ try {
734
+ const resp = await fetch('/auth/code-relay', {
735
+ method: 'POST',
736
+ headers: { 'Content-Type': 'application/json' },
737
+ body: JSON.stringify({ callbackUrl }),
738
+ });
739
+ const data = await resp.json();
740
+
741
+ if (resp.ok && data.success) {
742
+ if (addPollTimer) clearInterval(addPollTimer);
743
+ document.getElementById('addPasteSection').style.display = 'none';
744
+ document.getElementById('addCallbackInput').value = '';
745
+ infoEl.textContent = 'Account added successfully!';
746
+ infoEl.style.display = 'block';
747
+ await loadAccounts();
748
+ await loadStatus();
749
+ } else {
750
+ errEl.textContent = data.error || 'Failed to exchange code';
751
+ errEl.style.display = 'block';
752
+ }
753
+ } catch (err) {
754
+ errEl.textContent = 'Network error: ' + err.message;
755
+ errEl.style.display = 'block';
756
+ } finally {
757
+ btn.textContent = 'Submit';
758
+ btn.disabled = false;
759
+ }
760
+ }
761
+
762
+ // ── Device Code Flow (dashboard) ─────────────────
763
+ let dashDevicePollTimer = null;
764
+
765
+ async function dashStartDeviceCode() {
766
+ const btn = document.getElementById('dashDeviceBtn');
767
+ const panel = document.getElementById('dashDevicePanel');
768
+ const errEl = document.getElementById('dashDeviceError');
769
+ const successEl = document.getElementById('dashDeviceSuccess');
770
+ const statusEl = document.getElementById('dashDeviceStatus');
771
+ errEl.style.display = 'none';
772
+ successEl.style.display = 'none';
773
+
774
+ btn.disabled = true;
775
+ btn.textContent = 'Requesting code...';
776
+
777
+ try {
778
+ const resp = await fetch('/auth/device-login', { method: 'POST' });
779
+ const data = await resp.json();
780
+
781
+ if (!resp.ok || !data.userCode) {
782
+ throw new Error(data.error || 'Failed to request device code');
783
+ }
784
+
785
+ panel.style.display = 'block';
786
+ document.getElementById('dashUserCode').textContent = data.userCode;
787
+ const link = document.getElementById('dashVerifyLink');
788
+ link.href = data.verificationUriComplete || data.verificationUri;
789
+ link.textContent = data.verificationUri;
790
+ statusEl.innerHTML = '<span class="spinner"></span> Waiting for authorization...';
791
+
792
+ btn.textContent = 'Device Code Login';
793
+ btn.disabled = false;
794
+
795
+ const interval = (data.interval || 5) * 1000;
796
+ const deviceCode = data.deviceCode;
797
+ if (dashDevicePollTimer) clearInterval(dashDevicePollTimer);
798
+
799
+ dashDevicePollTimer = setInterval(async () => {
800
+ try {
801
+ const pollResp = await fetch('/auth/device-poll/' + encodeURIComponent(deviceCode));
802
+ const pollData = await pollResp.json();
803
+
804
+ if (pollData.success) {
805
+ clearInterval(dashDevicePollTimer);
806
+ statusEl.innerHTML = '';
807
+ successEl.textContent = 'Account added successfully!';
808
+ successEl.style.display = 'block';
809
+ panel.style.display = 'none';
810
+ await loadAccounts();
811
+ await loadStatus();
812
+ } else if (pollData.error) {
813
+ clearInterval(dashDevicePollTimer);
814
+ statusEl.innerHTML = '';
815
+ errEl.textContent = pollData.error;
816
+ errEl.style.display = 'block';
817
+ }
818
+ } catch {}
819
+ }, interval);
820
+
821
+ setTimeout(() => {
822
+ if (dashDevicePollTimer) {
823
+ clearInterval(dashDevicePollTimer);
824
+ statusEl.textContent = 'Code expired. Please try again.';
825
+ }
826
+ }, (data.expiresIn || 900) * 1000);
827
+
828
+ } catch (err) {
829
+ btn.textContent = 'Device Code Login';
830
+ btn.disabled = false;
831
+ errEl.textContent = err.message;
832
+ errEl.style.display = 'block';
833
+ }
834
+ }
835
+
836
+ // ── CLI Token Import (dashboard) ─────────────────
837
+ async function dashImportCli() {
838
+ const btn = document.getElementById('dashCliBtn');
839
+ const errEl = document.getElementById('dashCliError');
840
+ const successEl = document.getElementById('dashCliSuccess');
841
+ errEl.style.display = 'none';
842
+ successEl.style.display = 'none';
843
+ btn.disabled = true;
844
+ btn.textContent = 'Importing...';
845
+
846
+ try {
847
+ const resp = await fetch('/auth/import-cli', { method: 'POST' });
848
+ const data = await resp.json();
849
+
850
+ if (resp.ok && data.success) {
851
+ successEl.textContent = 'CLI token imported!';
852
+ successEl.style.display = 'block';
853
+ await loadAccounts();
854
+ await loadStatus();
855
+ } else {
856
+ errEl.textContent = data.error || 'Failed to import CLI token';
857
+ errEl.style.display = 'block';
858
+ }
859
+ } catch (err) {
860
+ errEl.textContent = 'Network error: ' + err.message;
861
+ errEl.style.display = 'block';
862
+ } finally {
863
+ btn.textContent = 'Import CLI Token';
864
+ btn.disabled = false;
865
+ }
866
+ }
867
+
868
  loadStatus();
869
  loadAccounts();
870
  </script>
public/login.html CHANGED
@@ -61,9 +61,8 @@
61
  background: #2ea043;
62
  }
63
  .btn-primary:disabled {
64
- background: #1a5e28;
65
  cursor: not-allowed;
66
- opacity: 0.7;
67
  }
68
  .divider {
69
  text-align: center;
@@ -117,18 +116,75 @@
117
  margin-top: 0.5rem;
118
  display: none;
119
  }
120
- .info {
121
- color: #58a6ff;
122
- font-size: 0.85rem;
123
- margin-top: 0.5rem;
124
- display: none;
125
- }
126
  .help {
127
  margin-top: 1rem;
128
  font-size: 0.8rem;
129
  color: #484f58;
130
  line-height: 1.5;
131
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  .spinner {
133
  display: inline-block;
134
  width: 14px;
@@ -140,9 +196,7 @@
140
  vertical-align: middle;
141
  margin-right: 6px;
142
  }
143
- @keyframes spin {
144
- to { transform: rotate(360deg); }
145
- }
146
  </style>
147
  </head>
148
  <body>
@@ -152,11 +206,51 @@
152
  <p>OpenAI-compatible API for Codex Desktop</p>
153
  </div>
154
  <div class="card">
155
- <button class="btn btn-primary" id="oauthBtn" onclick="startOAuth()">
156
  Login with ChatGPT
157
  </button>
158
- <div class="info" id="oauthInfo"></div>
159
- <div class="error" id="oauthError"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
  <div class="divider">or</div>
162
 
@@ -180,92 +274,219 @@
180
  </div>
181
 
182
  <script>
183
- let polling = false;
184
 
185
- async function startOAuth() {
186
- const btn = document.getElementById('oauthBtn');
187
- const infoEl = document.getElementById('oauthInfo');
188
- const errorEl = document.getElementById('oauthError');
 
 
 
189
 
 
 
190
  btn.disabled = true;
191
- btn.innerHTML = '<span class="spinner"></span>Connecting to Codex CLI...';
192
- infoEl.style.display = 'none';
193
- errorEl.style.display = 'none';
194
 
195
  try {
196
- const resp = await fetch('/auth/login');
197
  const data = await resp.json();
198
 
199
- if (data.authenticated) {
200
- window.location.href = '/';
201
- return;
202
  }
203
 
204
- if (data.error) {
205
- errorEl.textContent = data.error;
206
- errorEl.style.display = 'block';
207
- btn.disabled = false;
208
- btn.textContent = 'Login with ChatGPT';
209
- return;
210
- }
211
 
212
- if (data.authUrl) {
213
- // Open Auth0 login in new tab
214
- window.open(data.authUrl, '_blank');
 
215
 
216
- btn.innerHTML = '<span class="spinner"></span>Waiting for login...';
217
- infoEl.textContent = 'A new tab has been opened for ChatGPT login. Complete the login there, then this page will update automatically.';
218
- infoEl.style.display = 'block';
219
 
220
- // Start polling for auth completion
221
- startPolling();
222
- }
223
  } catch (err) {
224
- errorEl.textContent = 'Network error: ' + err.message;
225
- errorEl.style.display = 'block';
226
  btn.disabled = false;
227
- btn.textContent = 'Login with ChatGPT';
 
 
 
228
  }
229
  }
230
 
231
  function startPolling() {
232
- if (polling) return;
233
- polling = true;
234
-
235
- const poll = async () => {
236
- if (!polling) return;
237
  try {
238
  const resp = await fetch('/auth/status');
239
  const data = await resp.json();
240
  if (data.authenticated) {
241
- polling = false;
242
- document.getElementById('oauthInfo').textContent = 'Login successful! Redirecting...';
243
- document.getElementById('oauthInfo').style.display = 'block';
244
- setTimeout(() => window.location.href = '/', 500);
245
- return;
246
  }
247
- } catch {
248
- // ignore polling errors
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  }
250
- if (polling) {
251
- setTimeout(poll, 2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  }
253
- };
254
 
255
- poll();
 
 
 
 
 
 
256
 
257
- // Stop polling after 5 minutes
258
- setTimeout(() => {
259
- if (polling) {
260
- polling = false;
261
- const btn = document.getElementById('oauthBtn');
262
- btn.disabled = false;
263
- btn.textContent = 'Login with ChatGPT';
264
- document.getElementById('oauthInfo').style.display = 'none';
265
- document.getElementById('oauthError').textContent = 'Login timed out. Please try again.';
266
- document.getElementById('oauthError').style.display = 'block';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  }
268
- }, 5 * 60 * 1000);
 
 
 
 
 
 
269
  }
270
 
271
  async function submitToken() {
 
61
  background: #2ea043;
62
  }
63
  .btn-primary:disabled {
64
+ opacity: 0.5;
65
  cursor: not-allowed;
 
66
  }
67
  .divider {
68
  text-align: center;
 
116
  margin-top: 0.5rem;
117
  display: none;
118
  }
 
 
 
 
 
 
119
  .help {
120
  margin-top: 1rem;
121
  font-size: 0.8rem;
122
  color: #484f58;
123
  line-height: 1.5;
124
  }
125
+ #pasteSection {
126
+ display: none;
127
+ }
128
+ .paste-instructions {
129
+ background: #0d1117;
130
+ border: 1px solid #30363d;
131
+ border-radius: 6px;
132
+ padding: 0.75rem;
133
+ margin-bottom: 1rem;
134
+ font-size: 0.82rem;
135
+ line-height: 1.5;
136
+ color: #8b949e;
137
+ }
138
+ .paste-instructions strong {
139
+ color: #c9d1d9;
140
+ }
141
+ .paste-instructions ol {
142
+ margin: 0.5rem 0 0 1.2rem;
143
+ }
144
+ .btn-secondary {
145
+ display: block;
146
+ width: 100%;
147
+ padding: 12px 16px;
148
+ border: 1px solid #30363d;
149
+ border-radius: 8px;
150
+ background: #21262d;
151
+ color: #c9d1d9;
152
+ font-size: 1rem;
153
+ font-weight: 500;
154
+ cursor: pointer;
155
+ text-align: center;
156
+ transition: background 0.2s;
157
+ }
158
+ .btn-secondary:hover { background: #30363d; }
159
+ .btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
160
+ .device-code-box {
161
+ background: #0d1117;
162
+ border: 1px solid #30363d;
163
+ border-radius: 8px;
164
+ padding: 1.5rem;
165
+ text-align: center;
166
+ margin-top: 1rem;
167
+ display: none;
168
+ }
169
+ .device-code-box .user-code {
170
+ font-family: monospace;
171
+ font-size: 2rem;
172
+ font-weight: 700;
173
+ color: #58a6ff;
174
+ letter-spacing: 0.15em;
175
+ margin: 0.75rem 0;
176
+ }
177
+ .device-code-box .verify-link {
178
+ color: #3fb950;
179
+ text-decoration: none;
180
+ font-size: 0.9rem;
181
+ }
182
+ .device-code-box .verify-link:hover { text-decoration: underline; }
183
+ .device-code-box .status-text {
184
+ color: #8b949e;
185
+ font-size: 0.82rem;
186
+ margin-top: 0.75rem;
187
+ }
188
  .spinner {
189
  display: inline-block;
190
  width: 14px;
 
196
  vertical-align: middle;
197
  margin-right: 6px;
198
  }
199
+ @keyframes spin { to { transform: rotate(360deg); } }
 
 
200
  </style>
201
  </head>
202
  <body>
 
206
  <p>OpenAI-compatible API for Codex Desktop</p>
207
  </div>
208
  <div class="card">
209
+ <button class="btn btn-primary" id="loginBtn" onclick="startLogin()">
210
  Login with ChatGPT
211
  </button>
212
+
213
+ <div id="pasteSection">
214
+ <div class="paste-instructions">
215
+ <strong>Remote login:</strong> If the popup shows an error or you're on a different machine:
216
+ <ol>
217
+ <li>Complete login in the popup</li>
218
+ <li>Copy the full URL from the popup's address bar<br>(starts with <code>http://localhost:...</code>)</li>
219
+ <li>Paste it below and click Submit</li>
220
+ </ol>
221
+ </div>
222
+ <div class="input-group">
223
+ <label>Paste the callback URL here</label>
224
+ <textarea id="callbackInput" placeholder="http://localhost:54321/auth/callback?code=...&state=..."></textarea>
225
+ </div>
226
+ <button class="btn btn-primary" id="relayBtn" onclick="submitRelay()">
227
+ Submit Callback URL
228
+ </button>
229
+ <div class="error" id="relayError"></div>
230
+ <div class="success" id="relaySuccess"></div>
231
+ </div>
232
+
233
+ <div class="divider">or</div>
234
+
235
+ <button class="btn btn-primary" id="deviceCodeBtn" onclick="startDeviceCode()">
236
+ Sign in with Device Code
237
+ </button>
238
+ <div class="device-code-box" id="deviceCodeBox">
239
+ <div style="color:#8b949e;font-size:0.85rem">Enter this code at:</div>
240
+ <a class="verify-link" id="deviceVerifyLink" href="#" target="_blank" rel="noopener"></a>
241
+ <div class="user-code" id="deviceUserCode"></div>
242
+ <div class="status-text" id="deviceStatus"><span class="spinner"></span> Waiting for authorization...</div>
243
+ <div class="error" id="deviceError"></div>
244
+ <div class="success" id="deviceSuccess"></div>
245
+ </div>
246
+
247
+ <div style="margin-top:0.75rem">
248
+ <button class="btn-secondary" id="cliImportBtn" onclick="importCli()">
249
+ Import CLI Token (~/.codex/auth.json)
250
+ </button>
251
+ <div class="error" id="cliError"></div>
252
+ <div class="success" id="cliSuccess"></div>
253
+ </div>
254
 
255
  <div class="divider">or</div>
256
 
 
274
  </div>
275
 
276
  <script>
277
+ let pollTimer = null;
278
 
279
+ // Listen for postMessage from OAuth callback popup (cross-port communication)
280
+ window.addEventListener('message', (event) => {
281
+ if (event.data?.type === 'oauth-callback-success') {
282
+ if (pollTimer) clearInterval(pollTimer);
283
+ window.location.href = '/';
284
+ }
285
+ });
286
 
287
+ async function startLogin() {
288
+ const btn = document.getElementById('loginBtn');
289
  btn.disabled = true;
290
+ btn.innerHTML = '<span class="spinner"></span> Opening login...';
 
 
291
 
292
  try {
293
+ const resp = await fetch('/auth/login-start', { method: 'POST' });
294
  const data = await resp.json();
295
 
296
+ if (!resp.ok || !data.authUrl) {
297
+ throw new Error(data.error || 'Failed to start login');
 
298
  }
299
 
300
+ // Open Auth0 in popup
301
+ const popup = window.open(data.authUrl, 'oauth_login', 'width=600,height=700,scrollbars=yes');
 
 
 
 
 
302
 
303
+ // Show paste section
304
+ document.getElementById('pasteSection').style.display = 'block';
305
+ btn.innerHTML = 'Login with ChatGPT';
306
+ btn.disabled = false;
307
 
308
+ // Poll auth status β€” if callback server handles it, we auto-redirect
309
+ startPolling();
 
310
 
 
 
 
311
  } catch (err) {
312
+ btn.innerHTML = 'Login with ChatGPT';
 
313
  btn.disabled = false;
314
+ const errEl = document.getElementById('relayError');
315
+ errEl.textContent = err.message;
316
+ errEl.style.display = 'block';
317
+ document.getElementById('pasteSection').style.display = 'block';
318
  }
319
  }
320
 
321
  function startPolling() {
322
+ if (pollTimer) clearInterval(pollTimer);
323
+ pollTimer = setInterval(async () => {
 
 
 
324
  try {
325
  const resp = await fetch('/auth/status');
326
  const data = await resp.json();
327
  if (data.authenticated) {
328
+ clearInterval(pollTimer);
329
+ window.location.href = '/';
 
 
 
330
  }
331
+ } catch {}
332
+ }, 2000);
333
+
334
+ // Stop polling after 5 minutes
335
+ setTimeout(() => {
336
+ if (pollTimer) clearInterval(pollTimer);
337
+ }, 5 * 60 * 1000);
338
+ }
339
+
340
+ async function submitRelay() {
341
+ const callbackUrl = document.getElementById('callbackInput').value.trim();
342
+ const errEl = document.getElementById('relayError');
343
+ const successEl = document.getElementById('relaySuccess');
344
+ errEl.style.display = 'none';
345
+ successEl.style.display = 'none';
346
+
347
+ if (!callbackUrl) {
348
+ errEl.textContent = 'Please paste the callback URL';
349
+ errEl.style.display = 'block';
350
+ return;
351
+ }
352
+
353
+ const btn = document.getElementById('relayBtn');
354
+ btn.disabled = true;
355
+ btn.innerHTML = '<span class="spinner"></span> Exchanging...';
356
+
357
+ try {
358
+ const resp = await fetch('/auth/code-relay', {
359
+ method: 'POST',
360
+ headers: { 'Content-Type': 'application/json' },
361
+ body: JSON.stringify({ callbackUrl }),
362
+ });
363
+ const data = await resp.json();
364
+
365
+ if (resp.ok && data.success) {
366
+ successEl.textContent = 'Login successful! Redirecting...';
367
+ successEl.style.display = 'block';
368
+ if (pollTimer) clearInterval(pollTimer);
369
+ setTimeout(() => window.location.href = '/', 1000);
370
+ } else {
371
+ errEl.textContent = data.error || 'Failed to exchange code';
372
+ errEl.style.display = 'block';
373
  }
374
+ } catch (err) {
375
+ errEl.textContent = 'Network error: ' + err.message;
376
+ errEl.style.display = 'block';
377
+ } finally {
378
+ btn.innerHTML = 'Submit Callback URL';
379
+ btn.disabled = false;
380
+ }
381
+ }
382
+
383
+ // ── Device Code Flow ──────────────────────────────
384
+ let devicePollTimer = null;
385
+
386
+ async function startDeviceCode() {
387
+ const btn = document.getElementById('deviceCodeBtn');
388
+ const box = document.getElementById('deviceCodeBox');
389
+ const errEl = document.getElementById('deviceError');
390
+ const successEl = document.getElementById('deviceSuccess');
391
+ const statusEl = document.getElementById('deviceStatus');
392
+ errEl.style.display = 'none';
393
+ successEl.style.display = 'none';
394
+
395
+ btn.disabled = true;
396
+ btn.innerHTML = '<span class="spinner"></span> Requesting code...';
397
+
398
+ try {
399
+ const resp = await fetch('/auth/device-login', { method: 'POST' });
400
+ const data = await resp.json();
401
+
402
+ if (!resp.ok || !data.userCode) {
403
+ throw new Error(data.error || 'Failed to request device code');
404
  }
 
405
 
406
+ // Show the code box
407
+ box.style.display = 'block';
408
+ document.getElementById('deviceUserCode').textContent = data.userCode;
409
+ const link = document.getElementById('deviceVerifyLink');
410
+ link.href = data.verificationUriComplete || data.verificationUri;
411
+ link.textContent = data.verificationUri;
412
+ statusEl.innerHTML = '<span class="spinner"></span> Waiting for authorization...';
413
 
414
+ btn.innerHTML = 'Sign in with Device Code';
415
+ btn.disabled = false;
416
+
417
+ // Start polling
418
+ const interval = (data.interval || 5) * 1000;
419
+ const deviceCode = data.deviceCode;
420
+ if (devicePollTimer) clearInterval(devicePollTimer);
421
+
422
+ devicePollTimer = setInterval(async () => {
423
+ try {
424
+ const pollResp = await fetch('/auth/device-poll/' + encodeURIComponent(deviceCode));
425
+ const pollData = await pollResp.json();
426
+
427
+ if (pollData.success) {
428
+ clearInterval(devicePollTimer);
429
+ statusEl.innerHTML = '';
430
+ successEl.textContent = 'Login successful! Redirecting...';
431
+ successEl.style.display = 'block';
432
+ if (pollTimer) clearInterval(pollTimer);
433
+ setTimeout(() => window.location.href = '/', 1000);
434
+ } else if (pollData.error) {
435
+ clearInterval(devicePollTimer);
436
+ statusEl.innerHTML = '';
437
+ errEl.textContent = pollData.error;
438
+ errEl.style.display = 'block';
439
+ }
440
+ // if pollData.pending, keep polling
441
+ } catch {}
442
+ }, interval);
443
+
444
+ // Stop after expiry
445
+ setTimeout(() => {
446
+ if (devicePollTimer) {
447
+ clearInterval(devicePollTimer);
448
+ statusEl.textContent = 'Code expired. Please try again.';
449
+ }
450
+ }, (data.expiresIn || 900) * 1000);
451
+
452
+ } catch (err) {
453
+ btn.innerHTML = 'Sign in with Device Code';
454
+ btn.disabled = false;
455
+ errEl.textContent = err.message;
456
+ errEl.style.display = 'block';
457
+ }
458
+ }
459
+
460
+ // ── CLI Token Import ─────────────────────────────
461
+ async function importCli() {
462
+ const btn = document.getElementById('cliImportBtn');
463
+ const errEl = document.getElementById('cliError');
464
+ const successEl = document.getElementById('cliSuccess');
465
+ errEl.style.display = 'none';
466
+ successEl.style.display = 'none';
467
+ btn.disabled = true;
468
+ btn.textContent = 'Importing...';
469
+
470
+ try {
471
+ const resp = await fetch('/auth/import-cli', { method: 'POST' });
472
+ const data = await resp.json();
473
+
474
+ if (resp.ok && data.success) {
475
+ successEl.textContent = 'CLI token imported! Redirecting...';
476
+ successEl.style.display = 'block';
477
+ if (pollTimer) clearInterval(pollTimer);
478
+ setTimeout(() => window.location.href = '/', 1000);
479
+ } else {
480
+ errEl.textContent = data.error || 'Failed to import CLI token';
481
+ errEl.style.display = 'block';
482
  }
483
+ } catch (err) {
484
+ errEl.textContent = 'Network error: ' + err.message;
485
+ errEl.style.display = 'block';
486
+ } finally {
487
+ btn.textContent = 'Import CLI Token (~/.codex/auth.json)';
488
+ btn.disabled = false;
489
+ }
490
  }
491
 
492
  async function submitToken() {
src/auth/account-pool.ts CHANGED
@@ -141,7 +141,7 @@ export class AccountPool {
141
  * Add an account from a raw JWT token. Returns the entry ID.
142
  * Deduplicates by accountId.
143
  */
144
- addAccount(token: string): string {
145
  const accountId = extractChatGptAccountId(token);
146
  const profile = extractUserProfile(token);
147
 
@@ -151,6 +151,9 @@ export class AccountPool {
151
  if (existing.accountId === accountId) {
152
  // Update the existing entry's token
153
  existing.token = token;
 
 
 
154
  existing.email = profile?.email ?? existing.email;
155
  existing.planType = profile?.chatgpt_plan_type ?? existing.planType;
156
  existing.status = isTokenExpired(token) ? "expired" : "active";
@@ -164,6 +167,7 @@ export class AccountPool {
164
  const entry: AccountEntry = {
165
  id,
166
  token,
 
167
  email: profile?.email ?? null,
168
  accountId,
169
  planType: profile?.chatgpt_plan_type ?? null,
@@ -194,11 +198,14 @@ export class AccountPool {
194
  /**
195
  * Update an account's token (used by refresh scheduler).
196
  */
197
- updateToken(entryId: string, newToken: string): void {
198
  const entry = this.accounts.get(entryId);
199
  if (!entry) return;
200
 
201
  entry.token = newToken;
 
 
 
202
  const profile = extractUserProfile(newToken);
203
  entry.email = profile?.email ?? entry.email;
204
  entry.planType = profile?.chatgpt_plan_type ?? entry.planType;
@@ -436,6 +443,7 @@ export class AccountPool {
436
  const entry: AccountEntry = {
437
  id,
438
  token: data.token,
 
439
  email: data.userInfo?.email ?? null,
440
  accountId: accountId,
441
  planType: data.userInfo?.planType ?? null,
 
141
  * Add an account from a raw JWT token. Returns the entry ID.
142
  * Deduplicates by accountId.
143
  */
144
+ addAccount(token: string, refreshToken?: string | null): string {
145
  const accountId = extractChatGptAccountId(token);
146
  const profile = extractUserProfile(token);
147
 
 
151
  if (existing.accountId === accountId) {
152
  // Update the existing entry's token
153
  existing.token = token;
154
+ if (refreshToken !== undefined) {
155
+ existing.refreshToken = refreshToken ?? null;
156
+ }
157
  existing.email = profile?.email ?? existing.email;
158
  existing.planType = profile?.chatgpt_plan_type ?? existing.planType;
159
  existing.status = isTokenExpired(token) ? "expired" : "active";
 
167
  const entry: AccountEntry = {
168
  id,
169
  token,
170
+ refreshToken: refreshToken ?? null,
171
  email: profile?.email ?? null,
172
  accountId,
173
  planType: profile?.chatgpt_plan_type ?? null,
 
198
  /**
199
  * Update an account's token (used by refresh scheduler).
200
  */
201
+ updateToken(entryId: string, newToken: string, refreshToken?: string | null): void {
202
  const entry = this.accounts.get(entryId);
203
  if (!entry) return;
204
 
205
  entry.token = newToken;
206
+ if (refreshToken !== undefined) {
207
+ entry.refreshToken = refreshToken ?? null;
208
+ }
209
  const profile = extractUserProfile(newToken);
210
  entry.email = profile?.email ?? entry.email;
211
  entry.planType = profile?.chatgpt_plan_type ?? entry.planType;
 
443
  const entry: AccountEntry = {
444
  id,
445
  token: data.token,
446
+ refreshToken: null,
447
  email: data.userInfo?.email ?? null,
448
  accountId: accountId,
449
  planType: data.userInfo?.planType ?? null,
src/auth/chatgpt-oauth.ts CHANGED
@@ -1,404 +1,11 @@
1
- import { spawn, type ChildProcess } from "child_process";
2
- import { getConfig } from "../config.js";
3
  import {
4
  decodeJwtPayload,
5
  extractChatGptAccountId,
6
  isTokenExpired,
7
  } from "./jwt-utils.js";
8
 
9
- export interface OAuthResult {
10
- success: boolean;
11
- token?: string;
12
- error?: string;
13
- }
14
-
15
- const INIT_REQUEST_ID = "__codex-desktop_initialize__";
16
-
17
- /**
18
- * Approach 1: Login via Codex CLI subprocess (JSON-RPC over stdio).
19
- * Spawns `codex app-server` and uses JSON-RPC to initiate OAuth.
20
- *
21
- * Flow:
22
- * 1. Spawn `codex app-server`
23
- * 2. Send `initialize` handshake (required before any other request)
24
- * 3. Send `account/login/start` with type "chatgpt"
25
- * 4. CLI returns an Auth0 authUrl and starts a local callback server
26
- * 5. User completes OAuth in browser
27
- * 6. CLI sends `account/login/completed` notification with token
28
- */
29
- export async function loginViaCli(): Promise<{
30
- authUrl: string;
31
- waitForCompletion: () => Promise<OAuthResult>;
32
- }> {
33
- const { command, args } = await resolveCliCommand();
34
-
35
- return new Promise((resolveOuter, rejectOuter) => {
36
- const child = spawn(command, args, {
37
- stdio: ["pipe", "pipe", "pipe"],
38
- ...SPAWN_OPTS,
39
- });
40
-
41
- let buffer = "";
42
- let rpcId = 1;
43
- let authUrl = "";
44
- let initialized = false;
45
- let outerResolved = false;
46
- let awaitingAuthStatus = false;
47
- const AUTH_STATUS_ID = "__get_auth_status__";
48
-
49
- // Resolvers for the completion promise (token received)
50
- let resolveCompletion: (result: OAuthResult) => void;
51
- const completionPromise = new Promise<OAuthResult>((res) => {
52
- resolveCompletion = res;
53
- });
54
-
55
- const sendRpc = (
56
- method: string,
57
- params: Record<string, unknown> = {},
58
- id?: string | number,
59
- ) => {
60
- const msgId = id ?? rpcId++;
61
- const msg = JSON.stringify({
62
- jsonrpc: "2.0",
63
- id: msgId,
64
- method,
65
- params,
66
- });
67
- child.stdin.write(msg + "\n");
68
- };
69
-
70
- // Kill child on completion timeout (5 minutes)
71
- const killTimer = setTimeout(() => {
72
- if (!outerResolved) {
73
- rejectOuter(new Error("OAuth flow timed out (5 minutes)"));
74
- }
75
- resolveCompletion({
76
- success: false,
77
- error: "OAuth flow timed out",
78
- });
79
- child.kill();
80
- }, 5 * 60 * 1000);
81
-
82
- const cleanup = () => {
83
- clearTimeout(killTimer);
84
- };
85
-
86
- child.stdout.on("data", (chunk: Buffer) => {
87
- buffer += chunk.toString("utf8");
88
- const lines = buffer.split("\n");
89
- buffer = lines.pop()!;
90
-
91
- for (const line of lines) {
92
- if (!line.trim()) continue;
93
- try {
94
- const msg = JSON.parse(line);
95
-
96
- // Response to initialize request
97
- if (msg.id === INIT_REQUEST_ID && !initialized) {
98
- if (msg.error) {
99
- const errMsg =
100
- msg.error.message ?? "Failed to initialize app-server";
101
- cleanup();
102
- rejectOuter(new Error(errMsg));
103
- resolveCompletion({ success: false, error: errMsg });
104
- child.kill();
105
- return;
106
- }
107
- initialized = true;
108
- console.log(
109
- "[OAuth] Codex app-server initialized:",
110
- msg.result?.userAgent ?? "unknown",
111
- );
112
- // Now send the login request
113
- sendRpc("account/login/start", { type: "chatgpt" });
114
- continue;
115
- }
116
-
117
- // Response to account/login/start
118
- if (msg.result && msg.result.authUrl && !outerResolved) {
119
- authUrl = msg.result.authUrl;
120
- outerResolved = true;
121
- console.log(
122
- "[OAuth] Auth URL received, loginId:",
123
- msg.result.loginId,
124
- );
125
- resolveOuter({
126
- authUrl,
127
- waitForCompletion: () => completionPromise,
128
- });
129
- continue;
130
- }
131
-
132
- // Notification: login completed β€” need to fetch token via getAuthStatus
133
- if (msg.method === "account/login/completed" && msg.params) {
134
- const { success, error: loginError } = msg.params;
135
- console.log("[OAuth] Login completed, success:", success);
136
- if (success) {
137
- // Login succeeded but the notification doesn't include the token.
138
- // We must request it via getAuthStatus.
139
- awaitingAuthStatus = true;
140
- sendRpc(
141
- "getAuthStatus",
142
- { includeToken: true, refreshToken: false },
143
- AUTH_STATUS_ID,
144
- );
145
- } else {
146
- cleanup();
147
- resolveCompletion({
148
- success: false,
149
- error: loginError ?? "Login failed",
150
- });
151
- child.kill();
152
- }
153
- continue;
154
- }
155
-
156
- // Response to getAuthStatus β€” extract the token
157
- if (msg.id === AUTH_STATUS_ID && awaitingAuthStatus) {
158
- awaitingAuthStatus = false;
159
- cleanup();
160
- if (msg.error) {
161
- resolveCompletion({
162
- success: false,
163
- error: msg.error.message ?? "Failed to get auth status",
164
- });
165
- } else {
166
- const authToken = msg.result?.authToken ?? null;
167
- if (typeof authToken === "string") {
168
- console.log("[OAuth] Token received successfully");
169
- resolveCompletion({ success: true, token: authToken });
170
- } else {
171
- resolveCompletion({
172
- success: false,
173
- error: "getAuthStatus returned no token",
174
- });
175
- }
176
- }
177
- // Give CLI a moment to clean up, then kill
178
- setTimeout(() => child.kill(), 1000);
179
- continue;
180
- }
181
-
182
- // Notification: account/updated (auth status changed)
183
- if (msg.method === "account/updated" && msg.params) {
184
- console.log("[OAuth] Account updated:", msg.params.authMode);
185
- // If we haven't requested auth status yet and auth mode is set,
186
- // this might be our signal to fetch the token
187
- if (!awaitingAuthStatus && msg.params.authMode === "chatgpt") {
188
- awaitingAuthStatus = true;
189
- sendRpc(
190
- "getAuthStatus",
191
- { includeToken: true, refreshToken: false },
192
- AUTH_STATUS_ID,
193
- );
194
- }
195
- continue;
196
- }
197
-
198
- // Error response (to our login request)
199
- if (msg.error && msg.id !== INIT_REQUEST_ID) {
200
- const errMsg = msg.error.message ?? "Unknown JSON-RPC error";
201
- cleanup();
202
- if (!outerResolved) {
203
- outerResolved = true;
204
- rejectOuter(new Error(errMsg));
205
- }
206
- resolveCompletion({ success: false, error: errMsg });
207
- child.kill();
208
- }
209
- } catch {
210
- // Skip non-JSON lines (stderr leak, log output, etc.)
211
- }
212
- }
213
- });
214
-
215
- child.stderr?.on("data", (chunk: Buffer) => {
216
- const text = chunk.toString("utf8").trim();
217
- if (text) {
218
- console.log("[OAuth CLI stderr]", text);
219
- }
220
- });
221
-
222
- child.on("error", (err) => {
223
- const msg = `Failed to spawn Codex CLI: ${err.message}`;
224
- cleanup();
225
- if (!outerResolved) {
226
- outerResolved = true;
227
- rejectOuter(new Error(msg));
228
- }
229
- resolveCompletion({ success: false, error: msg });
230
- });
231
-
232
- child.on("close", (code) => {
233
- cleanup();
234
- if (!outerResolved) {
235
- outerResolved = true;
236
- rejectOuter(
237
- new Error(
238
- `Codex CLI exited with code ${code} before returning authUrl`,
239
- ),
240
- );
241
- }
242
- resolveCompletion({
243
- success: false,
244
- error: `Codex CLI exited with code ${code}`,
245
- });
246
- });
247
-
248
- // Step 1: Send the initialize handshake
249
- const config = getConfig();
250
- const originator = config.client.originator;
251
- sendRpc(
252
- "initialize",
253
- {
254
- clientInfo: {
255
- name: originator,
256
- title: originator,
257
- version: config.client.app_version,
258
- },
259
- },
260
- INIT_REQUEST_ID,
261
- );
262
- });
263
- }
264
-
265
- /**
266
- * Refresh an existing token via Codex CLI (JSON-RPC).
267
- * Spawns `codex app-server`, sends `initialize`, then `getAuthStatus` with refreshToken: true.
268
- * Returns the new token string, or throws on failure.
269
- */
270
- export async function refreshTokenViaCli(): Promise<string> {
271
- const { command, args } = await resolveCliCommand();
272
-
273
- return new Promise((resolve, reject) => {
274
- const child = spawn(command, args, {
275
- stdio: ["pipe", "pipe", "pipe"],
276
- ...SPAWN_OPTS,
277
- });
278
-
279
- let buffer = "";
280
- const AUTH_STATUS_ID = "__refresh_auth_status__";
281
- let initialized = false;
282
- let settled = false;
283
-
284
- const killTimer = setTimeout(() => {
285
- if (!settled) {
286
- settled = true;
287
- reject(new Error("Token refresh timed out (30s)"));
288
- }
289
- child.kill();
290
- }, 30_000);
291
-
292
- const cleanup = () => {
293
- clearTimeout(killTimer);
294
- };
295
-
296
- const sendRpc = (
297
- method: string,
298
- params: Record<string, unknown> = {},
299
- id?: string | number,
300
- ) => {
301
- const msg = JSON.stringify({
302
- jsonrpc: "2.0",
303
- id: id ?? 1,
304
- method,
305
- params,
306
- });
307
- child.stdin.write(msg + "\n");
308
- };
309
-
310
- child.stdout.on("data", (chunk: Buffer) => {
311
- buffer += chunk.toString("utf8");
312
- const lines = buffer.split("\n");
313
- buffer = lines.pop()!;
314
-
315
- for (const line of lines) {
316
- if (!line.trim()) continue;
317
- try {
318
- const msg = JSON.parse(line);
319
-
320
- // Response to initialize
321
- if (msg.id === INIT_REQUEST_ID && !initialized) {
322
- if (msg.error) {
323
- cleanup();
324
- settled = true;
325
- reject(new Error(msg.error.message ?? "Init failed"));
326
- child.kill();
327
- return;
328
- }
329
- initialized = true;
330
- // Request auth status with refresh
331
- sendRpc(
332
- "getAuthStatus",
333
- { includeToken: true, refreshToken: true },
334
- AUTH_STATUS_ID,
335
- );
336
- continue;
337
- }
338
-
339
- // Response to getAuthStatus
340
- if (msg.id === AUTH_STATUS_ID) {
341
- cleanup();
342
- if (msg.error) {
343
- settled = true;
344
- reject(new Error(msg.error.message ?? "getAuthStatus failed"));
345
- } else {
346
- const authToken = msg.result?.authToken ?? null;
347
- if (typeof authToken === "string") {
348
- settled = true;
349
- resolve(authToken);
350
- } else {
351
- settled = true;
352
- reject(new Error("getAuthStatus returned no token"));
353
- }
354
- }
355
- setTimeout(() => child.kill(), 500);
356
- continue;
357
- }
358
- } catch {
359
- // skip non-JSON
360
- }
361
- }
362
- });
363
-
364
- child.stderr?.on("data", () => {});
365
-
366
- child.on("error", (err) => {
367
- cleanup();
368
- if (!settled) {
369
- settled = true;
370
- reject(new Error(`Failed to spawn Codex CLI: ${err.message}`));
371
- }
372
- });
373
-
374
- child.on("close", (code) => {
375
- cleanup();
376
- if (!settled) {
377
- settled = true;
378
- reject(new Error(`Codex CLI exited with code ${code} during refresh`));
379
- }
380
- });
381
-
382
- // Send initialize
383
- const config = getConfig();
384
- const originator = config.client.originator;
385
- sendRpc(
386
- "initialize",
387
- {
388
- clientInfo: {
389
- name: originator,
390
- title: originator,
391
- version: config.client.app_version,
392
- },
393
- },
394
- INIT_REQUEST_ID,
395
- );
396
- });
397
- }
398
-
399
  /**
400
- * Approach 2: Manual token paste (fallback).
401
- * Validates a JWT token provided directly by the user.
402
  */
403
  export function validateManualToken(token: string): {
404
  valid: boolean;
@@ -428,46 +35,3 @@ export function validateManualToken(token: string): {
428
 
429
  return { valid: true };
430
  }
431
-
432
- /**
433
- * Check if the Codex CLI is available on the system.
434
- */
435
- export async function isCodexCliAvailable(): Promise<boolean> {
436
- try {
437
- await resolveCliCommand();
438
- return true;
439
- } catch {
440
- return false;
441
- }
442
- }
443
-
444
- // --- private helpers ---
445
-
446
- // On Windows, npm-installed binaries (.cmd scripts) require shell: true
447
- const IS_WINDOWS = process.platform === "win32";
448
- const SPAWN_OPTS = IS_WINDOWS ? { shell: true as const } : {};
449
-
450
- interface CliCommand {
451
- command: string;
452
- args: string[];
453
- }
454
-
455
- async function resolveCliCommand(): Promise<CliCommand> {
456
- // Try `codex` directly first
457
- if (await testCli("codex", ["--version"])) {
458
- return { command: "codex", args: ["app-server"] };
459
- }
460
- // Fall back to `npx codex`
461
- if (await testCli("npx", ["codex", "--version"])) {
462
- return { command: "npx", args: ["codex", "app-server"] };
463
- }
464
- throw new Error("Neither 'codex' nor 'npx codex' found in PATH");
465
- }
466
-
467
- function testCli(command: string, args: string[]): Promise<boolean> {
468
- return new Promise((resolve) => {
469
- const child = spawn(command, args, { stdio: "ignore", ...SPAWN_OPTS });
470
- child.on("error", () => resolve(false));
471
- child.on("close", (code) => resolve(code === 0));
472
- });
473
- }
 
 
 
1
  import {
2
  decodeJwtPayload,
3
  extractChatGptAccountId,
4
  isTokenExpired,
5
  } from "./jwt-utils.js";
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  /**
8
+ * Validate a manually-pasted JWT token.
 
9
  */
10
  export function validateManualToken(token: string): {
11
  valid: boolean;
 
35
 
36
  return { valid: true };
37
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/auth/oauth-pkce.ts ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Native OAuth PKCE flow for Auth0/OpenAI authentication.
3
+ * Replaces the Codex CLI dependency for login and token refresh.
4
+ */
5
+
6
+ import { randomBytes, createHash } from "crypto";
7
+ import { createServer, type Server } from "http";
8
+ import { readFileSync, existsSync } from "fs";
9
+ import { resolve } from "path";
10
+ import { homedir } from "os";
11
+ import { getConfig } from "../config.js";
12
+
13
+ export interface PKCEChallenge {
14
+ codeVerifier: string;
15
+ codeChallenge: string;
16
+ }
17
+
18
+ export interface TokenResponse {
19
+ access_token: string;
20
+ refresh_token?: string;
21
+ id_token?: string;
22
+ token_type: string;
23
+ expires_in?: number;
24
+ }
25
+
26
+ export interface DeviceCodeResponse {
27
+ device_code: string;
28
+ user_code: string;
29
+ verification_uri: string;
30
+ verification_uri_complete: string;
31
+ expires_in: number;
32
+ interval: number;
33
+ }
34
+
35
+ interface PendingSession {
36
+ codeVerifier: string;
37
+ redirectUri: string;
38
+ returnHost: string;
39
+ source: "login" | "dashboard";
40
+ createdAt: number;
41
+ }
42
+
43
+ /** In-memory store for pending OAuth sessions, keyed by `state`. */
44
+ const pendingSessions = new Map<string, PendingSession>();
45
+
46
+ // Clean up expired sessions every 60 seconds
47
+ const SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
48
+ setInterval(() => {
49
+ const now = Date.now();
50
+ for (const [state, session] of pendingSessions) {
51
+ if (now - session.createdAt > SESSION_TTL_MS) {
52
+ pendingSessions.delete(state);
53
+ }
54
+ }
55
+ }, 60_000).unref();
56
+
57
+ /**
58
+ * Generate a PKCE code_verifier + code_challenge (S256).
59
+ */
60
+ export function generatePKCE(): PKCEChallenge {
61
+ const codeVerifier = randomBytes(32)
62
+ .toString("base64url")
63
+ .replace(/[^a-zA-Z0-9\-._~]/g, "")
64
+ .slice(0, 128);
65
+
66
+ const codeChallenge = createHash("sha256")
67
+ .update(codeVerifier)
68
+ .digest("base64url");
69
+
70
+ return { codeVerifier, codeChallenge };
71
+ }
72
+
73
+ /**
74
+ * Build the Auth0 authorization URL for the PKCE flow.
75
+ */
76
+ export function buildAuthUrl(
77
+ redirectUri: string,
78
+ state: string,
79
+ codeChallenge: string,
80
+ ): string {
81
+ const config = getConfig();
82
+ // Build query string manually β€” OpenAI's auth server requires %20 for spaces,
83
+ // but URLSearchParams encodes spaces as '+' which causes AuthApiFailure.
84
+ const params: Record<string, string> = {
85
+ response_type: "code",
86
+ client_id: config.auth.oauth_client_id,
87
+ redirect_uri: redirectUri,
88
+ scope: "openid profile email offline_access",
89
+ code_challenge: codeChallenge,
90
+ code_challenge_method: "S256",
91
+ id_token_add_organizations: "true",
92
+ codex_cli_simplified_flow: "true",
93
+ state,
94
+ originator: "codex_cli_rs",
95
+ };
96
+ const qs = Object.entries(params)
97
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
98
+ .join("&");
99
+
100
+ const url = `${config.auth.oauth_auth_endpoint}?${qs}`;
101
+ console.log(`[OAuth] Auth URL: ${url}`);
102
+ return url;
103
+ }
104
+
105
+ /**
106
+ * Exchange an authorization code for tokens.
107
+ */
108
+ export async function exchangeCode(
109
+ code: string,
110
+ codeVerifier: string,
111
+ redirectUri: string,
112
+ ): Promise<TokenResponse> {
113
+ const config = getConfig();
114
+
115
+ const body = new URLSearchParams({
116
+ grant_type: "authorization_code",
117
+ client_id: config.auth.oauth_client_id,
118
+ code,
119
+ redirect_uri: redirectUri,
120
+ code_verifier: codeVerifier,
121
+ });
122
+
123
+ const resp = await fetch(config.auth.oauth_token_endpoint, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
126
+ body: body.toString(),
127
+ });
128
+
129
+ if (!resp.ok) {
130
+ const text = await resp.text();
131
+ throw new Error(`Token exchange failed (${resp.status}): ${text}`);
132
+ }
133
+
134
+ return resp.json() as Promise<TokenResponse>;
135
+ }
136
+
137
+ /**
138
+ * Refresh an access token using a refresh_token.
139
+ */
140
+ export async function refreshAccessToken(
141
+ refreshToken: string,
142
+ ): Promise<TokenResponse> {
143
+ const config = getConfig();
144
+
145
+ const body = new URLSearchParams({
146
+ grant_type: "refresh_token",
147
+ client_id: config.auth.oauth_client_id,
148
+ refresh_token: refreshToken,
149
+ });
150
+
151
+ const resp = await fetch(config.auth.oauth_token_endpoint, {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
154
+ body: body.toString(),
155
+ });
156
+
157
+ if (!resp.ok) {
158
+ const text = await resp.text();
159
+ throw new Error(`Token refresh failed (${resp.status}): ${text}`);
160
+ }
161
+
162
+ return resp.json() as Promise<TokenResponse>;
163
+ }
164
+
165
+ // ── Pending session management ─────────────────────────────────────
166
+
167
+ /**
168
+ * OpenAI only whitelists http://localhost:1455/auth/callback for this client_id.
169
+ * The Codex CLI always uses this port β€” no fallback to random ports.
170
+ */
171
+ const OAUTH_CALLBACK_PORT = 1455;
172
+
173
+ /**
174
+ * Create and store a new pending OAuth session.
175
+ *
176
+ * The redirect_uri is always http://localhost:1455/auth/callback to match
177
+ * the Codex CLI and OpenAI's whitelist. The caller must start a callback
178
+ * server on port 1455 via `startCallbackServer()`.
179
+ */
180
+ export function createOAuthSession(
181
+ originalHost: string,
182
+ source: "login" | "dashboard" = "login",
183
+ ): { state: string; authUrl: string; port: number } {
184
+ const { codeVerifier, codeChallenge } = generatePKCE();
185
+ const state = randomBytes(16).toString("hex");
186
+ const port = OAUTH_CALLBACK_PORT;
187
+
188
+ const redirectUri = `http://localhost:${port}/auth/callback`;
189
+
190
+ pendingSessions.set(state, {
191
+ codeVerifier,
192
+ redirectUri,
193
+ returnHost: originalHost,
194
+ source,
195
+ createdAt: Date.now(),
196
+ });
197
+
198
+ const authUrl = buildAuthUrl(redirectUri, state, codeChallenge);
199
+ return { state, authUrl, port };
200
+ }
201
+
202
+ /**
203
+ * Retrieve and consume a pending session by state.
204
+ * Returns null if not found or expired.
205
+ */
206
+ export function consumeSession(
207
+ state: string,
208
+ ): PendingSession | null {
209
+ const session = pendingSessions.get(state);
210
+ if (!session) return null;
211
+
212
+ pendingSessions.delete(state);
213
+
214
+ // Check expiry
215
+ if (Date.now() - session.createdAt > SESSION_TTL_MS) {
216
+ return null;
217
+ }
218
+
219
+ return session;
220
+ }
221
+
222
+ // ── Temporary callback server ──────────────────────────────────────
223
+
224
+ /** Track the active callback server so we can close it before starting a new one. */
225
+ let activeCallbackServer: Server | null = null;
226
+
227
+ /**
228
+ * Start a temporary HTTP server on 0.0.0.0:{port} that handles the OAuth
229
+ * callback (`/auth/callback`). Closes any previously active callback server
230
+ * first (since we always reuse port 1455).
231
+ *
232
+ * Auto-closes after 5 minutes or after a successful callback.
233
+ *
234
+ * @param port The port from createOAuthSession() (always 1455)
235
+ * @param onAccount Called with (accessToken, refreshToken) on success
236
+ */
237
+ export function startCallbackServer(
238
+ port: number,
239
+ onAccount: (accessToken: string, refreshToken: string | undefined) => void,
240
+ ): Server {
241
+ // Close any existing callback server on this port
242
+ if (activeCallbackServer) {
243
+ try { activeCallbackServer.close(); } catch {}
244
+ activeCallbackServer = null;
245
+ }
246
+
247
+ const server = createServer(async (req, res) => {
248
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
249
+
250
+ if (url.pathname !== "/auth/callback") {
251
+ res.writeHead(404, { "Content-Type": "text/plain" });
252
+ res.end("Not found");
253
+ return;
254
+ }
255
+
256
+ const code = url.searchParams.get("code");
257
+ const state = url.searchParams.get("state");
258
+ const error = url.searchParams.get("error");
259
+ const errorDesc = url.searchParams.get("error_description");
260
+
261
+ if (error) {
262
+ res.writeHead(200, { "Content-Type": "text/html" });
263
+ res.end(callbackResultHtml(false, errorDesc || error));
264
+ scheduleClose();
265
+ return;
266
+ }
267
+
268
+ if (!code || !state) {
269
+ res.writeHead(400, { "Content-Type": "text/html" });
270
+ res.end(callbackResultHtml(false, "Missing code or state parameter"));
271
+ scheduleClose();
272
+ return;
273
+ }
274
+
275
+ const session = consumeSession(state);
276
+ if (!session) {
277
+ res.writeHead(400, { "Content-Type": "text/html" });
278
+ res.end(callbackResultHtml(false, "Invalid or expired session. Please try again."));
279
+ scheduleClose();
280
+ return;
281
+ }
282
+
283
+ try {
284
+ const tokens = await exchangeCode(code, session.codeVerifier, session.redirectUri);
285
+ onAccount(tokens.access_token, tokens.refresh_token);
286
+ console.log(`[OAuth] Callback server on port ${port} β€” login successful`);
287
+ res.writeHead(200, { "Content-Type": "text/html" });
288
+ res.end(callbackResultHtml(true));
289
+ } catch (err) {
290
+ const msg = err instanceof Error ? err.message : String(err);
291
+ console.error(`[OAuth] Callback server token exchange failed: ${msg}`);
292
+ res.writeHead(200, { "Content-Type": "text/html" });
293
+ res.end(callbackResultHtml(false, msg));
294
+ }
295
+
296
+ scheduleClose();
297
+ });
298
+
299
+ function scheduleClose() {
300
+ setTimeout(() => {
301
+ try { server.close(); } catch {}
302
+ if (activeCallbackServer === server) activeCallbackServer = null;
303
+ }, 2000);
304
+ }
305
+
306
+ server.on("error", (err: NodeJS.ErrnoException) => {
307
+ if (err.code === "EADDRINUSE") {
308
+ console.error(`[OAuth] Port ${port} is in use β€” callback server not started. Previous login session may still be active.`);
309
+ } else {
310
+ console.error(`[OAuth] Callback server error: ${err.message}`);
311
+ }
312
+ });
313
+
314
+ server.listen(port, "0.0.0.0");
315
+ activeCallbackServer = server;
316
+ console.log(`[OAuth] Temporary callback server started on port ${port}`);
317
+
318
+ // Auto-close after 5 minutes
319
+ const timeout = setTimeout(() => {
320
+ try { server.close(); } catch {}
321
+ if (activeCallbackServer === server) activeCallbackServer = null;
322
+ console.log(`[OAuth] Temporary callback server on port ${port} timed out`);
323
+ }, 5 * 60 * 1000);
324
+ timeout.unref();
325
+
326
+ server.on("close", () => {
327
+ clearTimeout(timeout);
328
+ });
329
+
330
+ return server;
331
+ }
332
+
333
+ // ── Device Code Flow (RFC 8628) ────────────────────────────────────
334
+
335
+ /**
336
+ * Request a device code from Auth0/OpenAI.
337
+ */
338
+ export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
339
+ const config = getConfig();
340
+
341
+ const body = new URLSearchParams({
342
+ client_id: config.auth.oauth_client_id,
343
+ scope: "openid profile email offline_access",
344
+ });
345
+
346
+ const resp = await fetch("https://auth.openai.com/oauth/device/code", {
347
+ method: "POST",
348
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
349
+ body: body.toString(),
350
+ });
351
+
352
+ if (!resp.ok) {
353
+ const text = await resp.text();
354
+ throw new Error(`Device code request failed (${resp.status}): ${text}`);
355
+ }
356
+
357
+ return resp.json() as Promise<DeviceCodeResponse>;
358
+ }
359
+
360
+ /**
361
+ * Poll the token endpoint for a device code authorization.
362
+ * Returns tokens on success, or throws with "authorization_pending" / "slow_down" / other errors.
363
+ */
364
+ export async function pollDeviceToken(deviceCode: string): Promise<TokenResponse> {
365
+ const config = getConfig();
366
+
367
+ const body = new URLSearchParams({
368
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
369
+ device_code: deviceCode,
370
+ client_id: config.auth.oauth_client_id,
371
+ });
372
+
373
+ const resp = await fetch(config.auth.oauth_token_endpoint, {
374
+ method: "POST",
375
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
376
+ body: body.toString(),
377
+ });
378
+
379
+ if (!resp.ok) {
380
+ const data = (await resp.json()) as { error?: string; error_description?: string };
381
+ const err = new Error(data.error_description || data.error || `Poll failed (${resp.status})`);
382
+ (err as any).code = data.error;
383
+ throw err;
384
+ }
385
+
386
+ return resp.json() as Promise<TokenResponse>;
387
+ }
388
+
389
+ // ── CLI Token Import ───────────────────────────────────────────────
390
+
391
+ export interface CliAuthJson {
392
+ access_token?: string;
393
+ refresh_token?: string;
394
+ id_token?: string;
395
+ expires_at?: number;
396
+ }
397
+
398
+ /**
399
+ * Read and parse the Codex CLI auth.json file.
400
+ * Path: $CODEX_HOME/auth.json (default: ~/.codex/auth.json)
401
+ */
402
+ export function importCliAuth(): CliAuthJson {
403
+ const codexHome = process.env.CODEX_HOME || resolve(homedir(), ".codex");
404
+ const authPath = resolve(codexHome, "auth.json");
405
+
406
+ if (!existsSync(authPath)) {
407
+ throw new Error(`CLI auth file not found: ${authPath}`);
408
+ }
409
+
410
+ const raw = readFileSync(authPath, "utf-8");
411
+ const data = JSON.parse(raw) as CliAuthJson;
412
+
413
+ if (!data.access_token) {
414
+ throw new Error("CLI auth.json does not contain access_token");
415
+ }
416
+
417
+ return data;
418
+ }
419
+
420
+ function callbackResultHtml(success: boolean, error?: string): string {
421
+ const esc = (s: string) =>
422
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
423
+
424
+ if (success) {
425
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Login Successful</title>
426
+ <style>body{font-family:-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
427
+ .card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:2rem;text-align:center;max-width:400px}
428
+ h2{color:#3fb950;margin-bottom:1rem}</style></head>
429
+ <body><div class="card"><h2>Login Successful</h2><p>You can close this window.</p></div>
430
+ <script>
431
+ if(window.opener){try{window.opener.postMessage({type:'oauth-callback-success'},'*')}catch(e){}}
432
+ try{window.close()}catch{}
433
+ </script></body></html>`;
434
+ }
435
+
436
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Login Failed</title>
437
+ <style>body{font-family:-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
438
+ .card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:2rem;text-align:center;max-width:400px}
439
+ h2{color:#f85149;margin-bottom:1rem}</style></head>
440
+ <body><div class="card"><h2>Login Failed</h2><p>${esc(error || "Unknown error")}</p></div>
441
+ <script>
442
+ if(window.opener){try{window.opener.postMessage({type:'oauth-callback-error',error:${JSON.stringify(error || "Unknown error")}},'*')}catch(e){}}
443
+ </script></body></html>`;
444
+ }
src/auth/refresh-scheduler.ts CHANGED
@@ -1,11 +1,12 @@
1
  /**
2
  * RefreshScheduler β€” per-account JWT auto-refresh.
3
  * Schedules a refresh at `exp - margin` for each account.
 
4
  */
5
 
6
  import { getConfig } from "../config.js";
7
  import { decodeJwtPayload } from "./jwt-utils.js";
8
- import { refreshTokenViaCli } from "./chatgpt-oauth.js";
9
  import type { AccountPool } from "./account-pool.js";
10
 
11
  export class RefreshScheduler {
@@ -83,16 +84,29 @@ export class RefreshScheduler {
83
  const entry = this.pool.getEntry(entryId);
84
  if (!entry) return;
85
 
 
 
 
 
 
 
 
 
86
  console.log(`[RefreshScheduler] Refreshing account ${entryId} (${entry.email ?? "?"})`);
87
  this.pool.markStatus(entryId, "refreshing");
88
 
89
  const maxAttempts = 2;
90
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
91
  try {
92
- const newToken = await refreshTokenViaCli();
93
- this.pool.updateToken(entryId, newToken);
 
 
 
 
 
94
  console.log(`[RefreshScheduler] Account ${entryId} refreshed successfully`);
95
- this.scheduleOne(entryId, newToken);
96
  return;
97
  } catch (err) {
98
  const msg = err instanceof Error ? err.message : String(err);
 
1
  /**
2
  * RefreshScheduler β€” per-account JWT auto-refresh.
3
  * Schedules a refresh at `exp - margin` for each account.
4
+ * Uses OAuth refresh_token instead of Codex CLI.
5
  */
6
 
7
  import { getConfig } from "../config.js";
8
  import { decodeJwtPayload } from "./jwt-utils.js";
9
+ import { refreshAccessToken } from "./oauth-pkce.js";
10
  import type { AccountPool } from "./account-pool.js";
11
 
12
  export class RefreshScheduler {
 
84
  const entry = this.pool.getEntry(entryId);
85
  if (!entry) return;
86
 
87
+ if (!entry.refreshToken) {
88
+ console.warn(
89
+ `[RefreshScheduler] Account ${entryId} has no refresh_token, cannot auto-refresh`,
90
+ );
91
+ this.pool.markStatus(entryId, "expired");
92
+ return;
93
+ }
94
+
95
  console.log(`[RefreshScheduler] Refreshing account ${entryId} (${entry.email ?? "?"})`);
96
  this.pool.markStatus(entryId, "refreshing");
97
 
98
  const maxAttempts = 2;
99
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
100
  try {
101
+ const tokens = await refreshAccessToken(entry.refreshToken);
102
+ // Update token and refresh_token (if a new one was issued)
103
+ this.pool.updateToken(
104
+ entryId,
105
+ tokens.access_token,
106
+ tokens.refresh_token,
107
+ );
108
  console.log(`[RefreshScheduler] Account ${entryId} refreshed successfully`);
109
+ this.scheduleOne(entryId, tokens.access_token);
110
  return;
111
  } catch (err) {
112
  const msg = err instanceof Error ? err.message : String(err);
src/auth/types.ts CHANGED
@@ -20,6 +20,7 @@ export interface AccountUsage {
20
  export interface AccountEntry {
21
  id: string;
22
  token: string;
 
23
  email: string | null;
24
  accountId: string | null;
25
  planType: string | null;
 
20
  export interface AccountEntry {
21
  id: string;
22
  token: string;
23
+ refreshToken: string | null;
24
  email: string | null;
25
  accountId: string | null;
26
  planType: string | null;
src/config.ts CHANGED
@@ -25,6 +25,9 @@ const ConfigSchema = z.object({
25
  refresh_margin_seconds: z.number().default(300),
26
  rotation_strategy: z.enum(["least_used", "round_robin"]).default("least_used"),
27
  rate_limit_backoff_seconds: z.number().default(60),
 
 
 
28
  }),
29
  server: z.object({
30
  host: z.string().default("0.0.0.0"),
 
25
  refresh_margin_seconds: z.number().default(300),
26
  rotation_strategy: z.enum(["least_used", "round_robin"]).default("least_used"),
27
  rate_limit_backoff_seconds: z.number().default(60),
28
+ oauth_client_id: z.string().default("app_EMoamEEZ73f0CkXaXp7hrann"),
29
+ oauth_auth_endpoint: z.string().default("https://auth.openai.com/oauth/authorize"),
30
+ oauth_token_endpoint: z.string().default("https://auth.openai.com/oauth/token"),
31
  }),
32
  server: z.object({
33
  host: z.string().default("0.0.0.0"),
src/index.ts CHANGED
@@ -42,7 +42,7 @@ async function main() {
42
  app.use("*", errorHandler);
43
 
44
  // Mount routes
45
- const authRoutes = createAuthRoutes(accountPool);
46
  const accountRoutes = createAccountRoutes(accountPool, refreshScheduler, cookieJar);
47
  const chatRoutes = createChatRoutes(accountPool, sessionManager, cookieJar);
48
  const webRoutes = createWebRoutes(accountPool);
 
42
  app.use("*", errorHandler);
43
 
44
  // Mount routes
45
+ const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
46
  const accountRoutes = createAccountRoutes(accountPool, refreshScheduler, cookieJar);
47
  const chatRoutes = createChatRoutes(accountPool, sessionManager, cookieJar);
48
  const webRoutes = createWebRoutes(accountPool);
src/routes/accounts.ts CHANGED
@@ -16,7 +16,9 @@
16
  import { Hono } from "hono";
17
  import type { AccountPool } from "../auth/account-pool.js";
18
  import type { RefreshScheduler } from "../auth/refresh-scheduler.js";
19
- import { validateManualToken, isCodexCliAvailable, loginViaCli } from "../auth/chatgpt-oauth.js";
 
 
20
  import { CodexApi } from "../proxy/codex-api.js";
21
  import type { CodexUsageResponse } from "../proxy/codex-api.js";
22
  import type { CodexQuota, AccountInfo } from "../auth/types.js";
@@ -56,36 +58,21 @@ export function createAccountRoutes(
56
  return new CodexApi(token, accountId, cookieJar, entryId);
57
  }
58
 
59
- // Start OAuth flow to add a new account
60
- app.get("/auth/accounts/login", async (c) => {
61
- const cliAvailable = await isCodexCliAvailable();
62
- if (!cliAvailable) {
63
- return c.json(
64
- { error: "Codex CLI not available. Please use manual token entry." },
65
- 503,
66
- );
67
- }
68
 
69
- try {
70
- const session = await loginViaCli();
71
-
72
- // Background: wait for OAuth to complete, then add account to pool
73
- session.waitForCompletion().then((result) => {
74
- if (result.success && result.token) {
75
- const entryId = pool.addAccount(result.token);
76
- scheduler.scheduleOne(entryId, result.token);
77
- console.log("[Accounts] OAuth login completed β€” new account added:", entryId);
78
- } else {
79
- console.error("[Accounts] OAuth login failed:", result.error);
80
- }
81
- });
82
 
83
- return c.json({ authUrl: session.authUrl });
84
- } catch (err) {
85
- const msg = err instanceof Error ? err.message : String(err);
86
- console.error("[Accounts] CLI OAuth failed:", msg);
87
- return c.json({ error: msg }, 500);
88
- }
 
 
89
  });
90
 
91
  // List all accounts (with optional ?quota=true)
 
16
  import { Hono } from "hono";
17
  import type { AccountPool } from "../auth/account-pool.js";
18
  import type { RefreshScheduler } from "../auth/refresh-scheduler.js";
19
+ import { validateManualToken } from "../auth/chatgpt-oauth.js";
20
+ import { createOAuthSession, startCallbackServer } from "../auth/oauth-pkce.js";
21
+ import { getConfig } from "../config.js";
22
  import { CodexApi } from "../proxy/codex-api.js";
23
  import type { CodexUsageResponse } from "../proxy/codex-api.js";
24
  import type { CodexQuota, AccountInfo } from "../auth/types.js";
 
58
  return new CodexApi(token, accountId, cookieJar, entryId);
59
  }
60
 
61
+ // Start OAuth flow to add a new account β€” 302 redirect to Auth0
62
+ app.get("/auth/accounts/login", (c) => {
63
+ const config = getConfig();
64
+ const originalHost = c.req.header("host") || `localhost:${config.server.port}`;
 
 
 
 
 
65
 
66
+ const { authUrl, port } = createOAuthSession(originalHost, "dashboard");
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
+ // Start temporary callback server for same-machine callback
69
+ startCallbackServer(port, (accessToken, refreshToken) => {
70
+ const entryId = pool.addAccount(accessToken, refreshToken);
71
+ scheduler.scheduleOne(entryId, accessToken);
72
+ console.log(`[Auth] OAuth via callback server β€” account ${entryId} added`);
73
+ });
74
+
75
+ return c.redirect(authUrl);
76
  });
77
 
78
  // List all accounts (with optional ?quota=true)
src/routes/auth.ts CHANGED
@@ -1,20 +1,24 @@
1
  import { Hono } from "hono";
2
  import type { AccountPool } from "../auth/account-pool.js";
 
 
 
3
  import {
4
- isCodexCliAvailable,
5
- loginViaCli,
6
- validateManualToken,
7
- } from "../auth/chatgpt-oauth.js";
 
 
 
 
8
 
9
- export function createAuthRoutes(pool: AccountPool): Hono {
 
 
 
10
  const app = new Hono();
11
 
12
- // Pending OAuth session (one at a time)
13
- let pendingOAuth: {
14
- authUrl: string;
15
- waitForCompletion: () => Promise<{ success: boolean; token?: string; error?: string }>;
16
- } | null = null;
17
-
18
  // Auth status (JSON) β€” pool-level summary
19
  app.get("/auth/status", (c) => {
20
  const authenticated = pool.isAuthenticated();
@@ -29,39 +33,184 @@ export function createAuthRoutes(pool: AccountPool): Hono {
29
  });
30
  });
31
 
32
- // Start OAuth login β€” returns JSON with authUrl instead of redirecting
33
- app.get("/auth/login", async (c) => {
34
- if (pool.isAuthenticated()) {
35
- return c.json({ authenticated: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }
37
 
38
- const cliAvailable = await isCodexCliAvailable();
39
- if (!cliAvailable) {
40
- return c.json(
41
- { error: "Codex CLI not available. Please use manual token entry." },
42
- 503,
43
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
 
45
 
 
 
 
 
46
  try {
47
- const session = await loginViaCli();
48
- pendingOAuth = session;
49
-
50
- // Start background wait for completion
51
- session.waitForCompletion().then((result) => {
52
- if (result.success && result.token) {
53
- pool.addAccount(result.token);
54
- console.log("[Auth] OAuth login completed successfully");
55
- } else {
56
- console.error("[Auth] OAuth login failed:", result.error);
57
- }
58
- pendingOAuth = null;
59
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- return c.json({ authUrl: session.authUrl });
 
62
  } catch (err) {
63
  const msg = err instanceof Error ? err.message : String(err);
64
- console.error("[Auth] CLI OAuth failed:", msg);
65
  return c.json({ error: msg }, 500);
66
  }
67
  });
@@ -82,7 +231,8 @@ export function createAuthRoutes(pool: AccountPool): Hono {
82
  return c.json({ error: validation.error });
83
  }
84
 
85
- pool.addAccount(token);
 
86
  return c.json({ success: true });
87
  });
88
 
@@ -94,3 +244,29 @@ export function createAuthRoutes(pool: AccountPool): Hono {
94
 
95
  return app;
96
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { Hono } from "hono";
2
  import type { AccountPool } from "../auth/account-pool.js";
3
+ import type { RefreshScheduler } from "../auth/refresh-scheduler.js";
4
+ import { validateManualToken } from "../auth/chatgpt-oauth.js";
5
+ import { getConfig } from "../config.js";
6
  import {
7
+ createOAuthSession,
8
+ consumeSession,
9
+ exchangeCode,
10
+ startCallbackServer,
11
+ requestDeviceCode,
12
+ pollDeviceToken,
13
+ importCliAuth,
14
+ } from "../auth/oauth-pkce.js";
15
 
16
+ export function createAuthRoutes(
17
+ pool: AccountPool,
18
+ scheduler: RefreshScheduler,
19
+ ): Hono {
20
  const app = new Hono();
21
 
 
 
 
 
 
 
22
  // Auth status (JSON) β€” pool-level summary
23
  app.get("/auth/status", (c) => {
24
  const authenticated = pool.isAuthenticated();
 
33
  });
34
  });
35
 
36
+ // Start OAuth login β€” 302 redirect to Auth0 (same-machine shortcut)
37
+ app.get("/auth/login", (c) => {
38
+ const config = getConfig();
39
+ const originalHost = c.req.header("host") || `localhost:${config.server.port}`;
40
+
41
+ const { authUrl, port } = createOAuthSession(originalHost, "login");
42
+
43
+ // Start temporary callback server for same-machine callback
44
+ startCallbackServer(port, (accessToken, refreshToken) => {
45
+ const entryId = pool.addAccount(accessToken, refreshToken);
46
+ scheduler.scheduleOne(entryId, accessToken);
47
+ console.log(`[Auth] OAuth via callback server β€” account ${entryId} added`);
48
+ });
49
+
50
+ return c.redirect(authUrl);
51
+ });
52
+
53
+ // POST /auth/login-start β€” returns { authUrl, state } for popup flow
54
+ app.post("/auth/login-start", (c) => {
55
+ const config = getConfig();
56
+ const originalHost = c.req.header("host") || `localhost:${config.server.port}`;
57
+
58
+ const { authUrl, state, port } = createOAuthSession(originalHost, "login");
59
+
60
+ // Start temporary callback server for same-machine callback
61
+ startCallbackServer(port, (accessToken, refreshToken) => {
62
+ const entryId = pool.addAccount(accessToken, refreshToken);
63
+ scheduler.scheduleOne(entryId, accessToken);
64
+ console.log(`[Auth] OAuth via callback server β€” account ${entryId} added`);
65
+ });
66
+
67
+ return c.json({ authUrl, state });
68
+ });
69
+
70
+ // POST /auth/code-relay β€” accepts { callbackUrl }, parses code+state, exchanges tokens
71
+ app.post("/auth/code-relay", async (c) => {
72
+ const body = await c.req.json<{ callbackUrl: string }>();
73
+ const callbackUrl = body.callbackUrl?.trim();
74
+
75
+ if (!callbackUrl) {
76
+ return c.json({ error: "callbackUrl is required" }, 400);
77
+ }
78
+
79
+ let url: URL;
80
+ try {
81
+ url = new URL(callbackUrl);
82
+ } catch {
83
+ return c.json({ error: "Invalid URL" }, 400);
84
+ }
85
+
86
+ const code = url.searchParams.get("code");
87
+ const state = url.searchParams.get("state");
88
+ const error = url.searchParams.get("error");
89
+
90
+ if (error) {
91
+ const desc = url.searchParams.get("error_description") || error;
92
+ return c.json({ error: `OAuth error: ${desc}` }, 400);
93
+ }
94
+
95
+ if (!code || !state) {
96
+ return c.json({ error: "URL must contain code and state parameters" }, 400);
97
+ }
98
+
99
+ const session = consumeSession(state);
100
+ if (!session) {
101
+ return c.json({ error: "Invalid or expired session. Please try again." }, 400);
102
+ }
103
+
104
+ try {
105
+ const tokens = await exchangeCode(code, session.codeVerifier, session.redirectUri);
106
+ const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
107
+ scheduler.scheduleOne(entryId, tokens.access_token);
108
+
109
+ console.log(`[Auth] OAuth via code-relay β€” account ${entryId} added`);
110
+ return c.json({ success: true });
111
+ } catch (err) {
112
+ const msg = err instanceof Error ? err.message : String(err);
113
+ console.error("[Auth] Code relay token exchange failed:", msg);
114
+ return c.json({ error: `Token exchange failed: ${msg}` }, 500);
115
+ }
116
+ });
117
+
118
+ // OAuth callback β€” Auth0 redirects here after user login (legacy/fallback)
119
+ app.get("/auth/callback", async (c) => {
120
+ const code = c.req.query("code");
121
+ const state = c.req.query("state");
122
+ const error = c.req.query("error");
123
+ const errorDescription = c.req.query("error_description");
124
+
125
+ if (error) {
126
+ console.error(`[Auth] OAuth error: ${error} β€” ${errorDescription}`);
127
+ return c.html(errorPage(`OAuth error: ${errorDescription || error}`));
128
+ }
129
+
130
+ if (!code || !state) {
131
+ return c.html(errorPage("Missing code or state parameter"), 400);
132
  }
133
 
134
+ const session = consumeSession(state);
135
+ if (!session) {
136
+ return c.html(errorPage("Invalid or expired OAuth session. Please try again."), 400);
137
+ }
138
+
139
+ try {
140
+ const tokens = await exchangeCode(code, session.codeVerifier, session.redirectUri);
141
+ const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
142
+ scheduler.scheduleOne(entryId, tokens.access_token);
143
+
144
+ console.log(`[Auth] OAuth login completed β€” account ${entryId} added`);
145
+
146
+ // Redirect back to the original host the user was browsing from
147
+ const returnUrl = `http://${session.returnHost}/`;
148
+ return c.redirect(returnUrl);
149
+ } catch (err) {
150
+ const msg = err instanceof Error ? err.message : String(err);
151
+ console.error("[Auth] Token exchange failed:", msg);
152
+ return c.html(errorPage(`Token exchange failed: ${msg}`), 500);
153
  }
154
+ });
155
 
156
+ // ── Device Code Flow ────────────────────────────────────────────
157
+
158
+ // POST /auth/device-login β€” start device code flow
159
+ app.post("/auth/device-login", async (c) => {
160
  try {
161
+ const deviceResp = await requestDeviceCode();
162
+ console.log(`[Auth] Device code flow started β€” user_code: ${deviceResp.user_code}`);
163
+ return c.json({
164
+ userCode: deviceResp.user_code,
165
+ verificationUri: deviceResp.verification_uri,
166
+ verificationUriComplete: deviceResp.verification_uri_complete,
167
+ deviceCode: deviceResp.device_code,
168
+ expiresIn: deviceResp.expires_in,
169
+ interval: deviceResp.interval,
 
 
 
170
  });
171
+ } catch (err) {
172
+ const msg = err instanceof Error ? err.message : String(err);
173
+ console.error("[Auth] Device code request failed:", msg);
174
+ return c.json({ error: msg }, 500);
175
+ }
176
+ });
177
+
178
+ // GET /auth/device-poll/:deviceCode β€” poll for device code authorization
179
+ app.get("/auth/device-poll/:deviceCode", async (c) => {
180
+ const deviceCode = c.req.param("deviceCode");
181
+
182
+ try {
183
+ const tokens = await pollDeviceToken(deviceCode);
184
+ const entryId = pool.addAccount(tokens.access_token, tokens.refresh_token);
185
+ scheduler.scheduleOne(entryId, tokens.access_token);
186
+
187
+ console.log(`[Auth] Device code flow completed β€” account ${entryId} added`);
188
+ return c.json({ success: true });
189
+ } catch (err: any) {
190
+ const code = err.code || "unknown";
191
+ if (code === "authorization_pending" || code === "slow_down") {
192
+ return c.json({ pending: true, code });
193
+ }
194
+ const msg = err instanceof Error ? err.message : String(err);
195
+ console.error("[Auth] Device code poll failed:", msg);
196
+ return c.json({ error: msg }, 400);
197
+ }
198
+ });
199
+
200
+ // ── CLI Token Import ───────────────────────────────────────────
201
+
202
+ // POST /auth/import-cli β€” import token from Codex CLI auth.json
203
+ app.post("/auth/import-cli", async (c) => {
204
+ try {
205
+ const cliAuth = importCliAuth();
206
+ const entryId = pool.addAccount(cliAuth.access_token!, cliAuth.refresh_token);
207
+ scheduler.scheduleOne(entryId, cliAuth.access_token!);
208
 
209
+ console.log(`[Auth] CLI token imported β€” account ${entryId} added`);
210
+ return c.json({ success: true });
211
  } catch (err) {
212
  const msg = err instanceof Error ? err.message : String(err);
213
+ console.error("[Auth] CLI import failed:", msg);
214
  return c.json({ error: msg }, 500);
215
  }
216
  });
 
231
  return c.json({ error: validation.error });
232
  }
233
 
234
+ const entryId = pool.addAccount(token);
235
+ scheduler.scheduleOne(entryId, token);
236
  return c.json({ success: true });
237
  });
238
 
 
244
 
245
  return app;
246
  }
247
+
248
+ function errorPage(message: string): string {
249
+ return `<!DOCTYPE html>
250
+ <html><head><meta charset="UTF-8"><title>Login Error</title>
251
+ <style>
252
+ body { font-family: -apple-system, sans-serif; background: #0d1117; color: #c9d1d9;
253
+ display: flex; align-items: center; justify-content: center; min-height: 100vh; }
254
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 12px;
255
+ padding: 2rem; max-width: 420px; text-align: center; }
256
+ h2 { color: #f85149; margin-bottom: 1rem; }
257
+ a { color: #58a6ff; }
258
+ </style></head>
259
+ <body><div class="card">
260
+ <h2>Login Failed</h2>
261
+ <p>${escapeHtml(message)}</p>
262
+ <p style="margin-top:1rem"><a href="/">Back to Home</a></p>
263
+ </div></body></html>`;
264
+ }
265
+
266
+ function escapeHtml(str: string): string {
267
+ return str
268
+ .replace(/&/g, "&amp;")
269
+ .replace(/</g, "&lt;")
270
+ .replace(/>/g, "&gt;")
271
+ .replace(/"/g, "&quot;");
272
+ }