拒绝路径与错误语义 (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 error | agent 应做 |
|---|---|---|---|
| 无 token | 401 | (无 error,仅 Bearer) | 走授权流取 token |
| 签名错/伪造 | 401 | invalid_token | 重取 token(旧凭据废了) |
| 过期 | 401 | invalid_token | 刷新/重取 token |
aud 不对 | 401 | invalid_token | 按 RFC 9728 metadata 找对的 AS 重取 |
| scope 不足 | 403 | insufficient_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.ts 的 startHttpServer):
- 无 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.ts的requireScope——它抛insufficient_scope: requires "<scope>",并由src/agent/__tests__/mcp/auth.test.ts的expect(() => 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:serve→scripts/mcp-serve.ts→startHttpServer(8765),控制台打印tools: bpe_report, paired_bootstrap, assess_structuring。- 用 DeepSeek-V4(provider-agnostic runner 默认 deepseek)扮演无 scope 的 agent,带一个不含所需 scope 的 token 调受保护工具,捕获被拒响应 → 落成 transcript。
3. 今日实战
- 设
MCP_AUTH_SECRET,pnpm mcp:serve起 server。 - 用 DeepSeek-V4 runner(默认 deepseek provider)扮演无 scope 的 agent,调受保护工具,捕获被拒响应。
- 把「被拒」transcript(目标含 403 +
insufficient_scope)落成文件 committed。 - 若要真正产出 403 而非 401:先在
server.ts把requireScope失败拆成独立 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.ts的requireScope函数层 +auth.test.ts成立。不得把 server 回 403 写成已完成。
5. 常见误区 / 陷阱
- 401/403 混用:scope 不足回 401 → agent 死循环重取 token;token 失效回 403 → agent 放弃可补救的调用。
- 空
WWW-Authenticate或不带error:agent 解析不到invalid_token/insufficient_scope,无法自愈,只能盲目重试。 - body 回显验证细节:把「过期 vs 签名错」写进 body → token 状态 oracle;body 必须 opaque(本仓已做)。
- 以为 server 已回 403:本仓当前对 scope 不足回 401;真 403 分支需手动拆 catch(别照 seed 的目标当成现状)。
6. 学习资源(每条带 YYYY-MM)
- RFC 6750 OAuth 2.0 Bearer Token Usage(IETF,2012-10)—
WWW-Authenticate、invalid_token、insufficient_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.ts、src/agent/mcp/auth.ts、scripts/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/-flash(legacydeepseek-chat/-reasoner2026-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-Authenticate、insufficient_scope),让 agent 能自愈;403 端到端 transcript 待跑(需 key)。 - 明天:Day 66 — 成功路径端到端(scope 收敛 + sub/aud/jti 审计行)。