鉴权
一次 cookie 换 JWT、jose 本地 JWKS 验签的 stateless 设计;三条服务端入口(h() / requireAuth / 裸 verifyJwt)分别覆盖 JSON CRUD、流响应与 multipart、WebSocket;一处保留 cookie 例外用于 浏览器顶层导航触发的 OAuth 跳转。
这篇文档解决什么
业务路由的鉴权契约 + 关键设计决策记录。读完应当能回答这些问题:
- 客户端登录后,JWT 是怎么拿到的、存在哪里、什么时候用?
- 服务端为什么不查
session表? - 三条入口(
h()/requireAuth/verifyJwt)各用在哪、为什么不能统一? BETTER_AUTH_SECRET变了会发生什么、JWKS 表丢了会发生什么?
/api/auth/* 内部的 cookie session 管理由 better-auth 自己实现,不在本文范围。本文描述
项目代码在 better-auth 之上建立的契约。
关键参数
| 项 | 值 | 说明 |
|---|---|---|
| 签发 | 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 路由的局部例外) |
登录到首次 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 jwt 插件源码
——
expirationTime、definePayload等自定义选项 - jose · jwtVerify with JWKS
—— 验签 API,以及
createRemoteJWKSet的缓存参数 - 实时通信 —— WS 握手鉴权的上下文
- BUA 子系统 —— 扩展端独立的 pairing token 体系