返回 AICAP-180
B7 · Day 65OAuth 2.1 + MCP 安全 + CI gate

拒绝路径与错误语义 (WWW-Authenticate)

D64 把 guard 接到了网关,「拦得住」了;但怎么拒和拦不拦得住同样重要。今天打磨拒绝路径的语义——401 与 403 的区别、WWW-Authenticate 头该回什么、error="invalid_token" vs error="insufficient_scope"。为什么 PM/架构师要在意这种细节?因为自动化 agent 要靠这些语义自我修复:401 告诉它「去重新取 toke

阶段: B7 · OAuth 2.1 + MCP 安全 + CI gate(Day 61-70) 标签: #www-authenticate #401-vs-403 #insufficient-scope #agent-self-heal

今日导引(由浅入深)

D64 把 guard 接到了网关,「拦得住」了;但怎么拒拦不拦得住同样重要。今天打磨拒绝路径的语义——401 与 403 的区别、WWW-Authenticate 头该回什么、error="invalid_token" vs error="insufficient_scope"。为什么 PM/架构师要在意这种细节?因为自动化 agent 要靠这些语义自我修复:401 告诉它「去重新取 token」,403 告诉它「这个工具你权限不够,别再试了」。语义错了,agent 要么死循环重取 token,要么放弃本可补救的调用。这是 B7 从「安全」延伸到「可被机器消费的错误契约」的一天。最小可判定产出:一份含 403 + insufficient_scope 的「被拒」transcript。

1. 机理精读

401 vs 403:认证 vs 授权,必须分清。

  • 401 Unauthorized(语义其实是 Unauthenticated):没认证 / token 无效——没带 token、token 过期、签名错、aud 不对。含义是「我不知道你是谁 / 你的凭据无效」。补救:去(重新)取一个有效 token
  • 403 Forbidden已认证但权限不足——token 有效(签名对、没过期、aud 对),但携带的 scope 不覆盖本次调用的工具。含义是「我知道你是谁,但你没这个权限」。补救:别用这个 token 重试同一动作(重取 token 也没用,除非换一个含更大 scope 的 token,而那通常需要重新授权流程)。

把这两个混了,后果具体而严重:把 scope 不足错回成 401 → agent 以为是 token 失效 → 拿同一套凭据去 AS 反复重取 token → 死循环 + 打爆 AS。把 token 失效错回成 403 → agent 以为是权限问题 → 放弃了本可以靠刷新 token 解决的调用。

WWW-Authenticate 头:让拒绝「可被机器消费」。 RFC 6750/9728 要求 401 回 WWW-Authenticate: Bearer error="invalid_token",并可携带指向 protected-resource-metadata 的提示(resource_metadata=...),让 client 知道去哪个 AS 重新取 token。scope 不足则回 error="insufficient_scope"(可附 scope="aml:write" 说明缺哪个)。这套头不是给人看的,是给 agent 的自动化控制流读的——agent 解析 error 字段就能决定下一步:invalid_token→重取 token;insufficient_scope→升级权限或放弃。语义正确才能让自动化 agent 自我修复,否则 agent 只能瞎试。

为什么这对 AML Copilot 尤其关键。 AML 场景里,一个初筛 agent 被设计成只读(aml:read),它若误调 sar.draft(需 aml:write),server 必须回403 + insufficient_scope——这既是安全边界(最小权限生效),也是给上层编排一个清晰信号:「这一步需要升级到有 SAR 起草权限的 human/agent」。403 在这里同时是拒绝、是审计事件、是 HITL(human-in-the-loop)触发器。

401 vs 403 的判定表 + agent 自愈控制流

情形状态码WWW-Authenticate erroragent 应做
无 token401(无 error,仅 Bearer走授权流取 token
签名错/伪造401invalid_token重取 token(旧凭据废了)
过期401invalid_token刷新/重取 token
aud 不对401invalid_token按 RFC 9728 metadata 找对的 AS 重取
scope 不足403insufficient_scope(附 scope=不重试同动作;升级权限或交 HITL

控制流伪码(agent 侧):

resp = call_tool(token)
if resp.status == 401: token = reacquire(); retry
elif resp.status == 403: escalate_to_human()   # 重取无用
else: proceed

注意 401 与 403 触发截然不同的分支——这就是「语义正确才能让自动化 agent 自我修复」的字面含义。

边界:401/403 是 HTTP 传输层语义;JSON-RPC 工具层还有自己的错误码(-32602 INVALID_PARAMS 等,见 toolRegistry.ts)。两层不冲突:OAuth gate 在 HTTP 层先用 401/403 拦掉未授权请求,过了 gate 才进 JSON-RPC 层用 -32xxx 报工具/参数错。

为什么 body 必须 opaque(oracle 攻击补充)。 若 401 的 body 直接回 jose 的原文错误,攻击者就能从响应区分「token 过期」与「签名无效」:拿一个伪造 token 不断试,若回「过期」说明签名其实通过了(即攻击者猜中了密钥结构),若回「签名错」说明没猜中——这构成一个 signature/expiry oracle,把本应不可见的内部状态泄露给攻击者。本仓 server.ts 对所有 verify 失败统一回 opaque invalid_token,正是堵这个泄露。同理,403 也只该说「scope 不足」+ 缺哪个 scope(这个可以说,因为它是授权契约的一部分),但不该泄露 token 的其它内部状态。

2. 推导 / 手算 / 代码走读

走读本仓拒绝路径的真实实现:

HTTP 层(src/agent/mcp/server.tsstartHttpServer

  • 无 token:res.writeHead(401, { 'WWW-Authenticate': 'Bearer' }).end('missing bearer token') — 401 + WWW-Authenticate
  • token 无效(验签/过期/scope 抛错被 catch):res.writeHead(401, { 'WWW-Authenticate': 'Bearer error="invalid_token"' }).end('invalid_token') — 注意它对所有 verify/requireScope 失败统一回 401 + invalid_token,body 是 opaque(注释:不回显 jose 错误,避免 expiry/signature oracle)。

诚实标注:seed 的目标语义是「scope 不足回 403 + insufficient_scope」。但本仓 server.ts 当前把 requireScope(claims, 'mcp:call') 的失败也归入 catch → 统一回 401 invalid_token并未在 HTTP 层区分出 403/insufficient_scope。能产出真正 403 + insufficient_scope 的是单元层 auth.tsrequireScope——它抛 insufficient_scope: requires "<scope>",并由 src/agent/__tests__/mcp/auth.test.tsexpect(() => requireScope(claims, 'admin')).toThrow(/insufficient_scope/) 覆盖(已绿)。也就是说:401 路径已在 server.ts 实测;403/insufficient_scope 的语义目前只在 auth.ts 函数层成立,HTTP 层尚未拆出独立 403 分支。不要把「server 回 403」写成已完成——它现在回 401。

单元层(src/agent/mcp/auth.ts

  • requireScope(claims, scope)if (!tokenScopes(claims).includes(scope)) throw new Error('insufficient_scope: requires "${scope}"') — 这是 insufficient_scope 语义的源头。
  • 若要让 HTTP 层产出真 403,需把 requireScope 的失败与 verifyAccessToken 的失败分开 catch:前者 → 403 WWW-Authenticate: Bearer error="insufficient_scope",后者 → 401 invalid_token。这是落地 seed 语义的下一步改动点(当前未做,标待建)。

端到端(agent 经 pnpm mcp:serve

  • pnpm mcp:servescripts/mcp-serve.tsstartHttpServer(8765),控制台打印 tools: bpe_report, paired_bootstrap, assess_structuring
  • 用 DeepSeek-V4(provider-agnostic runner 默认 deepseek)扮演无 scope 的 agent,带一个不含所需 scope 的 token 调受保护工具,捕获被拒响应 → 落成 transcript。

3. 今日实战

  1. MCP_AUTH_SECRETpnpm mcp:serve 起 server。
  2. 用 DeepSeek-V4 runner(默认 deepseek provider)扮演无 scope 的 agent,调受保护工具,捕获被拒响应。
  3. 把「被拒」transcript(目标含 403 + insufficient_scope)落成文件 committed。
  4. 若要真正产出 403 而非 401:先在 server.tsrequireScope 失败拆成独立 403 分支(落地 seed 语义)。

4. 今日实测 / 产出

  • 「被拒」transcript 文件(含 403 + insufficient_scope)committed。
  • 诚实标注(逐字保留 seed):auth.ts/server.ts 拒绝语义已 built+tested(无 token→401 已实测);agent 端到端跑这份 transcript 为 待跑(需 key 跑)key 现已配置,将产出含 403 的真实记录
  • 补充诚实标注:当前 server.ts 对 scope 不足回的是 401 invalid_token,HTTP 层的 403/insufficient_scope 拆分尚未落地(待建);403 语义目前仅在 auth.tsrequireScope 函数层 + auth.test.ts 成立。不得把 server 回 403 写成已完成。

5. 常见误区 / 陷阱

  1. 401/403 混用:scope 不足回 401 → agent 死循环重取 token;token 失效回 403 → agent 放弃可补救的调用。
  2. WWW-Authenticate 或不带 error:agent 解析不到 invalid_token/insufficient_scope,无法自愈,只能盲目重试。
  3. body 回显验证细节:把「过期 vs 签名错」写进 body → token 状态 oracle;body 必须 opaque(本仓已做)。
  4. 以为 server 已回 403:本仓当前对 scope 不足回 401;真 403 分支需手动拆 catch(别照 seed 的目标当成现状)。

6. 学习资源(每条带 YYYY-MM)

  • RFC 6750 OAuth 2.0 Bearer Token Usage(IETF,2012-10)— WWW-Authenticateinvalid_tokeninsufficient_scope
  • RFC 9728 Protected Resource Metadata(IETF,2025-04)— 401 指向 AS 的 metadata 提示。
  • RFC 7235 HTTP/1.1 Authentication(IETF,2014-06)— 401 vs 403 的 HTTP 层定义。
  • MCP Security Best Practices(modelcontextprotocol.io,2026-01)— 拒绝语义与 agent 自愈。
  • DeepSeek API 文档(platform.deepseek.com,2026-06)— 模型 id deepseek-v4-pro/-flash
  • 本仓代码:src/agent/mcp/server.tssrc/agent/mcp/auth.tsscripts/mcp-serve.ts(2026-06)。

SOTA检查 (2026-06 更新)

  • 当前主流WWW-Authenticate: Bearer error="..." + RFC 9728 metadata 指向为当前推荐的拒绝语义;401/403 严格区分是 agent 自愈的前提。
  • 是否仍 SOTA:是。待 2026-07-28 MCP spec 定稿复查 metadata 指向是否成强制。
  • 过时黑名单:DeepSeek 模型 id 用 deepseek-v4-pro/-flashlegacy deepseek-chat/-reasoner 2026-07-24 退役),避免引用旧 id;避免回笼统 401 而不区分 403(agent 无法自愈)。
  • 下次复查点:2026-07-24(DeepSeek 旧 id 退役)核对 runner 默认 id;2026-07-28 spec 定稿复查 metadata 强制性。

衔接

  • 昨天:Day 64 — 把校验接到 MCP 工具网关(事中拦截,401/200)。
  • 今天:拒绝路径语义(401 vs 403、WWW-Authenticateinsufficient_scope),让 agent 能自愈;403 端到端 transcript 待跑(需 key)。
  • 明天:Day 66 — 成功路径端到端(scope 收敛 + sub/aud/jti 审计行)。