Merge pull request #185 from leonaii/main

feat(kiro): 添加批量导入refreshToken功能
This commit is contained in:
何夕2077 2026-01-08 18:22:51 +08:00 committed by GitHub
commit 2002ad4a16
4 changed files with 407 additions and 2 deletions

View file

@ -1478,4 +1478,152 @@ export async function refreshIFlowTokens(refreshToken) {
scope: tokenData.scope,
apiKey: userInfo.apiKey
};
}
/**
* Kiro Token 刷新常量
*/
const KIRO_REFRESH_CONSTANTS = {
REFRESH_URL: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken',
CONTENT_TYPE_JSON: 'application/json',
AUTH_METHOD_SOCIAL: 'social',
DEFAULT_PROVIDER: 'Google',
REQUEST_TIMEOUT: 30000,
DEFAULT_REGION: 'us-east-1'
};
/**
* 通过 refreshToken 获取 accessToken
* @param {string} refreshToken - Kiro refresh token
* @param {string} region - AWS 区域 (默认: us-east-1)
* @returns {Promise<Object>} 包含 accessToken 等信息的对象
*/
async function refreshKiroToken(refreshToken, region = KIRO_REFRESH_CONSTANTS.DEFAULT_REGION) {
const refreshUrl = KIRO_REFRESH_CONSTANTS.REFRESH_URL.replace('{{region}}', region);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), KIRO_REFRESH_CONSTANTS.REQUEST_TIMEOUT);
try {
const response = await fetch(refreshUrl, {
method: 'POST',
headers: {
'Content-Type': KIRO_REFRESH_CONSTANTS.CONTENT_TYPE_JSON
},
body: JSON.stringify({ refreshToken }),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
if (!data.accessToken) {
throw new Error('Invalid refresh response: Missing accessToken');
}
const expiresIn = data.expiresIn || 3600;
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
return {
accessToken: data.accessToken,
refreshToken: data.refreshToken || refreshToken,
profileArn: data.profileArn || '',
expiresAt: expiresAt,
authMethod: KIRO_REFRESH_CONSTANTS.AUTH_METHOD_SOCIAL,
provider: KIRO_REFRESH_CONSTANTS.DEFAULT_PROVIDER,
region: region
};
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error('Request timeout');
}
throw error;
}
}
/**
* 批量导入 Kiro refreshToken 并生成凭据文件
* @param {string[]} refreshTokens - refreshToken 数组
* @param {string} region - AWS 区域 (默认: us-east-1)
* @returns {Promise<Object>} 批量处理结果
*/
export async function batchImportKiroRefreshTokens(refreshTokens, region = KIRO_REFRESH_CONSTANTS.DEFAULT_REGION) {
const results = {
total: refreshTokens.length,
success: 0,
failed: 0,
details: []
};
for (let i = 0; i < refreshTokens.length; i++) {
const refreshToken = refreshTokens[i].trim();
if (!refreshToken) {
results.details.push({
index: i + 1,
success: false,
error: 'Empty token'
});
results.failed++;
continue;
}
try {
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} 正在刷新第 ${i + 1}/${refreshTokens.length} 个 token...`);
const tokenData = await refreshKiroToken(refreshToken, region);
// 生成文件路径: configs/kiro/{timestamp}_kiro-auth-token/{timestamp}_kiro-auth-token.json
const timestamp = Date.now();
const folderName = `${timestamp}_kiro-auth-token`;
const targetDir = path.join(process.cwd(), 'configs', 'kiro', folderName);
await fs.promises.mkdir(targetDir, { recursive: true });
const credPath = path.join(targetDir, `${folderName}.json`);
await fs.promises.writeFile(credPath, JSON.stringify(tokenData, null, 2));
const relativePath = path.relative(process.cwd(), credPath);
console.log(`${KIRO_OAUTH_CONFIG.logPrefix} Token ${i + 1} 已保存: ${relativePath}`);
results.details.push({
index: i + 1,
success: true,
path: relativePath,
expiresAt: tokenData.expiresAt
});
results.success++;
} catch (error) {
console.error(`${KIRO_OAUTH_CONFIG.logPrefix} Token ${i + 1} 刷新失败:`, error.message);
results.details.push({
index: i + 1,
success: false,
error: error.message
});
results.failed++;
}
}
// 如果有成功的,广播事件并自动关联
if (results.success > 0) {
broadcastEvent('oauth_batch_success', {
provider: 'claude-kiro-oauth',
count: results.success,
timestamp: new Date().toISOString()
});
// 自动关联新生成的凭据到 Pools
await autoLinkProviderConfigs(CONFIG);
}
return results;
}

View file

@ -56,7 +56,7 @@ import { getAllProviderModels, getProviderModels } from './provider-models.js';
import { CONFIG } from './config-manager.js';
import { serviceInstances, getServiceAdapter } from './adapter.js';
import { initApiService } from './service-manager.js';
import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth, handleKiroOAuth, handleIFlowOAuth } from './oauth-handlers.js';
import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth, handleKiroOAuth, handleIFlowOAuth, batchImportKiroRefreshTokens } from './oauth-handlers.js';
import {
generateUUID,
normalizePath,
@ -2126,6 +2126,46 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
return true;
}
// Batch import Kiro refresh tokens
// 批量导入 Kiro refreshToken
if (method === 'POST' && pathParam === '/api/kiro/batch-import-tokens') {
try {
const body = await getRequestBody(req);
const { refreshTokens, region } = body;
if (!refreshTokens || !Array.isArray(refreshTokens) || refreshTokens.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: 'refreshTokens array is required and must not be empty'
}));
return true;
}
console.log(`[Kiro Batch Import] Starting batch import of ${refreshTokens.length} tokens...`);
const result = await batchImportKiroRefreshTokens(refreshTokens, region || 'us-east-1');
console.log(`[Kiro Batch Import] Completed: ${result.success} success, ${result.failed} failed`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
...result
}));
return true;
} catch (error) {
console.error('[Kiro Batch Import] Error:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: error.message
}));
return true;
}
}
return false;
}

View file

@ -132,6 +132,19 @@ const translations = {
'oauth.kiro.step2': '使用您的 {method} 账号登录',
'oauth.kiro.step3': '授权完成后页面会自动关闭',
'oauth.kiro.step4': '刷新本页面查看凭据文件',
'oauth.kiro.batchImport': '批量导入 refreshToken',
'oauth.kiro.batchImportDesc': '批量导入已有的 refreshToken 生成凭据文件',
'oauth.kiro.batchImportInstructions': '请输入 refreshToken每行一个。系统将自动刷新并生成凭据文件。',
'oauth.kiro.refreshTokensLabel': 'RefreshToken 列表',
'oauth.kiro.refreshTokensPlaceholder': '每行输入一个 refreshToken\n例如:\naorAxxxxxxxx\naorAyyyyyyyy\naorAzzzzzzzz',
'oauth.kiro.tokenCount': '待导入数量:',
'oauth.kiro.importing': '正在导入中,请稍候...',
'oauth.kiro.startImport': '开始导入',
'oauth.kiro.noTokens': '请输入至少一个 refreshToken',
'oauth.kiro.importSuccess': '导入成功!共 {count} 个凭据已生成',
'oauth.kiro.importAllFailed': '导入失败!共 {count} 个 token 刷新失败',
'oauth.kiro.importPartial': '部分成功:{success} 个成功,{failed} 个失败',
'oauth.kiro.importError': '导入出错',
'oauth.iflow.step1': '点击下方按钮在浏览器中打开 iFlow 授权页面',
'oauth.iflow.step2': '使用您的 iFlow 账号登录并授权',
'oauth.iflow.step3': '授权完成后,系统会自动获取 API Key',
@ -560,6 +573,19 @@ const translations = {
'oauth.kiro.step2': 'Log in with your {method} account',
'oauth.kiro.step3': 'The page will close automatically after authorization',
'oauth.kiro.step4': 'Refresh this page to view the credentials file',
'oauth.kiro.batchImport': 'Batch Import refreshToken',
'oauth.kiro.batchImportDesc': 'Batch import existing refreshTokens to generate credential files',
'oauth.kiro.batchImportInstructions': 'Enter refreshTokens, one per line. The system will automatically refresh and generate credential files.',
'oauth.kiro.refreshTokensLabel': 'RefreshToken List',
'oauth.kiro.refreshTokensPlaceholder': 'Enter one refreshToken per line\nExample:\naorAxxxxxxxx\naorAyyyyyyyy\naorAzzzzzzzz',
'oauth.kiro.tokenCount': 'Tokens to import:',
'oauth.kiro.importing': 'Importing, please wait...',
'oauth.kiro.startImport': 'Start Import',
'oauth.kiro.noTokens': 'Please enter at least one refreshToken',
'oauth.kiro.importSuccess': 'Import successful! {count} credentials generated',
'oauth.kiro.importAllFailed': 'Import failed! {count} tokens failed to refresh',
'oauth.kiro.importPartial': 'Partial success: {success} succeeded, {failed} failed',
'oauth.kiro.importError': 'Import error',
'oauth.iflow.step1': 'Click the button below to open the iFlow authorization page',
'oauth.iflow.step2': 'Log in with your iFlow account and authorize',
'oauth.iflow.step3': 'After authorization, the system will automatically fetch the API Key',

View file

@ -489,6 +489,13 @@ function showKiroAuthMethodSelector(providerType) {
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.awsBuilderDesc">${t('oauth.kiro.awsBuilderDesc')}</div>
</div>
</button>
<button class="auth-method-btn" data-method="batch-import" style="display: flex; align-items: center; gap: 12px; padding: 16px; border: 2px solid #e0e0e0; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
<i class="fas fa-file-import" style="font-size: 24px; color: #10b981;"></i>
<div style="text-align: left;">
<div style="font-weight: 600; color: #333;" data-i18n="oauth.kiro.batchImport">${t('oauth.kiro.batchImport')}</div>
<div style="font-size: 12px; color: #666;" data-i18n="oauth.kiro.batchImportDesc">${t('oauth.kiro.batchImportDesc')}</div>
</div>
</button>
</div>
</div>
<div class="modal-footer">
@ -522,11 +529,195 @@ function showKiroAuthMethodSelector(providerType) {
btn.addEventListener('click', async () => {
const method = btn.dataset.method;
modal.remove();
await executeGenerateAuthUrl(providerType, { method });
if (method === 'batch-import') {
showKiroBatchImportModal();
} else {
await executeGenerateAuthUrl(providerType, { method });
}
});
});
}
/**
* 显示 Kiro 批量导入 refreshToken 模态框
*/
function showKiroBatchImportModal() {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.style.display = 'flex';
modal.innerHTML = `
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
<h3><i class="fas fa-file-import"></i> <span data-i18n="oauth.kiro.batchImport">${t('oauth.kiro.batchImport')}</span></h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="batch-import-instructions" style="margin-bottom: 16px; padding: 12px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;">
<p style="margin: 0; font-size: 14px; color: #166534;">
<i class="fas fa-info-circle"></i>
<span data-i18n="oauth.kiro.batchImportInstructions">${t('oauth.kiro.batchImportInstructions')}</span>
</p>
</div>
<div class="form-group">
<label for="batchRefreshTokens" style="display: block; margin-bottom: 8px; font-weight: 600; color: #374151;">
<span data-i18n="oauth.kiro.refreshTokensLabel">${t('oauth.kiro.refreshTokensLabel')}</span>
</label>
<textarea
id="batchRefreshTokens"
rows="10"
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; font-family: monospace; font-size: 13px; resize: vertical;"
placeholder="${t('oauth.kiro.refreshTokensPlaceholder')}"
data-i18n-placeholder="oauth.kiro.refreshTokensPlaceholder"
></textarea>
</div>
<div class="batch-import-stats" id="batchImportStats" style="display: none; margin-top: 12px; padding: 12px; background: #f3f4f6; border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span data-i18n="oauth.kiro.tokenCount">${t('oauth.kiro.tokenCount')}</span>
<span id="tokenCountValue" style="font-weight: 600;">0</span>
</div>
</div>
<div class="batch-import-progress" id="batchImportProgress" style="display: none; margin-top: 16px;">
<div style="display: flex; align-items: center; gap: 12px;">
<i class="fas fa-spinner fa-spin" style="color: #10b981;"></i>
<span data-i18n="oauth.kiro.importing">${t('oauth.kiro.importing')}</span>
</div>
<div class="progress-bar" style="margin-top: 8px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden;">
<div id="importProgressBar" style="height: 100%; width: 0%; background: #10b981; transition: width 0.3s;"></div>
</div>
</div>
<div class="batch-import-result" id="batchImportResult" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
</div>
<div class="modal-footer">
<button class="modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
<button class="btn btn-primary batch-import-submit" id="batchImportSubmit">
<i class="fas fa-upload"></i>
<span data-i18n="oauth.kiro.startImport">${t('oauth.kiro.startImport')}</span>
</button>
</div>
</div>
`;
document.body.appendChild(modal);
const textarea = modal.querySelector('#batchRefreshTokens');
const statsDiv = modal.querySelector('#batchImportStats');
const tokenCountValue = modal.querySelector('#tokenCountValue');
const progressDiv = modal.querySelector('#batchImportProgress');
const progressBar = modal.querySelector('#importProgressBar');
const resultDiv = modal.querySelector('#batchImportResult');
const submitBtn = modal.querySelector('#batchImportSubmit');
const closeBtn = modal.querySelector('.modal-close');
const cancelBtn = modal.querySelector('.modal-cancel');
// 实时统计 token 数量
textarea.addEventListener('input', () => {
const tokens = textarea.value.split('\n').filter(line => line.trim());
if (tokens.length > 0) {
statsDiv.style.display = 'block';
tokenCountValue.textContent = tokens.length;
} else {
statsDiv.style.display = 'none';
}
});
// 关闭按钮事件
[closeBtn, cancelBtn].forEach(btn => {
btn.addEventListener('click', () => {
modal.remove();
});
});
// 提交按钮事件
submitBtn.addEventListener('click', async () => {
const tokens = textarea.value.split('\n').filter(line => line.trim());
if (tokens.length === 0) {
showToast(t('common.warning'), t('oauth.kiro.noTokens'), 'warning');
return;
}
// 禁用输入和按钮
textarea.disabled = true;
submitBtn.disabled = true;
cancelBtn.disabled = true;
progressDiv.style.display = 'block';
resultDiv.style.display = 'none';
try {
const response = await window.apiClient.post('/kiro/batch-import-tokens', {
refreshTokens: tokens
});
progressBar.style.width = '100%';
if (response.success) {
// 显示结果
const isAllSuccess = response.failed === 0;
const isAllFailed = response.success === 0;
let resultClass, resultIcon, resultMessage;
if (isAllSuccess) {
resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;';
resultIcon = 'fa-check-circle';
resultMessage = t('oauth.kiro.importSuccess', { count: response.success });
} else if (isAllFailed) {
resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
resultIcon = 'fa-times-circle';
resultMessage = t('oauth.kiro.importAllFailed', { count: response.failed });
} else {
resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;';
resultIcon = 'fa-exclamation-triangle';
resultMessage = t('oauth.kiro.importPartial', { success: response.success, failed: response.failed });
}
resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`;
resultDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<i class="fas ${resultIcon}"></i>
<strong>${resultMessage}</strong>
</div>
${response.details && response.details.length > 0 ? `
<div style="max-height: 150px; overflow-y: auto; font-size: 12px; margin-top: 8px;">
${response.details.map(d => `
<div style="padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);">
Token ${d.index}: ${d.success
? `<span style="color: #166534;">✓ ${d.path}</span>`
: `<span style="color: #991b1b;">✗ ${d.error}</span>`}
</div>
`).join('')}
</div>
` : ''}
`;
progressDiv.style.display = 'none';
// 如果有成功的,刷新提供商列表
if (response.success > 0) {
loadProviders();
loadConfigList();
}
}
} catch (error) {
console.error('批量导入失败:', error);
resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;';
resultDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<i class="fas fa-times-circle"></i>
<strong>${t('oauth.kiro.importError')}: ${error.message}</strong>
</div>
`;
progressDiv.style.display = 'none';
} finally {
// 重新启用按钮
textarea.disabled = false;
submitBtn.disabled = false;
cancelBtn.disabled = false;
}
});
}
/**
* 执行生成授权链接
* @param {string} providerType - 提供商类型