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

成功路径端到端

B7 这一周是「给 MCP 工具网关装上 OAuth 2.1 资源服务器(RS)能力」的完整闭环。Day 61-64 建机理与 guard(RS 模型 / 三类攻击 / jose 校验内核 / 接进 toolRegistry),Day 65 走的是拒绝路径(401/403 + WWW-Authenticate),今天 Day 66 走的是成功路径——把一个只含最小 scope 的 token 注进

阶段: B7 · OAuth 2.1 + MCP 安全 + CI gate(Day 61-70) 标签: #oauth21 #least-privilege #audit-trail #mcp-security

今日导引(由浅入深)

B7 这一周是「给 MCP 工具网关装上 OAuth 2.1 资源服务器(RS)能力」的完整闭环。Day 61-64 建机理与 guard(RS 模型 / 三类攻击 / jose 校验内核 / 接进 toolRegistry),Day 65 走的是拒绝路径(401/403 + WWW-Authenticate),今天 Day 66 走的是成功路径——把一个只含最小 scope 的 token 注进 agent,让它经 server 成功调 2 个工具,并把 sub/aud/jti 写进审计行。在 B1→B18 能力曲线上,这是「安全可观测」支柱从「会拒」补齐到「会放且可追溯」的一格;明天 Day 67 起转向 eval CI gate(评测支柱)。今天的最小可判定产出:一份含 200 + 2 次工具调用 + 审计行 的成功 transcript(端到端实跑标注为待跑,需 key)。

由浅入深三层

  • :成功路径 = 让带 token 的请求返回 200。
  • :200 不够——成功路径多一层要求:放行的同时留痕(sub/aud/jti 审计行),否则合规视角下和裸放行无异。
  • :注入的 token 只含完成任务所需的最小 scope(scope 收敛),且每次调用把责任链钉进审计行,使「谁、用哪个 token、对哪个受众、调了什么」可重建。这是合规审计轨迹的基石。

1. 机理精读

最小权限(least privilege)在 token 层的落点 = scope 收敛。 OAuth 2.1 里 access token 的 scope 声明持有者「被授权做什么」。成功路径的关键不是「给一个能调所有工具的万能 token」,而是只注入完成当前任务所需的最小 scope 集合。这样即使 token 在会话中途泄露,攻击面也被限制在那几个 scope 上——与 Day 64「per-call scope 双校验」是同一最小权限原则在「签发面 vs 执行面」的两端:Day 64 管「调用时校验」,Day 66 管「签发时只给该给的」。

审计字段三元组 sub / aud / jti 是合规审计轨迹的基石。 一条可追溯的审计行要回答「谁、用哪个 token、对哪个受众、调了什么」:

  • sub(subject)= 主体身份,回答「谁」——是哪个 agent / 用户在调用;
  • aud(audience)= 受众,回答「这个 token 是签给哪个 RS 用的」——配合 RFC 8707(Resource Indicators, 现行)阻止 token 被转去打另一个服务(confused deputy);
  • jti(JWT ID)= token 唯一 id,回答「是哪一次签发的 token」——让同一主体的多次会话/多枚 token 可区分,也是 token 撤销列表(denylist)和重放检测的挂钩点。

为什么把这三者写进审计行而不是只记业务参数? 合规审计(尤其 AML 场景)要的是「可重建的责任链」:监管或事后复盘时,要能从一次 sar.draft 调用反查到「2026-xx-xx,主体 X 用 jti=Y 的 token(aud 限定本 RS、含 sar:write scope)发起」。业务参数(金额、账户)只回答「做了什么」,sub/aud/jti 才回答「谁有权这样做、凭据是否合法」——后者才是合规要追的那条线。

与相邻概念的边界。 jti 不是 session id:本仓 MCP server 是 stateless(Day 64/B6,每请求新建 server+transport),没有协议级 session;jti 是「token 维度」的唯一标识,不是「连接维度」的。审计行也不等于可观测的 trace span(那是 OTel/Langfuse 的事,本仓标为待接线)——审计行是合规证据(要持久、可不可篡改是重点),trace 是运维观测(可采样、可丢)。两者目标不同。

成功路径 vs 拒绝路径的对称性。 Day 65 处理「该拒就拒」:无 token→401、错 aud→reject、过期→reject、scope 不足→403。今天 Day 66 处理「该放就放」,但它不是 Day 65 的简单取反——成功路径多了一层「放行的同时留痕」的要求。一个只会返回 200、却没留下 sub/aud/jti 的 server,在合规视角下和「裸放行」无异:监管问「这次 sar.draft 是谁授权的」时答不上来。所以成功路径的难点不在「让它通过」,而在「通过时把责任链钉死」。

scope 收敛的粒度。 本仓 server 的 gate 当前只校验一个粗粒度 scope mcp:call(server.ts:48)——「能不能调 MCP」。而 Day 61 草拟的 5×scope 矩阵是更细的目标形态:每个受保护 AML 工具配独立 scope,使「能调 evidence.fetch」不等于「能调 sar.draft」。下面是该矩阵的形态(教学目标,非当前 server 已实现的细粒度):

工具所需最小 scope成功路径是否注入
evidence.fetchaml:evidence:read是(调查需读证据)
typology.matchaml:typology:read是(比对类型学)
sar.draftaml:sar:write否(本次任务不写 SAR)
audit.writeaml:audit:write是(系统侧自动写审计)
case.readaml:case:read视任务而定

成功路径的 scope 收敛即「只注入本次任务真正用到的那几行」——上表中若本次只做证据汇集+类型学比对,就只给 aml:evidence:read+aml:typology:read,不给 aml:sar:write。粗粒度 mcp:call → 细粒度 per-tool scope 是从 demo 走向生产的演进方向(待建)。

来源:OAuth 2.1(draft,2026 现行收敛中)、RFC 8707 Resource Indicators、MCP Security Best Practices(2026-01)。

2. 代码走读(src/agent/mcp/auth.ts + server.ts)

成功路径在本仓由 mintTokenverifyAccessTokenrequireScope 三原语 + server 的 200 分支构成。真实符号走读:

  1. mintToken(secret, { sub, scopes, ttlSec?, issuer?, audience? })(auth.ts:17)——用 jose 的 SignJWT 造一枚 HS256 token:.setSubject(opts.sub) 写入 subscopeopts.scopes.join(' ')(空格分隔,OAuth 约定),.setExpirationTime(nowSec + (ttlSec ?? 3600))成功路径的 scope 收敛就发生在调用方传入的 scopes 数组——只放该任务需要的几个。注意:当前 mintToken 写了 sub,但 jti 未由 mintToken 自动注入(jose 需 .setJti() 显式设置),所以「审计行写 jti」这一步是消费侧/审计侧的职责,本仓 auth.ts 尚未内建 jti 生成——这点要诚实标注,不能假装 token 自带 jti。
  2. verifyAccessToken(token, { secret, issuer?, audience? })(auth.ts:33)——jwtVerify 一次性校验签名 + iss/aud/exp/nbfalgorithms: ['HS256'] 钉死算法(防 alg confusion)。成功时返回 payload as TokenClaims
  3. tokenScopes(claims)(auth.ts:45)——把 claims.scope\s+ 切成数组;requireScope(claims, scope)(auth.ts:50)在缺 scope 时抛 insufficient_scope: requires "..."。成功路径下这两个不抛错即放行。
  4. server.ts 的 200 分支(server.ts:40-54)——startHttpServerprocess.env.MCP_AUTH_SECRET 存在时启用 gate:bearerFromHeader 取 token → verifyAccessTokenrequireScope(claims, 'mcp:call')两步都通过才落到下面 createMcpServer() + transport.handleRequest,真正派发 tools/call 返回 200。成功路径就是「带正确 scope token → 跳过 401 分支 → tools/call 执行」。
  5. 2 次工具调用挂哪里。 server 注册的工具来自 MCP_TOOLS(src/agent/mcp/tools.ts,含 bpe_report / paired_bootstrap / assess_structuring 等);成功 transcript 里「2 个工具」即 agent 连续发两条 tools/call,各得一条 200。
  6. 审计行风格参照 contextAudit.ts seed 说「复用 contextAudit.ts 风格」——注意 contextAudit.ts 实际做的是上下文 token 预算auditToolContext / contextBudget,用从零 BPE 估 relative token 成本),不是 OAuth 审计日志。所以「复用其风格」指的是「逐行结构化、纯函数、确定性」这种写法风格,而非直接调用它的函数。审计行字段(sub/aud/jti + tool + ts)需新写,不能误称 contextAudit 已产出 OAuth 审计行。

成功路径报文形态(手算走一遍)。 把一次成功调用拆成可验证的报文序列,便于判定 transcript 是否真成立:

  1. 签发:mintToken(secret, { sub:'agent-aml', scopes:['mcp:call'], audience:'momoweb3-aml-mcp', ttlSec:600 }) → 一枚 HS256 JWT,payload 含 { sub:'agent-aml', scope:'mcp:call', aud:'momoweb3-aml-mcp', iat, exp:iat+600 }
  2. 请求 1:POST /mcpAuthorization: Bearer <jwt>,body 为 {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"assess_structuring","arguments":{...}}}
  3. server:bearerFromHeader 取出 jwt → verifyAccessToken 通过(签名+aud+exp 都对)→ requireScope(claims,'mcp:call') 通过 → tools/call 执行 → 返回 200,结果在 result.content
  4. 审计行 1:{ ts, sub:'agent-aml', aud:'momoweb3-aml-mcp', jti:'<uuid>', tool:'assess_structuring', ok:true }
  5. 请求 2 + 审计行 2:同上换 bpe_report(或另一工具)→ 第二条 200 + 第二条审计行。

判定标准:transcript 里要能看到两条 200 + 两条 sub/aud/jti 齐全的审计行——少任一项都不算成功路径达成。

3. 今日实战

  1. mintToken(secret, { sub: 'agent-aml-investigator', scopes: ['mcp:call'], audience: 'momoweb3-aml-mcp' }) 造一枚只含最小 scope 的 token(成功路径的核心动作:不给多余 scope)。
  2. MCP_AUTH_SECRET=<secret> pnpm mcp:serve 起 server(server.ts 的 gate 据此门控)。
  3. 让 DeepSeek-V4(provider-agnostic runner 默认 deepseek)带该 token,经 server 连发 2 条 tools/call,各取一条 200。
  4. 每次调用写一条审计行 { ts, sub, aud, jti, tool, args_digest }(结构化、确定性,体例对齐 contextAudit.ts 的纯函数行式输出)。
  5. 把成功 transcript(含 200 + 2 工具 + 审计行)落 docs/aipa/day66-success-path.md,归档进作品集索引。
  6. 与 Day 65「被拒(403)」transcript 配对,形成「同一 server、一拒一放」的对照证据(Day 70 收口归档)。

4. 今日实测 / 产出

  • auth.tsmintToken/verifyAccessToken + server 200 路径:已 built+tested(无 token→401 已实测,mint 合法 token→200 已实测,整体 423 tests green)。
  • 「成功」transcript 文件(200 + 2 工具调用 + 审计行)committed —— 端到端 agent 实跑标注为 待跑(需 key 跑),将产出含 2 次 200 工具调用 + 审计行的真实记录。
  • 诚实补充:jti 自动注入在 auth.ts 尚未内建(mintToken 仅设 sub/scope/exp),审计侧 jti 生成为 待建;不得宣称 token 已自带 jti。

5. 常见误区 / 陷阱

  • 「成功就该给宽 scope」:把一枚 */宽 scope token 复用于所有工具直接违反最小权限——成功路径的难点恰恰是 scope 收敛到最小。
  • jti 当 session id:stateless server 没有协议级 session,jti 是 token 维度唯一 id,别混为会话标识。
  • 审计行只记业务参数不记 sub/aud/jti:那样只能回答「做了什么」,回答不了「谁有权这样做」,合规审计链断裂。
  • 误以为 contextAudit.ts 已产出 OAuth 审计行:它做的是 token 预算估算,与 OAuth 审计是两回事,只借「纯函数行式」写法风格。

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

  • OAuth 2.1 Authorization Framework(IETF draft,2026 现行收敛中)— scope / token 模型。
  • RFC 8707 Resource Indicators for OAuth 2.0(2020-02,现行)— aud 受众限定,防 confused deputy。
  • RFC 9728 Protected Resource Metadata(2025-04)— RS 元数据发现。
  • RFC 7519 JSON Web Token(2015-05,经典)— sub/aud/jti/exp 标准 claim 定义。
  • MCP Security Best Practices(2026-01)— 最小权限 + 审计字段建议。
  • jose(npm,维护活跃,2026 执行当周核版本)— SignJWT.setSubject/setJtijwtVerify
  • 本仓 src/agent/mcp/auth.ts + server.ts(2026-06)— 成功路径 200 分支、mint/verify/requireScope 三原语。

附:概念辨析(易混淆)

  • sub vs audsub 是「谁在调」(主体),aud 是「token 签给哪个 RS 用」(受众)。校验 aud 命中自己才放行,是防 confused deputy 的关键。
  • jti vs session idjti 是 token 维度唯一 id(每枚 token 一个);session id 是连接维度(stateless server 没有)。两者不可混。
  • scope 收敛 vs scope 校验:收敛在「签发面」(只给该给的,Day 66);校验在「执行面」(每次调用 per-call 比对,Day 64)。同一最小权限原则的两端。
  • 审计行 vs trace span:审计行是合规证据(持久、append-only、防篡改);trace span 是运维观测(可采样、可丢)。目标不同,不可互替。
  • 401 vs 403:401 未认证/token 无效(Day 65);403 已认证但 scope 不足。成功路径是「既过认证又过 scope」。

附:production gap 清单(诚实)

为避免把 demo 误当生产,明确本日成功路径的已建/待建边界:

  • 已建:HS256 自签 token 的 mint→verify→requireScope 闭环;server 的 401/200 分支(已实测)。
  • 待建:① jti 自动注入与重放检测(auth.ts 未内建 .setJti());② per-tool 细粒度 scope(当前只校 mcp:call);③ RS256+JWKS(生产正解,当前对称密钥仅供本地/测试);④ 审计行的持久化与防篡改存储(合规要求 append-only)。这些是从「教学装置」走向「可上线 RS」的剩余工作量。

SOTA检查 (2026-06 更新)

  • 当前主流:最小权限 token + jti 审计 = MCP Security Best Practices(2026-01)共识做法,仍 SOTA。
  • 待复查:MCP 2026-07-28 最终规范定稿后,须复验「审计字段是否被标准化」(若标准化,本仓审计行字段名需对齐规范)。
  • 过时黑名单:① 「同一宽 scope token 复用于所有工具」违反最小权限;② 「MCP 无授权层」旧叙事(2025 早期 spec)已过时;③ HS256 当生产方案(应迁 RS256+JWKS)。
  • 审计字段是否仍 SOTAsub/aud/jti 三元组是 RFC 7519 标准 claim + 2026-01 最佳实践的交集,仍 SOTA;待 07-28 spec 看是否补充 MCP 专属审计字段(如 tool 调用链 id)。
  • 下次复查点:2026-07-28(MCP spec 定稿,复验审计字段标准化与 RS 元数据端点要求);jose 版本执行当周核对。

自测题(讲得出才算掌握)

  1. 成功路径为什么不只是「返回 200」?少了什么就不算达成?(答:少 sub/aud/jti 审计行,合规视角等于裸放行。)
  2. audsub 各回答什么问题?aud 校验防的是哪类攻击?(答:sub=谁调、aud=签给哪个 RS;aud 校验防 confused deputy。)
  3. 本仓 server 当前校验的是哪个 scope?细粒度 per-tool scope 为何更安全?(答:mcp:call;细粒度使「能调 A」不等于「能调 B」。)
  4. 为什么 jti 不能当 session id?(答:stateless server 无协议级 session,jti 是 token 维度唯一 id。)

衔接

  • 昨天:Day 65 — 拒绝路径与错误语义(WWW-Authenticate):401/403 分清、error="insufficient_scope"
  • 今天:成功路径 = scope 收敛 + sub/aud/jti 审计行,让每次 200 工具调用可追溯。
  • 明天:Day 67 — eval CI gate 设计(M2):从「安全支柱」转入「评测支柱」,把可信基线冻成快照、fail-closed 阻断回归。