feat(provider): add OpenAI Codex browser OAuth flow (#398)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
880995af19
commit
31e80f256b
@@ -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 });
|
||||
|
||||
304
electron/utils/openai-codex-oauth.ts
Normal file
304
electron/utils/openai-codex-oauth.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user