鉴权与身份
业务路由每个请求查 0 次数据库——靠一枚真 JWT 本地验签,而非 session 表。代价是没有即时吊销。围绕这套 stateless 契约,讲清两步登录、三条服务端入口,以及一处不得不回退 cookie 的例外。
业务路由每个请求查 0 次数据库
Zapvol 的业务路由(/api/*,除 /api/auth 外)在每个请求上做鉴权时,一次数据库都不查:客户端带着一枚真正的 JWT,服务端用 jose 在本地验签,session 表看都不看。这枚 JWT 由 better-auth 的 jwt 插件签发(默认 EdDSA),公钥挂在 /api/auth/jwks,jose 拉取一次后就常驻内存复用。代价写在明处——没有即时吊销,JWT 一旦签出,30 天内一直有效。
这套 stateless 设计也换来一处别扭:cookie 与 JWT 职责不重叠,登录要走两步才拿得到 JWT。下文把这份契约讲清楚——两步登录、三条服务端入口、为什么用真 JWT 而非 session token,以及一处不得不回退到 cookie 的例外。至于 /api/auth/* 内部那套 cookie session,由 better-auth 自己实现,不在本文范围;本文讲的是项目代码在它之上建立的契约。
登录到首次 API 调用
Cookie 和 JWT 在系统里职责不重叠:cookie 只在 /api/auth/* 内部为 better-auth 自身用来换发 JWT; 业务路由(/api/* 除
/api/auth 外)只信 Bearer JWT,不读 cookie。这是为什么会出现 ① 登录写 cookie、② 需要再跑一次 getSession()
才拿到 JWT 的两步流程 —— better-auth 的 jwt 插件 after-hook 只匹配 /get-session 路径,login 响应本身不带
set-auth-jwt,必须额外触发一次。
第②步的 await authClient.getSession() 写在 auth-service.ts:23(login)和
:36(register)。登录函数必须等它完成才能 return,否则 useLogin().onSuccess
触发的页面跳转所发出的下一个 API 请求还没 Bearer 头,会全部 401。修改登录 / 注册流程时务必保留这一行。
第③步的 jose.jwtVerify 在 lib/auth-jwt.ts:35。jose 通过 createRemoteJWKSet
自带缓存,启动后冷热各发生一次:第一次请求触发拉 /api/auth/jwks(走 PG),之后 10 分钟内复用,过期再拉。
三条鉴权入口
三种使用形态共享同一个底层原语 lib/auth-jwt.ts:verifyJwt,但上层封装不同。先看分层关系:
写新路由时根据响应形态选入口,不要手动调用 verifyJwt(除非是 WS 那种连 c 都拿不到的特殊场景):
| 入口 | 适用 | 用在哪 |
|---|---|---|
h({ auth: true }, handler) | 标准 JSON CRUD,返回值自动 c.json(ok(data)) 包装 | 12 个普通路由 (agents.ts、chats.ts、projects.ts 等) |
requireAuth 中间件 + 裸 c | handler 需要直接操作 c,响应不是 JSON | tasks.ts /:id/stream /:id/messages、upload.ts、ai-text.ts 流端点 |
裸 verifyJwt(headers) | WebSocket 握手:token 走 ?token= query,需手动合成 header | ws.ts:50 |
底层都是 lib/auth-jwt.ts:verifyJwt。h() 还一并做了 body Zod 校验、返回值 ok() 包装与状态码控制,因此:
- 返回
Response对象(SSE / 文件下载) → 不能用h(),h()会把 Response 当数据 JSON 序列化 - body 是
multipart/form-data→ 不能用h()的body: schema选项,h()内部走c.req.json() - WS upgrade 路径里没有
c(在upgradeWebSocket回调内),只能裸调
requireAuth 中间件除验签外还会调用 RequestContext.set({ userId }),与 h()
行为一致,所以日志中间件能在两条路径都拿到 userId 标签。
为什么是真 JWT 而不是 Bearer + session token
设计阶段试过三种,记录在此防止未来“为什么不直接用 better-auth 的 bearer() 插件”重提。
| 方案 | 每请求 DB | 即时吊销 | Statelessness | 为什么没选 |
|---|---|---|---|---|
| Cookie + DB session(原始) | 1 次 PG · session 表 | 支持 | 否 | 跨域 cookie 在部分浏览器 / SameSite 策略下丢失,登录态不稳 |
Bearer + session token(bearer() 插件) | 1 次 PG · session 表 | 支持 | 否(token 是 DB 行的引用) | DB 查询成本不变,只是把 cookie 换成 header,不解决问题 |
| Bearer + 真 JWT (当前) | 0 次(jose 本地验签 + JWKS 内存 cache) | 不支持 | 是 | (选中) —— 用即时吊销换零 DB 查询 + 跨服务可验证 |
选择当前方案的具体理由:
- 业务路由占请求量 ≥ 95%,绝大多数 handler 只用
user.id,JWT payload 即够 - 即时吊销在内部工具语境下不是硬需求,接受“删 session 行后 JWT 仍可用 30 天”
- 改
BETTER_AUTH_SECRET即可让所有 JWKS 私钥解密失败,触发集体重发 JWT —— 核选项可用 - JWT 跨服务可验证,未来拆服务时不需要中央 session 库
一处例外:/api/mcp-oauth/authorize
这一条 GET 路由由 window.location.href = ... 顶层导航触发(mcp-server-row.tsx:169、
mcp-server-detail.tsx:80、mcp-selector.tsx:233 三处)。浏览器顶层导航无法携带 Authorization
header,只能用 cookie。
auth.ts 没有禁用 better-auth 自带的 cookie session 写入,登录响应里依然
Set-Cookie: better-auth.session_token=...。这条 OAuth 跳转鉴权就靠它。routes/mcp-oauth.ts:71 单独调
auth.api.getSession({ headers }) 走 cookie 路径,与业务路由的 JWT 路径独立。
切勿为求一致把这一处改成
verifyJwt:浏览器跳转无法带 Bearer,一改即 401。如果将来需要新增类似的顶层导航 GET 鉴权端点,沿用 cookie 模式即可。
不做什么
- 不做即时吊销。封禁 / 强制下线只能改
BETTER_AUTH_SECRET(让所有现有 JWT 集体失效,影响所有用户)或等过期。需要细粒度吊销时再加 Redis 黑名单(以jti为 key),目前没有这条路径。 - 不缓存用户态到 Redis。
require-roles中间件每请求查一次user_role表(~1ms,只在 admin 路由命中),延迟可接受。Tier / subscription 等用户属性同理,service 层用到时直接查 PG。这是 stateless 设计的有意决定 —— 加 Redis cache 会引入 cache invalidation 问题和新的故障模式。 - JWT payload 里不放 roles / tier。这两类属性改动应当立即生效,而 JWT payload 是签发时刻的快照,要 30 天才会被自然刷新。授权决策(roles / tier 网关)永远查 PG,JWT 只承担“你是谁”。
- Desktop 不走 server auth。
apps/desktop/src/renderer/ipc/auth.ts是本地 stub,session 写 localStorage,不与 server 通信。本文不覆盖。 - BUA 扩展自己一套握手。
/ws/browser端点用pairingToken而非 JWT,见 BUA 子系统。
故障排查
症状 1:登录后跳转任意页面,所有 API 401
按概率排序:
- localStorage 里没有
authToken:DevTools → Application → Local Storage 查看。为空则查看下一条 signIn.email后漏 awaitauthClient.getSession():auth-service.ts:23。漏掉必然导致此症状,因为set-auth-jwt仅在/get-session响应中设置,signIn 响应不带- CORS 没暴露
set-auth-jwt:index.ts里 cors 的exposeHeaders必须包含set-auth-jwt, 否则浏览器 JS 读不到响应头(原值会存在,只是 fetch API 拿不到) request.ts的 onRequest 没注入 Bearer:Network 标签查任意请求的 Request Headers,Authorization: Bearer ...应当出现
症状 2:重启 server 后所有 API 401
不应当发生。如果发生:
BETTER_AUTH_SECRET是否在.env里、重启后值不变?改了会让所有 JWKS 私钥解密失败jwks表有数据吗?SELECT count(*), max("created_at") FROM jwks;应当 ≥ 1- 浏览器手工访问
/api/auth/jwks能拿到 keys 数组吗?拿不到 → 服务端 jose 远程拉取也会失败
症状 3:点击 MCP “授权”跳到 OAuth 提供商前 401
cookie 已丢失。DevTools → Application → Cookies → 查看 better-auth.session_token。常见于:
- 用户清过缓存
- 跨域 cookie 被浏览器扩展拦掉(企业环境常见)
- 与 cookie SameSite 策略冲突的部署(把 server 部到不同 apex domain 下时)
这一处只能用 cookie,见上文“一处例外”。
症状 4:用户改了 email / name,JWT 里仍是旧值
预期行为。JWT payload 是签发时刻的 session.user 快照,30 天才自然刷新。需要新鲜数据的代码必须从 PG 查 user,不要信
c.get("user") 里除 id 外的字段。参考 admin-schedule-service.ts:113-114 的做法。
强制刷新单个用户的 JWT 没有官方路径。让用户重新登录是最快的办法。
关键参数
| 项 | 值 | 说明 |
|---|---|---|
| 签发 | better-auth ^1.5 jwt 插件 | 默认 EdDSA,JWKS 私钥经 BETTER_AUTH_SECRET 对称加密落 PG |
| 验签 | jose ^6.1 + createRemoteJWKSet | 本地验签,JWKS 拉一次后内存缓存 |
| JWT 过期 | 30 天 | auth.ts jwt 插件 expirationTime: "30d" |
| JWKS endpoint | /api/auth/jwks | jose remote JWKS 拉取地址,公开 |
| JWKS 缓存策略 | jose 内置 cooldown 30s / cacheMaxAge 10min | 首次请求击穿一次 PG,后续命中 jose 内存 |
| 客户端 token 存储 | localStorage["authToken"] | 仅 web;desktop 端自行 stub,不与 server 通信 |
| token 来源响应头 | set-auth-jwt(仅 /api/auth/get-session 响应) | 登录后必须显式跑 getSession() 触发签发 |
| WS token 传输 | ?token=<jwt> query 参数 | 浏览器 WebSocket API 不允许自定义 header |
| 即时吊销 | 不支持 | JWT 30 天内有效,改 BETTER_AUTH_SECRET 是核选项 |
| 业务路由每请求 DB | 0 次 | 不查 session、不查 user(require-roles 是 admin 路由的局部例外) |
延伸阅读
- better-auth jwt 插件源码
——
expirationTime、definePayload等自定义选项 - jose · jwtVerify with JWKS
—— 验签 API,以及
createRemoteJWKSet的缓存参数 - 实时通信 —— WS 握手鉴权的上下文
- BUA 子系统 —— 扩展端独立的 pairing token 体系