优化令牌刷新队列机制,增加缓冲队列减少重复刷新 更新提供商健康检查模型配置,添加iFlow和Codex相关模型 统一OAuth模块导出结构,整理各提供商OAuth实现 修复Kiro提供商403错误处理逻辑,改为标记需刷新而非直接标记不健康
851 lines
No EOL
31 KiB
JavaScript
851 lines
No EOL
31 KiB
JavaScript
import http from 'http';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import crypto from 'crypto';
|
||
import open from 'open';
|
||
import axios from 'axios';
|
||
import { broadcastEvent } from '../services/ui-manager.js';
|
||
import { autoLinkProviderConfigs } from '../services/service-manager.js';
|
||
import { CONFIG } from '../core/config-manager.js';
|
||
import { getProxyConfigForProvider } from '../utils/proxy-utils.js';
|
||
|
||
/**
|
||
* Codex OAuth 配置
|
||
*/
|
||
const CODEX_OAUTH_CONFIG = {
|
||
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
||
authUrl: 'https://auth.openai.com/oauth/authorize',
|
||
tokenUrl: 'https://auth.openai.com/oauth/token',
|
||
redirectUri: 'http://localhost:1455/auth/callback',
|
||
port: 1455,
|
||
scopes: 'openid email profile offline_access',
|
||
logPrefix: '[Codex Auth]'
|
||
};
|
||
|
||
/**
|
||
* Codex OAuth 认证类
|
||
* 实现 OAuth2 + PKCE 流程
|
||
*/
|
||
class CodexAuth {
|
||
constructor(config) {
|
||
this.config = config;
|
||
|
||
// 配置代理支持
|
||
const axiosConfig = { timeout: 30000 };
|
||
const proxyConfig = getProxyConfigForProvider(config, 'openai-codex-oauth');
|
||
if (proxyConfig) {
|
||
axiosConfig.httpAgent = proxyConfig.httpAgent;
|
||
axiosConfig.httpsAgent = proxyConfig.httpsAgent;
|
||
console.log('[Codex Auth] Proxy enabled for OAuth requests');
|
||
}
|
||
|
||
this.httpClient = axios.create(axiosConfig);
|
||
this.server = null; // 存储服务器实例
|
||
}
|
||
|
||
/**
|
||
* 生成 PKCE 代码
|
||
* @returns {{verifier: string, challenge: string}}
|
||
*/
|
||
generatePKCECodes() {
|
||
// 生成 code verifier (96 随机字节 → 128 base64url 字符)
|
||
const verifier = crypto.randomBytes(96)
|
||
.toString('base64url');
|
||
|
||
// 生成 code challenge (SHA256 of verifier)
|
||
const challenge = crypto.createHash('sha256')
|
||
.update(verifier)
|
||
.digest('base64url');
|
||
|
||
return { verifier, challenge };
|
||
}
|
||
|
||
/**
|
||
* 生成授权 URL(不启动完整流程)
|
||
* @returns {{authUrl: string, state: string, pkce: Object, server: Object}}
|
||
*/
|
||
async generateAuthUrl() {
|
||
const pkce = this.generatePKCECodes();
|
||
const state = crypto.randomBytes(16).toString('hex');
|
||
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Generating auth URL...`);
|
||
|
||
// 如果已有服务器在运行,先关闭
|
||
if (this.server) {
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Closing existing callback server...`);
|
||
try {
|
||
this.server.close();
|
||
this.server = null;
|
||
} catch (error) {
|
||
console.warn(`${CODEX_OAUTH_CONFIG.logPrefix} Failed to close existing server:`, error.message);
|
||
}
|
||
}
|
||
|
||
// 启动本地回调服务器
|
||
const server = await this.startCallbackServer();
|
||
this.server = server;
|
||
|
||
// 构建授权 URL
|
||
const authUrl = new URL(CODEX_OAUTH_CONFIG.authUrl);
|
||
authUrl.searchParams.set('client_id', CODEX_OAUTH_CONFIG.clientId);
|
||
authUrl.searchParams.set('response_type', 'code');
|
||
authUrl.searchParams.set('redirect_uri', CODEX_OAUTH_CONFIG.redirectUri);
|
||
authUrl.searchParams.set('scope', CODEX_OAUTH_CONFIG.scopes);
|
||
authUrl.searchParams.set('state', state);
|
||
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||
authUrl.searchParams.set('prompt', 'login');
|
||
authUrl.searchParams.set('id_token_add_organizations', 'true');
|
||
authUrl.searchParams.set('codex_cli_simplified_flow', 'true');
|
||
|
||
return {
|
||
authUrl: authUrl.toString(),
|
||
state,
|
||
pkce,
|
||
server
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 完成 OAuth 流程(在收到回调后调用)
|
||
* @param {string} code - 授权码
|
||
* @param {string} state - 状态参数
|
||
* @param {string} expectedState - 期望的状态参数
|
||
* @param {Object} pkce - PKCE 代码
|
||
* @returns {Promise<Object>} tokens 和凭据路径
|
||
*/
|
||
async completeOAuthFlow(code, state, expectedState, pkce) {
|
||
// 验证 state
|
||
if (state !== expectedState) {
|
||
throw new Error('State mismatch - possible CSRF attack');
|
||
}
|
||
|
||
// 用 code 换取 tokens
|
||
const tokens = await this.exchangeCodeForTokens(code, pkce.verifier);
|
||
|
||
// 解析 JWT 提取账户信息
|
||
const claims = this.parseJWT(tokens.id_token);
|
||
|
||
// 保存凭据(遵循 CLIProxyAPI 格式)
|
||
const credentials = {
|
||
id_token: tokens.id_token,
|
||
access_token: tokens.access_token,
|
||
refresh_token: tokens.refresh_token,
|
||
account_id: claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub,
|
||
last_refresh: new Date().toISOString(),
|
||
email: claims.email,
|
||
type: 'codex',
|
||
expired: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString()
|
||
};
|
||
|
||
// 保存凭据并获取路径
|
||
const saveResult = await this.saveCredentials(credentials);
|
||
const credPath = saveResult.credsPath;
|
||
const relativePath = saveResult.relativePath;
|
||
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Authentication successful!`);
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Email: ${credentials.email}`);
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Account ID: ${credentials.account_id}`);
|
||
|
||
// 关闭服务器
|
||
if (this.server) {
|
||
this.server.close();
|
||
this.server = null;
|
||
}
|
||
|
||
return {
|
||
...credentials,
|
||
credPath,
|
||
relativePath
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 启动 OAuth 流程
|
||
* @returns {Promise<Object>} 返回 tokens
|
||
*/
|
||
async startOAuthFlow() {
|
||
const pkce = this.generatePKCECodes();
|
||
const state = crypto.randomBytes(16).toString('hex');
|
||
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Starting OAuth flow...`);
|
||
|
||
// 启动本地回调服务器
|
||
const server = await this.startCallbackServer();
|
||
|
||
// 构建授权 URL
|
||
const authUrl = new URL(CODEX_OAUTH_CONFIG.authUrl);
|
||
authUrl.searchParams.set('client_id', CODEX_OAUTH_CONFIG.clientId);
|
||
authUrl.searchParams.set('response_type', 'code');
|
||
authUrl.searchParams.set('redirect_uri', CODEX_OAUTH_CONFIG.redirectUri);
|
||
authUrl.searchParams.set('scope', CODEX_OAUTH_CONFIG.scopes);
|
||
authUrl.searchParams.set('state', state);
|
||
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||
authUrl.searchParams.set('prompt', 'login');
|
||
authUrl.searchParams.set('id_token_add_organizations', 'true');
|
||
authUrl.searchParams.set('codex_cli_simplified_flow', 'true');
|
||
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Opening browser for authentication...`);
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} If browser doesn't open, visit: ${authUrl.toString()}`);
|
||
|
||
try {
|
||
await open(authUrl.toString());
|
||
} catch (error) {
|
||
console.warn(`${CODEX_OAUTH_CONFIG.logPrefix} Failed to open browser automatically:`, error.message);
|
||
}
|
||
|
||
// 等待回调
|
||
const result = await this.waitForCallback(server, state);
|
||
|
||
// 用 code 换取 tokens
|
||
const tokens = await this.exchangeCodeForTokens(result.code, pkce.verifier);
|
||
|
||
// 解析 JWT 提取账户信息
|
||
const claims = this.parseJWT(tokens.id_token);
|
||
|
||
// 保存凭据(遵循 CLIProxyAPI 格式)
|
||
const credentials = {
|
||
id_token: tokens.id_token,
|
||
access_token: tokens.access_token,
|
||
refresh_token: tokens.refresh_token,
|
||
account_id: claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub,
|
||
last_refresh: new Date().toISOString(),
|
||
email: claims.email,
|
||
type: 'codex',
|
||
expired: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString()
|
||
};
|
||
|
||
await this.saveCredentials(credentials);
|
||
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Authentication successful!`);
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Email: ${credentials.email}`);
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Account ID: ${credentials.account_id}`);
|
||
|
||
return credentials;
|
||
}
|
||
|
||
/**
|
||
* 启动回调服务器
|
||
* @returns {Promise<http.Server>}
|
||
*/
|
||
async startCallbackServer() {
|
||
return new Promise((resolve, reject) => {
|
||
const server = http.createServer();
|
||
|
||
server.on('request', (req, res) => {
|
||
if (req.url.startsWith('/auth/callback')) {
|
||
const url = new URL(req.url, `http://localhost:${CODEX_OAUTH_CONFIG.port}`);
|
||
const code = url.searchParams.get('code');
|
||
const state = url.searchParams.get('state');
|
||
const error = url.searchParams.get('error');
|
||
const errorDescription = url.searchParams.get('error_description');
|
||
|
||
if (error) {
|
||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||
res.end(`
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Authentication Failed</title>
|
||
<style>
|
||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||
h1 { color: #d32f2f; }
|
||
p { color: #666; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>❌ Authentication Failed</h1>
|
||
<p>${errorDescription || error}</p>
|
||
<p>You can close this window and try again.</p>
|
||
</body>
|
||
</html>
|
||
`);
|
||
server.emit('auth-error', new Error(errorDescription || error));
|
||
} else if (code && state) {
|
||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||
res.end(`
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Authentication Successful</title>
|
||
<style>
|
||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||
h1 { color: #4caf50; }
|
||
p { color: #666; }
|
||
.countdown { font-size: 24px; font-weight: bold; color: #2196f3; }
|
||
</style>
|
||
<script>
|
||
let countdown = 10;
|
||
setInterval(() => {
|
||
countdown--;
|
||
document.getElementById('countdown').textContent = countdown;
|
||
if (countdown <= 0) {
|
||
window.close();
|
||
}
|
||
}, 1000);
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<h1>✅ Authentication Successful!</h1>
|
||
<p>You can now close this window and return to the application.</p>
|
||
<p>This window will close automatically in <span id="countdown" class="countdown">10</span> seconds.</p>
|
||
</body>
|
||
</html>
|
||
`);
|
||
server.emit('auth-success', { code, state });
|
||
}
|
||
} else if (req.url === '/success') {
|
||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||
res.end('<h1>Success!</h1>');
|
||
}
|
||
});
|
||
|
||
server.listen(CODEX_OAUTH_CONFIG.port, () => {
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Callback server listening on port ${CODEX_OAUTH_CONFIG.port}`);
|
||
resolve(server);
|
||
});
|
||
|
||
server.on('error', (error) => {
|
||
if (error.code === 'EADDRINUSE') {
|
||
reject(new Error(`Port ${CODEX_OAUTH_CONFIG.port} is already in use. Please close other applications using this port.`));
|
||
} else {
|
||
reject(error);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 等待 OAuth 回调
|
||
* @param {http.Server} server
|
||
* @param {string} expectedState
|
||
* @returns {Promise<{code: string, state: string}>}
|
||
*/
|
||
async waitForCallback(server, expectedState) {
|
||
return new Promise((resolve, reject) => {
|
||
const timeout = setTimeout(() => {
|
||
server.close();
|
||
reject(new Error('Authentication timeout (10 minutes)'));
|
||
}, 10 * 60 * 1000); // 10 分钟
|
||
|
||
server.once('auth-success', (result) => {
|
||
clearTimeout(timeout);
|
||
server.close();
|
||
|
||
if (result.state !== expectedState) {
|
||
reject(new Error('State mismatch - possible CSRF attack'));
|
||
} else {
|
||
resolve(result);
|
||
}
|
||
});
|
||
|
||
server.once('auth-error', (error) => {
|
||
clearTimeout(timeout);
|
||
server.close();
|
||
reject(error);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 用授权码换取 tokens
|
||
* @param {string} code
|
||
* @param {string} codeVerifier
|
||
* @returns {Promise<Object>}
|
||
*/
|
||
async exchangeCodeForTokens(code, codeVerifier) {
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Exchanging authorization code for tokens...`);
|
||
|
||
try {
|
||
const response = await this.httpClient.post(
|
||
CODEX_OAUTH_CONFIG.tokenUrl,
|
||
new URLSearchParams({
|
||
grant_type: 'authorization_code',
|
||
client_id: CODEX_OAUTH_CONFIG.clientId,
|
||
code: code,
|
||
redirect_uri: CODEX_OAUTH_CONFIG.redirectUri,
|
||
code_verifier: codeVerifier
|
||
}).toString(),
|
||
{
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
'Accept': 'application/json'
|
||
}
|
||
}
|
||
);
|
||
|
||
return response.data;
|
||
} catch (error) {
|
||
console.error(`${CODEX_OAUTH_CONFIG.logPrefix} Token exchange failed:`, error.response?.data || error.message);
|
||
throw new Error(`Failed to exchange code for tokens: ${error.response?.data?.error_description || error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 刷新 tokens
|
||
* @param {string} refreshToken
|
||
* @returns {Promise<Object>}
|
||
*/
|
||
async refreshTokens(refreshToken) {
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Refreshing access token...`);
|
||
|
||
try {
|
||
const response = await this.httpClient.post(
|
||
CODEX_OAUTH_CONFIG.tokenUrl,
|
||
new URLSearchParams({
|
||
grant_type: 'refresh_token',
|
||
client_id: CODEX_OAUTH_CONFIG.clientId,
|
||
refresh_token: refreshToken
|
||
}).toString(),
|
||
{
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
'Accept': 'application/json'
|
||
}
|
||
}
|
||
);
|
||
|
||
const tokens = response.data;
|
||
const claims = this.parseJWT(tokens.id_token);
|
||
|
||
return {
|
||
id_token: tokens.id_token,
|
||
access_token: tokens.access_token,
|
||
refresh_token: tokens.refresh_token || refreshToken,
|
||
account_id: claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub,
|
||
last_refresh: new Date().toISOString(),
|
||
email: claims.email,
|
||
type: 'codex',
|
||
expired: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString()
|
||
};
|
||
} catch (error) {
|
||
console.error(`${CODEX_OAUTH_CONFIG.logPrefix} Token refresh failed:`, error.response?.data || error.message);
|
||
throw new Error(`Failed to refresh tokens: ${error.response?.data?.error_description || error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析 JWT token
|
||
* @param {string} token
|
||
* @returns {Object}
|
||
*/
|
||
parseJWT(token) {
|
||
try {
|
||
const parts = token.split('.');
|
||
if (parts.length !== 3) {
|
||
throw new Error('Invalid JWT token format');
|
||
}
|
||
|
||
// 解码 payload (base64url)
|
||
const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
|
||
return JSON.parse(payload);
|
||
} catch (error) {
|
||
console.error(`${CODEX_OAUTH_CONFIG.logPrefix} Failed to parse JWT:`, error.message);
|
||
throw new Error(`Failed to parse JWT token: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存凭据到文件
|
||
* @param {Object} creds
|
||
* @returns {Promise<Object>}
|
||
*/
|
||
async saveCredentials(creds) {
|
||
const email = creds.email || this.config.CODEX_EMAIL || 'default';
|
||
|
||
// 优先使用配置中指定的路径,否则保存到 configs/codex 目录
|
||
let credsPath;
|
||
if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) {
|
||
credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH;
|
||
} else {
|
||
// 保存到 configs/codex 目录(与其他供应商一致)
|
||
const projectDir = process.cwd();
|
||
const targetDir = path.join(projectDir, 'configs', 'codex');
|
||
await fs.promises.mkdir(targetDir, { recursive: true });
|
||
const timestamp = Date.now();
|
||
const filename = `${timestamp}_codex-${email}.json`;
|
||
credsPath = path.join(targetDir, filename);
|
||
}
|
||
|
||
try {
|
||
const credsDir = path.dirname(credsPath);
|
||
await fs.promises.mkdir(credsDir, { recursive: true });
|
||
await fs.promises.writeFile(credsPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
||
|
||
const relativePath = path.relative(process.cwd(), credsPath);
|
||
console.log(`${CODEX_OAUTH_CONFIG.logPrefix} Credentials saved to ${relativePath}`);
|
||
|
||
// 返回保存路径供后续使用
|
||
return { credsPath, relativePath };
|
||
} catch (error) {
|
||
console.error(`${CODEX_OAUTH_CONFIG.logPrefix} Failed to save credentials:`, error.message);
|
||
throw new Error(`Failed to save credentials: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载凭据
|
||
* @param {string} email
|
||
* @returns {Promise<Object|null>}
|
||
*/
|
||
async loadCredentials(email) {
|
||
// 优先使用配置中指定的路径,否则从 configs/codex 目录加载
|
||
let credsPath;
|
||
if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) {
|
||
credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH;
|
||
} else {
|
||
// 从 configs/codex 目录加载(与其他供应商一致)
|
||
const projectDir = process.cwd();
|
||
const targetDir = path.join(projectDir, 'configs', 'codex');
|
||
|
||
// 扫描目录找到匹配的凭据文件
|
||
try {
|
||
const files = await fs.promises.readdir(targetDir);
|
||
const emailPattern = email || 'default';
|
||
const matchingFile = files
|
||
.filter(f => f.includes(`codex-${emailPattern}`) && f.endsWith('.json'))
|
||
.sort()
|
||
.pop(); // 获取最新的文件
|
||
|
||
if (matchingFile) {
|
||
credsPath = path.join(targetDir, matchingFile);
|
||
} else {
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
if (error.code === 'ENOENT') {
|
||
return null;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const data = await fs.promises.readFile(credsPath, 'utf8');
|
||
return JSON.parse(data);
|
||
} catch (error) {
|
||
if (error.code === 'ENOENT') {
|
||
return null; // 文件不存在
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查凭据文件是否存在
|
||
* @param {string} email
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
async credentialsExist(email) {
|
||
// 优先使用配置中指定的路径,否则从 configs/codex 目录检查
|
||
let credsPath;
|
||
if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) {
|
||
credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH;
|
||
} else {
|
||
const projectDir = process.cwd();
|
||
const targetDir = path.join(projectDir, 'configs', 'codex');
|
||
|
||
try {
|
||
const files = await fs.promises.readdir(targetDir);
|
||
const emailPattern = email || 'default';
|
||
const hasMatch = files.some(f =>
|
||
f.includes(`codex-${emailPattern}`) && f.endsWith('.json')
|
||
);
|
||
return hasMatch;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
try {
|
||
await fs.promises.access(credsPath);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 带重试的 Codex token 刷新
|
||
* @param {string} refreshToken
|
||
* @param {Object} config
|
||
* @param {number} maxRetries
|
||
* @returns {Promise<Object>}
|
||
*/
|
||
export async function refreshCodexTokensWithRetry(refreshToken, config = {}, maxRetries = 3) {
|
||
const auth = new CodexAuth(config);
|
||
let lastError;
|
||
|
||
for (let i = 0; i < maxRetries; i++) {
|
||
try {
|
||
return await auth.refreshTokens(refreshToken);
|
||
} catch (error) {
|
||
lastError = error;
|
||
console.warn(`${CODEX_OAUTH_CONFIG.logPrefix} Retry ${i + 1}/${maxRetries} failed:`, error.message);
|
||
|
||
if (i < maxRetries - 1) {
|
||
// 指数退避
|
||
const delay = Math.min(1000 * Math.pow(2, i), 10000);
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
}
|
||
}
|
||
}
|
||
|
||
throw lastError;
|
||
}
|
||
|
||
/**
|
||
* 处理 Codex OAuth 认证
|
||
* @param {Object} currentConfig - 当前配置
|
||
* @param {Object} options - 选项
|
||
* @returns {Promise<Object>} 返回认证结果
|
||
*/
|
||
export async function handleCodexOAuth(currentConfig, options = {}) {
|
||
const auth = new CodexAuth(currentConfig);
|
||
|
||
try {
|
||
console.log('[Codex Auth] Generating OAuth URL...');
|
||
|
||
// 清理所有旧的会话和服务器
|
||
if (global.codexOAuthSessions && global.codexOAuthSessions.size > 0) {
|
||
console.log('[Codex Auth] Cleaning up old OAuth sessions...');
|
||
for (const [sessionId, session] of global.codexOAuthSessions.entries()) {
|
||
try {
|
||
// 清理定时器
|
||
if (session.pollTimer) {
|
||
clearInterval(session.pollTimer);
|
||
}
|
||
// 关闭服务器
|
||
if (session.server) {
|
||
session.server.close();
|
||
}
|
||
global.codexOAuthSessions.delete(sessionId);
|
||
} catch (error) {
|
||
console.warn(`[Codex Auth] Failed to clean up session ${sessionId}:`, error.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 生成授权 URL 和启动回调服务器
|
||
const { authUrl, state, pkce, server } = await auth.generateAuthUrl();
|
||
|
||
console.log('[Codex Auth] OAuth URL generated successfully');
|
||
|
||
// 存储 OAuth 会话信息,供后续回调使用
|
||
if (!global.codexOAuthSessions) {
|
||
global.codexOAuthSessions = new Map();
|
||
}
|
||
|
||
const sessionId = state; // 使用 state 作为 session ID
|
||
|
||
// 轮询计数器
|
||
let pollCount = 0;
|
||
const maxPollCount = 30; // 最多轮询次数(可随意更改)
|
||
const pollInterval = 3000; // 轮询间隔(毫秒)
|
||
let pollTimer = null;
|
||
let isCompleted = false;
|
||
|
||
// 创建会话对象
|
||
const session = {
|
||
auth,
|
||
state,
|
||
pkce,
|
||
server,
|
||
pollTimer: null,
|
||
createdAt: Date.now()
|
||
};
|
||
|
||
global.codexOAuthSessions.set(sessionId, session);
|
||
|
||
// 启动轮询日志
|
||
pollTimer = setInterval(() => {
|
||
pollCount++;
|
||
if (pollCount <= maxPollCount && !isCompleted) {
|
||
console.log(`[Codex Auth] Waiting for callback... (${pollCount}/${maxPollCount})`);
|
||
}
|
||
|
||
if (pollCount >= maxPollCount && !isCompleted) {
|
||
clearInterval(pollTimer);
|
||
const totalSeconds = (maxPollCount * pollInterval) / 1000;
|
||
console.log(`[Codex Auth] Polling timeout (${totalSeconds}s), releasing session for next authorization`);
|
||
|
||
// 清理会话和服务器
|
||
if (global.codexOAuthSessions.has(sessionId)) {
|
||
const session = global.codexOAuthSessions.get(sessionId);
|
||
if (session.server) {
|
||
session.server.close();
|
||
}
|
||
global.codexOAuthSessions.delete(sessionId);
|
||
}
|
||
}
|
||
}, pollInterval);
|
||
|
||
// 将 pollTimer 存储到会话中
|
||
session.pollTimer = pollTimer;
|
||
|
||
// 监听回调服务器的 auth-success 事件,自动完成 OAuth 流程
|
||
server.once('auth-success', async (result) => {
|
||
isCompleted = true;
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer);
|
||
}
|
||
|
||
try {
|
||
console.log('[Codex Auth] Received auth callback, completing OAuth flow...');
|
||
|
||
const session = global.codexOAuthSessions.get(sessionId);
|
||
if (!session) {
|
||
console.error('[Codex Auth] Session not found');
|
||
return;
|
||
}
|
||
|
||
// 完成 OAuth 流程
|
||
const credentials = await auth.completeOAuthFlow(result.code, result.state, session.state, session.pkce);
|
||
|
||
// 清理会话
|
||
global.codexOAuthSessions.delete(sessionId);
|
||
|
||
// 广播认证成功事件
|
||
broadcastEvent('oauth_success', {
|
||
provider: 'openai-codex-oauth',
|
||
credPath: credentials.credPath,
|
||
relativePath: credentials.relativePath,
|
||
timestamp: new Date().toISOString(),
|
||
email: credentials.email,
|
||
accountId: credentials.account_id
|
||
});
|
||
|
||
// 自动关联新生成的凭据到 Pools
|
||
await autoLinkProviderConfigs(CONFIG);
|
||
|
||
console.log('[Codex Auth] OAuth flow completed successfully');
|
||
} catch (error) {
|
||
console.error('[Codex Auth] Failed to complete OAuth flow:', error.message);
|
||
|
||
// 广播认证失败事件
|
||
broadcastEvent('oauth_error', {
|
||
provider: 'openai-codex-oauth',
|
||
error: error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
// 监听 auth-error 事件
|
||
server.once('auth-error', (error) => {
|
||
isCompleted = true;
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer);
|
||
}
|
||
|
||
console.error('[Codex Auth] Auth error:', error.message);
|
||
global.codexOAuthSessions.delete(sessionId);
|
||
|
||
broadcastEvent('oauth_error', {
|
||
provider: 'openai-codex-oauth',
|
||
error: error.message
|
||
});
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
authUrl: authUrl,
|
||
authInfo: {
|
||
provider: 'openai-codex-oauth',
|
||
method: 'oauth2-pkce',
|
||
sessionId: sessionId,
|
||
redirectUri: CODEX_OAUTH_CONFIG.redirectUri,
|
||
port: CODEX_OAUTH_CONFIG.port,
|
||
instructions: [
|
||
'1. 点击下方按钮在浏览器中打开授权链接',
|
||
'2. 使用您的 OpenAI 账户登录',
|
||
'3. 授权应用访问您的 Codex API',
|
||
'4. 授权成功后会自动保存凭据',
|
||
'5. 如果浏览器未自动跳转,请手动复制回调 URL'
|
||
]
|
||
}
|
||
};
|
||
} catch (error) {
|
||
console.error('[Codex Auth] Failed to generate OAuth URL:', error.message);
|
||
|
||
return {
|
||
success: false,
|
||
error: error.message,
|
||
authInfo: {
|
||
provider: 'openai-codex-oauth',
|
||
method: 'oauth2-pkce',
|
||
instructions: [
|
||
`1. 确保端口 ${CODEX_OAUTH_CONFIG.port} 未被占用`,
|
||
'2. 确保可以访问 auth.openai.com',
|
||
'3. 确保浏览器可以正常打开',
|
||
'4. 如果问题持续,请检查网络连接'
|
||
]
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理 Codex OAuth 回调
|
||
* @param {string} code - 授权码
|
||
* @param {string} state - 状态参数
|
||
* @returns {Promise<Object>} 返回认证结果
|
||
*/
|
||
export async function handleCodexOAuthCallback(code, state) {
|
||
try {
|
||
if (!global.codexOAuthSessions || !global.codexOAuthSessions.has(state)) {
|
||
throw new Error('Invalid or expired OAuth session');
|
||
}
|
||
|
||
const session = global.codexOAuthSessions.get(state);
|
||
const { auth, state: expectedState, pkce } = session;
|
||
|
||
console.log('[Codex Auth] Processing OAuth callback...');
|
||
|
||
// 完成 OAuth 流程
|
||
const result = await auth.completeOAuthFlow(code, state, expectedState, pkce);
|
||
|
||
// 清理会话
|
||
global.codexOAuthSessions.delete(state);
|
||
|
||
// 广播认证成功事件(与 gemini 格式一致)
|
||
broadcastEvent('oauth_success', {
|
||
provider: 'openai-codex-oauth',
|
||
credPath: result.credPath,
|
||
relativePath: result.relativePath,
|
||
timestamp: new Date().toISOString(),
|
||
email: result.email,
|
||
accountId: result.account_id
|
||
});
|
||
|
||
// 自动关联新生成的凭据到 Pools
|
||
await autoLinkProviderConfigs(CONFIG);
|
||
|
||
console.log('[Codex Auth] OAuth callback processed successfully');
|
||
|
||
return {
|
||
success: true,
|
||
message: 'Codex authentication successful',
|
||
credentials: result,
|
||
email: result.email,
|
||
accountId: result.account_id,
|
||
credPath: result.credPath,
|
||
relativePath: result.relativePath
|
||
};
|
||
} catch (error) {
|
||
console.error('[Codex Auth] OAuth callback failed:', error.message);
|
||
|
||
// 广播认证失败事件
|
||
broadcastEvent({
|
||
type: 'oauth-error',
|
||
provider: 'openai-codex-oauth',
|
||
error: error.message
|
||
});
|
||
|
||
return {
|
||
success: false,
|
||
error: error.message
|
||
};
|
||
}
|
||
} |