鉴权

一次 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/jwksjose 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 是核选项
业务路由每请求 DB0 次不查 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,必须额外触发一次。

sequenceDiagram participant C as Client participant BA as better-auth participant DB as PostgreSQL participant API as Business route rect rgba(251, 191, 36, 0.18) Note over C,API: ① Sign-in · 写 cookie · 建 session 行 C->>BA: POST /sign-in/email BA->>DB: INSERT session BA-->>C: 200 · Set-Cookie end rect rgba(244, 114, 182, 0.18) Note over C,API: ② getSession · 触发 JWT 签发 C->>BA: POST /get-session BA->>DB: SELECT session BA->>DB: SELECT jwks · jose 首次拉取 BA-->>C: 200 · Header set-auth-jwt C->>C: localStorage authToken 写入 end rect rgba(52, 211, 153, 0.18) Note over C,API: ③ 业务路由 · 信 Bearer JWT · 不查 DB C->>API: GET /api/tasks · Authorization Bearer API->>API: jose.jwtVerify(jwt, JWKS) API-->>C: 200 · ok(data) end

第②步的 await authClient.getSession() 写在 auth-service.ts:23(login)和 :36(register)。 登录函数必须等它完成才能 return,否则 useLogin().onSuccess 触发的页面跳转所发出的下一个 API 请求还没 Bearer 头,会全部 401。修改登录 / 注册流程时不要顺手删这一行。

第③步的 jose.jwtVerifylib/auth-jwt.ts:35。jose 通过 createRemoteJWKSet 自带缓存, 启动后冷热各发生一次:第一次请求触发拉 /api/auth/jwks(走 PG),之后 10 分钟内复用,过期再拉。

三条鉴权入口

三种使用形态共享同一个底层原语 lib/auth-jwt.ts:verifyJwt,但上层封装不同。先看分层关系:

鉴权入口分层 三种调用形态 · 共享一个底层原语 响应形态 (Route) JSON CRUD 12 个普通路由 流响应 / 文件上传 SSE · multipart WebSocket 握手 ws.ts 封装层 (Wrapper) h({ auth: true }) body zod · 返回值 ok() 包装 requireAuth 中间件 · 设 RequestContext 直接调用 无 c · 从 query 合成 header 底层原语 (Primitive) verifyJwt(headers) lib/auth-jwt.ts → jose.jwtVerify + JWKS 内存缓存

写新路由时根据响应形态选入口,不要手动调用 verifyJwt(除非是 WS 那种连 c 都拿不到的特殊场景):

入口适用用在哪
h({ auth: true }, handler)标准 JSON CRUD,返回值自动 c.json(ok(data)) 包装12 个普通路由 (agents.tschats.tsprojects.ts 等)
requireAuth 中间件 + 裸 chandler 需要直接操作 c,响应不是 JSONtasks.ts /:id/stream /:id/messagesupload.tsai-text.ts 流端点
verifyJwt(headers)WebSocket 握手:token 走 ?token= query,需手动合成 headerws.ts:50

底层都是 lib/auth-jwt.ts:verifyJwth() 顺手做了 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 查询 + 跨服务可验证

选择当前方案的具体理由:

  1. 业务路由占请求量 ≥ 95%,绝大多数 handler 只用 user.id,JWT payload 即够
  2. 即时吊销在内部工具语境下不是硬需求,接受”删 session 行后 JWT 仍可用 30 天”
  3. BETTER_AUTH_SECRET 即可让所有 JWKS 私钥解密失败,触发集体重发 JWT —— 核选项可用
  4. JWT 跨服务可验证,未来拆服务时不需要中央 session 库

一处例外:/api/mcp-oauth/authorize

这一条 GET 路由由 window.location.href = ... 顶层导航触发(mcp-server-row.tsx:169mcp-server-detail.tsx:80mcp-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),目前没有 这条路径。
  • 不缓存用户态到 Redisrequire-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 authapps/desktop/src/renderer/ipc/auth.ts 是本地 stub,session 写 localStorage,不与 server 通信。本文不覆盖。
  • BUA 扩展自己一套握手/ws/browser 端点用 pairingToken 而非 JWT,见 BUA 子系统

故障排查

症状 1:登录后跳转任意页面,所有 API 401

按概率排序:

  1. localStorage 里没有 authToken:DevTools → Application → Local Storage 查。空 → 看下一条
  2. signIn.email 后漏 await authClient.getSession():auth-service.ts:23 那行。漏掉 会必然出现这个症状,因为 set-auth-jwt 只在 /get-session 响应里设置,signIn 响应不带
  3. CORS 没暴露 set-auth-jwt:index.ts 里 cors 的 exposeHeaders 必须包含 set-auth-jwt, 否则浏览器 JS 读不到响应头(原值会存在,只是 fetch API 拿不到)
  4. request.ts 的 onRequest 没注入 Bearer:Network 标签查任意请求的 Request Headers, Authorization: Bearer ... 应当出现

症状 2:重启 server 后所有 API 401

不应当发生。如果发生:

  1. BETTER_AUTH_SECRET 是否在 .env 里、重启后值不变?改了会让所有 JWKS 私钥解密失败
  2. jwks 表有数据吗?SELECT count(*), max("created_at") FROM jwks; 应当 ≥ 1
  3. 浏览器手工访问 /api/auth/jwks 能拿到 keys 数组吗?拿不到 → 服务端 jose 远程拉取也会失败

症状 3:点击 MCP “授权”跳到 OAuth 提供商前 401

cookie 没了。DevTools → Application → Cookies → 看 better-auth.session_token。多见于:

  1. 用户清过缓存
  2. 跨域 cookie 被浏览器扩展拦掉(企业环境常见)
  3. 与 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 没有官方路径。让用户重新登录最快。

延伸阅读

这页有帮助吗?