OAuth 认证
Overview
Claude Code 实现 OAuth 2.0 使用 PKCE 的授权代码流程 (代码交换的证明密钥)根据 Anthropic 控制台或 Claude.ai 对用户进行身份验证。 二进制文件中未嵌入任何客户端密钥。 相反,加密验证器/挑战对使每个授权请求都不可伪造。
该实现分为三个层:
- 加密原语 —
services/oauth/crypto.ts:生成 PKCE 验证程序、S256 质询和 CSRF 状态。 - 网络客户端 —
services/oauth/client.ts:构建身份验证 URL、交换令牌代码、刷新令牌、获取用户配置文件。 - Orchestrator —
services/oauth/index.ts(OAuthService):连接侦听器,比较自动流与手动流,并格式化最终的令牌对象。
platform.claude.com/oauth/authorize) 用于基于 API 密钥的工作流程。 克劳德.ai(claude.com/cai/oauth/authorize) 用于直接通过其 claude.ai 帐户进行身份验证的 Pro/Max/Team/Enterprise 订阅者。
PKCE 原语
PKCE 通过将每个流绑定到只有发起进程知道的一次性机密来防止授权代码拦截。
crypto.ts — 完整源代码
import { createHash, randomBytes } from 'crypto' function base64URLEncode(buffer: Buffer): string { return buffer .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '') // RFC 4648 §5 — URL-safe, no padding } export function generateCodeVerifier(): string { return base64URLEncode(randomBytes(32)) // 256-bit entropy } export function generateCodeChallenge(verifier: string): string { const hash = createHash('sha256') hash.update(verifier) return base64URLEncode(hash.digest()) // S256 method } export function generateState(): string { return base64URLEncode(randomBytes(32)) // CSRF protection } crypto.ts
| Value | 如何生成 | 发送到服务器 | Purpose |
|---|---|---|---|
codeVerifier |
32个随机字节,base64url | 仅代币交换 | 证明这个过程启动了流程 |
codeChallenge |
SHA-256(验证器),base64url | 授权URL参数 | 服务器存储并稍后验证验证者 |
state |
32个随机字节,base64url | 认证地址+回调地址 | CSRF — 回调必须回显准确的状态 |
codeVerifier 已生成 在构造函数中 of OAuthService,在服务器启动之前。 这意味着它仅在登录尝试的生命周期内存在于内存中。
完整的 PKCE 序列图
下图显示了完整的自动 OAuth 流程。 手动回退在“打开浏览器”步骤中有所不同 - 请参阅下一节。
(CLI) participant LS as LocalServer
:PORT/callback participant B as Browser participant AS as Auth Server
claude.com / platform participant TS as Token Server
platform.claude.com/v1/oauth/token participant PS as Profile API
api.anthropic.com CC->>CC: generateCodeVerifier()
generateCodeChallenge()
generateState() CC->>LS: AuthCodeListener.start()
(OS assigns port) CC->>B: openBrowser(automaticFlowUrl)
?code_challenge=S256&state=...&redirect_uri=localhost:PORT CC-->>U: Show manual URL fallback in terminal B->>AS: GET /oauth/authorize?client_id=...&code_challenge=... AS->>U: Login page (email / SSO / magic link) U->>AS: Authenticate AS->>B: 302 → http://localhost:PORT/callback?code=AUTH_CODE&state=STATE B->>LS: GET /callback?code=AUTH_CODE&state=STATE LS->>LS: validate state === expectedState LS-->>CC: resolve(authorizationCode) CC->>TS: POST /v1/oauth/token
{ grant_type: authorization_code, code, code_verifier, redirect_uri } TS-->>CC: { access_token, refresh_token, expires_in, scope } CC->>PS: GET /api/oauth/profile
Authorization: Bearer access_token PS-->>CC: { account, organization } CC->>LS: handleSuccessRedirect(scopes)
→ 302 success page CC->>CC: installOAuthTokens()
save to keychain / secure storage
自动与手动身份验证流程
两个流程同时启动。 谁先提供授权码谁获胜。 关键洞察: OAuthService 将两个解析器与一个 Promise 进行竞争。
自动(浏览器重定向)
AuthCodeListener在操作系统分配的端口上启动 HTTP 服务器- 浏览器打开
automaticFlowUrlwithredirect_uri=localhost:PORT/callback - 登录后,身份验证服务器将浏览器重定向到本地服务器
- 回调处理程序验证
state,解决授权码承诺 - 浏览器收到成功重定向到
platform.claude.com/oauth/code/success
手动(复制粘贴后备)
- 终端显示
manualFlowUrlwithredirect_uri=platform.claude.com/oauth/code/callback - 用户打开 URL、进行身份验证并从浏览器复制生成的代码
- 用户将代码粘贴到 Claude Code 终端提示符中
handleManualAuthCodeInput()直接调用存储的解析器- 用于 SSH 会话或环境
localhost无法到达
OAuthService.startOAuthFlow() — 编排逻辑
async startOAuthFlow( authURLHandler: (url: string, automaticUrl?: string) => Promise<void>, options?: { skipBrowserOpen?: boolean; inferenceOnly?: boolean; ... } ): Promise<OAuthTokens> { // 1. Start the localhost callback server this.authCodeListener = new AuthCodeListener() this.port = await this.authCodeListener.start() // 2. Build both URLs from same PKCE values const manualFlowUrl = client.buildAuthUrl({ ...opts, isManual: true }) const automaticFlowUrl = client.buildAuthUrl({ ...opts, isManual: false }) // 3. Race: automatic (localhost) vs manual (paste) const authorizationCode = await this.waitForAuthorizationCode(state, async () => { if (options?.skipBrowserOpen) { await authURLHandler(manualFlowUrl, automaticFlowUrl) // SDK mode } else { await authURLHandler(manualFlowUrl) // Show manual to user await openBrowser(automaticFlowUrl) // Try automatic } }) // 4. Which flow won? const isAutomatic = this.authCodeListener?.hasPendingResponse() ?? false // 5. Exchange code for tokens const tokenResponse = await client.exchangeCodeForTokens( authorizationCode, state, this.codeVerifier, this.port!, !isAutomatic // isManual = true if auto did NOT win ) // 6. Fetch subscription/rate-limit tier from profile API const profileInfo = await client.fetchProfileInfo(tokenResponse.access_token) // 7. Redirect browser to success page, then cleanup if (isAutomatic) this.authCodeListener?.handleSuccessRedirect(scopes) return this.formatTokens(tokenResponse, profileInfo.subscriptionType, ...) } services/oauth/index.ts
claude_authenticate) 驱动登录,它设置 skipBrowserOpen: true。 两个 URL 均通过以下方式传递给调用者 authURLHandler。 SDK 客户端(而不是 Claude Code)决定在何处打开它们。
AuthCodeListener — 本地主机捕获服务器
AuthCodeListener 是一个最小的 Node.js HTTP 服务器,其唯一的工作是接收 OAuth 提供者的重定向并将授权代码交给等待的 Promise。
关键实施细节
端口分配
监听端口 0 让操作系统选择一个空闲端口。 这完全避免了“端口已在使用”类别的错误。 所选端口嵌入在 redirect_uri 身份验证服务器用于其回调。
状态验证
当回调到来时, validateAndRespond() 检查 state === expectedState 解决之前。 不匹配会返回 HTTP 400 并拒绝承诺——防止 CSRF。
待处理响应模式
服务器存储浏览器的 ServerResponse 对象在 pendingResponse before 解决授权代码承诺。 代币兑换成功后, handleSuccessRedirect() 完成浏览器请求并返回 302 到成功页面。 这可以防止浏览器选项卡挂起。
关闭时清理
If close() 当响应仍在等待时被调用(例如,令牌交换失败),服务器自动调用 handleErrorRedirect() 首先,确保浏览器始终得到响应。
AuthCodeListener.validateAndRespond() — CSRF 检查
private validateAndRespond( authCode: string | undefined, state: string | undefined, res: ServerResponse, ): void { if (!authCode) { res.writeHead(400) res.end('Authorization code not found') this.reject(new Error('No authorization code received')) return } if (state !== this.expectedState) { res.writeHead(400) res.end('Invalid state parameter') this.reject(new Error('Invalid state parameter')) return } // Store response for later redirect — keeps browser from hanging this.pendingResponse = res this.resolve(authCode) } auth-code-listener.ts
代币兑换
一旦获得授权代码,就会通过 POST 到令牌端点来交换访问+刷新令牌。
ExchangeCodeForTokens() — 请求正文
const requestBody = { grant_type: 'authorization_code', code: authorizationCode, redirect_uri: useManualRedirect ? getOauthConfig().MANUAL_REDIRECT_URL // https://platform.claude.com/oauth/code/callback : `http://localhost:${port}/callback`, // must match what was in auth URL client_id: getOauthConfig().CLIENT_ID, // '9d1c250a-e61b-44d9-88ed-5944d1962f5e' code_verifier: codeVerifier, // proves we started this flow state, } // POST https://platform.claude.com/v1/oauth/token const response = await axios.post(TOKEN_URL, requestBody, { headers: { 'Content-Type': 'application/json' }, timeout: 15000, }) client.ts
令牌请求中的redirect_uri 必须与授权请求中使用的redirect_uri 完全匹配。 这是服务器端强制执行的,也是另一种防重放措施。
inferenceOnly: true, 仅有的 user:inference 要求范围。 这些是长期存在的令牌,专为 SDK 编程访问而设计,其中完整范围集将过多。
OAuth 范围
范围决定了所颁发的访问令牌可以执行的操作。 Claude Code 在登录时请求 Console 和 Claude.ai 范围的并集,以便一个令牌可以为两条路径提供服务。
所有请求的范围(ALL_OAUTH_SCOPES)
| Scope | 用于 |
|---|---|
org:create_api_key | 控制台路径 — 为组织创建永久 API 密钥 |
user:profile | 从以下位置获取订阅类型、速率限制层、帐户/组织信息 /api/oauth/profile |
user:inference | Claude.ai 路径 — 通过 Claude.ai 订阅直接路由推理请求 |
user:sessions:claude_code | 特别针对 Claude Code 客户端的会话管理 |
user:mcp_servers | 访问并配置与帐户关联的 MCP 服务器 |
user:file_upload | 将文件上传到 Anthropic 基础设施进行处理 |
功能 shouldUseClaudeAIAuth(scopes) 检查是否 user:inference 存在。 如果是,推理调用将通过 Claude.ai 的基础设施进行路由; 否则使用控制台 API 密钥路径。
获取个人资料
令牌交换后,Claude Code 立即获取用户的个人资料以确定订阅类型和速率限制层。 配置文件驱动 UI 选择、模型可用性和功能标志。
fetchProfileInfo() — 订阅类型映射
export async function fetchProfileInfo(accessToken: string) { const profile = await getOauthProfileFromOauthToken(accessToken) const orgType = profile?.organization?.organization_type let subscriptionType: SubscriptionType | null = null switch (orgType) { case 'claude_max': subscriptionType = 'max'; break case 'claude_pro': subscriptionType = 'pro'; break case 'claude_enterprise': subscriptionType = 'enterprise'; break case 'claude_team': subscriptionType = 'team'; break default: subscriptionType = null } return { subscriptionType, rateLimitTier: profile?.organization?.rate_limit_tier ?? null, hasExtraUsageEnabled: profile?.organization?.has_extra_usage_enabled ?? null, billingType: profile?.organization?.billing_type ?? null, displayName: profile?.account?.display_name, accountCreatedAt: profile?.account?.created_at, subscriptionCreatedAt: profile?.organization?.subscription_created_at, rawProfile: profile, } } client.ts
订阅类型及其 org_type 键
billingType, accountCreatedAt, 和 subscriptionCreatedAt,并且安全存储已经有一个非空 subscriptionType and rateLimitTier,配置文件端点调用被完全跳过。 此优化每天在整个队列范围内减少大约 700 万个请求。
令牌存储——钥匙串架构
令牌存储在特定于平台的安全存储中,而不是存储在普通的配置文件中。 存储层在运行时选择 getSecureStorage().
| Platform | 主存储 | Fallback |
|---|---|---|
| macOS | macOsKeychainStorage — 使用 macOS security CLI (add-generic-password / find-generic-password) |
plainTextStorage — 加密的 JSON ~/.claude/ |
| Linux | plainTextStorage (计划支持 libsecret) |
— |
| Windows | plainTextStorage |
— |
macOS 钥匙串:为什么是十六进制和标准输入?
macOS security 调用命令来存储凭据。 两个值得注意的安全工程决策:
-
十六进制编码: JSON 令牌有效负载在存储之前转换为十六进制(
-X旗帜)。 这避免了 shell 引用问题,更重要的是,防止像 CrowdStrike 这样的进程监视器看到原始令牌值ps输出或系统调用日志。 -
标准输入首选项(
security -i): 当有效负载在 4032 字节(4096 - 64 余量)内时,它会通过 stdin 传递,而不是作为命令行参数传递。 这可以防止令牌出现在进程参数列表中。 如果有效负载超过限制,则使用 argv 回退并发出调试警告。 -
Stale-while-error: 如果一个
security子进程暂时失败,最后一个已知的正确值是从缓存中提供的,而不是向用户显示“未登录”错误。
installOAuthTokens() — 流程之后会发生什么
export async function installOAuthTokens(tokens: OAuthTokens): Promise<void> { // 1. Wipe old state first (clear keychain, reset caches) await performLogout({ clearOnboarding: false }) // 2. Store account info in global config (non-sensitive, JSON) const profile = tokens.profile ?? await getOauthProfileFromOauthToken(tokens.accessToken) if (profile) { storeOAuthAccountInfo({ accountUuid, emailAddress, organizationUuid, ... }) } // 3. Save tokens to secure storage (keychain on macOS) const storageResult = saveOAuthTokensIfNeeded(tokens) clearOAuthTokenCache() // 4. Fetch roles (org/workspace role) — non-critical, failure tolerated await fetchAndStoreUserRoles(tokens.accessToken).catch(logForDebugging) // 5. Console path: create a permanent API key via the token if (!shouldUseClaudeAIAuth(tokens.scopes)) { await createAndStoreApiKey(tokens.accessToken) } } cli/handlers/auth.ts
令牌刷新
访问令牌过期。 Claude Code 使用 5 分钟缓冲窗口在到期前主动刷新它们。 刷新流程被设计为对用户不可见。
isOAuthTokenExpired() — 缓冲区检查
export function isOAuthTokenExpired(expiresAt: number | null): boolean { if (expiresAt === null) return false const bufferTime = 5 * 60 * 1000 // 5 minutes early const expiresWithBuffer = Date.now() + bufferTime return expiresWithBuffer >= expiresAt } client.ts
freshOAuthToken() — 范围扩展和重复数据删除
export async function refreshOAuthToken( refreshToken: string, { scopes: requestedScopes }: { scopes?: string[] } = {}, ): Promise<OAuthTokens> { const requestBody = { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: getOauthConfig().CLIENT_ID, // Backend allows scope expansion on refresh (ALLOWED_SCOPE_EXPANSIONS) scope: (requestedScopes?.length ? requestedScopes : CLAUDE_AI_OAUTH_SCOPES).join(' '), } // Skip profile fetch if we already have all fields cached const haveProfileAlready = config.oauthAccount?.billingType !== undefined && config.oauthAccount?.accountCreatedAt !== undefined && existing?.subscriptionType != null // must check secure storage too const profileInfo = haveProfileAlready ? null : await fetchProfileInfo(accessToken) return { accessToken, refreshToken: newRefreshToken, // server may rotate refresh token expiresAt: Date.now() + expiresIn * 1000, scopes, subscriptionType: profileInfo?.subscriptionType ?? existing?.subscriptionType ?? null, rateLimitTier: profileInfo?.rateLimitTier ?? existing?.rateLimitTier ?? null, } } client.ts
CLAUDE_CODE_OAUTH_REFRESH_TOKEN 设置后,Claude Code 使用它来执行新的令牌交换。 关键的微妙之处: installOAuthTokens calls performLogout() after 刷新返回。 如果 refreshOAuthToken returned subscriptionType: null 因为它发现丢失的配置文件字段(已被注销擦除),后续刷新将永久丢失订阅类型。 修复方法:在注销擦除之前从安全存储中传递缓存值。
注销流程
注销不仅仅是删除令牌。 它需要清除依赖于当前身份的每一层状态。
PerformLogout() — 完整的拆卸序列
export async function performLogout({ clearOnboarding = false }): Promise<void> { // 1. Flush telemetry BEFORE clearing credentials // Prevents sending org-attributed events after account is wiped const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js') await flushTelemetry() await removeApiKey() // 2. Wipe secure storage (tokens from keychain) const secureStorage = getSecureStorage() secureStorage.delete() // 3. Clear all auth-dependent in-memory caches await clearAuthRelatedCaches() // 4. Update global config saveGlobalConfig(current => ({ ...current, oauthAccount: undefined, // clear account info ...(clearOnboarding && { hasCompletedOnboarding: false, subscriptionNoticeCount: 0, hasAvailableSubscription: false, }), })) } commands/logout/logout.ts
clearAuthRelatedCaches() 无效:
- OAuth 令牌记忆缓存(
getClaudeAIOAuthTokens.cache?.clear()) - 可信设备令牌缓存
- Beta 版和工具架构缓存
- 用户数据缓存(必须在GrowthBook刷新之前清除)
- GrowthBook 功能标志缓存
- Grove 配置缓存(通知 + 设置)
- 远程管理设置缓存
- 策略限制缓存
授权URL构建
buildAuthUrl() 将授权 URL 与所有必需的 OAuth + PKCE 参数以及可选提示组合在一起。
buildAuthUrl() — 完整参数集
export function buildAuthUrl({ codeChallenge, state, port, isManual, loginWithClaudeAi, inferenceOnly, orgUUID, loginHint, loginMethod }) { // Choose authorization server based on account type const authUrlBase = loginWithClaudeAi ? 'https://claude.com/cai/oauth/authorize' // 307s to claude.ai : 'https://platform.claude.com/oauth/authorize' const authUrl = new URL(authUrlBase) authUrl.searchParams.append('code', 'true') // show Claude Max upsell authUrl.searchParams.append('client_id', CLIENT_ID) authUrl.searchParams.append('response_type', 'code') authUrl.searchParams.append('redirect_uri', isManual ? 'https://platform.claude.com/oauth/code/callback' : `http://localhost:${port}/callback`) authUrl.searchParams.append('scope', scopesToUse.join(' ')) authUrl.searchParams.append('code_challenge', codeChallenge) authUrl.searchParams.append('code_challenge_method', 'S256') authUrl.searchParams.append('state', state) // Optional: pre-fill login form (standard OIDC) if (loginHint) authUrl.searchParams.append('login_hint', loginHint) // Optional: request specific login method if (loginMethod) authUrl.searchParams.append('login_method', loginMethod) // Optional: target specific org if (orgUUID) authUrl.searchParams.append('orgUUID', orgUUID) return authUrl.toString() } client.ts
?code=true 参数是 Claude 特定的标志,告诉登录页面显示 Claude Max 订阅追加销售。 它不是标准 OAuth 参数。
Enterprise 和 FedStart 配置
对于美国联邦/FedRAMP 部署(FedStart),所有 OAuth 端点都可以通过以下方式重定向到批准的基本 URL: CLAUDE_CODE_CUSTOM_OAUTH_URL 环境变量。
getOauthConfig() 中的白名单强制执行
const ALLOWED_OAUTH_BASE_URLS = [ 'https://beacon.claude-ai.staging.ant.dev', 'https://claude.fedstart.com', 'https://claude-staging.fedstart.com', ] const oauthBaseUrl = process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL if (oauthBaseUrl) { const base = oauthBaseUrl.replace(/\/$/, '') if (!ALLOWED_OAUTH_BASE_URLS.includes(base)) { throw new Error('CLAUDE_CODE_CUSTOM_OAUTH_URL is not an approved endpoint.') } // Override all OAuth URLs to point to FedStart deployment config = { ...config, BASE_API_URL: base, CONSOLE_AUTHORIZE_URL: `${base}/oauth/authorize`, ... } } constants/oauth.ts
严格的白名单可防止使用覆盖将 OAuth 令牌路由到任意服务器(凭据泄露攻击)。
要点
security -i (stdin) 以将它们排除在进程参数列表和进程监视器之外。 错误时失效缓存可防止暂时性子进程故障导致用户注销。/api/oauth/profile 呼叫被跳过。 这种优化每天消除了整个车队范围内数百万次 API 调用。user:inference 范围是指 Claude.ai 订阅者——推理直接通过 Claude.ai 基础设施进行。 缺少该范围意味着控制台路径 - API 密钥在登录后创建并用于所有请求。ALLOWED_SCOPE_EXPANSIONS 允许刷新授权包括超出初始授权范围的范围。 这使得新的范围(在用户首次登录后添加)可以在下次令牌刷新时选取,而无需重新登录。知识检查
code_verifier 但授权请求只包括 code_challenge?localhost:PORT/callback?shouldUseClaudeAIAuth() 返回 true?performLogout()?/api/oauth/profile 令牌刷新期间跳过了调用?