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

JWT 校验内核 (jose)

D61 定身份、D62 写红测试,今天进入 TDD 绿阶段第一步:把 JWT 校验内核跑通。这是 B7 里第一段真代码落地——用 jose 库实现 mintToken/verifyAccessToken,让 D62 那批红断言里的「过期拒/错密钥拒/audience 限定/scope 校验」逐条变绿。今天只追求一条 happy-path:mint 一个有效 token → verify 通过 →

阶段: B7 · OAuth 2.1 + MCP 安全 + CI gate(Day 61-70) 标签: #jwt #jose #hs256 #rs256-jwks

今日导引(由浅入深)

D61 定身份、D62 写红测试,今天进入 TDD 绿阶段第一步:把 JWT 校验内核跑通。这是 B7 里第一段真代码落地——用 jose 库实现 mintToken/verifyAccessToken,让 D62 那批红断言里的「过期拒/错密钥拒/audience 限定/scope 校验」逐条变绿。今天只追求一条 happy-path:mint 一个有效 token → verify 通过 → 拿到 claims。把这条闭环钉牢,D64 才能把它接到工具网关的 dispatch 前。最小可判定产出:1 条 happy-path 测试 green,无网络无 key。

1. 机理精读

JWT 校验的核心是「一次调用、多项检查」。 jose 的 jwtVerify(token, key, { issuer, audience }) 不只是验签名——它一次性校验:签名(防伪造/篡改)+ iss(issuer,token 是不是预期 AS 签的)+ aud(audience,token 是不是为这个 RS 签的,RFC 8707)+ exp(过期)+ nbf(not-before,生效时间)。把这些塞进一次调用,是为了不给开发者留「忘了验某项」的空子——尤其 aud,手写解析最常漏掉它(D62 的 token passthrough 就利用这个漏洞)。

HS256 vs RS256 + JWKS:对称 vs 非对称的取舍。 这是今天最该想清的权衡:

  • HS256(对称,共享密钥):签和验用同一个密钥。优点:零网络、确定性强、好测——本地拿着 secret 就能 mint 也能 verify,单元测试不依赖任何外部服务。缺点:密钥即可签可验,不可分发——你不能把这个 secret 交给 RS,否则 RS 就能伪造 token;所以 HS256 只适合「签验同体」的本地/测试场景。
  • RS256 + JWKS(非对称):AS 持私钥签,RS 只持公钥验。AS 把公钥集发布在 JWKS 端点(/.well-known/jwks.json),token header 里的 kid(key id)告诉 RS 用哪把公钥。优点:RS 永远拿不到签发能力(只有公钥),支持密钥轮换(换 kid,旧公钥保留一段时间让旧 token 平滑过期)。缺点:要处理 JWKS 缓存(不能每次验都拉远端)、kid 选键、轮换时序。这是生产正解。

为什么本仓选 HS256-pinned? 因为 AICAP 的纪律是「无 key、确定性、可测试」。auth.ts 注释写得明白:生产 RS 会用 RS256 via JWKS + audience-restrict per RFC 9728/8707,本地/demo/test 用 HS256 + 共享 secret。这不是偷懒,是把「授权逻辑的正确性」和「密钥分发的工程复杂度」解耦——前者今天就能确定性测试,后者标注为生产迁移项(不升级状态)。

维度HS256(本仓本地/测试)RS256 + JWKS(生产)
密钥单一共享 secretAS 私钥签 / RS 公钥验
RS 能否伪造 token能(持 secret 即可签)不能(只持公钥)
密钥轮换难(换 secret 全部失效)易(kid 选键,新旧并存)
网络依赖拉 JWKS(需缓存)
测试确定性需 mock JWKS
适用本地 demo / 单元测试多 RS 跨服务部署

alg pinning 这个细节别漏。 verifyAccessToken 显式传 algorithms: ['HS256']。这是防 alg-confusion 攻击:历史上有库默认「按 token header 的 alg 字段选验证方式」,攻击者把 alg 改成 none 或把 RS256 降级成 HS256(拿公钥当 HMAC 密钥)就能伪造。pin 死算法白名单,header 说什么都不信,是纵深防御。

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

走读 src/agent/mcp/auth.ts 的真实实现(jose-based,HS256-pinned):

  • keyOf(secret) = new TextEncoder().encode(secret) — 把字符串 secret 转成 jose 要的 Uint8Array
  • mintToken(secret, { sub, scopes, ttlSec=3600, issuer?, audience? })
    • new SignJWT({ scope: scopes.join(' ') }) — scope 以空格分隔塞进 payload(OAuth 惯例)。
    • .setProtectedHeader({ alg: 'HS256' }) — header pin HS256。
    • .setSubject(sub).setIssuedAt(nowSec).setExpirationTime(nowSec + ttlSec) — 标准 claims。
    • 关键测试钩子ttlSec 可为负(nowSec + (-10))→ 铸一个已过期 token,专供 D62 的过期拒断言。
    • 仅当传 issuer/audience 才链式 setIssuer/setAudience(可选 audience 绑定)。
  • verifyAccessToken(token, { secret, issuer?, audience? })
    • jwtVerify(token, keyOf(secret), { algorithms: ['HS256'], issuer, audience }) — 一次校签名 + iss + aud + exp/nbf。
    • 返回 payload as TokenClaims;任何一项不过都 reject(jose 抛错)。
  • tokenScopes(claims)claims.scope.split(/\s+/).filter(Boolean) — 空格切回数组。
  • requireScope(claims, scope):不含则抛 insufficient_scope: requires "<scope>"(OAuth 错误名,D65 接 403)。

happy-path 手推

mint('secret', { sub:'agent-1', scopes:['mcp:call','aml:read'] })
  → SignJWT payload={scope:'mcp:call aml:read'}, header={alg:HS256}, exp=now+3600
  → HMAC-SHA256(header.payload, secret) → token
verify(token, { secret:'secret' })
  → 验签名 ✓ exp 未到 ✓ (无 iss/aud 约束) → payload
  → claims.sub === 'agent-1' ✓  tokenScopes(claims) ⊇ ['mcp:call'] ✓

这正是 auth.test.ts 第一条 it('round-trips a valid token and exposes scopes') 的内容——已绿。

JWT 三段结构手拆(理解 jose 在验什么)。一个 JWT 是 base64url(header).base64url(payload).base64url(signature)

  • header{"alg":"HS256","typ":"JWT"} — 对应 setProtectedHeader({ alg: 'HS256' })
  • payload(claims){"scope":"mcp:call aml:read","sub":"agent-1","iat":...,"exp":...} — 标准 claims + 自定义 scope
  • signatureHMAC-SHA256(base64url(header) + "." + base64url(payload), secret)

verifyAccessToken 做的就是:用 secret 重算 signature 比对(验完整性/真实性),再读 payload 比 exp/iss/aud。任何一段被改(哪怕一字节),重算的 signature 就对不上,jose reject——这解释了 'rejects a forged/tampered token (wrong secret)' 为何成立(换 secret 重算必然不匹配)。

audience 限定的对照实验auth.test.ts 第 4 条 'audience-restricts (RFC 8707) when required'):mint 一个 audience:'mcp-A' 的 token,用 { audience:'mcp-B' } verify → jose 因 aud 不匹配 reject;用 { audience:'mcp-A' } verify → 通过且 sub 正确。这就是 RFC 8707 audience restriction 在代码里的最小可测形态——为 mcp-A 签的 token 打不动 mcp-B,D62 的 token passthrough 被这条堵死。

3. 今日实战

  1. 确认 src/agent/mcp/auth.tsverifyAccessToken(token, { aud, iss }) 用 jose 校验(已落地)。
  2. mintToken(secret, { sub, scopes, ttlSec }) 造一个有效 token,跑通 happy-path:mint → verify → 断言 sub 与 scopes。
  3. 测试环境注意:auth.test.ts 顶部标 // @vitest-environment node——因为 jose 用 Web Crypto + Uint8Array,jsdom 跨 realm 会报 "payload must be an instance of Uint8Array";server 实际跑在 Node,故在 Node 环境测。

4. 今日实测 / 产出

  • verifyAccessToken 通过 1 条 happy-path 测试(1 green)——mint→verify 闭环已验证,HS256-pinned,无网络无 key已完成
  • 生产应迁 RS256 + JWKS(kid 选键 + 轮换 + 缓存)。待建(生产迁移项)——不升级为已完成。

5. 常见误区 / 陷阱

  1. 手写 JWT 解析:自己 base64 解 payload、自己比 exp,极易漏 aud/nbf/alg pinning——用维护活跃的 jose。
  2. jsdom 跑 jose:默认 vitest jsdom 环境会因跨 realm 的 Uint8Array 报错,必须 @vitest-environment node
  3. 不 pin alg:默认按 token header 的 alg 选验证方式 → alg-confusion 降级攻击;必须 algorithms: ['HS256'] 白名单。
  4. 把 HS256 secret 当生产密钥分发:对称密钥给了 RS,RS 就能伪造 token;生产必须非对称。

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

  • jose 库文档(panva/jose,GitHub,2026-06 维护活跃)— SignJWT / jwtVerify API。
  • RFC 7519 JSON Web Token (JWT)(IETF,2015-05)— JWT 结构与标准 claims。
  • RFC 7518 JSON Web Algorithms (JWA)(IETF,2015-05)— HS256/RS256 定义。
  • RFC 7517 JSON Web Key (JWK)(IETF,2015-05)— JWKS / kid 轮换。
  • Auth0 Critical vulnerabilities in JSON Web Token libraries(2015,alg-confusion 经典分析;原理至今有效)。
  • 本仓代码:src/agent/mcp/auth.ts + src/agent/__tests__/mcp/auth.test.ts(2026-06)。

SOTA检查 (2026-06 更新)

  • 当前主流:jose 为现行主流 JS JWT 库(维护活跃,纯 Web Crypto,无依赖),HS256 用于本地/测试、RS256 + JWKS 用于生产是标准取舍。
  • 是否仍 SOTA:是。需定期核对 npm 版本与弃用算法告警(如 jose 对弱算法的 deprecation)。
  • 过时黑名单:避免手写 JWT 解析;避免已停更的 jsonwebtoken 旧版本(曾有 alg-confusion/none 算法 CVE);避免 alg: none;不把 HS256 当生产 RS 方案。
  • 下次复查点:升级 jose 主版本时核对 breaking changes;季度扫 npm audit 的 JWT 相关告警。

衔接

  • 昨天:Day 62 — MCP Security Best Practices 2026-01(6 条红断言)。
  • 今天:jose 实现 JWT 校验内核,happy-path mint→verify 绿,HS256-pinned 无 key。
  • 明天:Day 64 — 把校验接到 MCP 工具网关(dispatch 前事中拦截)。