跳至正文
来两杯美式
返回

一个系统,两种访客:给浏览器和 Agent 设计同一套身份体系

By 来两杯美式
发布于

上一系列讲的是 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 KeyAgent 的 REST / MCP 调用
sessionOrApiKeySession 或 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,再查 SessionresolveAnyIdentity 的第一行就是解析 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 位)

这里的取舍值得展开:可逆存储确实扩大了泄露面,所以我们没有给每个 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;
}

三个细节:

  1. 没有超级 Key。不存在一个能通过一切校验的万能凭据。系统里每条「特权」都是显式授权记录,可审计、可撤销。这放弃了运维上的巨大便利(出问题时塞一个超级 Key 进去什么都查得到),换来的是「权限清单即全部权限」的确定性。
  2. 负结果也缓存。「这个 Key 没有 X 权限」和「有」一样进缓存——否则每个未授权的请求都会打穿到数据库,权限模型反而成了 DoS 放大器。
  3. 拒绝不需要理由,授权才需要。所有分支的默认值都是 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

几个层层递进的校验,每一层防的东西都不一样:

校验通过后,这个身份被合成为一个 keyIdoauth- 开头的「Key 形态」进入系统——下游的权限校验代码不需要知道它其实不是 Key。但它有一个结构性差异:它的 scope 来自 JWT 的 claim,不在数据库里。所以 hasMcpScope 对它走单独的分支,直接用 token 携带的 scope 集合判断。这个「不查库」不是优化是语义:token 失效之前 claim 不可变,想改权限只能重新走授权流程拿新 token——OAuth 的权限时效和 token 生命周期天然绑定,这是它比长效 API Key 更安全的地方,也是它不如 Key 灵活的地方(改权限要重新授权)。

封禁怎么传导

身份体系的最后一个难题:用户被封禁(banned / role: "banned")之后,他名下的三种身份怎么死?

这个分层背后的判断:封禁是「人」的事件,不是「凭据」的事件;凭据的失效必须走凭据自己的生命周期。想清楚这句话,缓存策略怎么配就有了准绳——人的状态变化要求快传导,所以短 TTL;凭据的变化频率低且可以接受分钟级窗口,所以长 TTL。

这套设计的代价

照例说不好的一面:

  1. 一致性窗口是永久负债。两层缓存 + 短 TTL 用户缓存,意味着「撤销」「封禁」都有窗口期。每加一层缓存,这个问题的复杂度都非线性增长(我们已经有了四条失效路径)。如果重来,我会更早把「失效」做成一个统一入口,而不是每处缓存各自维护。
  2. 判别联合推高了调用方的心智。拿到 AuthIdentity 之后到处都是 type 分支。类型系统保证了不漏,但代码确实比「一个 user 对象走天下」啰嗦。这是显式性 tax,我认为值,但不是没有。
  3. 三种身份的测试矩阵。同一个业务入口,Session / Key / OAuth 三种身份 × 各自的缓存状态,组合起来测试数量翻倍还多。没有好的身份工厂(test fixture),这块的测试会迅速腐化。

小结

  1. 认证方式是请求的属性,不是系统的分界线——一个 resolveAuth、四种 AuthMode,Agent 只是又一种客户端;
  2. 显式凭据优先于环境凭据;管理面只收 Session,把最大的攻击面挡在最窄的门后;
  3. Token 默认不可逆(哈希),可逆(密文)是圈定范围的显式让步;
  4. 缓存解决的是 Agent 的流量画像,但「按 A 缓存、按 B 失效」和「负结果缓存」这两个细节决定它是资产还是负债;
  5. 默认拒绝、无超级 Key、封禁只作用于人——克制不是少做事,是把每一件事的边界画清楚。

身份解决了「Agent 是谁」。下一篇讲 Agent 平台的另一半时间维度:用户不在场时,事情怎么按时发生——Trigger/Action Registry 的自动化引擎,和它下面那层调度原语。

本系列下一篇:《定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层》


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.把存量系统交给 Agent,先想清楚 API、Skill、MCP 各是什么
  2. 02.存量系统 Agent 化,物理上到底怎么「接」
  3. 03.单点跑通之后,怎么把 Agent 能力沉淀成全公司可复用
  4. 04.DeepSeek Harness:把 Agent 宿主变成可组合的基础设施,企业能拿它做什么
  5. 05.一个系统,两种访客:给浏览器和 Agent 设计同一套身份体系
  6. 06.定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层
  7. 07.Agent 刚才干了什么:审计、调用日志与敏感数据豁免
  8. 08.提示词安全攻防全解析:越狱、注入与信息泄露
  9. 09.给生产 Agent 做体检(一):只用对话文本的黑盒诊断工作流
  10. 10.给生产 Agent 做体检(二):诊断之前,先定义人口——数据质检与风险筛选
  11. 11.给生产 Agent 做体检(三):让 LLM 稳定输出结构化诊断——严格 JSON Schema 实践
  12. 12.给生产 Agent 做体检(四):LLM 输出不完美怎么办——归一化、一次修复与失败隔离
  13. 13.给生产 Agent 做体检(五):不要消息队列——基于数据库的任务编排与可靠性
  14. 14.给生产 Agent 做体检(六):从诊断明细到 PM 能用的报告——评估结果的产品化
  15. 15.AI Agent:从工具到同事,中间隔着一层「自主性」
  16. 16.Agent 评测工程化(一):为什么不能只看平均分
  17. 17.Agent 评测工程化(二):Rubric 不是 Prompt,而是可执行的质量协议
  18. 18.Agent 评测工程化(三):从低分样本到问题簇
  19. 19.Agent 评测工程化(四):红线、一票否决与 1-5 分
  20. 20.Agent 评测工程化(五):让 LLM-as-judge 稳定输出结构化结果
  21. 21.Agent 评测工程化(六):评测前,先把 Agent 输出拆开
  22. 22.Agent 评测工程化(七):从评分报表到 PM 决策工作台
  23. 23.Agent 评测工程化(八):不用消息队列,也能跑长任务
  24. 24.Agent 评测工程化(九):把一次性评测变成持续优化体系

上一篇
定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层
下一篇
精选数据与垂直 AI:AI 时代的护城河在哪