feat(provider): add OpenAI Codex browser OAuth flow (#398)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-11 09:40:00 +08:00
committed by GitHub
Unverified
parent 880995af19
commit 31e80f256b
13 changed files with 655 additions and 70 deletions

View File

@@ -107,8 +107,10 @@ export async function handleProviderRoutes(
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
try {
const existing = await providerService.getAccount(accountId);
const runtimeProviderKey = existing?.vendorId === 'google' && existing.authMode === 'oauth_browser'
? 'google-gemini-cli'
const runtimeProviderKey = existing?.authMode === 'oauth_browser'
? (existing.vendorId === 'google'
? 'google-gemini-cli'
: (existing.vendorId === 'openai' ? 'openai-codex' : undefined))
: undefined;
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService.deleteLegacyProviderApiKey(accountId);
@@ -184,7 +186,7 @@ export async function handleProviderRoutes(
accountId?: string;
label?: string;
}>(req);
if (body.provider === 'google') {
if (body.provider === 'google' || body.provider === 'openai') {
await browserOAuthManager.startFlow(body.provider, {
accountId: body.accountId,
label: body.label,
@@ -214,6 +216,22 @@ export async function handleProviderRoutes(
return true;
}
if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers/oauth/submit');
try {
const body = await parseJsonBody<{ code: string }>(req);
const accepted = browserOAuthManager.submitManualCode(body.code || '');
if (!accepted) {
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
return true;
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/providers' && req.method === 'POST') {
logLegacyProviderRoute('POST /api/providers');
try {

View File

@@ -283,6 +283,10 @@ async function initialize(): Promise<void> {
hostEventBus.emit('oauth:start', payload);
});
browserOAuthManager.on('oauth:code', (payload) => {
hostEventBus.emit('oauth:code', payload);
});
browserOAuthManager.on('oauth:success', (payload) => {
hostEventBus.emit('oauth:success', { ...payload, success: true });
});

View File

@@ -1758,7 +1758,7 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
) => {
try {
logger.info(`provider:requestOAuth for ${provider}`);
if (provider === 'google') {
if (provider === 'google' || provider === 'openai') {
await browserOAuthManager.startFlow(provider, options);
} else {
await deviceOAuthManager.startFlow(provider, region, options);

View File

@@ -17,6 +17,8 @@ import { logger } from '../../utils/logger';
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.3-codex`;
type RuntimeProviderSyncContext = {
runtimeProviderKey: string;
@@ -53,20 +55,35 @@ export function getOpenClawProviderKey(type: string, providerId: string): string
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
const account = await getProviderAccount(config.id);
if (config.type === 'google' && account?.authMode === 'oauth_browser') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
if (account?.authMode === 'oauth_browser') {
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
}
return getOpenClawProviderKey(config.type, config.id);
}
async function isGoogleBrowserOAuthProvider(config: ProviderConfig): Promise<boolean> {
async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<string | null> {
const account = await getProviderAccount(config.id);
if (config.type !== 'google' || account?.authMode !== 'oauth_browser') {
return false;
if (account?.authMode !== 'oauth_browser') {
return null;
}
const secret = await getProviderSecret(config.id);
return secret?.type === 'oauth';
if (secret?.type !== 'oauth') {
return null;
}
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
return null;
}
export function getProviderModelRef(config: ProviderConfig): string | undefined {
@@ -396,8 +413,8 @@ export async function syncDefaultProviderToRuntime(
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
const isGoogleOAuthProvider = await isGoogleBrowserOAuthProvider(provider);
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || isGoogleOAuthProvider;
const browserOAuthRuntimeProvider = await getBrowserOAuthRuntimeProvider(provider);
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || Boolean(browserOAuthRuntimeProvider);
if (!isOAuthProvider) {
const modelOverride = provider.model
@@ -424,10 +441,10 @@ export async function syncDefaultProviderToRuntime(
await saveProviderKeyToOpenClaw(ock, providerKey);
}
} else {
if (isGoogleOAuthProvider) {
if (browserOAuthRuntimeProvider) {
const secret = await getProviderSecret(provider.id);
if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(GOOGLE_OAUTH_RUNTIME_PROVIDER, {
await saveOAuthTokenToOpenClaw(browserOAuthRuntimeProvider, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
@@ -436,17 +453,20 @@ export async function syncDefaultProviderToRuntime(
});
}
const defaultModelRef = browserOAuthRuntimeProvider === GOOGLE_OAUTH_RUNTIME_PROVIDER
? GOOGLE_OAUTH_DEFAULT_MODEL_REF
: OPENAI_OAUTH_DEFAULT_MODEL_REF;
const modelOverride = provider.model
? (provider.model.startsWith(`${GOOGLE_OAUTH_RUNTIME_PROVIDER}/`)
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
? provider.model
: `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/${provider.model}`)
: GOOGLE_OAUTH_DEFAULT_MODEL_REF;
: `${browserOAuthRuntimeProvider}/${provider.model}`)
: defaultModelRef;
await setOpenClawDefaultModel(GOOGLE_OAUTH_RUNTIME_PROVIDER, modelOverride, fallbackModels);
logger.info(`Configured openclaw.json for Google browser OAuth provider "${provider.id}"`);
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after provider switch to "${GOOGLE_OAUTH_RUNTIME_PROVIDER}"`,
`Scheduling Gateway restart after provider switch to "${browserOAuthRuntimeProvider}"`,
);
return;
}

View File

@@ -30,7 +30,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
category: 'official',
envVar: 'OPENAI_API_KEY',
defaultModelId: 'gpt-5.2',
supportedAuthModes: ['api_key'],
isOAuth: true,
supportsApiKey: true,
supportedAuthModes: ['api_key', 'oauth_browser'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
providerConfig: {

View File

@@ -2,14 +2,17 @@ import { EventEmitter } from 'events';
import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth';
import { loginOpenAICodexOAuth, type OpenAICodexOAuthCredentials } from './openai-codex-oauth';
import { getProviderService } from '../services/providers/provider-service';
import { getSecretStore } from '../services/secrets/secret-store';
import { saveOAuthTokenToOpenClaw } from './openclaw-auth';
export type BrowserOAuthProviderType = 'google';
export type BrowserOAuthProviderType = 'google' | 'openai';
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.3-codex';
class BrowserOAuthManager extends EventEmitter {
private activeProvider: BrowserOAuthProviderType | null = null;
@@ -17,6 +20,8 @@ class BrowserOAuthManager extends EventEmitter {
private activeLabel: string | null = null;
private active = false;
private mainWindow: BrowserWindow | null = null;
private pendingManualCodeResolve: ((value: string) => void) | null = null;
private pendingManualCodeReject: ((reason?: unknown) => void) | null = null;
setWindow(window: BrowserWindow) {
this.mainWindow = window;
@@ -36,38 +41,72 @@ class BrowserOAuthManager extends EventEmitter {
this.activeLabel = options?.label || null;
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
try {
if (provider !== 'google') {
throw new Error(`Unsupported browser OAuth provider type: ${provider}`);
}
if (provider === 'openai') {
// OpenAI flow may switch to manual callback mode; keep start API non-blocking.
void this.executeFlow(provider);
return true;
}
const token = await loginGeminiCliOAuth({
isRemote: false,
openUrl: async (url) => {
await shell.openExternal(url);
},
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
note: async (message, title) => {
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
},
prompt: async () => {
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
},
progress: {
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
stop: (message) => {
if (message) {
logger.info(`[BrowserOAuth] ${message}`);
await this.executeFlow(provider);
return true;
}
private async executeFlow(provider: BrowserOAuthProviderType): Promise<void> {
try {
const token = provider === 'google'
? await loginGeminiCliOAuth({
isRemote: false,
openUrl: async (url) => {
await shell.openExternal(url);
},
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
note: async (message, title) => {
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
},
prompt: async () => {
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
},
progress: {
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
stop: (message) => {
if (message) {
logger.info(`[BrowserOAuth] ${message}`);
}
},
},
})
: await loginOpenAICodexOAuth({
openUrl: async (url) => {
await shell.openExternal(url);
},
onProgress: (message) => logger.info(`[BrowserOAuth] ${message}`),
onManualCodeRequired: ({ authorizationUrl, reason }) => {
const message = reason === 'port_in_use'
? 'OpenAI OAuth callback port 1455 is in use. Complete sign-in, then paste the final callback URL or code.'
: 'OpenAI OAuth callback timed out. Paste the final callback URL or code to continue.';
const payload = {
provider,
mode: 'manual' as const,
authorizationUrl,
message,
};
this.emit('oauth:code', payload);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', payload);
}
},
},
});
onManualCodeInput: async () => {
return await new Promise<string>((resolve, reject) => {
this.pendingManualCodeResolve = resolve;
this.pendingManualCodeReject = reject;
});
},
});
await this.onSuccess(provider, token);
return true;
} catch (error) {
if (!this.active) {
return false;
return;
}
logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error);
this.emitError(error instanceof Error ? error.message : String(error));
@@ -75,7 +114,8 @@ class BrowserOAuthManager extends EventEmitter {
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
return false;
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
}
}
@@ -84,12 +124,28 @@ class BrowserOAuthManager extends EventEmitter {
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
if (this.pendingManualCodeReject) {
this.pendingManualCodeReject(new Error('OAuth flow cancelled'));
}
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
logger.info('[BrowserOAuth] Flow explicitly stopped');
}
submitManualCode(code: string): boolean {
const value = code.trim();
if (!value || !this.pendingManualCodeResolve) {
return false;
}
this.pendingManualCodeResolve(value);
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
return true;
}
private async onSuccess(
providerType: BrowserOAuthProviderType,
token: GeminiCliOAuthCredentials,
token: GeminiCliOAuthCredentials | OpenAICodexOAuthCredentials,
) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
@@ -97,26 +153,49 @@ class BrowserOAuthManager extends EventEmitter {
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
this.pendingManualCodeResolve = null;
this.pendingManualCodeReject = null;
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
const providerService = getProviderService();
const existing = await providerService.getAccount(accountId);
const isGoogle = providerType === 'google';
const runtimeProviderId = isGoogle ? GOOGLE_RUNTIME_PROVIDER_ID : OPENAI_RUNTIME_PROVIDER_ID;
const defaultModel = isGoogle ? GOOGLE_OAUTH_DEFAULT_MODEL : OPENAI_OAUTH_DEFAULT_MODEL;
const accountLabelDefault = isGoogle ? 'Google Gemini' : 'OpenAI Codex';
const oauthTokenEmail = 'email' in token && typeof token.email === 'string' ? token.email : undefined;
const oauthTokenSubject = 'projectId' in token && typeof token.projectId === 'string'
? token.projectId
: ('accountId' in token && typeof token.accountId === 'string' ? token.accountId : undefined);
const normalizedExistingModel = (() => {
const value = existing?.model?.trim();
if (!value) return undefined;
if (isGoogle) {
return value.includes('/') ? value.split('/').pop() : value;
}
// OpenAI OAuth uses openai-codex/* runtime; existing openai/* refs are incompatible.
if (value.startsWith('openai/')) return undefined;
if (value.startsWith('openai-codex/')) return value.split('/').pop();
return value.includes('/') ? value.split('/').pop() : value;
})();
const nextAccount = await providerService.createAccount({
id: accountId,
vendorId: providerType,
label: accountLabel || existing?.label || 'Google Gemini',
label: accountLabel || existing?.label || accountLabelDefault,
authMode: 'oauth_browser',
baseUrl: existing?.baseUrl,
apiProtocol: existing?.apiProtocol,
model: existing?.model || GOOGLE_OAUTH_DEFAULT_MODEL,
model: normalizedExistingModel || defaultModel,
fallbackModels: existing?.fallbackModels,
fallbackAccountIds: existing?.fallbackAccountIds,
enabled: existing?.enabled ?? true,
isDefault: existing?.isDefault ?? false,
metadata: {
...existing?.metadata,
email: token.email,
resourceUrl: GOOGLE_RUNTIME_PROVIDER_ID,
email: oauthTokenEmail,
resourceUrl: runtimeProviderId,
},
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -128,16 +207,16 @@ class BrowserOAuthManager extends EventEmitter {
accessToken: token.access,
refreshToken: token.refresh,
expiresAt: token.expires,
email: token.email,
subject: token.projectId,
email: oauthTokenEmail,
subject: oauthTokenSubject,
});
await saveOAuthTokenToOpenClaw(GOOGLE_RUNTIME_PROVIDER_ID, {
await saveOAuthTokenToOpenClaw(runtimeProviderId, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: token.email,
projectId: token.projectId,
email: oauthTokenEmail,
projectId: oauthTokenSubject,
});
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });

View File

@@ -0,0 +1,304 @@
import { createHash, randomBytes } from 'node:crypto';
import { createServer } from 'node:http';
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
// Must match the redirect URI expected by OpenAI Codex OAuth client.
const REDIRECT_URI = 'http://localhost:1455/auth/callback';
const SCOPE = 'openid profile email offline_access';
const JWT_CLAIM_PATH = 'https://api.openai.com/auth';
const ORIGINATOR = 'codex_cli_rs';
const SUCCESS_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authentication successful</title>
</head>
<body>
<p>Authentication successful. Return to ClawX to continue.</p>
</body>
</html>`;
export interface OpenAICodexOAuthCredentials {
access: string;
refresh: string;
expires: number;
accountId: string;
}
interface OpenAICodexAuthorizationFlow {
verifier: string;
state: string;
url: string;
}
interface OpenAICodexLocalServer {
close: () => void;
waitForCode: () => Promise<{ code: string } | null>;
}
function toBase64Url(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function createPkce(): { verifier: string; challenge: string } {
const verifier = toBase64Url(randomBytes(32));
const challenge = toBase64Url(createHash('sha256').update(verifier).digest());
return { verifier, challenge };
}
function createState(): string {
return toBase64Url(randomBytes(32));
}
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
const value = input.trim();
if (!value) {
return {};
}
try {
const url = new URL(value);
return {
code: url.searchParams.get('code') ?? undefined,
state: url.searchParams.get('state') ?? undefined,
};
} catch {
// not a URL
}
if (value.includes('#')) {
const [code, state] = value.split('#', 2);
return { code, state };
}
if (value.includes('code=')) {
const params = new URLSearchParams(value);
return {
code: params.get('code') ?? undefined,
state: params.get('state') ?? undefined,
};
}
return { code: value };
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const payload = parts[1];
if (!payload) {
return null;
}
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const decoded = Buffer.from(padded, 'base64').toString('utf8');
return JSON.parse(decoded) as Record<string, unknown>;
} catch {
return null;
}
}
function getAccountIdFromAccessToken(accessToken: string): string | null {
const payload = decodeJwtPayload(accessToken);
const authClaims = payload?.[JWT_CLAIM_PATH];
if (!authClaims || typeof authClaims !== 'object') {
return null;
}
const accountId = (authClaims as Record<string, unknown>).chatgpt_account_id;
if (typeof accountId !== 'string' || !accountId.trim()) {
return null;
}
return accountId;
}
async function createAuthorizationFlow(): Promise<OpenAICodexAuthorizationFlow> {
const { verifier, challenge } = createPkce();
const state = createState();
const url = new URL(AUTHORIZE_URL);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('scope', SCOPE);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
url.searchParams.set('id_token_add_organizations', 'true');
url.searchParams.set('codex_cli_simplified_flow', 'true');
url.searchParams.set('originator', ORIGINATOR);
return { verifier, state, url: url.toString() };
}
function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer | null> {
let lastCode: string | null = null;
const server = createServer((req, res) => {
try {
const url = new URL(req.url || '', 'http://localhost');
if (url.pathname !== '/auth/callback') {
res.statusCode = 404;
res.end('Not found');
return;
}
if (url.searchParams.get('state') !== state) {
res.statusCode = 400;
res.end('State mismatch');
return;
}
const code = url.searchParams.get('code');
if (!code) {
res.statusCode = 400;
res.end('Missing authorization code');
return;
}
lastCode = code;
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(SUCCESS_HTML);
} catch {
res.statusCode = 500;
res.end('Internal error');
}
});
return new Promise((resolve) => {
server
.listen(1455, 'localhost', () => {
resolve({
close: () => server.close(),
waitForCode: async () => {
const sleep = () => new Promise((r) => setTimeout(r, 100));
for (let i = 0; i < 600; i += 1) {
if (lastCode) {
return { code: lastCode };
}
await sleep();
}
return null;
},
});
})
.on('error', () => {
resolve(null);
});
});
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
): Promise<{ access: string; refresh: string; expires: number }> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: REDIRECT_URI,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`OpenAI token exchange failed (${response.status}): ${text}`);
}
const json = await response.json() as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== 'number') {
throw new Error('OpenAI token response missing fields');
}
return {
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
}
export async function loginOpenAICodexOAuth(options: {
openUrl: (url: string) => Promise<void>;
onProgress?: (message: string) => void;
onManualCodeRequired?: (payload: { authorizationUrl: string; reason: 'port_in_use' | 'callback_timeout' }) => void;
onManualCodeInput?: () => Promise<string>;
}): Promise<OpenAICodexOAuthCredentials> {
const { verifier, state, url } = await createAuthorizationFlow();
options.onProgress?.('Opening OpenAI sign-in page…');
const server = await startLocalOAuthServer(state);
try {
await options.openUrl(url);
options.onProgress?.(
server ? 'Waiting for OpenAI OAuth callback…' : 'Callback port unavailable, waiting for manual authorization code…',
);
let code: string | undefined;
if (server) {
const result = await server.waitForCode();
code = result?.code ?? undefined;
if (!code && options.onManualCodeInput) {
options.onManualCodeRequired?.({ authorizationUrl: url, reason: 'callback_timeout' });
code = await options.onManualCodeInput();
}
} else {
if (!options.onManualCodeInput) {
throw new Error('Cannot start OpenAI OAuth callback server on localhost:1455');
}
options.onManualCodeRequired?.({ authorizationUrl: url, reason: 'port_in_use' });
code = await options.onManualCodeInput();
}
if (!code) {
throw new Error('Missing OpenAI authorization code');
}
const parsed = parseAuthorizationInput(code);
if (parsed.state && parsed.state !== state) {
throw new Error('OpenAI OAuth state mismatch');
}
code = parsed.code;
if (!code) {
throw new Error('Missing OpenAI authorization code');
}
const token = await exchangeAuthorizationCode(code, verifier);
const accountId = getAccountIdFromAccessToken(token.access);
if (!accountId) {
throw new Error('Failed to extract OpenAI accountId from token');
}
return {
access: token.access,
refresh: token.refresh,
expires: token.expires,
accountId,
};
} finally {
server?.close();
}
}