mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: add Setup Token OAuth flow for simplified Claude account setup
Introduces a streamlined Setup Token authentication method that reduces the required OAuth scopes from 'org:create_api_key user:profile user:inference' to just 'user:inference', simplifying the account setup process for users who only need inference capabilities. Key changes: - Add Setup Token authorization endpoints in admin routes - Implement Setup Token OAuth flow with PKCE support in oauthHelper - Update AccountForm to support Setup Token as the default auth method - Add automatic authorization code extraction from callback URLs - Maintain full proxy configuration support for Setup Token flow - Preserve existing OAuth flow for advanced users requiring API key creation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
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账户
|
||||
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -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<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标准格式
|
||||
* @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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user