diff --git a/src/routes/admin.js b/src/routes/admin.js index 7a77c301..7d964d98 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1010,6 +1010,128 @@ router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res } }) +// 生成Claude setup-token授权URL +router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body // 接收代理配置 + const setupTokenParams = await oauthHelper.generateSetupTokenParams() + + // 将codeVerifier和state临时存储到Redis,用于后续验证 + const sessionId = require('crypto').randomUUID() + await redis.setOAuthSession(sessionId, { + type: 'setup-token', // 标记为setup-token类型 + codeVerifier: setupTokenParams.codeVerifier, + state: setupTokenParams.state, + codeChallenge: setupTokenParams.codeChallenge, + proxy: proxy || null, // 存储代理配置 + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期 + }) + + logger.success('🔗 Generated Setup Token authorization URL with proxy support') + return res.json({ + success: true, + data: { + authUrl: setupTokenParams.authUrl, + sessionId, + instructions: [ + '1. 复制上面的链接到浏览器中打开', + '2. 登录您的 Claude 账户并授权 Claude Code', + '3. 完成授权后,从返回页面复制 Authorization Code', + '4. 在添加账户表单中粘贴 Authorization Code' + ] + } + }) + } catch (error) { + logger.error('❌ Failed to generate Setup Token URL:', error) + return res + .status(500) + .json({ error: 'Failed to generate Setup Token URL', message: error.message }) + } +}) + +// 验证setup-token授权码并获取token +router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, async (req, res) => { + try { + const { sessionId, authorizationCode, callbackUrl } = req.body + + if (!sessionId || (!authorizationCode && !callbackUrl)) { + return res + .status(400) + .json({ error: 'Session ID and authorization code (or callback URL) are required' }) + } + + // 从Redis获取OAuth会话信息 + const oauthSession = await redis.getOAuthSession(sessionId) + if (!oauthSession) { + return res.status(400).json({ error: 'Invalid or expired OAuth session' }) + } + + // 检查是否是setup-token类型 + if (oauthSession.type !== 'setup-token') { + return res.status(400).json({ error: 'Invalid session type for setup token exchange' }) + } + + // 检查会话是否过期 + if (new Date() > new Date(oauthSession.expiresAt)) { + await redis.deleteOAuthSession(sessionId) + return res + .status(400) + .json({ error: 'OAuth session has expired, please generate a new authorization URL' }) + } + + // 统一处理授权码输入(可能是直接的code或完整的回调URL) + let finalAuthCode + const inputValue = callbackUrl || authorizationCode + + try { + finalAuthCode = oauthHelper.parseCallbackUrl(inputValue) + } catch (parseError) { + return res + .status(400) + .json({ error: 'Failed to parse authorization input', message: parseError.message }) + } + + // 交换Setup Token + const tokenData = await oauthHelper.exchangeSetupTokenCode( + finalAuthCode, + oauthSession.codeVerifier, + oauthSession.state, + oauthSession.proxy // 传递代理配置 + ) + + // 清理OAuth会话 + await redis.deleteOAuthSession(sessionId) + + logger.success('🎉 Successfully exchanged setup token authorization code for tokens') + return res.json({ + success: true, + data: { + claudeAiOauth: tokenData + } + }) + } catch (error) { + logger.error('❌ Failed to exchange setup token authorization code:', { + error: error.message, + sessionId: req.body.sessionId, + // 不记录完整的授权码,只记录长度和前几个字符 + codeLength: req.body.callbackUrl + ? req.body.callbackUrl.length + : req.body.authorizationCode + ? req.body.authorizationCode.length + : 0, + codePrefix: req.body.callbackUrl + ? `${req.body.callbackUrl.substring(0, 10)}...` + : req.body.authorizationCode + ? `${req.body.authorizationCode.substring(0, 10)}...` + : 'N/A' + }) + return res + .status(500) + .json({ error: 'Failed to exchange setup token authorization code', message: error.message }) + } +}) + // 获取所有Claude账户 router.get('/claude-accounts', authenticateAdmin, async (req, res) => { try { diff --git a/src/utils/oauthHelper.js b/src/utils/oauthHelper.js index 036c15fd..008eb4cc 100644 --- a/src/utils/oauthHelper.js +++ b/src/utils/oauthHelper.js @@ -15,15 +15,16 @@ const OAUTH_CONFIG = { TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token', CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback', - SCOPES: 'org:create_api_key user:profile user:inference' + SCOPES: 'org:create_api_key user:profile user:inference', + SCOPES_SETUP: 'user:inference' } /** * 生成随机的 state 参数 - * @returns {string} 随机生成的 state (64字符hex) + * @returns {string} 随机生成的 state (base64url编码) */ function generateState() { - return crypto.randomBytes(32).toString('hex') + return crypto.randomBytes(32).toString('base64url') } /** @@ -83,6 +84,46 @@ function generateOAuthParams() { } } +/** + * 生成 Setup Token 授权 URL + * @param {string} codeChallenge - PKCE code challenge + * @param {string} state - state 参数 + * @returns {string} 完整的授权 URL + */ +function generateSetupTokenAuthUrl(codeChallenge, state) { + const params = new URLSearchParams({ + code: 'true', + client_id: OAUTH_CONFIG.CLIENT_ID, + response_type: 'code', + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + scope: OAUTH_CONFIG.SCOPES_SETUP, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state + }) + + return `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` +} + +/** + * 生成Setup Token授权URL和相关参数 + * @returns {{authUrl: string, codeVerifier: string, state: string, codeChallenge: string}} + */ +function generateSetupTokenParams() { + const state = generateState() + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + + const authUrl = generateSetupTokenAuthUrl(codeChallenge, state) + + return { + authUrl, + codeVerifier, + state, + codeChallenge + } +} + /** * 创建代理agent * @param {object|null} proxyConfig - 代理配置对象 @@ -280,6 +321,111 @@ function parseCallbackUrl(input) { return cleanedCode } +/** + * 使用授权码交换Setup Token + * @param {string} authorizationCode - 授权码 + * @param {string} codeVerifier - PKCE code verifier + * @param {string} state - state 参数 + * @param {object|null} proxyConfig - 代理配置(可选) + * @returns {Promise} Claude格式的token响应 + */ +async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig = null) { + // 清理授权码,移除URL片段 + const cleanedCode = authorizationCode.split('#')[0]?.split('&')[0] ?? authorizationCode + + const params = { + grant_type: 'authorization_code', + client_id: OAUTH_CONFIG.CLIENT_ID, + code: cleanedCode, + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + code_verifier: codeVerifier, + state, + expires_in: 31536000 + } + + // 创建代理agent + const agent = createProxyAgent(proxyConfig) + + try { + logger.debug('🔄 Attempting Setup Token exchange', { + url: OAUTH_CONFIG.TOKEN_URL, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...`, + hasProxy: !!proxyConfig, + proxyType: proxyConfig?.type || 'none' + }) + + const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://claude.ai/', + Origin: 'https://claude.ai' + }, + httpsAgent: agent, + timeout: 30000 + }) + + const { data } = response + + // 返回Claude格式的token数据 + return { + accessToken: data.access_token, + refreshToken: '', + expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, + scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], + isMax: true + } + } catch (error) { + // 使用与标准OAuth相同的错误处理逻辑 + if (error.response) { + const { status } = error.response + const errorData = error.response.data + + logger.error('❌ Setup Token exchange failed with server error', { + status, + statusText: error.response.statusText, + data: errorData, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...` + }) + + let errorMessage = `HTTP ${status}` + if (errorData) { + if (typeof errorData === 'string') { + errorMessage += `: ${errorData}` + } else if (errorData.error) { + errorMessage += `: ${errorData.error}` + if (errorData.error_description) { + errorMessage += ` - ${errorData.error_description}` + } + } else { + errorMessage += `: ${JSON.stringify(errorData)}` + } + } + + throw new Error(`Setup Token exchange failed: ${errorMessage}`) + } else if (error.request) { + logger.error('❌ Setup Token exchange failed with network error', { + message: error.message, + code: error.code, + hasProxy: !!proxyConfig + }) + throw new Error( + 'Setup Token exchange failed: No response from server (network error or timeout)' + ) + } else { + logger.error('❌ Setup Token exchange failed with unknown error', { + message: error.message, + stack: error.stack + }) + throw new Error(`Setup Token exchange failed: ${error.message}`) + } + } +} + /** * 格式化为Claude标准格式 * @param {object} tokenData - token数据 @@ -300,12 +446,15 @@ function formatClaudeCredentials(tokenData) { module.exports = { OAUTH_CONFIG, generateOAuthParams, + generateSetupTokenParams, exchangeCodeForTokens, + exchangeSetupTokenCode, parseCallbackUrl, formatClaudeCredentials, generateState, generateCodeVerifier, generateCodeChallenge, generateAuthUrl, + generateSetupTokenAuthUrl, createProxyAgent } diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 4dbd8bc7..c2f12dd5 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -25,7 +25,7 @@
@@ -88,10 +88,14 @@ v-if="!isEdit && form.platform !== 'claude-console' && form.platform !== 'bedrock'" > -
+
+
+
+
+ + +
+
+
+ 2 +
+
+

在浏览器中打开链接并完成授权

+

+ 请在新标签页中打开授权链接,登录您的 Claude 账户并授权 Claude Code。 +

+
+

+ + 注意:如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。 +

+
+
+
+
+ + +
+
+
+ 3 +
+
+

输入 Authorization Code

+

+ 授权完成后,从返回页面复制 Authorization Code,并粘贴到下方输入框: +

+
+
+ +