Claude Code 源码分析第 25 课 · 第 06
第25课

OAuth 认证

PKCE 流程 · 令牌存储 · 自动与手动身份验证 · 配置文件获取 · 令牌刷新

Overview

Claude Code 实现 OAuth 2.0 使用 PKCE 的授权代码流程 (代码交换的证明密钥)根据 Anthropic 控制台或 Claude.ai 对用户进行身份验证。 二进制文件中未嵌入任何客户端密钥。 相反,加密验证器/挑战对使每个授权请求都不可伪造。

该实现分为三个层:

  • 加密原语services/oauth/crypto.ts:生成 PKCE 验证程序、S256 质询和 CSRF 状态。
  • 网络客户端services/oauth/client.ts:构建身份验证 URL、交换令牌代码、刷新令牌、获取用户配置文件。
  • Orchestratorservices/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 — 回调必须回显准确的状态
The codeVerifier 已生成 在构造函数中 of OAuthService,在服务器启动之前。 这意味着它仅在登录尝试的生命周期内存在于内存中。

完整的 PKCE 序列图

下图显示了完整的自动 OAuth 流程。 手动回退在“打开浏览器”步骤中有所不同 - 请参阅下一节。

sequenceDiagram participant U as User participant CC as Claude Code
(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
OAuth 2.0 PKCE 自动流程 — 从 CLI 启动到令牌存储在钥匙串中

自动与手动身份验证流程

两个流程同时启动。 谁先提供授权码谁获胜。 关键洞察: OAuthService 将两个解析器与一个 Promise 进行竞争。

自动(浏览器重定向)

  • AuthCodeListener 在操作系统分配的端口上启动 HTTP 服务器
  • 浏览器打开 automaticFlowUrl with redirect_uri=localhost:PORT/callback
  • 登录后,身份验证服务器将浏览器重定向到本地服务器
  • 回调处理程序验证 state,解决授权码承诺
  • 浏览器收到成功重定向到 platform.claude.com/oauth/code/success

手动(复制粘贴后备)

  • 终端显示 manualFlowUrl with redirect_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
跳过浏览器打开方式: 当 SDK 控制协议(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 完全匹配。 这是服务器端强制执行的,也是另一种防重放措施。

仅推理标记: When inferenceOnly: true, 仅有的 user:inference 要求范围。 这些是长期存在的令牌,专为 SDK 编程访问而设计,其中完整范围集将过多。

OAuth 范围

范围决定了所颁发的访问令牌可以执行的操作。 Claude Code 在登录时请求 Console 和 Claude.ai 范围的并集,以便一个令牌可以为两条路径提供服务。

所有请求的范围(ALL_OAUTH_SCOPES)

org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload
Scope用于
org:create_api_key控制台路径 — 为组织创建永久 API 密钥
user:profile从以下位置获取订阅类型、速率限制层、帐户/组织信息 /api/oauth/profile
user:inferenceClaude.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 键

max
claude_max
pro
claude_pro
team
claude_team
enterprise
claude_enterprise
配置文件跳过优化: 在例行令牌刷新期间,如果全局配置已经有 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
通过环境变量重新登录: When 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
The ?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 令牌路由到任意服务器(凭据泄露攻击)。

要点

1
二进制文件中没有客户端密钥。 PKCE 取代了它。 代码验证器在每次登录尝试时都会重新生成,仅存在于内存中,并且从不存储。 S256 挑战是服务器看到的。
2
自动和手动流程相互竞争。 两者同时启动。 自动流程打开浏览器并通过临时本地主机服务器捕获重定向。 手动流程会显示一个 URL,供用户在任何浏览器中访问并粘贴生成的代码。
3
令牌位于 macOS 的操作系统钥匙串中, 存储为十六进制通过 security -i (stdin) 以将它们排除在进程参数列表和进程监视器之外。 错误时失效缓存可防止暂时性子进程故障导致用户注销。
4
配置文件获取在刷新期间被优化。 如果所有配置文件字段已缓存在全局配置和安全存储中,则 /api/oauth/profile 呼叫被跳过。 这种优化每天消除了整个车队范围内数百万次 API 调用。
5
注销首先刷新遥测。 飞行中的分析事件带有组织归因。 在刷新之前清除凭据会将这些事件作为匿名发送,从而破坏使用数据。 排序是有意的并记录在代码中。
6
基于范围的两个独立的身份验证路径。 user:inference 范围是指 Claude.ai 订阅者——推理直接通过 Claude.ai 基础设施进行。 缺少该范围意味着控制台路径 - API 密钥在登录后创建并用于所有请求。
7
令牌刷新可以扩大范围。 后端的 ALLOWED_SCOPE_EXPANSIONS 允许刷新授权包括超出初始授权范围的范围。 这使得新的范围(在用户首次登录后添加)可以在下次令牌刷新时选取,而无需重新登录。

知识检查

Q1 为什么令牌请求包括 code_verifier 但授权请求只包括 code_challenge?
Q2 如果用户的浏览器无法访问会发生什么情况 localhost:PORT/callback?
Q3 什么触发 shouldUseClaudeAIAuth() 返回 true?
Q4 为什么在清除凭据之前会刷新遥测数据 performLogout()?
Q5 在 macOS 上,为什么钥匙串负载在存储之前转换为十六进制?
Q6 什么时候是 /api/oauth/profile 令牌刷新期间跳过了调用?