feat(oauth): 修复Kiro提供商403错误处理逻辑

优化令牌刷新队列机制,增加缓冲队列减少重复刷新
更新提供商健康检查模型配置,添加iFlow和Codex相关模型
统一OAuth模块导出结构,整理各提供商OAuth实现
修复Kiro提供商403错误处理逻辑,改为标记需刷新而非直接标记不健康
This commit is contained in:
hex2077 2026-01-18 14:53:14 +08:00
parent be814c5a56
commit dc4e6fe0b2
15 changed files with 3700 additions and 3194 deletions

View file

@ -45,12 +45,42 @@
### 3.2 凭据上传路由 ([`static/app/file-upload.js`](static/app/file-upload.js))
* 修改 `getProviderKey`,建立提供商标识与 `configs/` 子目录名的映射(例如:`new-provider-api` -> `new-provider`)。
### 3.3 配置管理界面 ([`static/components/section-config.html`](static/components/section-config.html))
### 3.3 凭据文件管理筛选器
需要在以下三个位置添加新提供商的筛选支持:
#### 3.3.1 HTML 筛选器选项 ([`static/components/section-upload-config.html`](static/components/section-upload-config.html))
`id="configProviderFilter"``<select>` 元素中添加新的 `<option>`
```html
<option value="new-provider-type" data-i18n="upload.providerFilter.newProvider">New Provider OAuth</option>
```
#### 3.3.2 JavaScript 提供商映射 ([`static/app/upload-config-manager.js`](static/app/upload-config-manager.js))
`detectProviderFromPath()` 函数的 `providerMappings` 数组中添加映射关系:
```javascript
{
patterns: ['configs/new-provider/', '/new-provider/'],
providerType: 'new-provider-type',
displayName: 'New Provider OAuth',
shortName: 'new-provider-oauth'
}
```
#### 3.3.3 多语言文案 ([`static/app/i18n.js`](static/app/i18n.js))
在中文和英文的翻译对象中添加筛选器文案:
```javascript
// 中文版本 (zh-CN)
'upload.providerFilter.newProvider': 'New Provider OAuth',
// 英文版本 (en-US)
'upload.providerFilter.newProvider': 'New Provider OAuth',
```
### 3.4 配置管理界面 ([`static/components/section-config.html`](static/components/section-config.html))
* **必须添加**:在 `id="modelProvider"`(初始化提供商选择)容器中添加对应的 `provider-tag` 按钮。
* **可选添加**:在 `id="proxyProviders"`(代理开关)中同步添加。
### 3.4 指南与教程 ([`static/components/section-guide.html`](static/components/section-guide.html))
* 在“项目简介”和“客户端配置指南”中添加新提供商的调用示例(如 `{provider}/v1/chat/completions`)。
### 3.5 指南与教程 ([`static/components/section-guide.html`](static/components/section-guide.html))
* 在"项目简介"和"客户端配置指南"中添加新提供商的调用示例(如 `{provider}/v1/chat/completions`)。
---

851
src/auth/codex-oauth.js Normal file
View file

@ -0,0 +1,851 @@
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
};
}
}

289
src/auth/gemini-oauth.js Normal file
View file

@ -0,0 +1,289 @@
import { OAuth2Client } from 'google-auth-library';
import http from 'http';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { broadcastEvent } from '../services/ui-manager.js';
import { autoLinkProviderConfigs } from '../services/service-manager.js';
import { CONFIG } from '../core/config-manager.js';
import { getGoogleAuthProxyConfig } from '../utils/proxy-utils.js';
/**
* OAuth 提供商配置
*/
const OAUTH_PROVIDERS = {
'gemini-cli-oauth': {
clientId: '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com',
clientSecret: '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',
port: 8085,
credentialsDir: '.gemini',
credentialsFile: 'oauth_creds.json',
scope: ['https://www.googleapis.com/auth/cloud-platform'],
logPrefix: '[Gemini Auth]'
},
'gemini-antigravity': {
clientId: '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com',
clientSecret: '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',
port: 8086,
credentialsDir: '.antigravity',
credentialsFile: 'oauth_creds.json',
scope: ['https://www.googleapis.com/auth/cloud-platform'],
logPrefix: '[Antigravity Auth]'
}
};
/**
* 活动的服务器实例管理
*/
const activeServers = new Map();
/**
* 生成 HTML 响应页面
* @param {boolean} isSuccess - 是否成功
* @param {string} message - 显示消息
* @returns {string} HTML 内容
*/
function generateResponsePage(isSuccess, message) {
const title = isSuccess ? '授权成功!' : '授权失败';
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
</head>
<body>
<div class="container">
<h1>${title}</h1>
<p>${message}</p>
</div>
</body>
</html>`;
}
/**
* 关闭指定端口的活动服务器
* @param {number} port - 端口号
* @returns {Promise<void>}
*/
async function closeActiveServer(provider, port = null) {
// 1. 关闭该提供商之前的所有服务器
const existing = activeServers.get(provider);
if (existing) {
await new Promise((resolve) => {
existing.server.close(() => {
activeServers.delete(provider);
console.log(`[OAuth] 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`);
resolve();
});
});
}
// 2. 如果指定了端口,检查是否有其他提供商占用了该端口
if (port) {
for (const [p, info] of activeServers.entries()) {
if (info.port === port) {
await new Promise((resolve) => {
info.server.close(() => {
activeServers.delete(p);
console.log(`[OAuth] 已关闭端口 ${port} 上被占用(提供商: ${p})的旧服务器`);
resolve();
});
});
}
}
}
}
/**
* 创建 OAuth 回调服务器
* @param {Object} config - OAuth 提供商配置
* @param {string} redirectUri - 重定向 URI
* @param {OAuth2Client} authClient - OAuth2 客户端
* @param {string} credPath - 凭据保存路径
* @param {string} provider - 提供商标识
* @returns {Promise<http.Server>} HTTP 服务器实例
*/
async function createOAuthCallbackServer(config, redirectUri, authClient, credPath, provider, options = {}) {
const port = parseInt(options.port) || config.port;
// 先关闭该提供商之前可能运行的所有服务器,或该端口上的旧服务器
await closeActiveServer(provider, port);
return new Promise((resolve, reject) => {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, redirectUri);
const code = url.searchParams.get('code');
const errorParam = url.searchParams.get('error');
if (code) {
console.log(`${config.logPrefix} 收到来自 Google 的成功回调: ${req.url}`);
try {
const { tokens } = await authClient.getToken(code);
let finalCredPath = credPath;
// 如果指定了保存到 configs 目录
if (options.saveToConfigs) {
const providerDir = options.providerDir;
const targetDir = path.join(process.cwd(), 'configs', providerDir);
await fs.promises.mkdir(targetDir, { recursive: true });
const timestamp = Date.now();
const filename = `${timestamp}_oauth_creds.json`;
finalCredPath = path.join(targetDir, filename);
}
await fs.promises.mkdir(path.dirname(finalCredPath), { recursive: true });
await fs.promises.writeFile(finalCredPath, JSON.stringify(tokens, null, 2));
console.log(`${config.logPrefix} 新令牌已接收并保存到文件: ${finalCredPath}`);
const relativePath = path.relative(process.cwd(), finalCredPath);
// 广播授权成功事件
broadcastEvent('oauth_success', {
provider: provider,
credPath: finalCredPath,
relativePath: relativePath,
timestamp: new Date().toISOString()
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(true, '您可以关闭此页面'));
} catch (tokenError) {
console.error(`${config.logPrefix} 获取令牌失败:`, tokenError);
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(false, `获取令牌失败: ${tokenError.message}`));
} finally {
server.close(() => {
activeServers.delete(provider);
});
}
} else if (errorParam) {
const errorMessage = `授权失败。Google 返回错误: ${errorParam}`;
console.error(`${config.logPrefix}`, errorMessage);
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(false, errorMessage));
server.close(() => {
activeServers.delete(provider);
});
} else {
console.log(`${config.logPrefix} 忽略无关请求: ${req.url}`);
res.writeHead(204);
res.end();
}
} catch (error) {
console.error(`${config.logPrefix} 处理回调时出错:`, error);
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(false, `服务器错误: ${error.message}`));
if (server.listening) {
server.close(() => {
activeServers.delete(provider);
});
}
}
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`${config.logPrefix} 端口 ${port} 已被占用`);
reject(new Error(`端口 ${port} 已被占用`));
} else {
console.error(`${config.logPrefix} 服务器错误:`, err);
reject(err);
}
});
const host = '0.0.0.0';
server.listen(port, host, () => {
console.log(`${config.logPrefix} OAuth 回调服务器已启动于 ${host}:${port}`);
activeServers.set(provider, { server, port });
resolve(server);
});
});
}
/**
* 处理 Google OAuth 授权通用函数
* @param {string} providerKey - 提供商键名
* @param {Object} currentConfig - 当前配置对象
* @param {Object} options - 额外选项
* @returns {Promise<Object>} 返回授权URL和相关信息
*/
async function handleGoogleOAuth(providerKey, currentConfig, options = {}) {
const config = OAUTH_PROVIDERS[providerKey];
if (!config) {
throw new Error(`未知的提供商: ${providerKey}`);
}
const port = parseInt(options.port) || config.port;
const host = 'localhost';
const redirectUri = `http://${host}:${port}`;
// 获取代理配置
const proxyConfig = getGoogleAuthProxyConfig(currentConfig, providerKey);
// 构建 OAuth2Client 选项
const oauth2Options = {
clientId: config.clientId,
clientSecret: config.clientSecret,
};
if (proxyConfig) {
oauth2Options.transporterOptions = proxyConfig;
console.log(`${config.logPrefix} Using proxy for OAuth token exchange`);
}
const authClient = new OAuth2Client(oauth2Options);
authClient.redirectUri = redirectUri;
const authUrl = authClient.generateAuthUrl({
access_type: 'offline',
prompt: 'select_account',
scope: config.scope
});
// 启动回调服务器
const credPath = path.join(os.homedir(), config.credentialsDir, config.credentialsFile);
try {
await createOAuthCallbackServer(config, redirectUri, authClient, credPath, providerKey, options);
} catch (error) {
throw new Error(`启动回调服务器失败: ${error.message}`);
}
return {
authUrl,
authInfo: {
provider: providerKey,
redirectUri: redirectUri,
port: port,
...options
}
};
}
/**
* 处理 Gemini CLI OAuth 授权
* @param {Object} currentConfig - 当前配置对象
* @param {Object} options - 额外选项
* @returns {Promise<Object>} 返回授权URL和相关信息
*/
export async function handleGeminiCliOAuth(currentConfig, options = {}) {
return handleGoogleOAuth('gemini-cli-oauth', currentConfig, options);
}
/**
* 处理 Gemini Antigravity OAuth 授权
* @param {Object} currentConfig - 当前配置对象
* @param {Object} options - 额外选项
* @returns {Promise<Object>} 返回授权URL和相关信息
*/
export async function handleGeminiAntigravityOAuth(currentConfig, options = {}) {
return handleGoogleOAuth('gemini-antigravity', currentConfig, options);
}

529
src/auth/iflow-oauth.js Normal file
View file

@ -0,0 +1,529 @@
import http from 'http';
import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
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';
/**
* iFlow OAuth 配置
*/
const IFLOW_OAUTH_CONFIG = {
// OAuth 端点
tokenEndpoint: 'https://iflow.cn/oauth/token',
authorizeEndpoint: 'https://iflow.cn/oauth',
userInfoEndpoint: 'https://iflow.cn/api/oauth/getUserInfo',
successRedirectURL: 'https://iflow.cn/oauth/success',
// 客户端凭据
clientId: '10009311001',
clientSecret: '4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW',
// 本地回调端口
callbackPort: 8087,
// 凭据存储
credentialsDir: '.iflow',
credentialsFile: 'oauth_creds.json',
// 日志前缀
logPrefix: '[iFlow Auth]'
};
/**
* 活动的 iFlow 回调服务器管理
*/
const activeIFlowServers = new Map();
/**
* 创建带代理支持的 fetch 请求
* 使用 axios 替代原生 fetch以正确支持代理配置
* @param {string} url - 请求 URL
* @param {Object} options - fetch 选项兼容 fetch API 格式
* @param {string} providerType - 提供商类型用于获取代理配置
* @returns {Promise<Object>} 返回类似 fetch Response 的对象
*/
async function fetchWithProxy(url, options = {}, providerType) {
const proxyConfig = getProxyConfigForProvider(CONFIG, providerType);
// 构建 axios 配置
const axiosConfig = {
url,
method: options.method || 'GET',
headers: options.headers || {},
timeout: 30000, // 30 秒超时
};
// 处理请求体
if (options.body) {
axiosConfig.data = options.body;
}
// 配置代理
if (proxyConfig) {
axiosConfig.httpAgent = proxyConfig.httpAgent;
axiosConfig.httpsAgent = proxyConfig.httpsAgent;
axiosConfig.proxy = false; // 禁用 axios 内置代理,使用我们的 agent
console.log(`[OAuth] Using proxy for ${providerType}: ${CONFIG.PROXY_URL}`);
}
try {
const axios = (await import('axios')).default;
const response = await axios(axiosConfig);
// 返回类似 fetch Response 的对象
return {
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.statusText,
headers: response.headers,
json: async () => response.data,
text: async () => typeof response.data === 'string' ? response.data : JSON.stringify(response.data),
};
} catch (error) {
// 处理 axios 错误,转换为类似 fetch 的响应格式
if (error.response) {
// 服务器返回了错误状态码
return {
ok: false,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
json: async () => error.response.data,
text: async () => typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data),
};
}
// 网络错误或其他错误
throw error;
}
}
/**
* 生成 HTML 响应页面
* @param {boolean} isSuccess - 是否成功
* @param {string} message - 显示消息
* @returns {string} HTML 内容
*/
function generateResponsePage(isSuccess, message) {
const title = isSuccess ? '授权成功!' : '授权失败';
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
</head>
<body>
<div class="container">
<h1>${title}</h1>
<p>${message}</p>
</div>
</body>
</html>`;
}
/**
* 生成 iFlow 授权链接
* @param {string} state - 状态参数
* @param {number} port - 回调端口
* @returns {Object} 包含 authUrl redirectUri
*/
function generateIFlowAuthorizationURL(state, port) {
const redirectUri = `http://localhost:${port}/oauth2callback`;
const params = new URLSearchParams({
loginMethod: 'phone',
type: 'phone',
redirect: redirectUri,
state: state,
client_id: IFLOW_OAUTH_CONFIG.clientId
});
const authUrl = `${IFLOW_OAUTH_CONFIG.authorizeEndpoint}?${params.toString()}`;
return { authUrl, redirectUri };
}
/**
* 交换授权码获取 iFlow 令牌
* @param {string} code - 授权码
* @param {string} redirectUri - 重定向 URI
* @returns {Promise<Object>} 令牌数据
*/
async function exchangeIFlowCodeForTokens(code, redirectUri) {
const form = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: IFLOW_OAUTH_CONFIG.clientId,
client_secret: IFLOW_OAUTH_CONFIG.clientSecret
});
// 生成 Basic Auth 头
const basicAuth = Buffer.from(`${IFLOW_OAUTH_CONFIG.clientId}:${IFLOW_OAUTH_CONFIG.clientSecret}`).toString('base64');
const response = await fetchWithProxy(IFLOW_OAUTH_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'Authorization': `Basic ${basicAuth}`
},
body: form.toString()
}, 'openai-iflow');
if (!response.ok) {
const errorText = await response.text();
throw new Error(`iFlow token exchange failed: ${response.status} ${errorText}`);
}
const tokenData = await response.json();
if (!tokenData.access_token) {
throw new Error('iFlow token: missing access token in response');
}
return {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
tokenType: tokenData.token_type,
scope: tokenData.scope,
expiresIn: tokenData.expires_in,
expiresAt: new Date(Date.now() + tokenData.expires_in * 1000).toISOString()
};
}
/**
* 获取 iFlow 用户信息包含 API Key
* @param {string} accessToken - 访问令牌
* @returns {Promise<Object>} 用户信息
*/
async function fetchIFlowUserInfo(accessToken) {
if (!accessToken || accessToken.trim() === '') {
throw new Error('iFlow api key: access token is empty');
}
const endpoint = `${IFLOW_OAUTH_CONFIG.userInfoEndpoint}?accessToken=${encodeURIComponent(accessToken)}`;
const response = await fetchWithProxy(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
}, 'openai-iflow');
if (!response.ok) {
const errorText = await response.text();
throw new Error(`iFlow user info failed: ${response.status} ${errorText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error('iFlow api key: request not successful');
}
if (!result.data || !result.data.apiKey) {
throw new Error('iFlow api key: missing api key in response');
}
// 获取邮箱或手机号作为账户标识
let email = (result.data.email || '').trim();
if (!email) {
email = (result.data.phone || '').trim();
}
if (!email) {
throw new Error('iFlow token: missing account email/phone in user info');
}
return {
apiKey: result.data.apiKey,
email: email,
phone: result.data.phone || ''
};
}
/**
* 关闭 iFlow 服务器
* @param {string} provider - 提供商标识
* @param {number} port - 端口号可选
*/
async function closeIFlowServer(provider, port = null) {
const existing = activeIFlowServers.get(provider);
if (existing) {
await new Promise((resolve) => {
existing.server.close(() => {
activeIFlowServers.delete(provider);
console.log(`${IFLOW_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`);
resolve();
});
});
}
if (port) {
for (const [p, info] of activeIFlowServers.entries()) {
if (info.port === port) {
await new Promise((resolve) => {
info.server.close(() => {
activeIFlowServers.delete(p);
console.log(`${IFLOW_OAUTH_CONFIG.logPrefix} 已关闭端口 ${port} 上的旧服务器`);
resolve();
});
});
}
}
}
}
/**
* 创建 iFlow OAuth 回调服务器
* @param {number} port - 端口号
* @param {string} redirectUri - 重定向 URI
* @param {string} expectedState - 预期的 state 参数
* @param {Object} options - 额外选项
* @returns {Promise<http.Server>} HTTP 服务器实例
*/
function createIFlowCallbackServer(port, redirectUri, expectedState, options = {}) {
return new Promise((resolve, reject) => {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://localhost:${port}`);
if (url.pathname === '/oauth2callback') {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
if (errorParam) {
console.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 授权失败: ${errorParam}`);
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(false, `授权失败: ${errorParam}`));
server.close(() => {
activeIFlowServers.delete('openai-iflow');
});
return;
}
if (state !== expectedState) {
console.error(`${IFLOW_OAUTH_CONFIG.logPrefix} State 验证失败`);
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(false, 'State 验证失败'));
server.close(() => {
activeIFlowServers.delete('openai-iflow');
});
return;
}
if (!code) {
console.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 缺少授权码`);
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(false, '缺少授权码'));
server.close(() => {
activeIFlowServers.delete('openai-iflow');
});
return;
}
console.log(`${IFLOW_OAUTH_CONFIG.logPrefix} 收到授权回调,正在交换令牌...`);
try {
// 1. 交换授权码获取令牌
const tokenData = await exchangeIFlowCodeForTokens(code, redirectUri);
console.log(`${IFLOW_OAUTH_CONFIG.logPrefix} 令牌交换成功`);
// 2. 获取用户信息(包含 API Key
const userInfo = await fetchIFlowUserInfo(tokenData.accessToken);
console.log(`${IFLOW_OAUTH_CONFIG.logPrefix} 用户信息获取成功: ${userInfo.email}`);
// 3. 组合完整的凭据数据
const credentialsData = {
access_token: tokenData.accessToken,
refresh_token: tokenData.refreshToken,
expiry_date: new Date(tokenData.expiresAt).getTime(),
token_type: tokenData.tokenType,
scope: tokenData.scope,
apiKey: userInfo.apiKey
};
// 4. 保存凭据
let credPath = path.join(os.homedir(), IFLOW_OAUTH_CONFIG.credentialsDir, IFLOW_OAUTH_CONFIG.credentialsFile);
if (options.saveToConfigs) {
const providerDir = options.providerDir || 'iflow';
const targetDir = path.join(process.cwd(), 'configs', providerDir);
await fs.promises.mkdir(targetDir, { recursive: true });
const timestamp = Date.now();
const filename = `${timestamp}_oauth_creds.json`;
credPath = path.join(targetDir, filename);
}
await fs.promises.mkdir(path.dirname(credPath), { recursive: true });
await fs.promises.writeFile(credPath, JSON.stringify(credentialsData, null, 2));
console.log(`${IFLOW_OAUTH_CONFIG.logPrefix} 凭据已保存: ${credPath}`);
const relativePath = path.relative(process.cwd(), credPath);
// 5. 广播授权成功事件
broadcastEvent('oauth_success', {
provider: 'openai-iflow',
credPath: credPath,
relativePath: relativePath,
email: userInfo.email,
timestamp: new Date().toISOString()
});
// 6. 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(true, `授权成功!账户: ${userInfo.email},您可以关闭此页面`));
} catch (tokenError) {
console.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 令牌处理失败:`, tokenError);
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(false, `令牌处理失败: ${tokenError.message}`));
} finally {
server.close(() => {
activeIFlowServers.delete('openai-iflow');
});
}
} else {
// 忽略其他请求
res.writeHead(204);
res.end();
}
} catch (error) {
console.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 处理回调出错:`, error);
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(false, `服务器错误: ${error.message}`));
if (server.listening) {
server.close(() => {
activeIFlowServers.delete('openai-iflow');
});
}
}
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 端口 ${port} 已被占用`);
reject(new Error(`端口 ${port} 已被占用`));
} else {
console.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 服务器错误:`, err);
reject(err);
}
});
const host = '0.0.0.0';
server.listen(port, host, () => {
console.log(`${IFLOW_OAUTH_CONFIG.logPrefix} OAuth 回调服务器已启动于 ${host}:${port}`);
resolve(server);
});
// 10 分钟超时自动关闭
setTimeout(() => {
if (server.listening) {
console.log(`${IFLOW_OAUTH_CONFIG.logPrefix} 回调服务器超时,自动关闭`);
server.close(() => {
activeIFlowServers.delete('openai-iflow');
});
}
}, 10 * 60 * 1000);
});
}
/**
* 处理 iFlow OAuth 授权
* @param {Object} currentConfig - 当前配置对象
* @param {Object} options - 额外选项
* - port: 自定义端口号
* - saveToConfigs: 是否保存到 configs 目录
* - providerDir: 提供商目录名
* @returns {Promise<Object>} 返回授权URL和相关信息
*/
export async function handleIFlowOAuth(currentConfig, options = {}) {
const port = parseInt(options.port) || IFLOW_OAUTH_CONFIG.callbackPort;
const providerKey = 'openai-iflow';
// 生成 state 参数
const state = crypto.randomBytes(16).toString('base64url');
// 生成授权链接
const { authUrl, redirectUri } = generateIFlowAuthorizationURL(state, port);
console.log(`${IFLOW_OAUTH_CONFIG.logPrefix} 生成授权链接: ${authUrl}`);
// 关闭之前可能存在的服务器
await closeIFlowServer(providerKey, port);
// 启动回调服务器
try {
const server = await createIFlowCallbackServer(port, redirectUri, state, options);
activeIFlowServers.set(providerKey, { server, port });
} catch (error) {
throw new Error(`启动 iFlow 回调服务器失败: ${error.message}`);
}
return {
authUrl,
authInfo: {
provider: 'openai-iflow',
redirectUri: redirectUri,
callbackPort: port,
state: state,
...options
}
};
}
/**
* 使用 refresh_token 刷新 iFlow 令牌
* @param {string} refreshToken - 刷新令牌
* @returns {Promise<Object>} 新的令牌数据
*/
export async function refreshIFlowTokens(refreshToken) {
const form = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: IFLOW_OAUTH_CONFIG.clientId,
client_secret: IFLOW_OAUTH_CONFIG.clientSecret
});
// 生成 Basic Auth 头
const basicAuth = Buffer.from(`${IFLOW_OAUTH_CONFIG.clientId}:${IFLOW_OAUTH_CONFIG.clientSecret}`).toString('base64');
const response = await fetchWithProxy(IFLOW_OAUTH_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'Authorization': `Basic ${basicAuth}`
},
body: form.toString()
}, 'openai-iflow');
if (!response.ok) {
const errorText = await response.text();
throw new Error(`iFlow token refresh failed: ${response.status} ${errorText}`);
}
const tokenData = await response.json();
if (!tokenData.access_token) {
throw new Error('iFlow token refresh: missing access token in response');
}
// 获取用户信息以更新 API Key
const userInfo = await fetchIFlowUserInfo(tokenData.access_token);
return {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token,
expiry_date: Date.now() + tokenData.expires_in * 1000,
token_type: tokenData.token_type,
scope: tokenData.scope,
apiKey: userInfo.apiKey
};
}

38
src/auth/index.js Normal file
View file

@ -0,0 +1,38 @@
// Codex OAuth
export {
refreshCodexTokensWithRetry,
handleCodexOAuth,
handleCodexOAuthCallback
} from './codex-oauth.js';
// Gemini OAuth
export {
handleGeminiCliOAuth,
handleGeminiAntigravityOAuth
} from './gemini-oauth.js';
// Qwen OAuth
export {
handleQwenOAuth
} from './qwen-oauth.js';
// Kiro OAuth
export {
handleKiroOAuth,
checkKiroCredentialsDuplicate,
batchImportKiroRefreshTokens,
batchImportKiroRefreshTokensStream,
importAwsCredentials
} from './kiro-oauth.js';
// iFlow OAuth
export {
handleIFlowOAuth,
refreshIFlowTokens
} from './iflow-oauth.js';
// Orchids OAuth
export {
importOrchidsToken,
handleOrchidsOAuth
} from './orchids-oauth.js';

1110
src/auth/kiro-oauth.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

280
src/auth/orchids-oauth.js Normal file
View file

@ -0,0 +1,280 @@
import fs from 'fs';
import path from 'path';
import { broadcastEvent } from '../services/ui-manager.js';
import { autoLinkProviderConfigs } from '../services/service-manager.js';
import { CONFIG } from '../core/config-manager.js';
/**
* Orchids OAuth 配置
*/
const ORCHIDS_OAUTH_CONFIG = {
// Clerk Token 端点
clerkTokenEndpoint: 'https://clerk.orchids.app/v1/client/sessions/{sessionId}/tokens',
clerkJsVersion: '5.114.0',
// 凭据存储
credentialsDir: 'orchids',
credentialsFile: 'orchids_creds.json',
// 日志前缀
logPrefix: '[Orchids Auth]'
};
/**
* 解析 Orchids 凭据字符串简化版
* 只需要 __client JWT 即可其他参数通过 Clerk API 自动获取
*
* 支持的格式:
* 1. JWT 字符串: "eyJhbGciOiJSUzI1NiJ9..." ( payload 中提取 rotating_token)
* 2. __client=xxx 格式: "__client=eyJhbGciOiJSUzI1NiJ9..."
* 3. 完整 Cookies 格式兼容旧版: "__client=xxx; __session=xxx"
* 4. JWT|xxx 格式兼容旧版
*
* @param {string} inputString - 输入字符串
* @returns {Object} 解析后的凭据数据
*/
function parseOrchidsCredentials(inputString) {
if (!inputString || typeof inputString !== 'string') {
throw new Error('Invalid input string');
}
const trimmedInput = inputString.trim();
// 格式1: 纯 JWT 字符串(三段式,以点分隔)
if (trimmedInput.split('.').length === 3 && !trimmedInput.includes('=') && !trimmedInput.includes('|')) {
console.log('[Orchids Auth] Detected pure JWT format');
// 尝试从 JWT payload 中提取 rotating_token
let rotatingToken = null;
try {
const parts = trimmedInput.split('.');
if (parts.length === 3) {
// 解码 JWT payload (Base64URL -> Base64 -> JSON)
let payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
// 添加 padding
while (payloadBase64.length % 4) {
payloadBase64 += '=';
}
const payloadJson = Buffer.from(payloadBase64, 'base64').toString('utf8');
const payload = JSON.parse(payloadJson);
if (payload.rotating_token) {
rotatingToken = payload.rotating_token;
console.log('[Orchids Auth] Extracted rotating_token from JWT payload');
}
}
} catch (e) {
console.warn('[Orchids Auth] Failed to extract rotating_token from JWT payload:', e.message);
}
return {
type: 'jwt',
clientJwt: trimmedInput,
rotatingToken: rotatingToken
};
}
// 格式2: __client=xxx 格式(可能包含或不包含 __session
if (trimmedInput.includes('__client=')) {
const clientMatch = trimmedInput.match(/__client=([^;]+)/);
if (clientMatch) {
const clientValue = clientMatch[1].trim();
// 处理可能的 | 分隔符(如 JWT|rotating_token
let jwtPart = clientValue;
let rotatingToken = null;
if (clientValue.includes('|')) {
const parts = clientValue.split('|');
jwtPart = parts[0];
rotatingToken = parts[1] || null;
}
if (jwtPart.split('.').length === 3) {
console.log('[Orchids Auth] Detected __client cookie format');
return {
type: 'jwt',
clientJwt: jwtPart,
rotatingToken: rotatingToken
};
}
}
throw new Error('Invalid __client value. Expected a valid JWT.');
}
// 格式3: JWT|rotating_token 格式
if (trimmedInput.includes('|')) {
const parts = trimmedInput.split('|');
if (parts.length >= 1) {
const jwtPart = parts[0].trim();
const rotatingToken = parts.length >= 2 ? parts[1].trim() : null;
if (jwtPart.split('.').length === 3) {
console.log('[Orchids Auth] Detected JWT|rotating_token format');
return {
type: 'jwt',
clientJwt: jwtPart,
rotatingToken: rotatingToken
};
}
}
}
throw new Error('Invalid format. Please provide the __client cookie value (JWT format). Example: eyJhbGciOiJSUzI1NiJ9...');
}
/**
* 解析 Orchids JWT Token 字符串 (保留用于向后兼容)
* @deprecated 请使用 parseOrchidsCredentials
* 格式: JWT|rotating_token
* JWT 包含 id (client_id) rotating_token
* @param {string} tokenString - 完整的 token 字符串
* @returns {Object} 解析后的 token 数据
*/
function parseOrchidsToken(tokenString) {
const result = parseOrchidsCredentials(tokenString);
if (result.type === 'legacy') {
return {
clientId: result.clientId,
rotatingToken: result.rotatingToken,
jwt: result.jwt,
rawPayload: result.rawPayload
};
}
// 对于新格式,返回兼容的结构
return {
clientId: null,
rotatingToken: result.clientValue,
jwt: null,
rawPayload: null
};
}
/**
* Clerk 获取 session token
* @param {string} sessionId - Clerk session ID
* @param {string} cookies - Cookie 字符串
* @returns {Promise<string>} JWT token
*/
async function getClerkSessionToken(sessionId, cookies) {
const tokenUrl = ORCHIDS_OAUTH_CONFIG.clerkTokenEndpoint
.replace('{sessionId}', sessionId) +
`?_clerk_js_version=${ORCHIDS_OAUTH_CONFIG.clerkJsVersion}`;
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': cookies,
'Origin': 'https://www.orchids.app'
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Clerk token request failed: ${response.status} ${errorText}`);
}
const data = await response.json();
return data.jwt;
}
/**
* 导入 Orchids 凭据并生成凭据文件简化版
* 只需要 __client JWT其他参数在运行时通过 Clerk API 自动获取
*
* @param {string} inputString - __client JWT 字符串
* @param {Object} options - 额外选项
* - workingDir: 默认工作目录
* @returns {Promise<Object>} 导入结果
*/
export async function importOrchidsToken(inputString, options = {}) {
try {
console.log(`${ORCHIDS_OAUTH_CONFIG.logPrefix} Parsing Orchids credentials (simplified)...`);
// 解析凭据 - 只提取 clientJwt
const credData = parseOrchidsCredentials(inputString);
if (!credData.clientJwt) {
throw new Error('Failed to extract clientJwt from input');
}
// 凭据数据 - 保存 clientJwt 和可选的 rotatingToken
const credentialsData = {
// 核心字段__client JWT必需的凭据
clientJwt: credData.clientJwt,
// 导入时间
importedAt: new Date().toISOString()
};
// 如果存在 rotatingToken也保存它可选备用
if (credData.rotatingToken) {
credentialsData.rotatingToken = credData.rotatingToken;
console.log(`${ORCHIDS_OAUTH_CONFIG.logPrefix} rotatingToken also saved for future use.`);
}
// 生成文件路径: configs/orchids/{timestamp}_orchids_creds/{timestamp}_orchids_creds.json
const timestamp = Date.now();
const folderName = `${timestamp}_orchids_creds`;
const targetDir = path.join(process.cwd(), 'configs', ORCHIDS_OAUTH_CONFIG.credentialsDir, folderName);
await fs.promises.mkdir(targetDir, { recursive: true });
const filename = `${folderName}.json`;
const credPath = path.join(targetDir, filename);
await fs.promises.writeFile(credPath, JSON.stringify(credentialsData, null, 2));
const relativePath = path.relative(process.cwd(), credPath);
console.log(`${ORCHIDS_OAUTH_CONFIG.logPrefix} Credentials saved to: ${relativePath}`);
console.log(`${ORCHIDS_OAUTH_CONFIG.logPrefix} Only clientJwt is stored. Session info will be fetched at runtime.`);
// 广播事件
broadcastEvent('oauth_success', {
provider: 'claude-orchids-oauth',
relativePath: relativePath,
timestamp: new Date().toISOString()
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
return {
success: true,
path: relativePath,
message: 'Credentials imported successfully. Session info will be fetched at runtime via Clerk API.'
};
} catch (error) {
console.error(`${ORCHIDS_OAUTH_CONFIG.logPrefix} Token import failed:`, error);
return {
success: false,
error: error.message
};
}
}
/**
* 处理 Orchids OAuth手动导入模式 - 简化版
* 只需要 __client JWT其他参数自动获取
* @param {Object} currentConfig - 当前配置对象
* @param {Object} options - 额外选项
* @returns {Promise<Object>} 返回导入说明
*/
export async function handleOrchidsOAuth(currentConfig, options = {}) {
// Orchids 使用简化的手动导入模式
// 只需要 __client cookie 的值
return {
authUrl: null,
authInfo: {
provider: 'claude-orchids-oauth',
method: 'manual-import',
instructions: [
'1. 登录 Orchids 平台 (https://orchids.app)',
'2. 打开浏览器开发者工具 (F12)',
'3. 切换到 Application > Cookies > https://orchids.app',
'4. 找到 __client 并复制其值(一个长的 JWT 字符串)',
'5. 使用 "导入 Token" 功能粘贴该值'
],
tokenFormat: 'eyJhbGciOiJSUzI1NiJ9...',
example: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImNsaWVudF8uLi4',
note: '只需要 __client 的值即可sessionId 等参数会自动获取'
}
};
}

339
src/auth/qwen-oauth.js Normal file
View file

@ -0,0 +1,339 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
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';
/**
* Qwen OAuth 配置
*/
const QWEN_OAUTH_CONFIG = {
clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
scope: 'openid profile email model.completion',
deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token',
grantType: 'urn:ietf:params:oauth:grant-type:device_code',
credentialsDir: '.qwen',
credentialsFile: 'oauth_creds.json',
logPrefix: '[Qwen Auth]'
};
/**
* 活动的轮询任务管理
*/
const activePollingTasks = new Map();
/**
* 创建带代理支持的 fetch 请求
* 使用 axios 替代原生 fetch以正确支持代理配置
* @param {string} url - 请求 URL
* @param {Object} options - fetch 选项兼容 fetch API 格式
* @param {string} providerType - 提供商类型用于获取代理配置
* @returns {Promise<Object>} 返回类似 fetch Response 的对象
*/
async function fetchWithProxy(url, options = {}, providerType) {
const proxyConfig = getProxyConfigForProvider(CONFIG, providerType);
// 构建 axios 配置
const axiosConfig = {
url,
method: options.method || 'GET',
headers: options.headers || {},
timeout: 30000, // 30 秒超时
};
// 处理请求体
if (options.body) {
axiosConfig.data = options.body;
}
// 配置代理
if (proxyConfig) {
axiosConfig.httpAgent = proxyConfig.httpAgent;
axiosConfig.httpsAgent = proxyConfig.httpsAgent;
axiosConfig.proxy = false; // 禁用 axios 内置代理,使用我们的 agent
console.log(`[OAuth] Using proxy for ${providerType}: ${CONFIG.PROXY_URL}`);
}
try {
const axios = (await import('axios')).default;
const response = await axios(axiosConfig);
// 返回类似 fetch Response 的对象
return {
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.statusText,
headers: response.headers,
json: async () => response.data,
text: async () => typeof response.data === 'string' ? response.data : JSON.stringify(response.data),
};
} catch (error) {
// 处理 axios 错误,转换为类似 fetch 的响应格式
if (error.response) {
// 服务器返回了错误状态码
return {
ok: false,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
json: async () => error.response.data,
text: async () => typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data),
};
}
// 网络错误或其他错误
throw error;
}
}
/**
* 生成 PKCE 代码验证器
* @returns {string} Base64URL 编码的随机字符串
*/
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
/**
* 生成 PKCE 代码挑战
* @param {string} codeVerifier - 代码验证器
* @returns {string} Base64URL 编码的 SHA256 哈希
*/
function generateCodeChallenge(codeVerifier) {
const hash = crypto.createHash('sha256');
hash.update(codeVerifier);
return hash.digest('base64url');
}
/**
* 停止活动的轮询任务
* @param {string} taskId - 任务标识符
*/
function stopPollingTask(taskId) {
const task = activePollingTasks.get(taskId);
if (task) {
task.shouldStop = true;
activePollingTasks.delete(taskId);
console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 已停止轮询任务: ${taskId}`);
}
}
/**
* 轮询获取 Qwen OAuth 令牌
* @param {string} deviceCode - 设备代码
* @param {string} codeVerifier - PKCE 代码验证器
* @param {number} interval - 轮询间隔
* @param {number} expiresIn - 过期时间
* @param {string} taskId - 任务标识符
* @param {Object} options - 额外选项
* @returns {Promise<Object>} 返回令牌信息
*/
async function pollQwenToken(deviceCode, codeVerifier, interval = 5, expiresIn = 300, taskId = 'default', options = {}) {
let credPath = path.join(os.homedir(), QWEN_OAUTH_CONFIG.credentialsDir, QWEN_OAUTH_CONFIG.credentialsFile);
const maxAttempts = Math.floor(expiresIn / interval);
let attempts = 0;
// 创建任务控制对象
const taskControl = { shouldStop: false };
activePollingTasks.set(taskId, taskControl);
console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 开始轮询令牌 [${taskId}],间隔 ${interval} 秒,最多尝试 ${maxAttempts}`);
const poll = async () => {
// 检查是否需要停止
if (taskControl.shouldStop) {
console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询任务 [${taskId}] 已被停止`);
throw new Error('轮询任务已被取消');
}
if (attempts >= maxAttempts) {
activePollingTasks.delete(taskId);
throw new Error('授权超时,请重新开始授权流程');
}
attempts++;
const bodyData = {
client_id: QWEN_OAUTH_CONFIG.clientId,
device_code: deviceCode,
grant_type: QWEN_OAUTH_CONFIG.grantType,
code_verifier: codeVerifier
};
const formBody = Object.entries(bodyData)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
try {
const response = await fetchWithProxy(QWEN_OAUTH_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: formBody
}, 'openai-qwen-oauth');
const data = await response.json();
if (response.ok && data.access_token) {
// 成功获取令牌
console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 成功获取令牌 [${taskId}]`);
// 如果指定了保存到 configs 目录
if (options.saveToConfigs) {
const targetDir = path.join(process.cwd(), 'configs', options.providerDir);
await fs.promises.mkdir(targetDir, { recursive: true });
const timestamp = Date.now();
const filename = `${timestamp}_oauth_creds.json`;
credPath = path.join(targetDir, filename);
}
// 保存令牌到文件
await fs.promises.mkdir(path.dirname(credPath), { recursive: true });
await fs.promises.writeFile(credPath, JSON.stringify(data, null, 2));
console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 令牌已保存到 ${credPath}`);
const relativePath = path.relative(process.cwd(), credPath);
// 清理任务
activePollingTasks.delete(taskId);
// 广播授权成功事件
broadcastEvent('oauth_success', {
provider: 'openai-qwen-oauth',
credPath: credPath,
relativePath: relativePath,
timestamp: new Date().toISOString()
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
return data;
}
// 检查错误类型
if (data.error === 'authorization_pending') {
// 用户尚未完成授权,继续轮询
console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 等待用户授权 [${taskId}]... (第 ${attempts}/${maxAttempts} 次尝试)`);
await new Promise(resolve => setTimeout(resolve, interval * 1000));
return poll();
} else if (data.error === 'slow_down') {
// 需要降低轮询频率
console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 降低轮询频率`);
await new Promise(resolve => setTimeout(resolve, (interval + 5) * 1000));
return poll();
} else if (data.error === 'expired_token') {
activePollingTasks.delete(taskId);
throw new Error('设备代码已过期,请重新开始授权流程');
} else if (data.error === 'access_denied') {
activePollingTasks.delete(taskId);
throw new Error('用户拒绝了授权请求');
} else {
activePollingTasks.delete(taskId);
throw new Error(`授权失败: ${data.error || '未知错误'}`);
}
} catch (error) {
if (error.message.includes('授权') || error.message.includes('过期') || error.message.includes('拒绝')) {
throw error;
}
console.error(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询出错:`, error);
// 网络错误,继续重试
await new Promise(resolve => setTimeout(resolve, interval * 1000));
return poll();
}
};
return poll();
}
/**
* 处理 Qwen OAuth 授权设备授权流程
* @param {Object} currentConfig - 当前配置对象
* @param {Object} options - 额外选项
* @returns {Promise<Object>} 返回授权URL和相关信息
*/
export async function handleQwenOAuth(currentConfig, options = {}) {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const bodyData = {
client_id: QWEN_OAUTH_CONFIG.clientId,
scope: QWEN_OAUTH_CONFIG.scope,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
};
const formBody = Object.entries(bodyData)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
try {
const response = await fetchWithProxy(QWEN_OAUTH_CONFIG.deviceCodeEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: formBody
}, 'openai-qwen-oauth');
if (!response.ok) {
throw new Error(`Qwen OAuth请求失败: ${response.status} ${response.statusText}`);
}
const deviceAuth = await response.json();
if (!deviceAuth.device_code || !deviceAuth.verification_uri_complete) {
throw new Error('Qwen OAuth响应格式错误缺少必要字段');
}
// 启动后台轮询获取令牌
const interval = 5;
// const expiresIn = deviceAuth.expires_in || 1800;
const expiresIn = 300;
// 生成唯一的任务ID
const taskId = `qwen-${deviceAuth.device_code.substring(0, 8)}-${Date.now()}`;
// 先停止之前可能存在的所有 Qwen 轮询任务
for (const [existingTaskId] of activePollingTasks.entries()) {
if (existingTaskId.startsWith('qwen-')) {
stopPollingTask(existingTaskId);
}
}
// 不等待轮询完成,立即返回授权信息
pollQwenToken(deviceAuth.device_code, codeVerifier, interval, expiresIn, taskId, options)
.catch(error => {
console.error(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询失败 [${taskId}]:`, error);
// 广播授权失败事件
broadcastEvent('oauth_error', {
provider: 'openai-qwen-oauth',
error: error.message,
timestamp: new Date().toISOString()
});
});
return {
authUrl: deviceAuth.verification_uri_complete,
authInfo: {
provider: 'openai-qwen-oauth',
deviceCode: deviceAuth.device_code,
userCode: deviceAuth.user_code,
verificationUri: deviceAuth.verification_uri,
verificationUriComplete: deviceAuth.verification_uri_complete,
expiresIn: expiresIn,
interval: interval,
codeVerifier: codeVerifier
}
};
} catch (error) {
console.error(`${QWEN_OAUTH_CONFIG.logPrefix} 请求失败:`, error);
throw new Error(`Qwen OAuth 授权失败: ${error.message}`);
}
}

View file

@ -1353,8 +1353,16 @@ async saveCredentialsToFile(filePath, newData) {
// Handle 403 (Forbidden) - mark as unhealthy immediately, no retry
if (status === 403) {
console.log('[Kiro] Received 403. Marking credential as unhealthy...');
this._markCredentialUnhealthy('403 Forbidden', error);
console.log('[Kiro] Received 403. Marking credential as need refresh...');
// 1. 先刷新 UUID
const newUuid = this._refreshUuid();
if (newUuid) {
console.log(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`);
this.uuid = newUuid;
}
this._markCredentialNeedRefresh('403 Forbidden', error);
// Mark error for credential switch without recording error count
error.shouldSwitchCredential = true;
error.skipErrorCount = true;
@ -1861,8 +1869,16 @@ async saveCredentialsToFile(filePath, newData) {
// Handle 403 (Forbidden) - mark as unhealthy immediately, no retry
if (status === 403) {
console.log('[Kiro] Received 403 in stream. Marking credential as unhealthy...');
this._markCredentialUnhealthy('403 Forbidden', error);
console.log('[Kiro] Received 403 in stream. Marking credential as need refresh...');
// 1. 先刷新 UUID
const newUuid = this._refreshUuid();
if (newUuid) {
console.log(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`);
this.uuid = newUuid;
}
this._markCredentialNeedRefresh('403 Forbidden', error);
// Mark error for credential switch without recording error count
error.shouldSwitchCredential = true;
error.skipErrorCount = true;
@ -2754,7 +2770,7 @@ async saveCredentialsToFile(filePath, newData) {
if (status === 403) {
console.log('[Kiro] Received 403 on getUsageLimits. Marking credential as unhealthy (no retry)...');
this._markCredentialUnhealthy('403 Forbidden on usage query', formattedError);
this._markCredentialNeedRefresh('403 Forbidden on usage query', formattedError);
throw formattedError;
}

View file

@ -1,4 +1,5 @@
import * as fs from 'fs'; // Import fs module
import * as crypto from 'crypto'; // Import crypto module for UUID generation
import { getServiceAdapter } from './adapter.js';
import { MODEL_PROVIDER, getProtocolPrefix } from '../utils/common.js';
import { getProviderModels } from './provider-models.js';
@ -8,26 +9,29 @@ import axios from 'axios';
* Manages a pool of API service providers, handling their health and selection.
*/
export class ProviderPoolManager {
// 默认健康检查模型配置
// 键名必须与 MODEL_PROVIDER 常量值一致
static DEFAULT_HEALTH_CHECK_MODELS = {
'gemini-cli-oauth': 'gemini-2.5-flash',
'gemini-antigravity': 'gemini-2.5-flash',
'openai-custom': 'gpt-3.5-turbo',
'claude-custom': 'claude-3-7-sonnet-20250219',
'claude-kiro-oauth': 'claude-haiku-4-5',
'openai-qwen-oauth': 'qwen3-coder-flash',
'openaiResponses-custom': 'gpt-4o-mini'
};
// 默认健康检查模型配置
// 键名必须与 MODEL_PROVIDER 常量值一致
static DEFAULT_HEALTH_CHECK_MODELS = {
'gemini-cli-oauth': 'gemini-2.5-flash',
'gemini-antigravity': 'gemini-2.5-flash',
'openai-custom': 'gpt-3.5-turbo',
'claude-custom': 'claude-3-7-sonnet-20250219',
'claude-kiro-oauth': 'claude-haiku-4-5',
'claude-orchids-oauth': 'claude-haiku-4-5',
'openai-qwen-oauth': 'qwen3-coder-flash',
'openai-iflow': 'qwen3-coder-plus',
'openai-codex-oauth': 'gpt-5-codex-mini',
'openaiResponses-custom': 'gpt-4o-mini'
};
constructor(providerPools, options = {}) {
this.providerPools = providerPools;
this.globalConfig = options.globalConfig || {}; // 存储全局配置
this.providerStatus = {}; // Tracks health and usage for each provider instance
this.roundRobinIndex = {}; // Tracks the current index for round-robin selection for each provider type
// 使用 ?? 运算符确保 0 也能被正确设置,而不是被 || 替换为默认值
this.maxErrorCount = options.maxErrorCount ?? 3; // Default to 3 errors before marking unhealthy
this.healthCheckInterval = options.healthCheckInterval ?? 10 * 60 * 1000; // Default to 10 minutes
constructor(providerPools, options = {}) {
this.providerPools = providerPools;
this.globalConfig = options.globalConfig || {}; // 存储全局配置
this.providerStatus = {}; // Tracks health and usage for each provider instance
this.roundRobinIndex = {}; // Tracks the current index for round-robin selection for each provider type
// 使用 ?? 运算符确保 0 也能被正确设置,而不是被 || 替换为默认值
this.maxErrorCount = options.maxErrorCount ?? 3; // Default to 3 errors before marking unhealthy
this.healthCheckInterval = options.healthCheckInterval ?? 10 * 60 * 1000; // Default to 10 minutes
// 日志级别控制
this.logLevel = options.logLevel || 'info'; // 'debug', 'info', 'warn', 'error'
@ -54,12 +58,17 @@ export class ProviderPoolManager {
perProvider: options.globalConfig?.REFRESH_CONCURRENCY_PER_PROVIDER ?? 1 // 每个提供商内部最大并行数
};
this.refreshQueues = {}; // 按 providerType 分组的队列
this.activeProviderRefreshes = 0; // 当前正在刷新的提供商类型数量
this.globalRefreshWaiters = []; // 等待全局并发槽位的任务
this.warmupTarget = options.globalConfig?.WARMUP_TARGET || 0; // 默认预热0个节点
this.refreshingUuids = new Set(); // 正在刷新的节点 UUID 集合
this.refreshQueues = {}; // 按 providerType 分组的队列
// 缓冲队列机制延迟5秒去重后再执行刷新
this.refreshBufferQueues = {}; // 按 providerType 分组的缓冲队列
this.refreshBufferTimers = {}; // 按 providerType 分组的定时器
this.bufferDelay = options.globalConfig?.REFRESH_BUFFER_DELAY ?? 5000; // 默认5秒缓冲延迟
this.initializeProviderStatus();
}
@ -75,12 +84,31 @@ export class ProviderPoolManager {
for (const providerStatus of providers) {
const config = providerStatus.config;
// 根据 providerType 确定配置文件路径字段名
let configPath = null;
if (providerType.startsWith('claude-kiro')) {
configPath = config.KIRO_OAUTH_CREDS_FILE_PATH;
} else if (providerType.startsWith('gemini-cli')) {
configPath = config.GEMINI_OAUTH_CREDS_FILE_PATH;
} else if (providerType.startsWith('gemini-antigravity')) {
configPath = config.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH;
} else if (providerType.startsWith('openai-qwen')) {
configPath = config.QWEN_OAUTH_CREDS_FILE_PATH;
} else if (providerType.startsWith('openai-iflow')) {
configPath = config.IFLOW_OAUTH_CREDS_FILE_PATH;
} else if (providerType.startsWith('openai-codex')) {
configPath = config.CODEX_OAUTH_CREDS_FILE_PATH;
} else if (providerType.startsWith('claude-orchids')) {
configPath = config.ORCHIDS_CREDS_FILE_PATH;
}
// console.log(`Checking node ${providerStatus.uuid} (${providerType}) expiry date... configPath: ${configPath}`);
// 排除不健康和禁用的节点
if (!config.isHealthy || config.isDisabled) continue;
if (config.configPath && fs.existsSync(config.configPath)) {
if (configPath && fs.existsSync(configPath)) {
try {
const fileContent = fs.readFileSync(config.configPath, 'utf8');
const fileContent = fs.readFileSync(configPath, 'utf8');
const data = JSON.parse(fileContent);
// 获取对应的适配器
@ -95,6 +123,7 @@ export class ProviderPoolManager {
if (typeof serviceAdapter.isExpiryDateNear === 'function') {
// 适配器内部自行判断,不传参
needsRefresh = serviceAdapter.isExpiryDateNear();
this._log('info', `Node ${providerStatus.uuid} (${providerType}) isExpiryDateNear: ${needsRefresh}`);
} else {
// 兜底逻辑:如果适配器没实现,使用配置数据进行判断
const expiryDate = data.expiry_date || data.expires_at || data.expiry;
@ -111,6 +140,8 @@ export class ProviderPoolManager {
} catch (err) {
this._log('error', `Failed to check expiry for node ${providerStatus.uuid}: ${err.message}`);
}
} else {
this._log('debug', `Node ${providerStatus.uuid} (${providerType}) has no valid config file path or file does not exist.`);
}
}
}
@ -157,18 +188,98 @@ export class ProviderPoolManager {
}
/**
* 将节点放入刷新队列
* 将节点放入缓冲队列延迟5秒后去重并执行刷新
* @param {string} providerType
* @param {object} providerStatus
* @param {boolean} force - 是否强制刷新跳过缓冲队列
* @private
*/
_enqueueRefresh(providerType, providerStatus, force = false) {
const uuid = providerStatus.uuid;
// 如果已经在刷新中,直接返回
if (this.refreshingUuids.has(uuid)) {
this._log('debug', `Node ${uuid} is already in refresh queue.`);
return;
}
// 初始化缓冲队列
if (!this.refreshBufferQueues[providerType]) {
this.refreshBufferQueues[providerType] = new Map(); // 使用 Map 自动去重
}
const bufferQueue = this.refreshBufferQueues[providerType];
// 检查是否已在缓冲队列中
const existing = bufferQueue.get(uuid);
const isNewEntry = !existing;
// 更新或添加节点(保留 force: true 状态)
bufferQueue.set(uuid, {
providerStatus,
force: existing ? (existing.force || force) : force
});
if (isNewEntry) {
this._log('debug', `Node ${uuid} added to buffer queue for ${providerType}. Buffer size: ${bufferQueue.size}`);
} else {
this._log('debug', `Node ${uuid} already in buffer queue, updated force flag. Buffer size: ${bufferQueue.size}`);
}
// 只在新增节点或缓冲队列为空时重置定时器
// 避免频繁重置导致刷新被无限延迟
if (isNewEntry || !this.refreshBufferTimers[providerType]) {
// 清除之前的定时器
if (this.refreshBufferTimers[providerType]) {
clearTimeout(this.refreshBufferTimers[providerType]);
}
// 设置新的定时器延迟5秒后处理缓冲队列
this.refreshBufferTimers[providerType] = setTimeout(() => {
this._flushRefreshBuffer(providerType);
}, this.bufferDelay);
}
}
/**
* 处理缓冲队列将去重后的节点放入实际刷新队列
* @param {string} providerType
* @private
*/
_flushRefreshBuffer(providerType) {
const bufferQueue = this.refreshBufferQueues[providerType];
if (!bufferQueue || bufferQueue.size === 0) {
return;
}
this._log('info', `Flushing refresh buffer for ${providerType}. Processing ${bufferQueue.size} unique nodes.`);
// 将缓冲队列中的所有节点放入实际刷新队列
for (const [uuid, { providerStatus, force }] of bufferQueue.entries()) {
this._enqueueRefreshImmediate(providerType, providerStatus, force);
}
// 清空缓冲队列和定时器
bufferQueue.clear();
delete this.refreshBufferTimers[providerType];
}
/**
* 立即将节点放入刷新队列内部方法由缓冲队列调用
* @param {string} providerType
* @param {object} providerStatus
* @param {boolean} force
* @private
*/
_enqueueRefreshImmediate(providerType, providerStatus, force = false) {
const uuid = providerStatus.uuid;
// 再次检查是否已经在刷新中(防止并发问题)
if (this.refreshingUuids.has(uuid)) {
this._log('debug', `Node ${uuid} is already in refresh queue (immediate check).`);
return;
}
this.refreshingUuids.add(uuid);
// 初始化提供商队列
@ -183,31 +294,36 @@ export class ProviderPoolManager {
const runTask = async () => {
try {
await this._refreshNodeToken(providerType, providerStatus);
await this._refreshNodeToken(providerType, providerStatus, force);
} catch (err) {
this._log('error', `Failed to process refresh for node ${uuid}: ${err.message}`);
} finally {
this.refreshingUuids.delete(uuid);
queue.activeCount--;
// 确保在异步操作中 queue 仍然存在
// 再次获取当前队列引用
const currentQueue = this.refreshQueues[providerType];
if (!currentQueue) return;
currentQueue.activeCount--;
// 1. 尝试从当前提供商队列中取下一个任务
if (currentQueue && currentQueue.waitingTasks.length > 0) {
if (currentQueue.waitingTasks.length > 0) {
const nextTask = currentQueue.waitingTasks.shift();
currentQueue.activeCount++;
// 使用 setImmediate 或 Promise.resolve().then 避免过深的递归
// 使用 Promise.resolve().then 避免过深的递归
Promise.resolve().then(nextTask);
} else if (currentQueue && currentQueue.activeCount === 0) {
} else if (currentQueue.activeCount === 0) {
// 2. 如果当前提供商的所有任务都完成了,释放全局槽位
this.activeProviderRefreshes--;
delete this.refreshQueues[providerType]; // 清理空队列
// 只有在确定队列为空且没有新任务时才清理
if (currentQueue.waitingTasks.length === 0 &&
this.refreshQueues[providerType] === currentQueue) {
this.activeProviderRefreshes--;
delete this.refreshQueues[providerType]; // 清理空队列
}
// 3. 尝试启动下一个等待中的提供商队列
if (this.globalRefreshWaiters.length > 0) {
const nextProviderStart = this.globalRefreshWaiters.shift();
// 同样避免过深的递归
Promise.resolve().then(nextProviderStart);
}
}
@ -215,7 +331,6 @@ export class ProviderPoolManager {
};
const tryStartProviderQueue = () => {
// 再次检查是否已经从 refreshingUuids 中移除(虽然可能性小,但为了健壮性)
if (queue.activeCount < this.refreshConcurrency.perProvider) {
queue.activeCount++;
runTask();
@ -225,22 +340,27 @@ export class ProviderPoolManager {
};
// 检查全局并发限制(按提供商分组)
// 如果该提供商已经在运行,或者全局槽位还没满,则直接开始
if (this.refreshQueues[providerType].activeCount > 0 || this.activeProviderRefreshes < this.refreshConcurrency.global) {
if (this.refreshQueues[providerType].activeCount === 0) {
this.activeProviderRefreshes++;
}
// 情况1: 该提供商已经在运行,直接加入其队列(不占用新的全局槽位)
if (this.refreshQueues[providerType].activeCount > 0) {
tryStartProviderQueue();
} else {
// 否则进入全局等待列表
}
// 情况2: 该提供商未运行,需要检查全局槽位
else if (this.activeProviderRefreshes < this.refreshConcurrency.global) {
this.activeProviderRefreshes++;
tryStartProviderQueue();
}
// 情况3: 全局槽位已满,进入等待队列
else {
this.globalRefreshWaiters.push(() => {
// 重新获取最新的队列引用,因为可能在等待期间被清理过(虽然逻辑上此时不应该被清理)
// 重新获取最新的队列引用
if (!this.refreshQueues[providerType]) {
this.refreshQueues[providerType] = {
activeCount: 0,
waitingTasks: []
};
}
// 重要:从等待队列启动时需要增加全局计数
this.activeProviderRefreshes++;
tryStartProviderQueue();
});
}
@ -256,7 +376,9 @@ export class ProviderPoolManager {
// 检查刷新次数是否已达上限最大3次
const currentRefreshCount = config.refreshCount || 0;
if (currentRefreshCount >= 3 && !force) {
this._log('warn', `Node ${providerStatus.uuid} has reached maximum refresh count (3), skipping refresh`);
this._log('warn', `Node ${providerStatus.uuid} has reached maximum refresh count (3), marking as unhealthy`);
// 标记为不健康
this.markProviderUnhealthyImmediately(providerType, config, 'Maximum refresh count (3) reached');
return;
}
@ -281,9 +403,7 @@ export class ProviderPoolManager {
const startTime = Date.now();
force ? await serviceAdapter.forceRefreshToken() : await serviceAdapter.refreshToken()
const duration = Date.now() - startTime;
this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`);
// 注意:根据反馈,这里不再执行健康检查验证,直接标记为健康
this.markProviderHealthy(providerType, config, false);
this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`);
} else {
throw new Error(`refreshToken method not implemented for ${providerType}`);
}
@ -307,9 +427,9 @@ export class ProviderPoolManager {
// 1. 基础健康分:不健康的排最后
if (!config.isHealthy || config.isDisabled) return 1000000;
// 2. 预热/刷新分:5分钟内刷新过且使用次数极少的节点视为“新鲜”,分数极低(最高优)
// 2. 预热/刷新分:2分钟内刷新过且使用次数极少的节点视为“新鲜”,分数极低(最高优)
const isFresh = config.lastHealthCheckTime &&
(now - new Date(config.lastHealthCheckTime).getTime() < 300000) &&
(now - new Date(config.lastHealthCheckTime).getTime() < 120000) &&
(config.usageCount === 0);
if (isFresh) return -1000;
@ -904,6 +1024,8 @@ export class ProviderPoolManager {
if (provider) {
provider.config.needsRefresh = false;
provider.config.refreshCount = 0;
// 更新为可用
provider.config.lastHealthCheckTime = new Date().toISOString();
// 标记为健康,以便立即投入使用
this._log('info', `Reset refresh status and marked healthy for provider ${uuid} (${providerType})`);
@ -1240,7 +1362,7 @@ export class ProviderPoolManager {
}
// 所有尝试都失败
this._log('error', `Health check failed for ${providerType} after ${maxRetries} attempts: ${lastError?.message}`);
this._log('error', `Health check failed for ${providerType} after ${healthCheckRequests.length} attempts: ${lastError?.message}`);
return { success: false, modelName, errorMessage: lastError?.message || 'All health check attempts failed' };
}

View file

@ -336,6 +336,8 @@ const translations = {
'upload.providerFilter.qwen': 'Qwen OAuth',
'upload.providerFilter.antigravity': 'Antigravity',
'upload.providerFilter.orchids': 'Orchids OAuth',
'upload.providerFilter.codex': 'Codex OAuth',
'upload.providerFilter.iflow': 'iFlow OAuth',
'upload.providerFilter.other': '其他/未识别',
'upload.statusFilter': '关联状态',
'upload.statusFilter.all': '全部状态',
@ -1047,6 +1049,8 @@ const translations = {
'upload.providerFilter.qwen': 'Qwen OAuth',
'upload.providerFilter.antigravity': 'Antigravity',
'upload.providerFilter.orchids': 'Orchids OAuth',
'upload.providerFilter.codex': 'Codex OAuth',
'upload.providerFilter.iflow': 'iFlow OAuth',
'upload.providerFilter.other': 'Other/Unknown',
'upload.statusFilter': 'Association Status',
'upload.statusFilter.all': 'All Status',

View file

@ -445,7 +445,7 @@ function generateAuthButton(providerType) {
if (providerType === 'openai-codex-oauth') {
return `
<button class="generate-auth-btn" title="生成 Codex OAuth 授权链接">
<i class="fas fa-code" style="color: #10b981;"></i>
<i class="fas fa-key"></i>
<span data-i18n="providers.auth.generate">${t('providers.auth.generate')}</span>
</button>
`;

View file

@ -855,6 +855,12 @@ function detectProviderFromPath(filePath) {
providerType: 'openai-codex-oauth',
displayName: 'OpenAI Codex OAuth',
shortName: 'codex-oauth'
},
{
patterns: ['configs/iflow/', '/iflow/'],
providerType: 'openai-iflow-oauth',
displayName: 'OpenAI iFlow OAuth',
shortName: 'iflow-oauth'
}
];

View file

@ -24,6 +24,8 @@
<option value="openai-qwen-oauth" data-i18n="upload.providerFilter.qwen">Qwen OAuth</option>
<option value="gemini-antigravity" data-i18n="upload.providerFilter.antigravity">Antigravity</option>
<option value="claude-orchids-oauth" data-i18n="upload.providerFilter.orchids">Orchids OAuth</option>
<option value="openai-codex-oauth" data-i18n="upload.providerFilter.codex">Codex OAuth</option>
<option value="openai-iflow-oauth" data-i18n="upload.providerFilter.iflow">iFlow OAuth</option>
<option value="other" data-i18n="upload.providerFilter.other">其他/未识别</option>
</select>
</div>