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(生产) |
|---|---|---|
| 密钥 | 单一共享 secret | AS 私钥签 / 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。 - signature:
HMAC-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. 今日实战
- 确认
src/agent/mcp/auth.ts的verifyAccessToken(token, { aud, iss })用 jose 校验(已落地)。 - 用
mintToken(secret, { sub, scopes, ttlSec })造一个有效 token,跑通 happy-path:mint → verify → 断言sub与 scopes。 - 测试环境注意:
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. 常见误区 / 陷阱
- 手写 JWT 解析:自己 base64 解 payload、自己比
exp,极易漏aud/nbf/alg pinning——用维护活跃的 jose。 - jsdom 跑 jose:默认 vitest jsdom 环境会因跨 realm 的
Uint8Array报错,必须@vitest-environment node。 - 不 pin alg:默认按 token header 的
alg选验证方式 → alg-confusion 降级攻击;必须algorithms: ['HS256']白名单。 - 把 HS256 secret 当生产密钥分发:对称密钥给了 RS,RS 就能伪造 token;生产必须非对称。
6. 学习资源(每条带 YYYY-MM)
- jose 库文档(panva/jose,GitHub,2026-06 维护活跃)—
SignJWT/jwtVerifyAPI。 - 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 前事中拦截)。