feat(oauth): 重构OAuth授权流程并支持自定义端口

- 重构OAuth回调服务器管理,改为按提供商而非端口存储
- 在授权模态框中添加端口自定义功能
- 支持在生成授权URL时指定自定义端口
- 更新Dockerfile和文档以反映新增的OAuth端口需求
- 将授权逻辑从event-handlers.js移至provider-manager.js
- 优化服务器关闭逻辑,避免端口冲突
This commit is contained in:
hex2077 2025-12-27 17:11:19 +08:00
parent 16b7ee454b
commit fc3eef0b3d
9 changed files with 147 additions and 94 deletions

View file

@ -26,7 +26,7 @@ USER root
RUN mkdir -p /app/logs
# 暴露端口
EXPOSE 3000
EXPOSE 3000 8085 8086 19876-19880
# 添加健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \

View file

@ -93,12 +93,12 @@ AIClient-2-APIを使い始める最も推奨される方法は、自動起動ス
#### 🐳 Docker クイックスタート (推奨)
```bash
docker run -d -p 3000:3000 --restart=always -v "指定パス:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api
docker run -d -p 3000:3000 -p 8085:8085 -p 8086:8086 -p 19876-19880:19876-19880 --restart=always -v "指定パス:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api
```
**パラメータ説明**
- `-d`:バックグラウンドでコンテナを実行
- `-p 3000:3000`コンテナ内の3000ポートをホストの3000ポートにマッピング
- `-p 3000:3000 ...`ポートマッピング。3000はWeb UI用、その他はOAuthコールバック用Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880
- `--restart=always`:コンテナ自動再起動ポリシー
- `-v "指定パス:/app/configs"`:設定ディレクトリをマウント(「指定パス」を実際のパスに置き換えてください、例:`/home/user/aiclient-configs`
- `--name aiclient2api`:コンテナ名

View file

@ -92,12 +92,12 @@
#### 🐳 Docker 快捷启动 (推荐)
```bash
docker run -d -p 3000:3000 --restart=always -v "指定路径:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api
docker run -d -p 3000:3000 -p 8085:8085 -p 8086:8086 -p 19876-19880:19876-19880 --restart=always -v "指定路径:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api
```
**参数说明**
- `-d`:后台运行容器
- `-p 3000:3000`:将容器内 3000 端口映射到主机 3000 端口
- `-p 3000:3000 ...`端口映射。3000 为 Web UI其余为 OAuth 回调端口Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880
- `--restart=always`:容器自动重启策略
- `-v "指定路径:/app/configs"`:挂载配置目录(请将"指定路径"替换为实际路径,如 `/home/user/aiclient-configs`
- `--name aiclient2api`:容器名称

View file

@ -93,12 +93,12 @@ The most recommended way to use AIClient-2-API is to start it through an automat
#### 🐳 Docker Quick Start (Recommended)
```bash
docker run -d -p 3000:3000 --restart=always -v "your_path:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api
docker run -d -p 3000:3000 -p 8085:8085 -p 8086:8086 -p 19876-19880:19876-19880 --restart=always -v "your_path:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api
```
**Parameter Description**:
- `-d`: Run container in background
- `-p 3000:3000`: Map container port 3000 to host port 3000
- `-p 3000:3000 ...`: Port mapping. 3000 is for Web UI, others are for OAuth callbacks (Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880)
- `--restart=always`: Container auto-restart policy
- `-v "your_path:/app/configs"`: Mount configuration directory (replace "your_path" with actual path, e.g., `/home/user/aiclient-configs`)
- `--name aiclient2api`: Container name

View file

@ -26,7 +26,6 @@ const httpsAgent = new https.Agent({
});
// --- Constants ---
const AUTH_REDIRECT_PORT = 8086;
const CREDENTIALS_DIR = '.antigravity';
const CREDENTIALS_FILE = 'oauth_creds.json';
const DEFAULT_ANTIGRAVITY_BASE_URL_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com';
@ -240,7 +239,6 @@ export class AntigravityApiService {
this.config = config;
this.host = config.HOST;
this.oauthCredsFilePath = config.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH;
this.baseURL = DEFAULT_ANTIGRAVITY_BASE_URL_DAILY; // 使用通用 GEMINI_BASE_URL 配置
this.userAgent = DEFAULT_USER_AGENT; // 支持通用 USER_AGENT 配置
this.projectId = config.PROJECT_ID;
@ -249,7 +247,7 @@ export class AntigravityApiService {
this.baseUrlAutopush = config.ANTIGRAVITY_BASE_URL_AUTOPUSH || DEFAULT_ANTIGRAVITY_BASE_URL_AUTOPUSH;
// 多环境降级顺序
this.baseURLs = this.baseURL ? [this.baseURL] : [
this.baseURLs = [
this.baseUrlDaily,
this.baseUrlAutopush
// ANTIGRAVITY_BASE_URL_PROD // 生产环境已注释

View file

@ -135,17 +135,33 @@ function generateResponsePage(isSuccess, message) {
* @param {number} port - 端口号
* @returns {Promise<void>}
*/
async function closeActiveServer(port) {
const existingServer = activeServers.get(port);
if (existingServer && existingServer.listening) {
return new Promise((resolve) => {
existingServer.close(() => {
activeServers.delete(port);
console.log(`[OAuth] 已关闭端口 ${port} 上的旧服务器`);
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();
});
});
}
}
}
}
/**
@ -158,8 +174,9 @@ async function closeActiveServer(port) {
* @returns {Promise<http.Server>} HTTP 服务器实例
*/
async function createOAuthCallbackServer(config, redirectUri, authClient, credPath, provider, options = {}) {
// 先关闭该端口上的旧服务器
await closeActiveServer(config.port);
const port = parseInt(options.port) || config.port;
// 先关闭该提供商之前可能运行的所有服务器,或该端口上的旧服务器
await closeActiveServer(provider, port);
return new Promise((resolve, reject) => {
const server = http.createServer(async (req, res) => {
@ -210,7 +227,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
res.end(generateResponsePage(false, `获取令牌失败: ${tokenError.message}`));
} finally {
server.close(() => {
activeServers.delete(config.port);
activeServers.delete(provider);
});
}
} else if (errorParam) {
@ -220,7 +237,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(generateResponsePage(false, errorMessage));
server.close(() => {
activeServers.delete(config.port);
activeServers.delete(provider);
});
} else {
console.log(`${config.logPrefix} 忽略无关请求: ${req.url}`);
@ -234,7 +251,7 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
if (server.listening) {
server.close(() => {
activeServers.delete(config.port);
activeServers.delete(provider);
});
}
}
@ -242,8 +259,8 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`${config.logPrefix} 端口 ${config.port} 已被占用`);
reject(new Error(`端口 ${config.port} 已被占用`));
console.error(`${config.logPrefix} 端口 ${port} 已被占用`);
reject(new Error(`端口 ${port} 已被占用`));
} else {
console.error(`${config.logPrefix} 服务器错误:`, err);
reject(err);
@ -251,9 +268,9 @@ async function createOAuthCallbackServer(config, redirectUri, authClient, credPa
});
const host = '0.0.0.0';
server.listen(config.port, host, () => {
console.log(`${config.logPrefix} OAuth 回调服务器已启动于 ${host}:${config.port}`);
activeServers.set(config.port, server);
server.listen(port, host, () => {
console.log(`${config.logPrefix} OAuth 回调服务器已启动于 ${host}:${port}`);
activeServers.set(provider, { server, port });
resolve(server);
});
});
@ -272,8 +289,9 @@ async function handleGoogleOAuth(providerKey, currentConfig, options = {}) {
throw new Error(`未知的提供商: ${providerKey}`);
}
const port = parseInt(options.port) || config.port;
const host = 'localhost';
const redirectUri = `http://${host}:${config.port}`;
const redirectUri = `http://${host}:${port}`;
const authClient = new OAuth2Client(config.clientId, config.clientSecret);
authClient.redirectUri = redirectUri;
@ -298,7 +316,8 @@ async function handleGoogleOAuth(providerKey, currentConfig, options = {}) {
authInfo: {
provider: providerKey,
redirectUri: redirectUri,
port: config.port
port: port,
...options
}
};
}
@ -607,7 +626,17 @@ async function handleKiroSocialAuth(provider, currentConfig, options = {}) {
const state = crypto.randomBytes(16).toString('base64url');
// 启动本地回调服务器并获取端口
const handlerPort = await startKiroCallbackServer(codeVerifier, state, options);
let handlerPort;
const providerKey = 'claude-kiro-oauth';
if (options.port) {
const port = parseInt(options.port);
await closeKiroServer(providerKey, port);
const server = await createKiroHttpCallbackServer(port, codeVerifier, state, options);
activeKiroServers.set(providerKey, { server, port });
handlerPort = port;
} else {
handlerPort = await startKiroCallbackServer(codeVerifier, state, options);
}
// 使用 HTTP localhost 作为 redirect_uri
const redirectUri = `http://127.0.0.1:${handlerPort}/oauth/callback`;
@ -629,7 +658,8 @@ async function handleKiroSocialAuth(provider, currentConfig, options = {}) {
socialProvider: provider,
port: handlerPort,
redirectUri: redirectUri,
state: state
state: state,
...options
}
};
}
@ -718,7 +748,8 @@ async function handleKiroBuilderIDDeviceCode(currentConfig, options = {}) {
verificationUri: deviceAuth.verificationUri,
verificationUriComplete: deviceAuth.verificationUriComplete,
expiresIn: deviceAuth.expiresIn,
interval: deviceAuth.interval
interval: deviceAuth.interval,
...options
}
};
}
@ -855,7 +886,7 @@ async function startKiroCallbackServer(codeVerifier, expectedState, options = {}
try {
const server = await createKiroHttpCallbackServer(port, codeVerifier, expectedState, options);
activeKiroServers.set(port, server);
activeKiroServers.set('claude-kiro-oauth', { server, port });
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} 回调服务器已启动于端口 ${port}`);
return port;
} catch (err) {
@ -869,17 +900,31 @@ async function startKiroCallbackServer(codeVerifier, expectedState, options = {}
/**
* 关闭 Kiro 服务器
*/
async function closeKiroServer(port) {
const existingServer = activeKiroServers.get(port);
if (existingServer && existingServer.listening) {
return new Promise((resolve) => {
existingServer.close(() => {
activeKiroServers.delete(port);
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭端口 ${port} 上的旧服务器`);
async function closeKiroServer(provider, port = null) {
const existing = activeKiroServers.get(provider);
if (existing) {
await new Promise((resolve) => {
existing.server.close(() => {
activeKiroServers.delete(provider);
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider}端口 ${existing.port} 上的旧服务器`);
resolve();
});
});
}
if (port) {
for (const [p, info] of activeKiroServers.entries()) {
if (info.port === port) {
await new Promise((resolve) => {
info.server.close(() => {
activeKiroServers.delete(p);
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭端口 ${port} 上的旧服务器`);
resolve();
});
});
}
}
}
}
/**
@ -975,7 +1020,7 @@ function createKiroHttpCallbackServer(port, codeVerifier, expectedState, options
// 关闭服务器
server.close(() => {
activeKiroServers.delete(port);
activeKiroServers.delete('claude-kiro-oauth');
});
} else {
@ -996,7 +1041,7 @@ function createKiroHttpCallbackServer(port, codeVerifier, expectedState, options
setTimeout(() => {
if (server.listening) {
server.close(() => {
activeKiroServers.delete(port);
activeKiroServers.delete('claude-kiro-oauth');
});
}
}, KIRO_OAUTH_CONFIG.authTimeout);

View file

@ -39,7 +39,8 @@ import {
updateTimeDisplay,
loadProviders,
openProviderManager,
showAuthModal
showAuthModal,
executeGenerateAuthUrl
} from './provider-manager.js';
import {
@ -150,6 +151,7 @@ window.showProviderManagerModal = showProviderManagerModal;
window.refreshProviderConfig = refreshProviderConfig;
window.fileUploadHandler = fileUploadHandler;
window.showAuthModal = showAuthModal;
window.executeGenerateAuthUrl = executeGenerateAuthUrl;
// 配置管理相关全局函数
window.viewConfig = viewConfig;

View file

@ -261,52 +261,13 @@ async function handleGenerateCreds(event) {
* 实际执行授权逻辑
*/
async function proceedWithAuth(providerType, targetInputId, extraOptions = {}) {
try {
showToast(t('common.info'), t('modal.provider.auth.initializing'), 'info');
// 使用 fileUploadHandler 中的 getProviderKey 获取目录名称
const providerDir = fileUploadHandler.getProviderKey(providerType);
const response = await window.apiClient.post(
`/providers/${encodeURIComponent(providerType)}/generate-auth-url`,
{
saveToConfigs: true,
providerDir: providerDir,
...extraOptions
}
);
if (response.success && response.authUrl) {
// 使用自定义事件监听授权成功,以便自动填充路径
const handleSuccess = (e) => {
const data = e.detail;
if (data.provider === providerType && data.relativePath) {
const input = document.getElementById(targetInputId);
if (input) {
input.value = data.relativePath;
input.dispatchEvent(new Event('input', { bubbles: true }));
showToast(t('common.success'), t('modal.provider.auth.success'), 'success');
}
window.removeEventListener('oauth_success_event', handleSuccess);
}
};
window.addEventListener('oauth_success_event', handleSuccess);
// 调用 provider-manager.js 中的 showAuthModal (假设已在全局作用域或通过某种方式可用)
// 如果不可用,我们需要在 app.js 中导出它
if (window.showAuthModal) {
window.showAuthModal(response.authUrl, response.authInfo);
} else {
// 降级处理:如果在 app.js 中没导出,尝试直接打开
window.open(response.authUrl, '_blank');
showToast(t('common.info'), t('modal.provider.auth.window'), 'info');
}
} else {
showToast(t('common.error'), t('modal.provider.auth.failed'), 'error');
}
} catch (error) {
console.error('生成凭据失败:', error);
showToast(t('common.error'), t('modal.provider.auth.failed') + `: ${error.message}`, 'error');
if (window.executeGenerateAuthUrl) {
await window.executeGenerateAuthUrl(providerType, {
targetInputId,
...extraOptions
});
} else {
console.error('executeGenerateAuthUrl not found');
}
}

View file

@ -451,6 +451,24 @@ async function executeGenerateAuthUrl(providerType, extraOptions = {}) {
);
if (response.success && response.authUrl) {
// 如果提供了 targetInputId设置成功监听器
if (extraOptions.targetInputId) {
const targetInputId = extraOptions.targetInputId;
const handleSuccess = (e) => {
const data = e.detail;
if (data.provider === providerType && data.relativePath) {
const input = document.getElementById(targetInputId);
if (input) {
input.value = data.relativePath;
input.dispatchEvent(new Event('input', { bubbles: true }));
showToast(t('common.success'), t('modal.provider.auth.success'), 'success');
}
window.removeEventListener('oauth_success_event', handleSuccess);
}
};
window.addEventListener('oauth_success_event', handleSuccess);
}
// 显示授权信息模态框
showAuthModal(response.authUrl, response.authInfo);
} else {
@ -492,7 +510,8 @@ function showAuthModal(authUrl, authInfo) {
// 获取需要开放的端口号(从 authInfo 或当前页面 URL
const requiredPort = authInfo.callbackPort || authInfo.port || window.location.port || '3000';
const isDeviceFlow = authInfo.provider === 'openai-qwen-oauth' || (authInfo.provider === 'claude-kiro-oauth' && authInfo.authMethod === 'builder-id');
let instructionsHtml = '';
if (authInfo.provider === 'openai-qwen-oauth') {
instructionsHtml = `
@ -545,11 +564,19 @@ function showAuthModal(authUrl, authInfo) {
<div class="auth-info">
<p><strong data-i18n="oauth.modal.provider">${t('oauth.modal.provider')}</strong> ${authInfo.provider}</p>
<div class="port-info-section" style="margin: 12px 0; padding: 12px; background: #fef3c7; border: 1px solid #fcd34d; border-radius: 8px;">
<p style="margin: 0; display: flex; align-items: center; gap: 8px;">
<div style="margin: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<i class="fas fa-network-wired" style="color: #d97706;"></i>
<strong data-i18n="oauth.modal.requiredPort">${t('oauth.modal.requiredPort')}</strong>
<code style="background: #fff; padding: 2px 8px; border-radius: 4px; font-weight: bold; color: #d97706;">${requiredPort}</code>
</p>
${isDeviceFlow ?
`<code style="background: #fff; padding: 2px 8px; border-radius: 4px; font-weight: bold; color: #d97706;">${requiredPort}</code>` :
`<div style="display: flex; align-items: center; gap: 4px;">
<input type="number" class="auth-port-input" value="${requiredPort}" style="width: 80px; padding: 2px 8px; border: 1px solid #d97706; border-radius: 4px; font-weight: bold; color: #d97706; background: white;">
<button class="regenerate-port-btn" title="${t('common.generate')}" style="background: none; border: 1px solid #d97706; border-radius: 4px; cursor: pointer; color: #d97706; padding: 2px 6px;">
<i class="fas fa-sync-alt"></i>
</button>
</div>`
}
</div>
<p style="margin: 8px 0 0 0; font-size: 0.85rem; color: #92400e;" data-i18n="oauth.modal.portNote">${t('oauth.modal.portNote')}</p>
</div>
${instructionsHtml}
@ -585,6 +612,25 @@ function showAuthModal(authUrl, authInfo) {
});
});
// 重新生成按钮事件
const regenerateBtn = modal.querySelector('.regenerate-port-btn');
if (regenerateBtn) {
regenerateBtn.onclick = async () => {
const newPort = modal.querySelector('.auth-port-input').value;
if (newPort && newPort !== requiredPort) {
modal.remove();
// 构造重新请求的参数
const options = { ...authInfo, port: newPort };
// 移除不需要传递回后端的字段
delete options.provider;
delete options.redirectUri;
delete options.callbackPort;
await executeGenerateAuthUrl(authInfo.provider, options);
}
};
}
// 复制链接按钮
const copyBtn = modal.querySelector('.copy-btn');
copyBtn.addEventListener('click', () => {
@ -715,5 +761,6 @@ export {
renderProviders,
updateProviderStatsDisplay,
openProviderManager,
showAuthModal
showAuthModal,
executeGenerateAuthUrl
};