mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
Merge pull request #215 from qyinter/main
feat: add Setup Token OAuth flow for simplified Claude account setup
This commit is contained in:
@@ -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账户
|
// 获取所有Claude账户
|
||||||
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,15 +15,16 @@ const OAUTH_CONFIG = {
|
|||||||
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
||||||
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
||||||
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
|
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 参数
|
* 生成随机的 state 参数
|
||||||
* @returns {string} 随机生成的 state (64字符hex)
|
* @returns {string} 随机生成的 state (base64url编码)
|
||||||
*/
|
*/
|
||||||
function generateState() {
|
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
|
* 创建代理agent
|
||||||
* @param {object|null} proxyConfig - 代理配置对象
|
* @param {object|null} proxyConfig - 代理配置对象
|
||||||
@@ -280,6 +321,111 @@ function parseCallbackUrl(input) {
|
|||||||
return cleanedCode
|
return cleanedCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用授权码交换Setup Token
|
||||||
|
* @param {string} authorizationCode - 授权码
|
||||||
|
* @param {string} codeVerifier - PKCE code verifier
|
||||||
|
* @param {string} state - state 参数
|
||||||
|
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||||
|
* @returns {Promise<object>} 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标准格式
|
* 格式化为Claude标准格式
|
||||||
* @param {object} tokenData - token数据
|
* @param {object} tokenData - token数据
|
||||||
@@ -300,12 +446,15 @@ function formatClaudeCredentials(tokenData) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
OAUTH_CONFIG,
|
OAUTH_CONFIG,
|
||||||
generateOAuthParams,
|
generateOAuthParams,
|
||||||
|
generateSetupTokenParams,
|
||||||
exchangeCodeForTokens,
|
exchangeCodeForTokens,
|
||||||
|
exchangeSetupTokenCode,
|
||||||
parseCallbackUrl,
|
parseCallbackUrl,
|
||||||
formatClaudeCredentials,
|
formatClaudeCredentials,
|
||||||
generateState,
|
generateState,
|
||||||
generateCodeVerifier,
|
generateCodeVerifier,
|
||||||
generateCodeChallenge,
|
generateCodeChallenge,
|
||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
|
generateSetupTokenAuthUrl,
|
||||||
createProxyAgent
|
createProxyAgent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
<!-- 步骤指示器 -->
|
<!-- 步骤指示器 -->
|
||||||
<div
|
<div
|
||||||
v-if="!isEdit && form.addType === 'oauth'"
|
v-if="!isEdit && (form.addType === 'oauth' || form.addType === 'setup-token')"
|
||||||
class="mb-4 flex items-center justify-center sm:mb-8"
|
class="mb-4 flex items-center justify-center sm:mb-8"
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-2 sm:space-x-4">
|
<div class="flex items-center space-x-2 sm:space-x-4">
|
||||||
@@ -88,10 +88,14 @@
|
|||||||
v-if="!isEdit && form.platform !== 'claude-console' && form.platform !== 'bedrock'"
|
v-if="!isEdit && form.platform !== 'claude-console' && form.platform !== 'bedrock'"
|
||||||
>
|
>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700">添加方式</label>
|
<label class="mb-3 block text-sm font-semibold text-gray-700">添加方式</label>
|
||||||
<div class="flex gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input v-model="form.addType" class="mr-2" type="radio" value="setup-token" />
|
||||||
|
<span class="text-sm text-gray-700">Setup Token (推荐)</span>
|
||||||
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input v-model="form.addType" class="mr-2" type="radio" value="oauth" />
|
<input v-model="form.addType" class="mr-2" type="radio" value="oauth" />
|
||||||
<span class="text-sm text-gray-700">OAuth 授权 (推荐)</span>
|
<span class="text-sm text-gray-700">OAuth 授权</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input v-model="form.addType" class="mr-2" type="radio" value="manual" />
|
<input v-model="form.addType" class="mr-2" type="radio" value="manual" />
|
||||||
@@ -574,7 +578,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="
|
v-if="
|
||||||
form.addType === 'oauth' &&
|
(form.addType === 'oauth' || form.addType === 'setup-token') &&
|
||||||
form.platform !== 'claude-console' &&
|
form.platform !== 'claude-console' &&
|
||||||
form.platform !== 'bedrock'
|
form.platform !== 'bedrock'
|
||||||
"
|
"
|
||||||
@@ -608,6 +612,158 @@
|
|||||||
@success="handleOAuthSuccess"
|
@success="handleOAuthSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 步骤2: Setup Token授权 -->
|
||||||
|
<div v-if="oauthStep === 2 && form.addType === 'setup-token'" class="space-y-6">
|
||||||
|
<!-- Claude Setup Token流程 -->
|
||||||
|
<div v-if="form.platform === 'claude'">
|
||||||
|
<div class="rounded-lg border border-blue-200 bg-blue-50 p-6">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"
|
||||||
|
>
|
||||||
|
<i class="fas fa-key text-white" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="mb-3 font-semibold text-blue-900">Claude Setup Token 授权</h4>
|
||||||
|
<p class="mb-4 text-sm text-blue-800">
|
||||||
|
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 步骤1: 生成授权链接 -->
|
||||||
|
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="mb-2 font-medium text-blue-900">点击下方按钮生成授权链接</p>
|
||||||
|
<button
|
||||||
|
v-if="!setupTokenAuthUrl"
|
||||||
|
class="btn btn-primary px-4 py-2 text-sm"
|
||||||
|
:disabled="setupTokenLoading"
|
||||||
|
@click="generateSetupTokenAuthUrl"
|
||||||
|
>
|
||||||
|
<i v-if="!setupTokenLoading" class="fas fa-link mr-2" />
|
||||||
|
<div v-else class="loading-spinner mr-2" />
|
||||||
|
{{ setupTokenLoading ? '生成中...' : '生成 Setup Token 授权链接' }}
|
||||||
|
</button>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
class="form-input flex-1 bg-gray-50 font-mono text-xs"
|
||||||
|
readonly
|
||||||
|
type="text"
|
||||||
|
:value="setupTokenAuthUrl"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200"
|
||||||
|
title="复制链接"
|
||||||
|
@click="copySetupTokenAuthUrl"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
setupTokenCopied ? 'fas fa-check text-green-500' : 'fas fa-copy'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-700"
|
||||||
|
@click="regenerateSetupTokenAuthUrl"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤2: 访问链接并授权 -->
|
||||||
|
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="mb-2 font-medium text-blue-900">在浏览器中打开链接并完成授权</p>
|
||||||
|
<p class="mb-2 text-sm text-blue-700">
|
||||||
|
请在新标签页中打开授权链接,登录您的 Claude 账户并授权 Claude Code。
|
||||||
|
</p>
|
||||||
|
<div class="rounded border border-yellow-300 bg-yellow-50 p-3">
|
||||||
|
<p class="text-xs text-yellow-800">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1" />
|
||||||
|
<strong>注意:</strong
|
||||||
|
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤3: 输入授权码 -->
|
||||||
|
<div class="rounded-lg border border-blue-300 bg-white/80 p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="mb-2 font-medium text-blue-900">输入 Authorization Code</p>
|
||||||
|
<p class="mb-3 text-sm text-blue-700">
|
||||||
|
授权完成后,从返回页面复制 Authorization Code,并粘贴到下方输入框:
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-semibold text-gray-700">
|
||||||
|
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="setupTokenAuthCode"
|
||||||
|
class="form-input w-full resize-none font-mono text-sm"
|
||||||
|
placeholder="粘贴从Claude Code授权页面获取的Authorization Code..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
请粘贴从Claude Code授权页面复制的Authorization Code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
|
||||||
|
type="button"
|
||||||
|
@click="oauthStep = 1"
|
||||||
|
>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||||
|
:disabled="!canExchangeSetupToken || setupTokenExchanging"
|
||||||
|
type="button"
|
||||||
|
@click="exchangeSetupTokenCode"
|
||||||
|
>
|
||||||
|
<div v-if="setupTokenExchanging" class="loading-spinner mr-2" />
|
||||||
|
{{ setupTokenExchanging ? '验证中...' : '完成授权' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 编辑模式 -->
|
<!-- 编辑模式 -->
|
||||||
<div v-if="isEdit" class="space-y-6">
|
<div v-if="isEdit" class="space-y-6">
|
||||||
<!-- 基本信息 -->
|
<!-- 基本信息 -->
|
||||||
@@ -1014,6 +1170,14 @@ const show = ref(true)
|
|||||||
const oauthStep = ref(1)
|
const oauthStep = ref(1)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// Setup Token 相关状态
|
||||||
|
const setupTokenLoading = ref(false)
|
||||||
|
const setupTokenExchanging = ref(false)
|
||||||
|
const setupTokenAuthUrl = ref('')
|
||||||
|
const setupTokenAuthCode = ref('')
|
||||||
|
const setupTokenCopied = ref(false)
|
||||||
|
const setupTokenSessionId = ref('')
|
||||||
|
|
||||||
// 初始化代理配置
|
// 初始化代理配置
|
||||||
const initProxyConfig = () => {
|
const initProxyConfig = () => {
|
||||||
if (props.account?.proxy && props.account.proxy.host && props.account.proxy.port) {
|
if (props.account?.proxy && props.account.proxy.host && props.account.proxy.port) {
|
||||||
@@ -1039,7 +1203,7 @@ const initProxyConfig = () => {
|
|||||||
// 表单数据
|
// 表单数据
|
||||||
const form = ref({
|
const form = ref({
|
||||||
platform: props.account?.platform || 'claude',
|
platform: props.account?.platform || 'claude',
|
||||||
addType: 'oauth',
|
addType: 'setup-token',
|
||||||
name: props.account?.name || '',
|
name: props.account?.name || '',
|
||||||
description: props.account?.description || '',
|
description: props.account?.description || '',
|
||||||
accountType: props.account?.accountType || 'shared',
|
accountType: props.account?.accountType || 'shared',
|
||||||
@@ -1092,6 +1256,11 @@ const canProceed = computed(() => {
|
|||||||
return form.value.name?.trim() && form.value.platform
|
return form.value.name?.trim() && form.value.platform
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 计算是否可以交换Setup Token code
|
||||||
|
const canExchangeSetupToken = computed(() => {
|
||||||
|
return setupTokenAuthUrl.value && setupTokenAuthCode.value.trim()
|
||||||
|
})
|
||||||
|
|
||||||
// // 计算是否可以创建
|
// // 计算是否可以创建
|
||||||
// const canCreate = computed(() => {
|
// const canCreate = computed(() => {
|
||||||
// if (form.value.addType === 'manual') {
|
// if (form.value.addType === 'manual') {
|
||||||
@@ -1140,6 +1309,98 @@ const nextStep = async () => {
|
|||||||
oauthStep.value = 2
|
oauthStep.value = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup Token 相关方法
|
||||||
|
// 生成Setup Token授权URL
|
||||||
|
const generateSetupTokenAuthUrl = async () => {
|
||||||
|
setupTokenLoading.value = true
|
||||||
|
try {
|
||||||
|
const proxyConfig = form.value.proxy?.enabled
|
||||||
|
? {
|
||||||
|
proxy: {
|
||||||
|
type: form.value.proxy.type,
|
||||||
|
host: form.value.proxy.host,
|
||||||
|
port: parseInt(form.value.proxy.port),
|
||||||
|
username: form.value.proxy.username || null,
|
||||||
|
password: form.value.proxy.password || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const result = await accountsStore.generateClaudeSetupTokenUrl(proxyConfig)
|
||||||
|
setupTokenAuthUrl.value = result.authUrl
|
||||||
|
setupTokenSessionId.value = result.sessionId
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || '生成Setup Token授权链接失败', 'error')
|
||||||
|
} finally {
|
||||||
|
setupTokenLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成Setup Token授权URL
|
||||||
|
const regenerateSetupTokenAuthUrl = () => {
|
||||||
|
setupTokenAuthUrl.value = ''
|
||||||
|
setupTokenAuthCode.value = ''
|
||||||
|
generateSetupTokenAuthUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制Setup Token授权URL
|
||||||
|
const copySetupTokenAuthUrl = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(setupTokenAuthUrl.value)
|
||||||
|
setupTokenCopied.value = true
|
||||||
|
showToast('链接已复制', 'success')
|
||||||
|
setTimeout(() => {
|
||||||
|
setupTokenCopied.value = false
|
||||||
|
}, 2000)
|
||||||
|
} catch (error) {
|
||||||
|
// 降级方案
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.value = setupTokenAuthUrl.value
|
||||||
|
document.body.appendChild(input)
|
||||||
|
input.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(input)
|
||||||
|
setupTokenCopied.value = true
|
||||||
|
showToast('链接已复制', 'success')
|
||||||
|
setTimeout(() => {
|
||||||
|
setupTokenCopied.value = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交换Setup Token授权码
|
||||||
|
const exchangeSetupTokenCode = async () => {
|
||||||
|
if (!canExchangeSetupToken.value) return
|
||||||
|
|
||||||
|
setupTokenExchanging.value = true
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
sessionId: setupTokenSessionId.value,
|
||||||
|
callbackUrl: setupTokenAuthCode.value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加代理配置(如果启用)
|
||||||
|
if (form.value.proxy?.enabled) {
|
||||||
|
data.proxy = {
|
||||||
|
type: form.value.proxy.type,
|
||||||
|
host: form.value.proxy.host,
|
||||||
|
port: parseInt(form.value.proxy.port),
|
||||||
|
username: form.value.proxy.username || null,
|
||||||
|
password: form.value.proxy.password || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data)
|
||||||
|
|
||||||
|
// 调用相同的成功处理函数
|
||||||
|
await handleOAuthSuccess(tokenInfo)
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || 'Setup Token授权失败,请检查授权码是否正确', 'error')
|
||||||
|
} finally {
|
||||||
|
setupTokenExchanging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理OAuth成功
|
// 处理OAuth成功
|
||||||
const handleOAuthSuccess = async (tokenInfo) => {
|
const handleOAuthSuccess = async (tokenInfo) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -1586,6 +1847,48 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听Setup Token授权码输入,自动提取URL中的code参数
|
||||||
|
watch(setupTokenAuthCode, (newValue) => {
|
||||||
|
if (!newValue || typeof newValue !== 'string') return
|
||||||
|
|
||||||
|
const trimmedValue = newValue.trim()
|
||||||
|
|
||||||
|
// 如果内容为空,不处理
|
||||||
|
if (!trimmedValue) return
|
||||||
|
|
||||||
|
// 检查是否是 URL 格式(包含 http:// 或 https://)
|
||||||
|
const isUrl = trimmedValue.startsWith('http://') || trimmedValue.startsWith('https://')
|
||||||
|
|
||||||
|
// 如果是 URL 格式
|
||||||
|
if (isUrl) {
|
||||||
|
// 检查是否是正确的 localhost:45462 开头的 URL
|
||||||
|
if (trimmedValue.startsWith('http://localhost:45462')) {
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmedValue)
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
// 成功提取授权码
|
||||||
|
setupTokenAuthCode.value = code
|
||||||
|
showToast('成功提取授权码!', 'success')
|
||||||
|
console.log('Successfully extracted authorization code from URL')
|
||||||
|
} else {
|
||||||
|
// URL 中没有 code 参数
|
||||||
|
showToast('URL 中未找到授权码参数,请检查链接是否正确', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// URL 解析失败
|
||||||
|
console.error('Failed to parse URL:', error)
|
||||||
|
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 错误的 URL(不是 localhost:45462 开头)
|
||||||
|
showToast('请粘贴以 http://localhost:45462 开头的链接', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果不是 URL,保持原值(兼容直接输入授权码)
|
||||||
|
})
|
||||||
|
|
||||||
// 监听账户类型变化
|
// 监听账户类型变化
|
||||||
watch(
|
watch(
|
||||||
() => form.value.accountType,
|
() => form.value.accountType,
|
||||||
|
|||||||
@@ -398,6 +398,42 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成Claude Setup Token URL
|
||||||
|
const generateClaudeSetupTokenUrl = async (proxyConfig) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
'/admin/claude-accounts/generate-setup-token-url',
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
if (response.success) {
|
||||||
|
return response.data // 返回整个对象,包含authUrl和sessionId
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '生成Setup Token URL失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交换Claude Setup Token Code
|
||||||
|
const exchangeClaudeSetupTokenCode = async (data) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
'/admin/claude-accounts/exchange-setup-token-code',
|
||||||
|
data
|
||||||
|
)
|
||||||
|
if (response.success) {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '交换Setup Token授权码失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 生成Gemini OAuth URL
|
// 生成Gemini OAuth URL
|
||||||
const generateGeminiAuthUrl = async (proxyConfig) => {
|
const generateGeminiAuthUrl = async (proxyConfig) => {
|
||||||
try {
|
try {
|
||||||
@@ -480,6 +516,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
refreshClaudeToken,
|
refreshClaudeToken,
|
||||||
generateClaudeAuthUrl,
|
generateClaudeAuthUrl,
|
||||||
exchangeClaudeCode,
|
exchangeClaudeCode,
|
||||||
|
generateClaudeSetupTokenUrl,
|
||||||
|
exchangeClaudeSetupTokenCode,
|
||||||
generateGeminiAuthUrl,
|
generateGeminiAuthUrl,
|
||||||
exchangeGeminiCode,
|
exchangeGeminiCode,
|
||||||
sortAccounts,
|
sortAccounts,
|
||||||
|
|||||||
Reference in New Issue
Block a user