mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-17 04:33:32 +00:00
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
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>
488 lines
15 KiB
TypeScript
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 };
|