eigent/electron/main/subscriptionAuth/index.ts
Tong Chen 6300702844
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Pre-commit / pre-commit (push) Has been cancelled
Test / Run Web + Local Brain Smoke (push) Has been cancelled
Test / Run Frontend Guardrails (push) Has been cancelled
Test / Run Python Tests (push) Has been cancelled
release: v1.0.1 (#1710)
Co-authored-by: Douglas <douglas.ym.lai@gmail.com>
Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com>
Co-authored-by: Tao Sun <168447269+fengju0213@users.noreply.github.com>
Co-authored-by: Weijie Bai <happy.regina.bai@gmail.com>
2026-06-27 18:14:29 +08:00

488 lines
15 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { BrowserWindow, shell, type IpcMain } from 'electron';
import log from 'electron-log';
import http from 'node:http';
import {
DEFAULT_CODEX_OAUTH_CALLBACK_HOST,
DEFAULT_CODEX_OAUTH_CALLBACK_PATH,
DEFAULT_CODEX_OAUTH_CALLBACK_PORT,
codexOAuthAuthUrl,
codexOAuthClientId,
codexOAuthOriginator,
codexOAuthRedirectUri,
codexOAuthScopes,
codexOAuthTokenUrl,
} from './codexOAuth';
import {
clearCodexCredential,
getCodexAccountStatus,
saveCodexCredential,
} from './credentialStore';
import {
codexAccountMetadataFromAccessToken,
expiresAtFromTokenPayload,
} from './oauthToken';
import { createPkcePair } from './pkce';
import { getCodexResolverEnv } from './resolverServer';
import type { CodexSubscriptionIpcResult } from './types';
const CODEX_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
type PendingCodexOAuth = {
email: string;
codeVerifier: string;
redirectUri: string;
expiresAtMs: number;
};
export type CodexOAuthCallbackResult = {
handled: boolean;
error_code?: string;
};
const pendingCodexOAuthByState = new Map<string, PendingCodexOAuth>();
// The local OAuth callback server only needs to run during an active login.
// We start it on demand and stop it once no login is pending (or the state TTL
// lapses), so we don't hold port 1455 for the whole app lifetime.
let oauthCallbackServer: http.Server | null = null;
let oauthCallbackServerStarting: Promise<void> | null = null;
let oauthCallbackIdleTimer: ReturnType<typeof setTimeout> | null = null;
const OAUTH_CALLBACK_IDLE_GRACE_MS = 30 * 1000;
function normalizeEmail(email: unknown): string {
return typeof email === 'string' ? email.trim() : '';
}
function pruneExpiredOAuthState(now = Date.now()) {
for (const [state, pending] of pendingCodexOAuthByState.entries()) {
if (pending.expiresAtMs <= now) {
pendingCodexOAuthByState.delete(state);
}
}
}
function notifyCodexStatusChanged(payload?: { error_code?: string }) {
for (const browserWindow of BrowserWindow.getAllWindows()) {
if (!browserWindow.isDestroyed()) {
browserWindow.webContents.send(
'subscription-auth:codex-status-changed',
payload
);
}
}
}
function writeOAuthHtml(
res: http.ServerResponse,
statusCode: number,
title: string,
body: string
) {
res.writeHead(statusCode, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
// Close the socket after responding so the callback server can stop
// promptly instead of lingering on a keep-alive connection.
connection: 'close',
});
res.end(`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
color-scheme: light dark;
background: Canvas;
color: CanvasText;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
text-align: center;
}
main { max-width: 520px; }
h1 { margin: 0 0 12px; font-size: 28px; }
p { margin: 0; color: GrayText; line-height: 1.6; }
</style>
</head>
<body>
<main>
<h1>${title}</h1>
<p>${body}</p>
</main>
</body>
</html>`);
}
async function handleLocalOAuthCallback(
req: http.IncomingMessage,
res: http.ServerResponse
) {
const callbackUrl = new URL(
req.url || '/',
`http://${DEFAULT_CODEX_OAUTH_CALLBACK_HOST}:${DEFAULT_CODEX_OAUTH_CALLBACK_PORT}`
);
if (callbackUrl.pathname !== DEFAULT_CODEX_OAUTH_CALLBACK_PATH) {
writeOAuthHtml(
res,
404,
'Authentication failed',
'Callback route not found.'
);
return;
}
const result = await completeCodexOAuthCallback(callbackUrl);
if (!result.handled) {
writeOAuthHtml(
res,
400,
'Authentication failed',
'Unexpected OAuth callback.'
);
return;
}
notifyCodexStatusChanged(
result.error_code ? { error_code: result.error_code } : undefined
);
if (result.error_code) {
writeOAuthHtml(
res,
400,
'Authentication failed',
'Return to Eigent and try signing in again.'
);
stopCallbackServerWhenIdle();
return;
}
writeOAuthHtml(
res,
200,
'Authentication successful',
'OpenAI Codex is connected. You can close this window.'
);
stopCallbackServerWhenIdle();
}
function clearCallbackServerIdleTimer() {
if (oauthCallbackIdleTimer) {
clearTimeout(oauthCallbackIdleTimer);
oauthCallbackIdleTimer = null;
}
}
function stopLocalOAuthCallbackServer() {
clearCallbackServerIdleTimer();
const server = oauthCallbackServer;
oauthCallbackServer = null;
oauthCallbackServerStarting = null;
if (!server) return;
server.close((error) => {
if (error) {
log.warn('[CODEX AUTH] Failed to stop OAuth callback server:', error);
} else {
log.info('[CODEX AUTH] Local OAuth callback server stopped');
}
});
}
// Stop the server once nothing is pending; otherwise keep it for the other
// in-flight login(s).
function stopCallbackServerWhenIdle() {
if (pendingCodexOAuthByState.size === 0) {
stopLocalOAuthCallbackServer();
}
}
// Auto-stop after the state TTL (+ grace) so an abandoned login never leaves
// the port held indefinitely.
function armCallbackServerIdleTimeout() {
clearCallbackServerIdleTimer();
oauthCallbackIdleTimer = setTimeout(() => {
oauthCallbackIdleTimer = null;
pruneExpiredOAuthState();
if (pendingCodexOAuthByState.size === 0) {
stopLocalOAuthCallbackServer();
} else {
armCallbackServerIdleTimeout();
}
}, CODEX_OAUTH_STATE_TTL_MS + OAUTH_CALLBACK_IDLE_GRACE_MS);
// Never keep the app alive just for this timer.
oauthCallbackIdleTimer.unref?.();
}
async function ensureLocalOAuthCallbackServer(): Promise<void> {
if (oauthCallbackServer) {
armCallbackServerIdleTimeout();
return;
}
if (oauthCallbackServerStarting) return oauthCallbackServerStarting;
oauthCallbackServerStarting = new Promise<void>((resolve, reject) => {
const server = http.createServer((req, res) => {
void handleLocalOAuthCallback(req, res).catch((error) => {
log.warn('[CODEX AUTH] Local OAuth callback failed:', error);
writeOAuthHtml(
res,
500,
'Authentication failed',
'Eigent could not complete the OAuth callback.'
);
});
});
const handleListenError = (error: NodeJS.ErrnoException) => {
oauthCallbackServer = null;
oauthCallbackServerStarting = null;
reject(
new Error(
error.code === 'EADDRINUSE'
? 'oauth_callback_port_in_use'
: 'oauth_callback_unavailable'
)
);
};
server.once('error', handleListenError);
server.listen(
DEFAULT_CODEX_OAUTH_CALLBACK_PORT,
DEFAULT_CODEX_OAUTH_CALLBACK_HOST,
() => {
server.off('error', handleListenError);
// Keep logging post-startup errors instead of crashing the process.
server.on('error', (error) => {
log.warn('[CODEX AUTH] OAuth callback server error:', error);
});
oauthCallbackServer = server;
oauthCallbackServerStarting = null;
armCallbackServerIdleTimeout();
log.info('[CODEX AUTH] Local OAuth callback server started', {
host: DEFAULT_CODEX_OAUTH_CALLBACK_HOST,
port: DEFAULT_CODEX_OAUTH_CALLBACK_PORT,
});
resolve();
}
);
});
return oauthCallbackServerStarting;
}
export function registerCodexSubscriptionAuthIpcHandlers(ipcMain: IpcMain) {
ipcMain.handle('subscription-auth:codex-status', (_event, email: unknown) => {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail) {
return {
connected: false,
status: 'not_connected',
last_error_code: 'email_required',
};
}
return getCodexAccountStatus(normalizedEmail);
});
ipcMain.handle(
'subscription-auth:codex-disconnect',
(_event, email: unknown): CodexSubscriptionIpcResult => {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail) {
return { success: false, error_code: 'email_required' };
}
clearCodexCredential(normalizedEmail);
return { success: true };
}
);
ipcMain.handle(
'subscription-auth:codex-login',
async (_event, email: unknown): Promise<CodexSubscriptionIpcResult> => {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail) {
return { success: false, error_code: 'email_required' };
}
try {
await ensureLocalOAuthCallbackServer();
} catch (error: any) {
log.warn('[CODEX AUTH] Failed to start local OAuth callback:', error);
const errorCode =
error?.message === 'oauth_callback_port_in_use'
? 'oauth_callback_port_in_use'
: 'oauth_callback_unavailable';
return { success: false, error_code: errorCode };
}
pruneExpiredOAuthState();
const pkce = createPkcePair();
const redirectUri = codexOAuthRedirectUri();
pendingCodexOAuthByState.set(pkce.state, {
email: normalizedEmail,
codeVerifier: pkce.codeVerifier,
redirectUri,
expiresAtMs: Date.now() + CODEX_OAUTH_STATE_TTL_MS,
});
const authUrl = codexOAuthAuthUrl();
const clientId = codexOAuthClientId();
const url = new URL(authUrl);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('state', pkce.state);
url.searchParams.set('code_challenge', pkce.codeChallenge);
url.searchParams.set('code_challenge_method', pkce.codeChallengeMethod);
url.searchParams.set('scope', codexOAuthScopes());
url.searchParams.set('id_token_add_organizations', 'true');
url.searchParams.set('codex_cli_simplified_flow', 'true');
url.searchParams.set('originator', codexOAuthOriginator());
try {
await shell.openExternal(url.toString());
} catch (error) {
pendingCodexOAuthByState.delete(pkce.state);
stopCallbackServerWhenIdle();
log.warn('[CODEX AUTH] Failed to open OAuth URL:', error);
return { success: false, error_code: 'oauth_open_browser_failed' };
}
return { success: true };
}
);
}
async function exchangeCodexOAuthCode(
code: string,
pending: PendingCodexOAuth
): Promise<Record<string, unknown>> {
const tokenUrl = codexOAuthTokenUrl();
const clientId = codexOAuthClientId();
const body = new URLSearchParams();
body.set('grant_type', 'authorization_code');
body.set('client_id', clientId);
body.set('code', code);
body.set('redirect_uri', pending.redirectUri);
body.set('code_verifier', pending.codeVerifier);
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
},
body,
});
if (!response.ok) {
throw new Error(`token_exchange_failed_${response.status}`);
}
const payload = (await response.json()) as Record<string, unknown>;
const accessToken = payload.access_token;
if (typeof accessToken !== 'string' || !accessToken.trim()) {
throw new Error('token_exchange_missing_access_token');
}
return payload;
}
export async function completeCodexOAuthCallback(
callbackUrl: URL
): Promise<CodexOAuthCallbackResult> {
pruneExpiredOAuthState();
const state = callbackUrl.searchParams.get('state');
const provider = callbackUrl.searchParams.get('provider');
const pending = state ? pendingCodexOAuthByState.get(state) : undefined;
if (!pending && provider !== 'codex') {
return { handled: false };
}
if (!state || !pending) {
log.warn('[CODEX AUTH] Ignoring OAuth callback without matching state');
return { handled: true, error_code: 'oauth_state_mismatch' };
}
if (pending.expiresAtMs <= Date.now()) {
pendingCodexOAuthByState.delete(state);
log.warn('[CODEX AUTH] Ignoring expired OAuth callback state');
return { handled: true, error_code: 'oauth_state_expired' };
}
pendingCodexOAuthByState.delete(state);
const error = callbackUrl.searchParams.get('error');
if (error) {
log.warn('[CODEX AUTH] OAuth callback returned an error:', error);
return { handled: true, error_code: error };
}
const code = callbackUrl.searchParams.get('code');
if (!code) {
log.warn('[CODEX AUTH] OAuth callback did not include a code');
return { handled: true, error_code: 'oauth_code_missing' };
}
try {
const tokenPayload = await exchangeCodexOAuthCode(code, pending);
const accountMetadata = codexAccountMetadataFromAccessToken(
tokenPayload.access_token as string
);
saveCodexCredential(pending.email, {
access_token: tokenPayload.access_token as string,
refresh_token:
typeof tokenPayload.refresh_token === 'string'
? tokenPayload.refresh_token
: null,
token_type:
typeof tokenPayload.token_type === 'string'
? tokenPayload.token_type
: 'Bearer',
expires_at: expiresAtFromTokenPayload(tokenPayload),
account_label:
typeof tokenPayload.account_label === 'string'
? tokenPayload.account_label
: accountMetadata.account_label || pending.email,
external_account_id:
typeof tokenPayload.external_account_id === 'string'
? tokenPayload.external_account_id
: accountMetadata.external_account_id,
scopes:
typeof tokenPayload.scope === 'string'
? tokenPayload.scope.split(/\s+/).filter(Boolean)
: codexOAuthScopes().split(/\s+/).filter(Boolean),
status:
typeof tokenPayload.refresh_token === 'string'
? 'connected'
: 'connected_non_refreshable',
updated_at: new Date().toISOString(),
});
} catch (error: any) {
const errorCode =
typeof error?.message === 'string'
? error.message
: 'token_exchange_failed';
log.warn('[CODEX AUTH] Failed to complete OAuth callback:', errorCode);
return { handled: true, error_code: errorCode };
}
return { handled: true };
}
export { getCodexResolverEnv };