第 41 章:OAuth 与认证架构

核心设计问题

Agent 系统的认证为什么比普通应用复杂得多?为什么"登录一次就够了"的假设在 Agent 系统中不成立?

普通应用的认证是二元操作:用户登录或登出。但 AI Agent 系统的认证是多维度的:用户需要被认证,每次敏感操作需要被授权,多个进程可能同时竞争同一个 token,第三方云提供商(Bedrock/Vertex)有自己的认证体系,企业用户可能需要通过组织的 OAuth 流程登录。

Claude Code 的认证架构需要同时满足这些需求,同时保持对开发者友好的简洁体验。

认证流程总览

flowchart TB START[用户启动 claude] --> MODE{运行模式?} MODE --> |CI/非交互| CI_CHECK[检查环境变量
ANTHROPIC_API_KEY
CLAUDE_CODE_OAUTH_TOKEN] CI_CHECK --> |有| CI_AUTH[使用环境变量认证] CI_CHECK --> |无| CI_ERR[抛出错误] MODE --> |交互模式| SOURCE{检查认证源} SOURCE --> BARE{--bare 模式?} BARE --> |是| BARE_AUTH[仅 ANTHROPIC_API_KEY
或 apiKeyHelper] BARE --> |否| MULTI_SOURCE MULTI_SOURCE{检查多个认证源} MULTI_SOURCE --> |ANTHROPIC_AUTH_TOKEN| ENV_TOKEN[环境变量 Token] MULTI_SOURCE --> |CLAUDE_CODE_OAUTH_TOKEN| OAUTH_ENV[OAuth 环境变量] MULTI_SOURCE --> |FD Token| FD_TOKEN[文件描述符 Token] MULTI_SOURCE --> |apiKeyHelper| HELPER[执行外部脚本
获取 API Key] MULTI_SOURCE --> |OAuth Keychain| OAUTH_FLOW[OAuth PKCE 流程] MULTI_SOURCE --> |macOS Keychain| KEYCHAIN[macOS 钥匙串] MULTI_SOURCE --> |Config 文件| CONFIG_KEY[primaryApiKey] OAUTH_FLOW --> |浏览器授权| PKCE[PKCE 流程] PKCE --> |localhost 回调| CODE[授权码] CODE --> |token exchange| TOKENS[Access + Refresh Token] TOKENS --> STORAGE[安全存储
~/.claude/.credentials.json
或 macOS 钥匙串] style PKCE fill:#4ecdc4,color:#fff style STORAGE fill:#ffd93d,color:#333 style CI_ERR fill:#ff6b6b,color:#fff

多源认证的优先级链

getAnthropicApiKeyWithSource 函数实现了一个精心设计的认证源优先级链:

flowchart TD ENTRY[getAnthropicApiKeyWithSource] --> BARE_CHECK{bare 模式?} BARE_CHECK --> |是| BARE_PATH[仅 ANTHROPIC_API_KEY
或 apiKeyHelper from --settings] BARE_CHECK --> |否| HOME_SPACE{运行在 Homespace?} HOME_SPACE --> |是| SKIP_ENV[跳过 ANTHROPIC_API_KEY
使用 Console Key] HOME_SPACE --> |否| PRINT_CHECK PRINT_CHECK{--print / CI 模式?} PRINT_CHECK --> |是| FD_CHECK1{有 FD API Key?} FD_CHECK1 --> |有| RETURN_FD1[返回 FD Key] FD_CHECK1 --> |无| ENV_CHECK1{有 ANTHROPIC_API_KEY?} ENV_CHECK1 --> |有| RETURN_ENV1[返回环境变量 Key] ENV_CHECK1 --> |无| ERR_REQUIRE[要求设置环境变量] PRINT_CHECK --> |否| APPROVED{API Key 已批准?} APPROVED --> |是| RETURN_APPROVED[返回已批准的环境变量 Key] APPROVED --> |否| FD_CHECK2{有 FD API Key?} FD_CHECK2 --> |有| RETURN_FD2[返回 FD Key] FD_CHECK2 --> |否| HELPER_CHECK{有 apiKeyHelper?} HELPER_CHECK --> |是| HELPER_CACHE[从缓存读取
SWR 策略] HELPER_CHECK --> |否| KEYCHAIN_CONFIG[Keychain 或 Config] KEYCHAIN_CONFIG --> |找到| RETURN_STORED[返回存储的 Key] KEYCHAIN_CONFIG --> |未找到| RETURN_NONE[返回 null: source='none'] style ERR_REQUIRE fill:#ff6b6b,color:#fff style HELPER_CACHE fill:#ffd93d,color:#333

这个优先级链的设计逻辑是:

  1. 环境变量优先:CI/CD 场景需要通过环境变量注入认证
  2. 文件描述符(FD)机制:CCR(Claude Code Remote)和 Claude Desktop 通过 FD 传递 token,避免在命令行参数中暴露
  3. apiKeyHelper:企业用户可以通过外部脚本动态获取 API Key
  4. 安全存储:macOS 钥匙串或 .credentials.json 文件存储 OAuth token

apiKeyHelper 的 SWR 缓存

apiKeyHelper 的缓存设计采用了 Stale-While-Revalidate(SWR)策略:

// utils/auth.ts
export async function getApiKeyFromApiKeyHelper(
  isNonInteractiveSession: boolean,
): Promise<string | null> {
  if (_apiKeyHelperCache) {
    if (Date.now() - _apiKeyHelperCache.timestamp < ttl) {
      return _apiKeyHelperCache.value  // 缓存命中,直接返回
    }
    // 缓存过期,后台刷新
    if (!_apiKeyHelperInflight) {
      _apiKeyHelperInflight = {
        promise: _runAndCache(isNonInteractiveSession, false, epoch),
        startedAt: null,  // 后台刷新不记录开始时间
      }
    }
    return _apiKeyHelperCache.value  // 返回过期值,等后台刷新
  }
  // 冷启动,阻塞等待
  _apiKeyHelperInflight = {
    promise: _runAndCache(isNonInteractiveSession, true, epoch),
    startedAt: Date.now(),
  }
  return _apiKeyHelperInflight.promise
}

SWR 的好处是:即使外部脚本的执行很慢(有些企业的密钥管理服务延迟可达数百毫秒),用户也不会感受到卡顿——过期的缓存值立即返回,新鲜值在后台异步获取。

Epoch 机制防止缓存污染

当设置变更或认证失败时,clearApiKeyHelperCache 会递增 epoch:

let _apiKeyHelperEpoch = 0

export function clearApiKeyHelperCache(): void {
  _apiKeyHelperEpoch++
  _apiKeyHelperCache = null
  _apiKeyHelperInflight = null
}

正在执行的异步操作会捕获当前的 epoch 值。如果操作完成时 epoch 已经改变(说明中间发生了缓存清除),操作结果会被丢弃:

async function _runAndCache(isNonInteractiveSession, isCold, epoch) {
  try {
    const value = await _executeApiKeyHelper(isNonInteractiveSession)
    if (epoch !== _apiKeyHelperEpoch) return value  // epoch 变了,不更新缓存
    if (value !== null) {
      _apiKeyHelperCache = { value, timestamp: Date.now() }
    }
    return value
  } catch (e) {
    if (epoch !== _apiKeyHelperEpoch) return ' '
    // SWR 路径:暂态失败不应替换有效的过期值
    if (!isCold && _apiKeyHelperCache && _apiKeyHelperCache.value !== ' ') {
      _apiKeyHelperCache = { ..._apiKeyHelperCache, timestamp: Date.now() }
      return _apiKeyHelperCache.value
    }
    _apiKeyHelperCache = { value: ' ', timestamp: Date.now() }
    return ' '
  }
}

' ' 是一个哨兵值,表示"apiKeyHelper 执行失败"。这不是 null——null 意味着"没有配置 apiKeyHelper",会让调用方继续尝试其他认证源。' ' 意味着"apiKeyHelper 配置了但失败了",调用方不应该回退到其他源。

OAuth PKCE 流程

Claude Code 使用 OAuth Authorization Code Flow with PKCE(Proof Key for Code Exchange)进行用户认证。

sequenceDiagram participant CLI as Claude CLI participant Local as localhost Server
(AuthCodeListener) participant Browser as 用户浏览器 participant OAuth as OAuth Provider
claude.com CLI->>Local: 启动 localhost 监听
(OS 分配端口) CLI->>CLI: 生成 code_verifier + code_challenge
(SHA256 + Base64URL) CLI->>Browser: 打开授权 URL
(带 code_challenge + state + redirect_uri) Browser->>OAuth: 用户登录并授权 OAuth->>Browser: 302 重定向到
localhost:port/callback?code=XXX&state=YYY Browser->>Local: GET /callback?code=XXX&state=YYY Local->>Local: 验证 state 参数
(CSRF 防护) Local->>CLI: 返回 authorization code CLI->>OAuth: POST /v1/oauth/token
(code + code_verifier) OAuth->>CLI: 返回 access_token + refresh_token CLI->>Local: 重定向浏览器到成功页面 CLI->>CLI: 保存 token 到安全存储

AuthCodeListener:安全的本地回调

services/oauth/auth-code-listener.ts 中的 AuthCodeListener 是 OAuth 流程的关键组件:

export class AuthCodeListener {
  private localServer: Server
  private port: number = 0
  private expectedState: string | null = null
  private pendingResponse: ServerResponse | null = null

  async start(port?: number): Promise<number> {
    return new Promise((resolve, reject) => {
      this.localServer.listen(port ?? 0, 'localhost', () => {
        const address = this.localServer.address() as AddressInfo
        this.port = address.port
        resolve(this.port)
      })
    })
  }
}

端口 0 让操作系统自动分配一个可用端口,避免了端口冲突。expectedState 参数实现 CSRF 防护——OAuth 提供者重定向回来时携带的 state 必须与发起时匹配。

pendingResponse 保存了浏览器的 HTTP 响应对象,用于在 token 交换完成后将浏览器重定向到成功页面。不同授权范围(Console vs Claude.ai)会重定向到不同的成功页面。

OAuth Token 存储与多进程安全

Token 被存储在安全存储中,实现因平台而异:

export function saveOAuthTokensIfNeeded(tokens: OAuthTokens): {
  success: boolean
  warning?: string
} {
  const secureStorage = getSecureStorage()
  const storageData = secureStorage.read() || {}
  const existingOauth = storageData.claudeAiOauth

  storageData.claudeAiOauth = {
    accessToken: tokens.accessToken,
    refreshToken: tokens.refreshToken,
    expiresAt: tokens.expiresAt,
    scopes: tokens.scopes,
    // 不会用 null 覆盖有效的订阅类型
    subscriptionType:
      tokens.subscriptionType ?? existingOauth?.subscriptionType ?? null,
    rateLimitTier:
      tokens.rateLimitTier ?? existingOauth?.rateLimitTier ?? null,
  }
  return secureStorage.update(storageData)
}

注意 subscriptionType 的处理:刷新 token 时的 profile 请求可能失败(网络错误),此时返回的 subscriptionTypenull。如果直接覆盖,会丢失之前存储的有效订阅类型。所以使用 ?? 操作符回退到已有值。

Token 刷新的并发控制

当多个 Claude Code 进程同时运行时(多终端、多窗口),token 刷新需要精心协调。

flowchart TD API_401[API 返回 401] --> DEDUP{相同 token 的
刷新请求已存在?} DEDUP --> |是| WAIT[等待已有请求] DEDUP --> |否| START_REFRESH[开始刷新] START_REFRESH --> CLEAR[清除所有缓存] CLEAR --> ASYNC_READ[异步读取安全存储] ASYNC_READ --> KEYCHAIN_CHECK{Keychain 中的 token
和失败的相同?} KEYCHAIN_CHECK --> |不同| OTHER_TAB[另一个进程已刷新
使用新 token] KEYCHAIN_CHECK --> |相同| ACQUIRE_LOCK[获取文件锁] ACQUIRE_LOCK --> |ELOCKED| RETRY{重试次数 < 5?} RETRY --> |是| WAIT_RETRY[等待 1-2 秒后重试] RETRY --> |否| FAIL[刷新失败] ACQUIRE_LOCK --> |成功| RE_CHECK[再次检查 token
双重检查] RE_CHECK --> EXPIRED{仍过期?} EXPIRED --> |否| RACE_RESOLVED[另一个进程已刷新] EXPIRED --> |是| DO_REFRESH[执行 refreshOAuthToken] DO_REFRESH --> SAVE_NEW[保存新 token] SAVE_NEW --> CLEAR_CACHE[清除所有缓存] style DEDUP fill:#4ecdc4,color:#fff style ACQUIRE_LOCK fill:#ffd93d,color:#333 style OTHER_TAB fill:#6bcb77,color:#fff

进程内去重

const pending401Handlers = new Map<string, Promise<boolean>>()

export function handleOAuth401Error(failedAccessToken: string): Promise<boolean> {
  const pending = pending401Handlers.get(failedAccessToken)
  if (pending) return pending  // 去重

  const promise = handleOAuth401ErrorImpl(failedAccessToken).finally(() => {
    pending401Handlers.delete(failedAccessToken)
  })
  pending401Handlers.set(failedAccessToken, promise)
  return promise
}

同一个过期 token 的所有 401 错误都被合并为一个刷新操作。

跨进程文件锁

release = await lockfile.lock(claudeDir)

获取到锁后,再次检查 token 是否仍然过期——另一个进程可能在我们等待锁的期间已经刷新了 token。

跨进程缓存失效

async function invalidateOAuthCacheIfDiskChanged(): Promise<void> {
  try {
    const { mtimeMs } = await stat(join(getClaudeConfigHomeDir(), '.credentials.json'))
    if (mtimeMs !== lastCredentialsMtimeMs) {
      lastCredentialsMtimeMs = mtimeMs
      clearOAuthTokenCache()  // 磁盘文件变了,清除内存缓存
    }
  } catch {
    getClaudeAIOAuthTokens.cache?.clear?.()
  }
}

这是一个基于文件修改时间的简单但有效的跨进程通信机制:每个进程在每次 token 读取前检查 .credentials.json 的 mtime,如果发现变化就清除内存缓存。

认证源的安全考量

项目级设置的安全防护

apiKeyHelperawsAuthRefreshgcpAuthRefresh 等外部脚本可以从项目级设置中配置。这意味着一个恶意项目可以通过 .claude/settings.json 注入任意命令。

Claude Code 的防护策略是信任门槛

if (isApiKeyHelperFromProjectOrLocalSettings()) {
  const hasTrust = checkHasTrustDialogAccepted()
  if (!hasTrust && !isNonInteractiveSession) {
    const error = new Error(
      'Security: apiKeyHelper executed before workspace trust is confirmed.'
    )
    logAntError('apiKeyHelper invoked before trust check', error)
    logEvent('tengu_apiKeyHelper_missing_trust', {})
    return null  // 拒绝执行
  }
}

项目级的外部脚本只有在用户通过信任对话框确认后才能执行。非交互模式(CI)跳过这个检查,因为 CI 环境被认为是受控的。

OAuth 配置的白名单机制

CLAUDE_CODE_CUSTOM_OAUTH_URL 允许覆盖 OAuth 端点,但只限于白名单中的 URL:

const ALLOWED_OAUTH_BASE_URLS = [
  'https://beacon.claude-ai.staging.ant.dev',
  'https://claude.fedstart.com',
  'https://claude-staging.fedstart.com',
]

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.')
  }
}

这防止了 OAuth token 被发送到任意端点的风险。

组织强制登录

企业环境可以通过 forceLoginOrgUUID 设置要求用户必须登录到特定组织:

export async function validateForceLoginOrg(): Promise<OrgValidationResult> {
  const profile = await getOauthProfileFromOauthToken(tokens.accessToken)
  if (!profile) {
    return { valid: false, message: 'Unable to verify organization...' }
  }
  const tokenOrgUuid = profile.organization.uuid
  if (tokenOrgUuid === requiredOrgUuid) {
    return { valid: true }
  }
  return { valid: false, message: '...wrong organization...' }
}

组织验证总是从服务器的 profile 端点获取权威数据,而不是信任本地缓存的组织 ID——因为本地配置文件是用户可写的。

多提供商认证架构

Claude Code 不仅支持 Anthropic 直连,还支持 AWS Bedrock、Google Vertex AI 和 Foundry:

flowchart TD AUTH[认证入口] --> PROVIDER{API 提供商} PROVIDER --> |firstParty| ANTHROPIC[Anthropic 认证
OAuth 或 API Key] PROVIDER --> |bedrock| AWS[AWS 认证
awsCredentialExport
awsAuthRefresh] PROVIDER --> |vertex| GCP[GCP 认证
Google Auth Library
gcpAuthRefresh] PROVIDER --> |foundry| FOUNDRY[Foundry 认证] AWS --> AWS_STS[STS Caller Identity
检查凭证有效性] AWS_STS --> |有效| SKIP_REFRESH[跳过刷新] AWS_STS --> |无效| DO_REFRESH[执行刷新命令
3 分钟超时] GCP --> GCP_PROBE[Access Token 探针
5 秒超时] GCP_PROBE --> |有效| SKIP_GCP[跳过刷新] GCP_PROBE --> |超时/失败| DO_GCP[执行刷新命令
3 分钟超时] DO_REFRESH --> STATUS[AwsAuthStatusManager
UI 状态管理] DO_GCP --> STATUS style AWS fill:#ff9900,color:#fff style GCP fill:#4285f4,color:#fff style ANTHROPIC fill:#d97757,color:#fff

每个提供商的认证刷新都遵循相同的模式:先探测当前凭证是否有效,只在失效时才执行刷新命令。这避免了对 SSO 流程的不必要触发。

设计启示

1. Agent 认证需要"零摩擦"

Agent 系统的认证频率远高于普通应用——每次对话、每个工具调用都可能触发认证检查。如果每次都需要用户手动操作,体验会极其糟糕。Claude Code 通过 SWR 缓存、后台刷新、跨进程协调实现了"登录一次,持续可用"的体验。

2. 多进程环境是认证的隐藏复杂性

多个 Claude Code 实例可能同时运行,它们共享同一个 OAuth token。如果不协调刷新,会出现"终端 A 刷新 token 导致终端 B 的 token 失效"的问题。文件锁 + mtime 检查 + 进程内去重的组合方案优雅地解决了这个问题。

3. 项目级信任是 Agent 安全的关键边界

Agent 在用户的项目目录中执行,而项目可能包含恶意的配置文件。将信任验证与认证源绑定,确保了外部脚本不会在用户不知情的情况下执行。

4. 哨兵值比 null 更有表达力

apiKeyHelper 使用 ' ' 而不是 null 表示失败,让调用方可以区分"没有配置"和"配置了但失败了"。这种精细的语义区分在认证链中至关重要——错误的回退可能导致使用不期望的认证源。