fix(plugin-auth): choose a JWT signing algorithm the host supports (#3585) - #5044
Conversation
…3585) better-auth's `jwt` plugin defaults to EdDSA/Ed25519. On a host whose WebCrypto lacks Ed25519 (StackBlitz/WebContainer) jose's `generateKeyPair` throws, and because the plugin's `after` hook signs a `set-auth-jwt` header for EVERY session, the first `/get-session` after sign-in returned 500 — on a plain dev server, since the OIDC provider defaults on whenever the MCP server is. Probe the capability once per manager (using the exact algorithm descriptor jose uses) and pin `jwks.keyPairConfig` to EdDSA/Ed25519 or ES256 accordingly. Pinning the algorithm is not sufficient on its own: `resolveSigningKey` falls back to `getLatestKey()` — ANY algorithm — when no key matches the configured one, so a deployment that had already minted an EdDSA key would still select it and die in `importJWK`. On a host without Ed25519 we therefore also install better-auth's `adapter.getJwks` keyring seam and hide keys the host cannot import, so a fresh ES256 key is minted and the deployment converges. Rows are hidden, never deleted. The seam is installed ONLY on such a host, so every normal deployment runs better-auth's stock read path unchanged. Finally, a signing failure now degrades the header rather than the session: `/get-session` returns the session and omits `set-auth-jwt`, reporting once with an error that names the algorithm and is queryable via `getDegradedAuthFeatures()` under a new `jwtSigning` key. Note the guard must return the `{headers,response}` shape `runAfterHooks` reads — returning bare `undefined` just moves the 500 one frame up. Tests run the real better-auth pipeline against a WebCrypto with Ed25519 removed, including the upgrade path where a real better-auth-minted EdDSA key already exists. better-auth's EdDSA default and the `/get-session` hook shape are pinned in better-auth-schema-parity.test.ts so an upgrade that moves either fails a unit test rather than a production login. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
… dispatch predicate `check:engine-double-contract` (#4550, landed after this branch was written) flagged the fake engine in auth-manager.jwt-eddsa-fallback.test.ts: its `delete` accepted any predicate, so it was structurally looser than `ObjectQL.delete`, which is how #4434 shipped a dead REST route with a green suite. Route the fake's `delete` through `assertEngineDeleteDispatch` — the producer's own decision — rather than hand-mirroring the guard. That required `@objectstack/objectql` as a devDependency of `@objectstack/plugin-auth` (workspace protocol, the way plugin-approvals declares it); no cycle, since nothing reachable from objectql depends on plugin-auth. The suite stays green because better-auth's ObjectQL adapter only ever deletes by scalar id — `delete`/`deleteMany`/`consumeOne` each resolve the row first and then call `delete(object, { where: { id } })` — so the assertion now pins that property instead of assuming it. The devDependency also invalidates the stated blocker on the sibling baseline entry for auth-manager.optional-plugin-isolation.test.ts ("plugin-auth does not depend on @objectstack/objectql"), so that entry's `why`/`closes` are corrected to the measured state: the dependency exists, what remains is a one-line pin for its own PR. Counts are untouched — the ratchet does not move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
…y prove Measured with a probe (temporary console.info in the fake's delete, run and reverted): the paths this file drives — sign-up → get-session → /jwks — never reach a delete, so the pin cannot flip this suite red today. The previous comment could be read as claiming it does. State it plainly instead: the assertion is a forward guard on better-auth's adapter continuing to delete only by scalar id, so an upgrade that routes a session/verification purge through as a bare predicate fails here rather than 500ing on a server. A gate claim nobody can reproduce is how a green run stops meaning anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
|
CI 记录(identity 车道 PM): Generated by Claude Code |
返工:把 fake engine 的
|
PM 复核(返工轮):返工通过;落地顺序裁定 A —— 等 #5032 落 main 后重跑,本 PR 不再改动返工验收
落地顺序:A(排序问题,PM 直接裁定,不上升)现状:22 绿,唯一的红是
兜底:若 #5032 超过一个工作日未落地,把 B 作为安全政策问题正式升级维护者拍板(带 ignoreUntil 日期),不让发版关键修复(登录打死)无限期等待。 Generated by Claude Code |
Picks up caf144a (#5052, Fixes #5032) so 'Validate Package Dependencies' re-runs against the repaired base (undici 7.29.0 / hono 4.12.34 / fast-uri 3.1.5). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
…ectQL delete dispatch The #5233 fake engine's delete() accepted call shapes ObjectQLEngine.delete refuses, so check:engine-double-contract flagged it as an unpinned double. Route it through assertEngineDeleteDispatch from '@objectstack/objectql' — the same in-package pattern as auth-manager.jwt-eddsa-fallback.test.ts and session-of-record.test.ts (#4550) — rather than taking a baseline entry. The devDependency was already present from #5044. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
Fixes #3585
背景
在 WebCrypto 缺少 Ed25519 的宿主上(报告来自 StackBlitz/WebContainer),只要 OIDC provider 开着,登录后的第一个请求就 500。而 OIDC provider 在 MCP server 打开时默认就是开的(
resolveOidcProviderEnabled→isMcpServerEnabled(),默认true),所以一个从没打算做 IdP 的应用,登录直接不可用。根因是一个继承来的默认值:
plugin-auth注册 better-auth 的jwt插件时没有传jwks.keyPairConfig,于是 better-auth 的默认 EdDSA / Ed25519 生效,jose 向 WebCrypto 请求{ name: 'Ed25519' },宿主不支持就抛OperationError。之所以会打到普通 cookie 登录(而不只是 OAuth 客户端),是因为该插件在/get-session上挂了afterhook,给每一个 session 都签一个set-auth-jwt头。我已在
origin/main(以及 better-auth1.7.0-rc.2的node_modules源码)上逐条复核过 issue 里引用的行:jwt({ schema: buildJwtPluginSchema() })确实在auth-manager.ts:2175;rc.2 的utils.mjs里默认值仍是options?.jwks?.keyPairConfig ?? { alg: "EdDSA", crv: "Ed25519" }。前提在 rc.2 上依然成立。按 PM 的范围决定,本 PR 实现方案 1 + 方案 3,不碰
packages/spec(方案 2 属于协议面,另一条车道)。改动
1. 按能力选算法,而不是继承默认值
新增
packages/plugins/plugin-auth/src/jwt-key-algorithm.ts。在构建实例时探测一次 WebCrypto 能否生成 Ed25519 密钥对,支持则显式钉住EdDSA/Ed25519,不支持则回退 ES256。探测用的是 jose 解析
EdDSA时用的同一个算法描述符({ name: 'Ed25519' },见 joselib/jws_algorithms.js)—— 探测和它所预测的操作必须问同一个问题,否则探测通过、签名照样炸。这里刻意没有复用文件里已有的
isWebContainerRuntime():宿主名只是"能不能做 Ed25519"的代理变量,两头都错 —— 它漏掉其它没有 Ed25519 的运行时,也会在 WebContainer 补上支持后继续降级。2. 已经存在 EdDSA key 的部署(这一条最容易咬到真实部署)
只钉算法是不够的。 better-auth 的
resolveSigningKey选 key 的方式是:后半段是任意算法的兜底。一个已经在
sys_jwks里签发过 EdDSA key、随后迁到无 Ed25519 宿主的部署:getLatestKeyByAlg('ES256')找不到 → 兜底拿到那把 EdDSA key → 下一行importJWK(privateWebKey, 'EdDSA')直接炸。所以单靠keyPairConfig会修好全新部署、留着升级路径继续坏。因此在无 Ed25519 的宿主上,额外接入 better-auth 有文档的
adapter.getJwkskeyring seam,把本机无法 import 的 key 挡掉。于是getLatestKey()也返回空,resolveSigningKey直接铸一把新的 ES256 key,部署收敛到可用状态,而不是一直坏着。/api/v1/auth/jwks也不再广播这些 key:它既签不了也验不了,广播就是"机器可读表面撒谎"(AGENTS.md 路由规则 Add Changesets and GitHub Actions automation #4)。3. 签名失败降级的是「头」,不是「session」
/get-session现在照常返回 session,只是不带set-auth-jwt,不再 500。适用于任何原因的签名失败(两种算法都不可用、sys_jwks不可写、OS_AUTH_SECRET轮换后解不开旧 key)。失败只播报一次,错误信息点名算法、说明「登录和 cookie 认证不受影响」、并给出
OS_OIDC_PROVIDER_ENABLED=false这个出口;同时进getDegradedAuthFeatures(),用新 keyjwtSigning。一个值得记下的坑:守卫必须返回
runAfterHooks会读的{ headers, response }形状 —— 直接return undefined只是把 500 往上挪一帧(result.headers是无保护读)。这一条已单独写了回归测试。守卫挂不上时会大声报错而不是静默放行 —— 一个悄悄停止守卫的守卫比没有守卫更糟。
测试
jwt-key-algorithm.test.ts(29 个):探测/回退决策、legacy 行(alg/crv为 null)从 key 材料判定曲线、过滤器在支持 Ed25519 时是 identity、守卫的返回形状与"挂不上要返回 false"。auth-manager.jwt-eddsa-fallback.test.ts(8 个):跑真实 better-auth 管线,把crypto.subtle.generateKey/importKey对{name:'Ed25519'}打成OperationError。这比只 stub 我们自己的探测更强 —— 只 stub 探测的话,真实 WebCrypto 仍能 import Ed25519,"错选了库存 EdDSA key"这种回归会测试通过、线上照炸。其中包含真实升级路径:先让健康宿主用 better-auth 自己的createJwk铸一把真 EdDSA key,再切到无 Ed25519 宿主。better-auth-schema-parity.test.ts追加 4 条升级绊线:钉住 better-auth 仍默认 EdDSA/Ed25519、/get-sessionafter-hook 仍是我们包装的形状、adapter.getJwksseam 仍在。升级动了任何一处,挂的是单测,不是线上登录。已合入
origin/main(cbc844e)后重跑上述 test + typecheck,仍全绿;合入的三个提交与packages/plugins/plugin-auth、packages/spec零重叠。范围
packages/spec/**零改动(方案 2 未实现)。content/docs/releases/零改动;用户可见变更走.changeset/jwt-eddsa-host-fallback.md。auth-manager.ts:155有一条Unused eslint-disable directive警告,在origin/main上就已存在(原第 149 行),不属于本 PR 范围,未动。🤖 Generated with Claude Code
https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
Generated by Claude Code