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

@@ -110,7 +110,7 @@ AIタスクを自動的に実行するようスケジュール設定できます
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
### 🔐 セキュアなプロバイダー統合
複数のAIプロバイダーOpenAI、Anthropicなどに接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。
複数のAIプロバイダーOpenAI、Anthropicなどに接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuthCodex サブスクリプション)の両方に対応しています。
### 🌙 アダプティブテーマ
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
@@ -149,7 +149,7 @@ pnpm dev
ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします:
1. **言語と地域** 使用する言語・地域の設定
2. **AIプロバイダー** サポートされているプロバイダーのAPIキーを入力
2. **AIプロバイダー** APIキーまたは OAuthブラウザ/デバイスログイン対応プロバイダー)で追加
3. **スキルバンドル** 一般的なユースケース向けの事前設定スキルを選択
4. **検証** メインインターフェースに入る前に設定をテスト

View File

@@ -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

View File

@@ -111,7 +111,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
### 🔐 安全的供应商集成
连接多个 AI 供应商OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。
连接多个 AI 供应商OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
### 🌙 自适应主题
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
@@ -150,7 +150,7 @@ pnpm dev
首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤:
1. **语言与区域** 配置你的首选语言和地区
2. **AI 供应商** 输入所支持供应商的 API 密钥
2. **AI 供应商** 通过 API 密钥或 OAuth支持浏览器/设备登录的供应商)添加账号
3. **技能包** 选择适用于常见场景的预配置技能
4. **验证** 在进入主界面前测试你的配置

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();
}
}

View File

@@ -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<string | null>(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<string, unknown>;
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({
<Loader2 className="h-10 w-10 animate-spin text-blue-500 mx-auto" />
<p className="text-[13px] font-medium text-muted-foreground animate-pulse">{t('aiProviders.oauth.requestingCode')}</p>
</div>
) : oauthData.mode === 'manual' ? (
<div className="space-y-4 w-full">
<div className="space-y-2">
<h3 className="font-semibold text-[16px] text-foreground">Complete OpenAI Login</h3>
<p className="text-[13px] text-muted-foreground text-left bg-black/5 dark:bg-white/5 p-4 rounded-xl">
{oauthData.message || 'Open the authorization page, complete login, then paste the callback URL or code below.'}
</p>
</div>
<Button
variant="secondary"
className="w-full rounded-full h-[42px] font-semibold"
onClick={() => invokeIpc('shell:openExternal', oauthData.authorizationUrl)}
>
<ExternalLink className="h-4 w-4 mr-2" />
Open Authorization Page
</Button>
<Input
placeholder="Paste callback URL or code"
value={manualCodeInput}
onChange={(e) => setManualCodeInput(e.target.value)}
className={inputClasses}
/>
<Button
className="w-full rounded-full h-[42px] font-semibold bg-[#0a84ff] hover:bg-[#007aff] text-white"
onClick={handleSubmitManualOAuthCode}
disabled={!manualCodeInput.trim()}
>
Submit Code
</Button>
<Button variant="ghost" className="w-full rounded-full h-[42px] font-semibold text-muted-foreground" onClick={handleCancelOAuth}>
Cancel
</Button>
</div>
) : (
<div className="space-y-5 w-full">
<div className="space-y-2">
@@ -1272,4 +1347,4 @@ function AddProviderDialog({
</Card>
</div>
);
}
}

View File

@@ -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',

View File

@@ -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<string | null>(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<string, unknown>;
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({
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
<p className="text-sm text-muted-foreground animate-pulse">Requesting secure login code...</p>
</div>
) : oauthData.mode === 'manual' ? (
<div className="space-y-4 w-full">
<div className="space-y-1">
<h3 className="font-medium text-lg">Complete OpenAI Login</h3>
<p className="text-sm text-muted-foreground text-left mt-2">
{oauthData.message || 'Open the authorization page, complete login, then paste the callback URL or code below.'}
</p>
</div>
<Button
variant="secondary"
className="w-full"
onClick={() => invokeIpc('shell:openExternal', oauthData.authorizationUrl)}
>
<ExternalLink className="h-4 w-4 mr-2" />
Open Authorization Page
</Button>
<Input
placeholder="Paste callback URL or code"
value={manualCodeInput}
onChange={(e) => setManualCodeInput(e.target.value)}
/>
<Button
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
onClick={handleSubmitManualOAuthCode}
disabled={!manualCodeInput.trim()}
>
Submit Code
</Button>
<Button variant="ghost" size="sm" className="w-full mt-2" onClick={handleCancelOAuth}>
Cancel
</Button>
</div>
) : (
<div className="space-y-4 w-full">
<div className="space-y-1">