diff --git a/src/routes/admin/claudeAccounts.js b/src/routes/admin/claudeAccounts.js index e0964ea3..13dd1a63 100644 --- a/src/routes/admin/claudeAccounts.js +++ b/src/routes/admin/claudeAccounts.js @@ -255,6 +255,108 @@ router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, asy } }) +// ============================================================================= +// Cookie自动授权端点 (基于sessionKey自动完成OAuth流程) +// ============================================================================= + +// 普通OAuth的Cookie自动授权 +router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, res) => { + try { + const { sessionKey, proxy } = req.body + + // 验证sessionKey参数 + if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) { + return res.status(400).json({ + success: false, + error: 'sessionKey不能为空', + message: '请提供有效的sessionKey值' + }) + } + + const trimmedSessionKey = sessionKey.trim() + + logger.info('🍪 Starting Cookie-based OAuth authorization', { + sessionKeyLength: trimmedSessionKey.length, + sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...', + hasProxy: !!proxy + }) + + // 执行Cookie自动授权流程 + const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, false) + + logger.success('🎉 Cookie-based OAuth authorization completed successfully') + + return res.json({ + success: true, + data: { + claudeAiOauth: result.claudeAiOauth, + organizationUuid: result.organizationUuid, + capabilities: result.capabilities + } + }) + } catch (error) { + logger.error('❌ Cookie-based OAuth authorization failed:', { + error: error.message, + sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0 + }) + + return res.status(500).json({ + success: false, + error: 'Cookie授权失败', + message: error.message + }) + } +}) + +// Setup Token的Cookie自动授权 +router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async (req, res) => { + try { + const { sessionKey, proxy } = req.body + + // 验证sessionKey参数 + if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) { + return res.status(400).json({ + success: false, + error: 'sessionKey不能为空', + message: '请提供有效的sessionKey值' + }) + } + + const trimmedSessionKey = sessionKey.trim() + + logger.info('🍪 Starting Cookie-based Setup Token authorization', { + sessionKeyLength: trimmedSessionKey.length, + sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...', + hasProxy: !!proxy + }) + + // 执行Cookie自动授权流程(Setup Token模式) + const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, true) + + logger.success('🎉 Cookie-based Setup Token authorization completed successfully') + + return res.json({ + success: true, + data: { + claudeAiOauth: result.claudeAiOauth, + organizationUuid: result.organizationUuid, + capabilities: result.capabilities + } + }) + } catch (error) { + logger.error('❌ Cookie-based Setup Token authorization failed:', { + error: error.message, + sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0 + }) + + return res.status(500).json({ + success: false, + error: 'Cookie授权失败', + 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 28cf306d..556e4243 100644 --- a/src/utils/oauthHelper.js +++ b/src/utils/oauthHelper.js @@ -18,6 +18,13 @@ const OAUTH_CONFIG = { SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限 } +// Cookie自动授权配置常量 +const COOKIE_OAUTH_CONFIG = { + CLAUDE_AI_URL: 'https://claude.ai', + ORGANIZATIONS_URL: 'https://claude.ai/api/organizations', + AUTHORIZE_URL_TEMPLATE: 'https://claude.ai/v1/oauth/{organization_uuid}/authorize' +} + /** * 生成随机的 state 参数 * @returns {string} 随机生成的 state (base64url编码) @@ -570,8 +577,299 @@ function extractExtInfo(data) { return Object.keys(ext).length > 0 ? ext : null } +// ============================================================================= +// Cookie自动授权相关方法 (基于Clove项目实现) +// ============================================================================= + +/** + * 构建带Cookie的请求头 + * @param {string} sessionKey - sessionKey值 + * @returns {object} 请求头对象 + */ +function buildCookieHeaders(sessionKey) { + return { + Accept: 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': 'no-cache', + Cookie: `sessionKey=${sessionKey}`, + Origin: COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL, + Referer: `${COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL}/new`, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } +} + +/** + * 使用Cookie获取组织UUID和能力列表 + * @param {string} sessionKey - sessionKey值 + * @param {object|null} proxyConfig - 代理配置(可选) + * @returns {Promise<{organizationUuid: string, capabilities: string[]}>} + */ +async function getOrganizationInfo(sessionKey, proxyConfig = null) { + const headers = buildCookieHeaders(sessionKey) + const agent = createProxyAgent(proxyConfig) + + try { + if (agent) { + logger.info(`🌐 Using proxy for organization info: ${ProxyHelper.maskProxyInfo(proxyConfig)}`) + } + + logger.debug('🔄 Fetching organization info with Cookie', { + url: COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, + hasProxy: !!proxyConfig + }) + + const axiosConfig = { + headers, + timeout: 30000, + maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302) + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.get(COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, axiosConfig) + + if (!response.data || !Array.isArray(response.data)) { + throw new Error('获取组织信息失败:响应格式无效') + } + + // 找到具有chat能力且能力最多的组织 + let bestOrg = null + let maxCapabilities = [] + + for (const org of response.data) { + const capabilities = org.capabilities || [] + + // 必须有chat能力 + if (!capabilities.includes('chat')) { + continue + } + + // 选择能力最多的组织 + if (capabilities.length > maxCapabilities.length) { + bestOrg = org + maxCapabilities = capabilities + } + } + + if (!bestOrg || !bestOrg.uuid) { + throw new Error('未找到具有chat能力的组织') + } + + logger.success('✅ Found organization', { + uuid: bestOrg.uuid, + capabilities: maxCapabilities + }) + + return { + organizationUuid: bestOrg.uuid, + capabilities: maxCapabilities + } + } catch (error) { + if (error.response) { + const { status } = error.response + + if (status === 403 || status === 401) { + throw new Error('Cookie授权失败:无效的sessionKey或已过期') + } + + if (status === 302) { + throw new Error('请求被Cloudflare拦截,请稍后重试') + } + + throw new Error(`获取组织信息失败:HTTP ${status}`) + } else if (error.request) { + throw new Error('获取组织信息失败:网络错误或超时') + } + + throw error + } +} + +/** + * 使用Cookie自动获取授权code + * @param {string} sessionKey - sessionKey值 + * @param {string} organizationUuid - 组织UUID + * @param {string} scope - 授权scope + * @param {object|null} proxyConfig - 代理配置(可选) + * @returns {Promise<{authorizationCode: string, codeVerifier: string, state: string}>} + */ +async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyConfig = null) { + // 生成PKCE参数 + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + // 构建授权URL + const authorizeUrl = COOKIE_OAUTH_CONFIG.AUTHORIZE_URL_TEMPLATE.replace( + '{organization_uuid}', + organizationUuid + ) + + // 构建请求payload + const payload = { + response_type: 'code', + client_id: OAUTH_CONFIG.CLIENT_ID, + organization_uuid: organizationUuid, + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + scope, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + } + + const headers = { + ...buildCookieHeaders(sessionKey), + 'Content-Type': 'application/json' + } + + const agent = createProxyAgent(proxyConfig) + + try { + if (agent) { + logger.info( + `🌐 Using proxy for Cookie authorization: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } + + logger.debug('🔄 Requesting authorization with Cookie', { + url: authorizeUrl, + scope, + hasProxy: !!proxyConfig + }) + + const axiosConfig = { + headers, + timeout: 30000, + maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302) + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.post(authorizeUrl, payload, axiosConfig) + + // 从响应中获取redirect_uri + const redirectUri = response.data?.redirect_uri + + if (!redirectUri) { + throw new Error('授权响应中未找到redirect_uri') + } + + logger.debug('📎 Got redirect URI', { redirectUri: `${redirectUri.substring(0, 80)}...` }) + + // 解析redirect_uri获取authorization code + const url = new URL(redirectUri) + const authorizationCode = url.searchParams.get('code') + const responseState = url.searchParams.get('state') + + if (!authorizationCode) { + throw new Error('redirect_uri中未找到授权码') + } + + // 构建完整的授权码(包含state,如果有的话) + const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode + + logger.success('✅ Got authorization code via Cookie', { + codeLength: authorizationCode.length, + codePrefix: `${authorizationCode.substring(0, 10)}...` + }) + + return { + authorizationCode: fullCode, + codeVerifier, + state + } + } catch (error) { + if (error.response) { + const { status } = error.response + + if (status === 403 || status === 401) { + throw new Error('Cookie授权失败:无效的sessionKey或已过期') + } + + if (status === 302) { + throw new Error('请求被Cloudflare拦截,请稍后重试') + } + + const errorData = error.response.data + let errorMessage = `HTTP ${status}` + + if (errorData) { + if (typeof errorData === 'string') { + errorMessage += `: ${errorData}` + } else if (errorData.error) { + errorMessage += `: ${errorData.error}` + } + } + + throw new Error(`授权请求失败:${errorMessage}`) + } else if (error.request) { + throw new Error('授权请求失败:网络错误或超时') + } + + throw error + } +} + +/** + * 完整的Cookie自动授权流程 + * @param {string} sessionKey - sessionKey值 + * @param {object|null} proxyConfig - 代理配置(可选) + * @param {boolean} isSetupToken - 是否为Setup Token模式 + * @returns {Promise<{claudeAiOauth: object, organizationUuid: string, capabilities: string[]}>} + */ +async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = false) { + logger.info('🍪 Starting Cookie-based OAuth flow', { + isSetupToken, + hasProxy: !!proxyConfig + }) + + // 步骤1:获取组织信息 + logger.debug('Step 1/3: Fetching organization info...') + const { organizationUuid, capabilities } = await getOrganizationInfo(sessionKey, proxyConfig) + + // 步骤2:确定scope并获取授权code + const scope = isSetupToken ? OAUTH_CONFIG.SCOPES_SETUP : 'user:profile user:inference' + + logger.debug('Step 2/3: Getting authorization code...', { scope }) + const { authorizationCode, codeVerifier, state } = await authorizeWithCookie( + sessionKey, + organizationUuid, + scope, + proxyConfig + ) + + // 步骤3:交换token + logger.debug('Step 3/3: Exchanging token...') + const tokenData = isSetupToken + ? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig) + : await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig) + + logger.success('✅ Cookie-based OAuth flow completed', { + isSetupToken, + organizationUuid, + hasAccessToken: !!tokenData.accessToken, + hasRefreshToken: !!tokenData.refreshToken + }) + + return { + claudeAiOauth: tokenData, + organizationUuid, + capabilities + } +} + module.exports = { OAUTH_CONFIG, + COOKIE_OAUTH_CONFIG, generateOAuthParams, generateSetupTokenParams, exchangeCodeForTokens, @@ -584,5 +882,10 @@ module.exports = { generateCodeChallenge, generateAuthUrl, generateSetupTokenAuthUrl, - createProxyAgent + createProxyAgent, + // Cookie自动授权相关方法 + buildCookieHeaders, + getOrganizationInfo, + authorizeWithCookie, + oauthWithCookie } diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 121beddc..1aa498c9 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -2029,6 +2029,7 @@ Claude Setup Token 授权 -

- 请按照以下步骤通过 Setup Token 完成 Claude 账户的授权: -

-
+ +
+

+ 选择授权方式: +

+
+ + +
+
+ + +
+

+ 请按照以下步骤通过 Setup Token 完成 Claude 账户的授权: +

+ + +
+

+ 使用 sessionKey 自动完成授权,无需手动打开链接。 +

+ +
+
+
+ +