上一系列讲的是 Agent 怎么用上平台的能力:薄壳 Skill、能力目录、按会话下发的协议。但一直没正面回答一个更底层的问题——
Agent 敲的门和浏览器敲的门,是同一扇吗?
我们的答案:是同一扇。人类用户带 Session Cookie 来,Agent 带 API Key 来,OAuth 客户端带 JWT 来,但它们进入系统后走的是同一个身份解析器、同一套权限模型、同一份审计日志。这个「一个系统,两种(乃至三种)访客」的设计,是 Agent 平台最容易被做坏的一块地基——做得好,Agent 只是又一种客户端;做得坏,你会得到两套并行演进的 API、两套权限、两次同样的坑。
浏览器不再是唯一的访客
先看错误做法长什么样。最常见的本能反应是「给 Agent 单开一套接口」:
浏览器 → /api/... (Session 认证,登录态,人操作)
Agent → /agent-api/... (API Key 认证,机器调用)
这套结构在试点期毫无问题,甚至更快。但它注定腐化:业务逻辑开始两处复制,权限规则改一处漏一处,Session 侧加了审计 Key 侧没有,最后没人说得清两边的行为差异。本质上这是把「调用者不同」误当成了「系统不同」。
正确的抽象是反过来的:认证方式是请求的属性,不是系统的分界线。系统只有一个,每个入口(每个 tRPC procedure、每个 REST 路由)自己声明「我接受什么样的身份」。于是在我们的代码里,身份解析收口成一个函数:
resolveAuth(headers, mode);
一个解析器,四种模式
mode 只有四个值,每个入口必须选一个:
| AuthMode | 接受的身份 | 典型场景 |
|---|---|---|
public | 匿名 | 健康检查、公开元数据 |
session | 仅 Session | 管理后台、个人设置页 |
apiKey | 仅 API Key | Agent 的 REST / MCP 调用 |
sessionOrApiKey | Session 或 API Key | 两类访客共用的业务数据面 |
这个设计的关键不在「支持四种」,而在由谁声明:不是客户端声明「我是 Key 请求」,而是服务端每个入口写死自己信任什么。tRPC 的 Context 构建统一走 sessionOrApiKey,REST 的 restHandler 按路由声明模式,两者共用同一个 resolveAuth——浏览器前端、REST 消费者、MCP Server 里 Agent 发起的调用,身份解析逻辑只有一份。
返回值也值得说:resolveAuth 不返回「用户或 null」,而是一个判别联合——成功时给出 { type: "session" | "apiKey" | "anonymous", ... },失败时给出 { status, error }。调用方拿到的不是「一个人」,而是「一种身份及其全部上下文」(Session 对象,或 Key 的归属信息)。下游的权限判断针对「身份类型」做分支,而不是猜。
显式凭据优先于环境凭据
sessionOrApiKey 模式下有个必须想清楚的细节:如果一个请求同时带了 Session Cookie 和 API Key,算谁?
我们的规则:Key 优先,先查 Key,再查 Session。resolveAnyIdentity 的第一行就是解析 API Key,命中即返回,Session 根本不看。
这不是实现顺序的小事,而是一条原则:
显式凭据(explicit credential)优先于环境凭据(ambient credential)。
Cookie 是环境凭据——浏览器只要登录过,每个请求都自动携带,用户通常意识不到它在那里。API Key 是显式凭据——它一定出现在这次请求的 Authorization 头里,是有人明确放进来的。当两者冲突时,环境凭据「恰好也在」不能覆盖显式凭据「故意如此」。这和 OAuth 规范里 token 请求的客户端认证是同一个思想。
实际场景里这条规则救过我们:Agent 宿主嵌在 Web 页面里运行时,请求会同时带上页面的 Cookie 和配置的 Key。如果 Session 优先,Agent 的调用会被错误地记到「当时开着页面的那个人」头上;Key 优先,归属永远明确。
配套的另一半在 tRPC 侧:protectedProcedure 里硬编码校验 auth.type === "session",带 Key 的请求直接拒绝。也就是说管理后台这类「人操作」的界面,哪怕你拿到一个合法的 Key 也进不去。为什么这么不灵活?因为浏览器是攻击面最大的客户端——XSS、CSRF 都发生在浏览器上下文里;把管理操作限制在 Session 通道,等于给「最危险的调用来源」配了「最窄的门」。Agent 的 Key 再多权限,也摸不到管理面。
Token 的两种形态:哈希与密文
API Key 的 Token 在数据库里存了两列,这个「存两份」的设计解释过多次,值得写下来。
const tokenHash = await hashToken(token); // SHA-256 hex
const encryptedToken = await encryptToken(token); // AES-256-GCM 密文
// 表里同时存:tokenHash(唯一索引)、encryptedToken、prefix(前 8 位)
tokenHash:不可逆,用于认证。每个请求拿 Token 算哈希、查库(或查缓存),明文永远不进任何查询路径。数据库泄露时攻击者拿到的只是哈希。encryptedToken:可逆,用于「再次下发」。密钥从BETTER_AUTH_SECRET用 PBKDF2 派生(AES-256-GCM,随机 IV 前置拼接)。为什么需要可逆?因为平台的集成向导要把用户已生成的 Key 再次展示给用户填进 Agent 配置——Token 只在创建时明文可见是教科书式的安全实践,但「向导替你复用现有 Key」的体验要求服务端必须能还原它。
这里的取舍值得展开:可逆存储确实扩大了泄露面,所以我们没有给每个 Key 都开这个能力,而是只对 source: "rest_api"(用户自己在向导流程里生成的 Key)保存密文,且解密只发生在用户本人发起的复用流程里。「不可逆」是默认,「可逆」是特定场景的显式让步——让步就要圈住让步的范围。
顺带一提复用的选 Key 逻辑:向导生成凭据时,先在用户现有 Key 里找「未被撤销、未过期、scope 足够覆盖、剩余有效期达标」的,按过期时间从晚到晚排序选最合适的复用;找不到才新发一个。宁可复用也不堆 Key——每多一个长命 Key,泄露面就大一分。
两层缓存:把认证从每个请求一次 DB 查询里解救出来
Agent 和浏览器的流量画像完全不同:浏览器一个用户一天几百个请求已经很多;Agent 是一个 Key 可以打出每秒几十个工具调用。如果每个请求都查一次数据库,认证本身就是瓶颈。
于是身份解析带了两层缓存:Redis(跨进程共享)+ 进程内 Map(热点兜底)。命中路径是 Redis → 进程内存 → 数据库,任何一层命中都返回同一个 ApiKeyUser 结构。几个细节比「有两层缓存」本身更重要:
并发回源合并(inflight map)。 缓存失效瞬间,N 个并发请求会同时 miss、同时查库。用一个「进行中的 Promise」表把并发回源合并成一次:第一个请求发起查询,后续请求 await 同一个 Promise。两层缓存各自维护 inflight 表,这解决的是「缓存击穿」,但不用任何锁,纯粹靠 Promise 的引用共享。
Redis 的内容不可信。 Redis 里的数据可能跨部署版本存活——代码升级后缓存结构变了,旧格式的缓存还在 TTL 里。所以从 Redis 读出的任何内容都要先过结构校验,字段类型对不上就当 miss 回源。这一条不设防,某次改 ApiKeyUser 加字段就会炸出一批诡异的线上错误。
缓存 TTL 与 Key 自身过期时间取 min。 Key 的 TTL 是 30 分钟,缓存 TTL 是 5 分钟——缓存按短的算。否则会出现「Key 已过期,缓存还在替它续命」的怪象。
失效靠反向索引。 撤销一个 Key 时按 keyId 失效缓存,但缓存键是 tokenHash(不可能从 keyId 反推)。于是 Redis 里维护一条 keyId → tokenHash 的反向索引,进程内同理。「按 A 缓存、按 B 失效」是分布式缓存的经典难题,反向索引是最朴素的解,代价是索引本身也要维护生命周期。
还有一个容易忽略的东西:每个解析结果都带 resolve: { source: "cache" | "database", cacheRemainingSeconds }。这不是业务数据,是给排障用的——线上怀疑「权限改了没生效」时,第一件事就是看请求走的是缓存还是库、缓存还剩几秒。缓存系统不做可观测性,等于给自己埋雷。
最后是诚实的一致性窗口:撤销一个 Key,最多 TTL 秒内它仍然有效。这是缓存的物理学,绕不过去。我们把 TTL 配在分钟级,并让权限校验(下一节)走独立的缓存失效链路,窗口可预期。要绝对即时的撤销,就得每请求查库——在内部平台的流量画像下,这个代价值得吗?我们认为不值,但你应该带着这个问题做自己的决定。
默认拒绝,没有超级 Key
权限校验函数 hasScope 的完整逻辑只有十几行,但每一行都是收着的:
export async function hasScope(apiKeyUser, resourceType, resourceId) {
if (!apiKeyUser) return false; // 没有身份 = 拒绝
const cached = await getCachedScope(...); // 缓存里存过结论(含"拒绝")
if (cached !== undefined) return cached;
const count = await db.apiKeyScope.count({ where: {...} });
const allowed = count > 0; // 查不到授权记录 = 拒绝
setCachedScope(..., allowed).catch(() => {}); // 异步回填,失败不影响
return allowed;
}
三个细节:
- 没有超级 Key。不存在一个能通过一切校验的万能凭据。系统里每条「特权」都是显式授权记录,可审计、可撤销。这放弃了运维上的巨大便利(出问题时塞一个超级 Key 进去什么都查得到),换来的是「权限清单即全部权限」的确定性。
- 负结果也缓存。「这个 Key 没有 X 权限」和「有」一样进缓存——否则每个未授权的请求都会打穿到数据库,权限模型反而成了 DoS 放大器。
- 拒绝不需要理由,授权才需要。所有分支的默认值都是 false,授权是唯一的显式路径。
scope 本身的模型(工具集 × 动作的二维结构、read < write < admin 的等级比较)我在之前两篇里专门写过,这里不重复。这篇想强调的是:那套 scope 模型之所以敢做复杂,是因为校验入口足够简单——一个默认拒绝的纯函数,缓存和降级都围着一件事转。
第三种访客:OAuth 合成身份
MCP 的 OAuth 认证又带来第三种身份:Agent 宿主通过标准 OAuth 流程从企业 IdP(我们是 Keycloak)拿到 JWT access token,带着它来连 MCP Server。这既不是 Session 也不是我们签发的 Key,校验逻辑完全不同:
const { payload } = await jwtVerify(token, JWKS, {
issuer: env.KEYCLOAK_ISSUER, // 签发者校验
...(MCP_RESOURCE_URL ? { audience: MCP_RESOURCE_URL } : {}), // 受众校验
});
if (payload.typ !== "Bearer") return null; // 只接受 access token
几个层层递进的校验,每一层防的东西都不一样:
- RS256 签名 + JWKS:公钥从 IdP 的 JWKS 端点拉取,
jose的createRemoteJWKSet自带缓存、并发去重和密钥轮换感知——密钥轮换是自建 JWT 校验最容易漏的坑,交给库比手写 TTL 缓存可靠。 - audience 强校验(RFC 8707):token 必须是「签给我们这个 MCP 资源」的。这是 MCP 规范明确要求的防 token passthrough——没有 aud 校验,签给系统 A 的 token 可以原样拿来敲系统 B 的门,两个系统信同一个 IdP 时尤其危险。
typ检查:只接受 access token,拒收 ID token。理论上 aud 校验已经能挡住,这里是纵深防御——防御层可以冗余,前提是每层都知道自己是冗余。- 所有失败统一返回 null:签名错、过期、iss 不匹配、JWKS 拉取失败,对调用方全部表现为「认证不通过」,错误细节只进日志。不给探测者区分失败原因的机会。
校验通过后,这个身份被合成为一个 keyId 以 oauth- 开头的「Key 形态」进入系统——下游的权限校验代码不需要知道它其实不是 Key。但它有一个结构性差异:它的 scope 来自 JWT 的 claim,不在数据库里。所以 hasMcpScope 对它走单独的分支,直接用 token 携带的 scope 集合判断。这个「不查库」不是优化是语义:token 失效之前 claim 不可变,想改权限只能重新走授权流程拿新 token——OAuth 的权限时效和 token 生命周期天然绑定,这是它比长效 API Key 更安全的地方,也是它不如 Key 灵活的地方(改权限要重新授权)。
封禁怎么传导
身份体系的最后一个难题:用户被封禁(banned / role: "banned")之后,他名下的三种身份怎么死?
- Session:请求时查用户状态,封禁即拒绝。用户状态本身走一个短 TTL 的进程内缓存 + inflight 合并,避免「每个请求都先查一次用户表」。
- 用户绑定的 API Key:解析 Key 后同样校验归属用户的状态。注意顺序——Key 的存在性走长 TTL 缓存(Key 很少变),用户活跃度走短 TTL 缓存(封禁要快生效),两层缓存各管各的时效。
- 系统 Key(
userId为 null):不因创建者被封禁而失效。系统 Key 服务于共享资源,它的生死只由revokedAt/expiresAt控制。封人不能顺手杀掉跑在共享链路上的 Key——「创建者出事」和「凭据失效」是两件事,绑在一起就是误伤。
这个分层背后的判断:封禁是「人」的事件,不是「凭据」的事件;凭据的失效必须走凭据自己的生命周期。想清楚这句话,缓存策略怎么配就有了准绳——人的状态变化要求快传导,所以短 TTL;凭据的变化频率低且可以接受分钟级窗口,所以长 TTL。
这套设计的代价
照例说不好的一面:
- 一致性窗口是永久负债。两层缓存 + 短 TTL 用户缓存,意味着「撤销」「封禁」都有窗口期。每加一层缓存,这个问题的复杂度都非线性增长(我们已经有了四条失效路径)。如果重来,我会更早把「失效」做成一个统一入口,而不是每处缓存各自维护。
- 判别联合推高了调用方的心智。拿到
AuthIdentity之后到处都是type分支。类型系统保证了不漏,但代码确实比「一个 user 对象走天下」啰嗦。这是显式性 tax,我认为值,但不是没有。 - 三种身份的测试矩阵。同一个业务入口,Session / Key / OAuth 三种身份 × 各自的缓存状态,组合起来测试数量翻倍还多。没有好的身份工厂(test fixture),这块的测试会迅速腐化。
小结
- 认证方式是请求的属性,不是系统的分界线——一个
resolveAuth、四种 AuthMode,Agent 只是又一种客户端; - 显式凭据优先于环境凭据;管理面只收 Session,把最大的攻击面挡在最窄的门后;
- Token 默认不可逆(哈希),可逆(密文)是圈定范围的显式让步;
- 缓存解决的是 Agent 的流量画像,但「按 A 缓存、按 B 失效」和「负结果缓存」这两个细节决定它是资产还是负债;
- 默认拒绝、无超级 Key、封禁只作用于人——克制不是少做事,是把每一件事的边界画清楚。
身份解决了「Agent 是谁」。下一篇讲 Agent 平台的另一半时间维度:用户不在场时,事情怎么按时发生——Trigger/Action Registry 的自动化引擎,和它下面那层调度原语。
本系列下一篇:《定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层》