From 31e80f256b8e45af262440e6e974867f6817adbc Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Wed, 11 Mar 2026 09:40:00 +0800 Subject: [PATCH] feat(provider): add OpenAI Codex browser OAuth flow (#398) Co-authored-by: zuolingxuan --- README.ja-JP.md | 4 +- README.md | 4 +- README.zh-CN.md | 4 +- electron/api/routes/providers.ts | 24 +- electron/main/index.ts | 4 + electron/main/ipc-handlers.ts | 2 +- .../providers/provider-runtime-sync.ts | 52 ++- electron/shared/providers/registry.ts | 4 +- electron/utils/browser-oauth.ts | 153 ++++++--- electron/utils/openai-codex-oauth.ts | 304 ++++++++++++++++++ src/components/settings/ProvidersSettings.tsx | 83 ++++- src/lib/providers.ts | 12 +- src/pages/Setup/index.tsx | 75 ++++- 13 files changed, 655 insertions(+), 70 deletions(-) create mode 100644 electron/utils/openai-codex-oauth.ts diff --git a/README.ja-JP.md b/README.ja-JP.md index 7e93e34ec..c745690bb 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -110,7 +110,7 @@ AIタスクを自動的に実行するようスケジュール設定できます 事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。 ### 🔐 セキュアなプロバイダー統合 -複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。 +複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。 ### 🌙 アダプティブテーマ ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。 @@ -149,7 +149,7 @@ pnpm dev ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします: 1. **言語と地域** – 使用する言語・地域の設定 -2. **AIプロバイダー** – サポートされているプロバイダーのAPIキーを入力 +2. **AIプロバイダー** – APIキーまたは OAuth(ブラウザ/デバイスログイン対応プロバイダー)で追加 3. **スキルバンドル** – 一般的なユースケース向けの事前設定スキルを選択 4. **検証** – メインインターフェースに入る前に設定をテスト diff --git a/README.md b/README.md index 111c38eba..9783f8a38 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Schedule AI tasks to run automatically. Define triggers, set intervals, and let Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required. ### 🔐 Secure Provider Integration -Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. +Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in. ### 🌙 Adaptive Theming Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically. @@ -149,7 +149,7 @@ pnpm dev When you launch ClawX for the first time, the **Setup Wizard** will guide you through: 1. **Language & Region** – Configure your preferred locale -2. **AI Provider** – Enter your API keys for supported providers +2. **AI Provider** – Add providers with API keys or OAuth (for providers that support browser/device login) 3. **Skill Bundles** – Select pre-configured skills for common use cases 4. **Verification** – Test your configuration before entering the main interface diff --git a/README.zh-CN.md b/README.zh-CN.md index 43cda7dc4..cae60837b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -111,7 +111,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。 ### 🔐 安全的供应商集成 -连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。 +连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。 ### 🌙 自适应主题 支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。 @@ -150,7 +150,7 @@ pnpm dev 首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤: 1. **语言与区域** – 配置你的首选语言和地区 -2. **AI 供应商** – 输入所支持供应商的 API 密钥 +2. **AI 供应商** – 通过 API 密钥或 OAuth(支持浏览器/设备登录的供应商)添加账号 3. **技能包** – 选择适用于常见场景的预配置技能 4. **验证** – 在进入主界面前测试你的配置 diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index fb07dbaac..78b7945f3 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -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 { diff --git a/electron/main/index.ts b/electron/main/index.ts index 289d3136e..7e4c5d979 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -283,6 +283,10 @@ async function initialize(): Promise { 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 }); }); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 879780b64..de6a31a53 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -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); diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 87fe38ff8..4d5b7dff1 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -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 { 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 { +async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise { 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; } diff --git a/electron/shared/providers/registry.ts b/electron/shared/providers/registry.ts index d02b00cad..311b9d8f0 100644 --- a/electron/shared/providers/registry.ts +++ b/electron/shared/providers/registry.ts @@ -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: { diff --git a/electron/utils/browser-oauth.ts b/electron/utils/browser-oauth.ts index 822037f91..f698b0d48 100644 --- a/electron/utils/browser-oauth.ts +++ b/electron/utils/browser-oauth.ts @@ -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 { + 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((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 }); diff --git a/electron/utils/openai-codex-oauth.ts b/electron/utils/openai-codex-oauth.ts new file mode 100644 index 000000000..8c656e41a --- /dev/null +++ b/electron/utils/openai-codex-oauth.ts @@ -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 = ` + + + + + Authentication successful + + +

Authentication successful. Return to ClawX to continue.

+ +`; + +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 | 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; + } 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).chatgpt_account_id; + if (typeof accountId !== 'string' || !accountId.trim()) { + return null; + } + + return accountId; +} + +async function createAuthorizationFlow(): Promise { + 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 { + 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; + onProgress?: (message: string) => void; + onManualCodeRequired?: (payload: { authorizationUrl: string; reason: 'port_in_use' | 'callback_timeout' }) => void; + onManualCodeInput?: () => Promise; +}): Promise { + 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(); + } +} diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index d15420dd5..35ae7b611 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -752,10 +752,16 @@ function AddProviderDialog({ // OAuth Flow State const [oauthFlowing, setOauthFlowing] = useState(false); const [oauthData, setOauthData] = useState<{ + mode: 'device'; verificationUri: string; userCode: string; expiresIn: number; + } | { + mode: 'manual'; + authorizationUrl: string; + message?: string; } | null>(null); + const [manualCodeInput, setManualCodeInput] = useState(''); const [oauthError, setOauthError] = useState(null); // For providers that support both OAuth and API key, let the user choose. // Default to the vendor's declared auth mode instead of hard-coding OAuth. @@ -792,13 +798,28 @@ function AddProviderDialog({ // Manage OAuth events useEffect(() => { const handleCode = (data: unknown) => { - setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number }); + const payload = data as Record; + if (payload?.mode === 'manual') { + setOauthData({ + mode: 'manual', + authorizationUrl: String(payload.authorizationUrl || ''), + message: typeof payload.message === 'string' ? payload.message : undefined, + }); + } else { + setOauthData({ + mode: 'device', + verificationUri: String(payload.verificationUri || ''), + userCode: String(payload.userCode || ''), + expiresIn: Number(payload.expiresIn || 300), + }); + } setOauthError(null); }; const handleSuccess = async (data: unknown) => { setOauthFlowing(false); setOauthData(null); + setManualCodeInput(''); setValidationError(null); const { onClose: close, t: translate } = latestRef.current; @@ -813,8 +834,9 @@ function AddProviderDialog({ const store = useProviderStore.getState(); await store.refreshProviderSnapshot(); - // Auto-set as default if no default is currently configured - if (!store.defaultAccountId && accountId) { + // OAuth sign-in should immediately become active default to avoid + // leaving runtime on an API-key-only provider/model. + if (accountId) { await store.setDefaultAccount(accountId); } } catch (err) { @@ -857,6 +879,7 @@ function AddProviderDialog({ setOauthFlowing(true); setOauthData(null); + setManualCodeInput(''); setOauthError(null); try { @@ -879,6 +902,7 @@ function AddProviderDialog({ const handleCancelOAuth = async () => { setOauthFlowing(false); setOauthData(null); + setManualCodeInput(''); setOauthError(null); pendingOAuthRef.current = null; await hostApiFetch('/api/providers/oauth/cancel', { @@ -886,6 +910,20 @@ function AddProviderDialog({ }); }; + const handleSubmitManualOAuthCode = async () => { + const value = manualCodeInput.trim(); + if (!value) return; + try { + await hostApiFetch('/api/providers/oauth/submit', { + method: 'POST', + body: JSON.stringify({ code: value }), + }); + setOauthError(null); + } catch (error) { + setOauthError(String(error)); + } + }; + const availableTypes = PROVIDER_TYPE_INFO.filter((type) => { const vendor = vendorMap.get(type.id); if (!vendor) { @@ -1198,6 +1236,43 @@ function AddProviderDialog({

{t('aiProviders.oauth.requestingCode')}

+ ) : oauthData.mode === 'manual' ? ( +
+
+

Complete OpenAI Login

+

+ {oauthData.message || 'Open the authorization page, complete login, then paste the callback URL or code below.'} +

+
+ + + + setManualCodeInput(e.target.value)} + className={inputClasses} + /> + + + + +
) : (
@@ -1272,4 +1347,4 @@ function AddProviderDialog({
); -} \ No newline at end of file +} diff --git a/src/lib/providers.ts b/src/lib/providers.ts index f38f75c74..cf6a332cb 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -122,7 +122,17 @@ import { providerIcons } from '@/assets/providers'; /** All supported provider types with UI metadata */ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ { id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...', model: 'Claude', requiresApiKey: true }, - { id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true }, + { + id: 'openai', + name: 'OpenAI', + icon: '💚', + placeholder: 'sk-proj-...', + model: 'GPT', + requiresApiKey: true, + isOAuth: true, + supportsApiKey: true, + apiKeyUrl: 'https://platform.openai.com/api-keys', + }, { id: 'google', name: 'Google', diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index 41e8a8a62..b504356ee 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -720,23 +720,44 @@ function ProviderContent({ // OAuth Flow State const [oauthFlowing, setOauthFlowing] = useState(false); const [oauthData, setOauthData] = useState<{ + mode: 'device'; verificationUri: string; userCode: string; expiresIn: number; + } | { + mode: 'manual'; + authorizationUrl: string; + message?: string; } | null>(null); + const [manualCodeInput, setManualCodeInput] = useState(''); const [oauthError, setOauthError] = useState(null); const pendingOAuthRef = useRef<{ accountId: string; label: string } | null>(null); // Manage OAuth events useEffect(() => { const handleCode = (data: unknown) => { - setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number }); + const payload = data as Record; + if (payload?.mode === 'manual') { + setOauthData({ + mode: 'manual', + authorizationUrl: String(payload.authorizationUrl || ''), + message: typeof payload.message === 'string' ? payload.message : undefined, + }); + } else { + setOauthData({ + mode: 'device', + verificationUri: String(payload.verificationUri || ''), + userCode: String(payload.userCode || ''), + expiresIn: Number(payload.expiresIn || 300), + }); + } setOauthError(null); }; const handleSuccess = async (data: unknown) => { setOauthFlowing(false); setOauthData(null); + setManualCodeInput(''); setKeyValid(true); const payload = (data as { accountId?: string } | undefined) || undefined; @@ -796,6 +817,7 @@ function ProviderContent({ setOauthFlowing(true); setOauthData(null); + setManualCodeInput(''); setOauthError(null); try { @@ -821,11 +843,26 @@ function ProviderContent({ const handleCancelOAuth = async () => { setOauthFlowing(false); setOauthData(null); + setManualCodeInput(''); setOauthError(null); pendingOAuthRef.current = null; await hostApiFetch('/api/providers/oauth/cancel', { method: 'POST' }); }; + const handleSubmitManualOAuthCode = async () => { + const value = manualCodeInput.trim(); + if (!value) return; + try { + await hostApiFetch('/api/providers/oauth/submit', { + method: 'POST', + body: JSON.stringify({ code: value }), + }); + setOauthError(null); + } catch (error) { + setOauthError(String(error)); + } + }; + // On mount, try to restore previously configured provider useEffect(() => { let cancelled = false; @@ -1303,6 +1340,42 @@ function ProviderContent({

Requesting secure login code...

+ ) : oauthData.mode === 'manual' ? ( +
+
+

Complete OpenAI Login

+

+ {oauthData.message || 'Open the authorization page, complete login, then paste the callback URL or code below.'} +

+
+ + + + setManualCodeInput(e.target.value)} + /> + + + + +
) : (