Spaces:
Paused
Paused
Merge pull request #4 from icebear0828/feat/native-oauth
Browse filesfeat: native OAuth PKCE login with localhost:1455 redirect
- config/default.yaml +4 -1
- public/dashboard.html +316 -95
- public/login.html +293 -72
- src/auth/account-pool.ts +10 -2
- src/auth/chatgpt-oauth.ts +1 -437
- src/auth/oauth-pkce.ts +444 -0
- src/auth/refresh-scheduler.ts +18 -4
- src/auth/types.ts +1 -0
- src/config.ts +3 -0
- src/index.ts +1 -1
- src/routes/accounts.ts +16 -29
- src/routes/auth.ts +212 -36
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: "
|
| 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="
|
| 270 |
-
<div class="login-info" id="
|
| 271 |
-
<div class="login-error" id="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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="
|
| 156 |
Login with ChatGPT
|
| 157 |
</button>
|
| 158 |
-
|
| 159 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
<div class="divider">or</div>
|
| 162 |
|
|
@@ -180,92 +274,219 @@
|
|
| 180 |
</div>
|
| 181 |
|
| 182 |
<script>
|
| 183 |
-
let
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
| 189 |
|
|
|
|
|
|
|
| 190 |
btn.disabled = true;
|
| 191 |
-
btn.innerHTML = '<span class="spinner"></span>
|
| 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.
|
| 200 |
-
|
| 201 |
-
return;
|
| 202 |
}
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
errorEl.style.display = 'block';
|
| 207 |
-
btn.disabled = false;
|
| 208 |
-
btn.textContent = 'Login with ChatGPT';
|
| 209 |
-
return;
|
| 210 |
-
}
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
infoEl.style.display = 'block';
|
| 219 |
|
| 220 |
-
// Start polling for auth completion
|
| 221 |
-
startPolling();
|
| 222 |
-
}
|
| 223 |
} catch (err) {
|
| 224 |
-
|
| 225 |
-
errorEl.style.display = 'block';
|
| 226 |
btn.disabled = false;
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
| 228 |
}
|
| 229 |
}
|
| 230 |
|
| 231 |
function startPolling() {
|
| 232 |
-
if (
|
| 233 |
-
|
| 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 |
-
|
| 242 |
-
|
| 243 |
-
document.getElementById('oauthInfo').style.display = 'block';
|
| 244 |
-
setTimeout(() => window.location.href = '/', 500);
|
| 245 |
-
return;
|
| 246 |
}
|
| 247 |
-
} catch {
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
}
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
}
|
| 253 |
-
};
|
| 254 |
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
}
|
| 268 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
*
|
| 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
| 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 {
|
| 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
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
console.log(`[RefreshScheduler] Account ${entryId} refreshed successfully`);
|
| 95 |
-
this.scheduleOne(entryId,
|
| 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
|
|
|
|
|
|
|
| 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",
|
| 61 |
-
const
|
| 62 |
-
|
| 63 |
-
return c.json(
|
| 64 |
-
{ error: "Codex CLI not available. Please use manual token entry." },
|
| 65 |
-
503,
|
| 66 |
-
);
|
| 67 |
-
}
|
| 68 |
|
| 69 |
-
|
| 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 |
-
|
| 84 |
-
|
| 85 |
-
const
|
| 86 |
-
|
| 87 |
-
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
export function createAuthRoutes(
|
|
|
|
|
|
|
|
|
|
| 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 β
|
| 33 |
-
app.get("/auth/login",
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
-
const
|
| 39 |
-
if (!
|
| 40 |
-
return c.
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
|
|
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
try {
|
| 47 |
-
const
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
console.error("[Auth] OAuth login failed:", result.error);
|
| 57 |
-
}
|
| 58 |
-
pendingOAuth = null;
|
| 59 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
|
|
|
| 62 |
} catch (err) {
|
| 63 |
const msg = err instanceof Error ? err.message : String(err);
|
| 64 |
-
console.error("[Auth] CLI
|
| 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, "&")
|
| 269 |
+
.replace(/</g, "<")
|
| 270 |
+
.replace(/>/g, ">")
|
| 271 |
+
.replace(/"/g, """);
|
| 272 |
+
}
|