第 16 章:MCP 认证体系
远程 MCP 服务器需要认证机制来保护其暴露的工具和资源。Claude Code 实现了一个完整的 OAuth 2.0 认证体系,支持从基本的授权码流到企业级的跨账户访问(XAA)。本章将深入 src/services/mcp/auth.ts 和 xaa.ts,剖析这一认证体系的每个层面。
16.1 OAuth 授权流
16.1.1 ClaudeAuthProvider 架构
ClaudeAuthProvider 是 MCP SDK 的 OAuthClientProvider 接口的完整实现,它管理了一个 MCP 服务器连接的完整 OAuth 生命周期:
// src/services/mcp/auth.ts
export class ClaudeAuthProvider implements OAuthClientProvider {
private serverName: string
private serverConfig: McpSSEServerConfig | McpHTTPServerConfig
private redirectUri: string
private _codeVerifier?: string
private _authorizationUrl?: string
private _state?: string
private _scopes?: string
private _metadata?: Awaited<ReturnType<typeof discoverAuthorizationServerMetadata>>
private _refreshInProgress?: Promise<OAuthTokens | undefined>
private _pendingStepUpScope?: string
constructor(
serverName: string,
serverConfig: McpSSEServerConfig | McpHTTPServerConfig,
redirectUri: string = buildRedirectUri(),
handleRedirection = false,
onAuthorizationUrl?: (url: string) => void,
skipBrowserOpen?: boolean,
) {
this.serverName = serverName
this.serverConfig = serverConfig
this.redirectUri = redirectUri
this.handleRedirection = handleRedirection
this.onAuthorizationUrlCallback = onAuthorizationUrl
this.skipBrowserOpen = skipBrowserOpen ?? false
}
设计要点:
_refreshInProgress使用 Promise 作为锁——当一个 token 刷新正在进行时,并发请求等待同一个 Promise 而不是各自发起刷新_pendingStepUpScope支持 OAuth Step-Up 认证——当服务器要求更高权限(scope elevation)时,标记待提升的 scope
16.1.2 客户端元数据与 CIMD
get clientMetadata(): OAuthClientMetadata {
const metadata: OAuthClientMetadata = {
client_name: `Claude Code (${this.serverName})`,
redirect_uris: [this.redirectUri],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none', // 公共客户端
}
const metadataScope = getScopeFromMetadata(this._metadata)
if (metadataScope) {
metadata.scope = metadataScope
}
return metadata
}
// CIMD (SEP-991): URL 形式的 client_id
get clientMetadataUrl(): string | undefined {
const override = process.env.MCP_OAUTH_CLIENT_METADATA_URL
if (override) return override
return MCP_CLIENT_METADATA_URL
}
Claude Code 使用 token_endpoint_auth_method: 'none' 声明自己为公共客户端(Public Client)——因为 CLI 工具无法安全地存储 client_secret。CIMD(Client ID Metadata Document,SEP-991)允许使用 URL 作为 client_id,这是 MCP 生态的一个重要创新。
16.1.3 授权服务器发现
async function fetchAuthServerMetadata(
serverName: string,
serverUrl: string,
configuredMetadataUrl: string | undefined,
fetchFn?: FetchLike,
resourceMetadataUrl?: URL,
): Promise<...> {
if (configuredMetadataUrl) {
// 用户配置的元数据 URL
if (!configuredMetadataUrl.startsWith('https://')) {
throw new Error(`authServerMetadataUrl must use https://`)
}
const authFetch = fetchFn ?? createAuthFetch()
const response = await authFetch(configuredMetadataUrl, { ... })
return OAuthMetadataSchema.parse(await response.json())
}
try {
// RFC 9728: Protected Resource Metadata → 发现授权服务器
const { authorizationServerMetadata } = await discoverOAuthServerInfo(serverUrl, {
...(fetchFn && { fetchFn }),
...(resourceMetadataUrl && { resourceMetadataUrl }),
})
if (authorizationServerMetadata) return authorizationServerMetadata
} catch (err) {
logMCPDebug(serverName, `RFC 9728 discovery failed, falling back: ${errorMessage(err)}`)
}
// 回退: RFC 8414 路径感知发现
const url = new URL(serverUrl)
if (url.pathname === '/') return undefined
return discoverAuthorizationServerMetadata(url, { ...(fetchFn && { fetchFn }) })
}
发现流程遵循三步回退策略:
flowchart TD
A[开始发现] --> B{配置了 metadataUrl?}
B -->|是| C[直接获取配置 URL]
C -->|成功| D[返回元数据]
C -->|失败| E[抛出错误]
B -->|否| F[RFC 9728 PRM 发现]
F -->|成功| G[通过 PRM 找到 AS]
G --> D
F -->|失败| H{URL 有路径?}
H -->|是| I[RFC 8414 路径感知回退]
I -->|成功| D
I -->|失败| J[返回 undefined]
H -->|否| J
16.1.4 本地回调服务器
OAuth 授权码流需要一个本地 HTTP 服务器来接收回调:
// src/services/mcp/oauthPort.ts
export function buildRedirectUri(port?: number): string {
return `http://127.0.0.1:${port || findAvailablePort()}/callback`
}
回调服务器在 127.0.0.1 上监听,接收授权码并用它交换 access token。端口号动态分配以避免冲突。
16.1.5 OAuth 错误规范化
某些 OAuth 服务器(特别是 Slack)不遵守标准的错误响应格式:
// src/services/mcp/auth.ts
const NONSTANDARD_INVALID_GRANT_ALIASES = new Set([
'invalid_refresh_token',
'expired_refresh_token',
'token_expired',
])
export async function normalizeOAuthErrorBody(response: Response): Promise<Response> {
if (!response.ok) return response
const text = await response.text()
let parsed: unknown
try { parsed = jsonParse(text) } catch { return new Response(text, response) }
// 如果是有效的 token 响应,直接返回
if (OAuthTokensSchema.safeParse(parsed).success) {
return new Response(text, response)
}
// 检查是否是错误响应包装在 200 中
const result = OAuthErrorResponseSchema.safeParse(parsed)
if (!result.success) return new Response(text, response)
// 规范化非标准错误码
const normalized = NONSTANDARD_INVALID_GRANT_ALIASES.has(result.data.error)
? { error: 'invalid_grant', error_description: `...` }
: result.data
return new Response(jsonStringify(normalized), {
status: 400, statusText: 'Bad Request',
headers: response.headers,
})
}
Slack 等服务器返回 HTTP 200 + {"error":"invalid_refresh_token"},而 RFC 6749 规定应该返回 HTTP 400 + {"error":"invalid_grant"}。这个规范化层将非标准响应转换为标准格式,确保 SDK 的错误处理逻辑正确工作。
16.2 Token 管理
16.2.1 安全存储
Token 通过 SecureStorage 接口存储,在 macOS 上使用系统 Keychain:
async tokens(): Promise<OAuthTokens | undefined> {
const storage = getSecureStorage()
const data = await storage.readAsync()
const serverKey = getServerKey(this.serverName, this.serverConfig)
const tokenData = data?.mcpOAuth?.[serverKey]
// ...
}
存储 key 是服务器名称和配置哈希的组合,防止同名不同配置的服务器共享凭据:
export function getServerKey(
serverName: string,
serverConfig: McpSSEServerConfig | McpHTTPServerConfig,
): string {
const configJson = jsonStringify({
type: serverConfig.type,
url: serverConfig.url,
headers: serverConfig.headers || {},
})
const hash = createHash('sha256').update(configJson).digest('hex').substring(0, 16)
return `${serverName}|${hash}`
}
16.2.2 Token 刷新
Token 刷新有多层保护机制:
- 锁文件:使用文件锁防止多个 Claude Code 实例同时刷新
- 重试策略:区分瞬时错误(重试)和永久错误(失效)
- 跨进程感知:Keychain 缓存的 TTL 允许一个实例看到另一个实例的刷新结果
// tokens() 方法中的性能注释
// 我们不在这里 clearKeychainCache()——tokens() 被 MCP SDK 的
// _commonHeaders 在每次请求中调用,强制 cache miss 会触发
// 30-40 次/秒的阻塞 spawnSync(`security find-generic-password`)
这条注释揭示了一个重要的性能考量:tokens() 在每次 MCP 请求中都被调用,如果每次都读取 Keychain 会导致严重的 CPU 消耗(在 CPU profile 中占到 7.2%)。
16.2.3 Token 撤销
export async function revokeServerTokens(
serverName: string,
serverConfig: McpSSEServerConfig | McpHTTPServerConfig,
{ preserveStepUpState = false } = {},
): Promise<void> {
// 1. 发现撤销端点
const metadata = await fetchAuthServerMetadata(serverName, asUrl, ...)
// 2. 先撤销 refresh_token(更重要——防止生成新的 access_token)
if (tokenData.refreshToken) {
await revokeToken({ ..., tokenTypeHint: 'refresh_token' })
}
// 3. 再撤销 access_token
if (tokenData.accessToken) {
await revokeToken({ ..., tokenTypeHint: 'access_token' })
}
// 4. 清除本地存储
clearServerTokensFromLocalStorage(serverName, serverConfig)
// 5. 可选保留 Step-Up 状态
if (preserveStepUpState && tokenData.stepUpScope) {
// 保留 scope 和 discovery 状态,方便下次认证
}
}
撤销顺序很重要:先撤销 refresh_token,因为它是长期凭证。即使 access_token 撤销失败,只要 refresh_token 被撤销就无法生成新的 access_token。
RFC 7009 的两种认证方式都被支持:
async function revokeToken({ endpoint, token, tokenTypeHint, clientId, clientSecret, ... }) {
// 优先使用 client_secret_basic(RFC 7009 标准)
if (clientId && clientSecret) {
if (authMethod === 'client_secret_post') {
params.set('client_id', clientId)
params.set('client_secret', clientSecret)
} else {
const basic = Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64')
headers.Authorization = `Basic ${basic}`
}
}
try {
await axios.post(endpoint, params, { headers })
} catch (error) {
// 回退:某些非 RFC 7009 兼容的服务器需要 Bearer auth
if (error.response?.status === 401 && accessToken) {
params.delete('client_id')
params.delete('client_secret')
await axios.post(endpoint, params, {
headers: { ...headers, Authorization: `Bearer ${accessToken}` },
})
}
}
}
16.3 XAA 跨账户访问
Cross-App Access(XAA)是 MCP 认证体系中最精密的部分,它允许在不弹出浏览器的情况下获取 MCP 访问令牌。
16.3.1 XAA 协议链
XAA 基于三个 RFC 标准构建了一条 token 交换链:
sequenceDiagram
participant CC as Claude Code
participant IdP as 身份提供商 (IdP)
participant AS as 授权服务器 (AS)
participant MCP as MCP Server
Note over CC: 步骤 0: 缓存检查
CC->>CC: 检查缓存的 id_token
Note over CC,IdP: 步骤 1: 获取 id_token (仅首次)
CC->>IdP: OIDC 授权码流 (PKCE)
IdP-->>CC: id_token (缓存到 Keychain)
Note over CC,MCP: 步骤 2: PRM 发现
CC->>MCP: RFC 9728 PRM 发现
MCP-->>CC: resource, authorization_servers[]
Note over CC,AS: 步骤 3: AS 元数据发现
CC->>AS: RFC 8414 AS 元数据
AS-->>CC: issuer, token_endpoint, grant_types
Note over CC,IdP: 步骤 4: Token Exchange (RFC 8693)
CC->>IdP: id_token → ID-JAG
IdP-->>CC: ID-JAG (Identity Assertion Authorization Grant)
Note over CC,AS: 步骤 5: JWT Bearer Grant (RFC 7523)
CC->>AS: ID-JAG → access_token
AS-->>CC: access_token + refresh_token
16.3.2 XAA 核心实现
// src/services/mcp/xaa.ts
export async function performCrossAppAccess(
serverUrl: string,
config: XaaConfig,
serverName = 'xaa',
abortSignal?: AbortSignal,
): Promise<XaaResult> {
const fetchFn = makeXaaFetch(abortSignal)
// Layer 2: PRM 发现
const prm = await discoverProtectedResource(serverUrl, { fetchFn })
// Layer 2: AS 发现(遍历所有广告的 AS,找到支持 jwt-bearer 的)
let asMeta: AuthorizationServerMetadata | undefined
for (const asUrl of prm.authorization_servers) {
let candidate = await discoverAuthorizationServer(asUrl, { fetchFn })
if (candidate.grant_types_supported &&
!candidate.grant_types_supported.includes(JWT_BEARER_GRANT)) {
continue
}
asMeta = candidate
break
}
if (!asMeta) throw new Error('XAA: no authorization server supports jwt-bearer')
// 选择认证方法
const authMethods = asMeta.token_endpoint_auth_methods_supported
const authMethod = authMethods &&
!authMethods.includes('client_secret_basic') &&
authMethods.includes('client_secret_post')
? 'client_secret_post' : 'client_secret_basic'
// Layer 2: Token Exchange (id_token → ID-JAG)
const jag = await requestJwtAuthorizationGrant({
tokenEndpoint: config.idpTokenEndpoint,
audience: asMeta.issuer,
resource: prm.resource,
idToken: config.idpIdToken,
clientId: config.idpClientId,
clientSecret: config.idpClientSecret,
fetchFn,
})
// Layer 2: JWT Bearer Grant (ID-JAG → access_token)
const tokens = await exchangeJwtAuthGrant({
tokenEndpoint: asMeta.token_endpoint,
assertion: jag.jwtAuthGrant,
clientId: config.clientId,
clientSecret: config.clientSecret,
authMethod,
fetchFn,
})
return { ...tokens, authorizationServerUrl: asMeta.issuer }
}
16.3.3 安全验证
XAA 实现了多层安全验证:
RFC 9728 资源不匹配保护:
export async function discoverProtectedResource(serverUrl: string, opts?): Promise<...> {
const prm = await discoverOAuthProtectedResourceMetadata(serverUrl, ...)
if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) {
throw new Error(`XAA: PRM resource mismatch: expected ${serverUrl}, got ${prm.resource}`)
}
return { resource: prm.resource, authorization_servers: prm.authorization_servers }
}
RFC 8414 发行者不匹配保护:
export async function discoverAuthorizationServer(asUrl: string, opts?): Promise<...> {
if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
throw new Error(`XAA: issuer mismatch: expected ${asUrl}, got ${meta.issuer}`)
}
// HTTPS 强制
if (new URL(meta.token_endpoint).protocol !== 'https:') {
throw new Error(`XAA: refusing non-HTTPS token endpoint: ${meta.token_endpoint}`)
}
}
Token 泄露防护:
const SENSITIVE_TOKEN_RE =
/"(access_token|refresh_token|id_token|assertion|subject_token|client_secret)"\s*:\s*"[^"]*"/g
function redactTokens(raw: unknown): string {
const s = typeof raw === 'string' ? raw : jsonStringify(raw)
return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`)
}
所有包含 token 的日志输出都经过 redactTokens 处理,确保调试日志不会泄露敏感凭据。正则表达式匹配六种 token 相关字段,无论 JSON 嵌套深度如何。
16.3.4 错误分类与 id_token 缓存策略
XaaTokenExchangeError 携带了是否应该清除缓存 id_token 的信息:
export class XaaTokenExchangeError extends Error {
readonly shouldClearIdToken: boolean
constructor(message: string, shouldClearIdToken: boolean) {
super(message)
this.name = 'XaaTokenExchangeError'
this.shouldClearIdToken = shouldClearIdToken
}
}
- 4xx 错误(invalid_grant 等):id_token 被拒绝,清除缓存
- 5xx 错误:IdP 暂时不可用,id_token 可能仍有效,保留缓存
- 200 但格式错误:协议违规,清除缓存
16.4 IdP 集成
xaaIdpLogin.ts 管理与身份提供商(Identity Provider)的集成。
16.4.1 OIDC 发现
// src/services/mcp/xaaIdpLogin.ts
export async function discoverOidc(issuer: string): Promise<OidcDiscovery> {
// 标准 OIDC 发现:/.well-known/openid-configuration
// 返回 authorization_endpoint, token_endpoint 等
}
16.4.2 id_token 生命周期
id_token 的获取和缓存遵循以下流程:
- 首次使用时通过 OIDC 授权码流(带 PKCE)获取 id_token
- id_token 缓存到系统 Keychain,按 IdP issuer 键控
- 后续的 XAA 请求直接使用缓存的 id_token
- 如果 id_token 过期或被拒绝,重新发起 OIDC 流程
这个设计的关键优势是一次浏览器登录即可访问所有 XAA 配置的 MCP 服务器——id_token 是按 IdP 缓存的,而非按 MCP 服务器。
16.4.3 XAA 配置
export type XaaConfig = {
clientId: string // MCP 服务器 AS 的 client_id
clientSecret: string // MCP 服务器 AS 的 client_secret
idpClientId: string // IdP 的 client_id
idpClientSecret?: string // IdP 的 client_secret(可选)
idpIdToken: string // 用户的 id_token
idpTokenEndpoint: string // IdP 的 token endpoint
}
注意 IdP client_secret 是可选的——某些 IdP 将 Claude Code 注册为 Public Client,只需要 PKCE 而不需要 secret。
16.5 通道权限
channelPermissions.ts 实现了通过外部通道(如 Telegram)进行权限审批的机制。
16.5.1 通道权限架构
// src/services/mcp/channelPermissions.ts
export type ChannelPermissionCallbacks = {
onResponse(requestId: string, handler: (response: ChannelPermissionResponse) => void): () => void
resolve(requestId: string, behavior: 'allow' | 'deny', fromServer: string): boolean
}
当 Claude Code 遇到权限请求时,它不仅显示本地 UI 对话框,还同时通过活跃的通道 MCP 服务器发送审批请求。第一个响应者(本地 UI 或远程通道)获胜。
16.5.2 短 ID 生成
const ID_ALPHABET = 'abcdefghijkmnopqrstuvwxyz' // 25 字母,无 'l'
export function shortRequestId(toolUseID: string): string {
let candidate = hashToId(toolUseID)
for (let salt = 0; salt < 10; salt++) {
if (!ID_AVOID_SUBSTRINGS.some(bad => candidate.includes(bad))) {
return candidate
}
candidate = hashToId(`${toolUseID}:${salt}`)
}
return candidate
}
短 ID 的设计考虑周到:
- 使用 25 个字母(排除了
l,因为它在很多字体中与1/I混淆) - 5 字母给出 25^5 = 9.8M 的空间,对单会话内的并发请求绰绰有余
- 纯字母避免手机用户切换键盘模式
- 脏词过滤确保 ID 可以安全地出现在手机短信中
16.5.3 通道能力检查
export function filterPermissionRelayClients<T extends {
type: string; name: string;
capabilities?: { experimental?: Record<string, unknown> }
}>(
clients: readonly T[],
isInAllowlist: (name: string) => boolean,
): (T & { type: 'connected' })[] {
return clients.filter(
(c): c is T & { type: 'connected' } =>
c.type === 'connected' &&
isInAllowlist(c.name) &&
c.capabilities?.experimental?.['claude/channel'] !== undefined &&
c.capabilities?.experimental?.['claude/channel/permission'] !== undefined,
)
}
一个 MCP 服务器必须同时满足四个条件才能成为权限审批通道:
- 已连接状态
- 在会话的
--channels允许列表中 - 声明了
claude/channel能力 - 声明了
claude/channel/permission能力
第四个条件是服务器的显式 opt-in——一个聊天中继通道不会因为它能转发消息就自动成为权限审批表面。源码注释中引用了一位安全审查者的顾虑:“users may be unpleasantly surprised”,这个四重检查就是对此的回应。
16.5.4 安全边界分析
源码注释中的安全分析值得完整引用:
/**
* Kenneth's "would this let Claude self-approve?": the approving party is
* the human via the channel, not Claude. But the trust boundary isn't the
* terminal — it's the allowlist (tengu_harbor_ledger). A compromised
* channel server CAN fabricate "yes <id>" without the human seeing the
* prompt. Accepted risk: a compromised channel already has unlimited
* conversation-injection turns (social-engineer over time, wait for
* acceptEdits, etc.); inject-then-self-approve is faster, not more
* capable. The dialog slows a compromised channel; it doesn't stop one.
*/
这段分析承认了一个已接受的风险:被入侵的通道服务器可以伪造审批。但它论证了这不会增加攻击者的能力——一个已入侵的通道已经可以注入任意对话内容,等待 acceptEdits 模式后执行任意操作。权限对话框增加了攻击的延迟,但不能阻止已经控制了通道的攻击者。
通道权限的回复格式使用结构化事件而非文本匹配:
// CC 生成 ID 并发送提示
// 服务器解析用户回复 "yes tbxkq"
// 服务器发送结构化事件:notifications/claude/channel/permission
// { request_id: "tbxkq", behavior: "allow" }
// CC 匹配 pending map,不做文本正则匹配
CC 永远不对通道中的文本做正则匹配——只接受服务器发送的结构化事件。这确保了通道中的普通对话文本不会意外触发权限审批。
flowchart TD
A[权限请求] --> B{通道权限已启用?}
B -->|否| C[仅本地 UI]
B -->|是| D[过滤合格通道]
D --> E{有合格通道?}
E -->|否| C
E -->|是| F[生成短 ID]
F --> G[并行发送]
G --> H[本地 UI 对话框]
G --> I[通道审批请求]
H --> J{竞赛: 谁先响应?}
I --> J
J -->|本地 UI| K[应用本地决策]
J -->|通道| L[应用通道决策]
K --> M[取消另一方]
L --> M
整个 MCP 认证体系展现了对真实世界复杂性的深刻理解:OAuth 服务器的非标准行为、跨进程的 token 管理、企业级的 IdP 集成、移动通道的安全模型。每一层都在标准协议的基础上增加了防御性工程,确保在各种边界条件下仍能正确运作。