Skip to content

fix(plugin-auth): choose a JWT signing algorithm the host supports (#3585) - #5044

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-3585-jwt-eddsa-fallback
Aug 4, 2026
Merged

fix(plugin-auth): choose a JWT signing algorithm the host supports (#3585)#5044
os-zhuang merged 5 commits into
mainfrom
claude/issue-3585-jwt-eddsa-fallback

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #3585

背景

在 WebCrypto 缺少 Ed25519 的宿主上(报告来自 StackBlitz/WebContainer),只要 OIDC provider 开着,登录后的第一个请求就 500。而 OIDC provider 在 MCP server 打开时默认就是开的(resolveOidcProviderEnabledisMcpServerEnabled(),默认 true),所以一个从没打算做 IdP 的应用,登录直接不可用。

根因是一个继承来的默认值:plugin-auth 注册 better-auth 的 jwt 插件时没有传 jwks.keyPairConfig,于是 better-auth 的默认 EdDSA / Ed25519 生效,jose 向 WebCrypto 请求 { name: 'Ed25519' },宿主不支持就抛 OperationError。之所以会打到普通 cookie 登录(而不只是 OAuth 客户端),是因为该插件在 /get-session 上挂了 after hook,给每一个 session 都签一个 set-auth-jwt 头。

我已在 origin/main(以及 better-auth 1.7.0-rc.2node_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' },见 jose lib/jws_algorithms.js)—— 探测和它所预测的操作必须问同一个问题,否则探测通过、签名照样炸。

这里刻意没有复用文件里已有的 isWebContainerRuntime():宿主名只是"能不能做 Ed25519"的代理变量,两头都错 —— 它漏掉其它没有 Ed25519 的运行时,也会在 WebContainer 补上支持后继续降级。

2. 已经存在 EdDSA key 的部署(这一条最容易咬到真实部署)

只钉算法是不够的。 better-auth 的 resolveSigningKey 选 key 的方式是:

const primaryAlg = options?.jwks?.keyPairConfig?.alg ?? 'EdDSA';
key = await adapter.getLatestKeyByAlg(ctx, primaryAlg) ?? await adapter.getLatestKey(ctx);

后半段是任意算法的兜底。一个已经在 sys_jwks 里签发过 EdDSA key、随后迁到无 Ed25519 宿主的部署:getLatestKeyByAlg('ES256') 找不到 → 兜底拿到那把 EdDSA key → 下一行 importJWK(privateWebKey, 'EdDSA') 直接炸。所以单靠 keyPairConfig修好全新部署、留着升级路径继续坏

因此在无 Ed25519 的宿主上,额外接入 better-auth 有文档的 adapter.getJwks keyring seam,把本机无法 import 的 key 挡掉。于是 getLatestKey() 也返回空,resolveSigningKey 直接铸一把新的 ES256 key,部署收敛到可用状态,而不是一直坏着。

  • 行是隐藏,不是删除 —— 迁回有 Ed25519 的宿主,原 key 立刻恢复可见。
  • 该宿主的 /api/v1/auth/jwks 也不再广播这些 key:它既签不了也验不了,广播就是"机器可读表面撒谎"(AGENTS.md 路由规则 Add Changesets and GitHub Actions automation #4)。
  • 这个 seam 只在需要的宿主上安装。正常部署跑的是 better-auth 原生读路径,里面没有任何 ObjectStack 代码,没有可回归的面。

3. 签名失败降级的是「头」,不是「session」

/get-session 现在照常返回 session,只是不带 set-auth-jwt,不再 500。适用于任何原因的签名失败(两种算法都不可用、sys_jwks 不可写、OS_AUTH_SECRET 轮换后解不开旧 key)。

失败只播报一次,错误信息点名算法、说明「登录和 cookie 认证不受影响」、并给出 OS_OIDC_PROVIDER_ENABLED=false 这个出口;同时进 getDegradedAuthFeatures(),用新 key jwtSigning

一个值得记下的坑:守卫必须返回 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-session after-hook 仍是我们包装的形状、adapter.getJwks seam 仍在。升级动了任何一处,挂的是单测,不是线上登录。
pnpm --filter @objectstack/plugin-auth test      → Test Files 31 passed (31), Tests 699 passed (699)
pnpm --filter @objectstack/plugin-auth typecheck → tsc --noEmit,无输出
node scripts/check-startup-registry-verdict.mjs  → ✓ 47 seam(s), none recording a contradictable verdict
node scripts/check-durability-degradation-log-level.mjs → ✓ 10 seam(s), all loud or rethrowing

已合入 origin/main(cbc844e)后重跑上述 test + typecheck,仍全绿;合入的三个提交与 packages/plugins/plugin-authpackages/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

claude added 2 commits August 4, 2026 00:41
…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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 1:38am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

… 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
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Aug 4, 2026
…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
@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

CI 记录(identity 车道 PM):Validate Package Dependencies 的红不是本 PR 引入的 —— 它是 main 全仓性的红,#5032 已立案跟踪(undici 7.28.0 / hono / fast-uri 共 8 条新发布的 OSV 公告,已有人认领)。本 PR 的依赖面改动只有 workspace 内部 devDependency(@objectstack/objectql),不触及这三个包的解析版本。等 #5032 的修复落 main 后随基分支恢复重跑;本 PR 不为此重推。


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

返工:把 fake engine 的 delete 钉到 ObjectQL 的 dispatch 谓词上

上一轮 ESLint job 里的 pnpm check:engine-double-contract 稳定失败:

x PINNED: packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts
  declares 1 engine double(s) whose delete() does not route through
  assertEngineDeleteDispatch (line 42).

这个 gate 是昨天才随 #4948(issue #4550)落地的 —— 它把 ObjectQL.delete() 的分派判断抽成 packages/objectql/src/engine-delete-dispatch.ts,要求 test double 导入生产端的谓词,而不是手抄一份。写这个测试时它还不存在。

改动(只有这一件事)

  1. auth-manager.jwt-eddsa-fallback.test.ts 的内存 engine,delete 开头改为 assertEngineDeleteDispatch(q)(从 @objectstack/objectql 导入),与 plugin-sharing 里已钉住的几个 fake 写法一致。没有选 baseline 豁免这条路 —— 手抄 if 正是这个 gate 要消灭的第二份契约。
  2. @objectstack/plugin-authdevDependencies 补上 @objectstack/objectql: workspace:*(workspace 协议,与 plugin-approvals 的声明方式一致)。无环:从 objectql 可达的 15 个包里没有 plugin-auth。
  3. baseline 里 auth-manager.optional-plugin-isolation.test.ts 那条的 why 原文写的是「plugin-auth 不依赖 objectql,所以钉不了」—— 这条理由被 2. 作废了,于是把它更正为实测状态(依赖已在,剩下的是一行改动,留给它自己的 PR)。计数没动,ratchet 不受影响。

一处诚实的说明

用临时 console.info 探针实测过(跑完即还原):本文件驱动的路径(sign-up → get-session → /jwks)当前根本不会走到 delete,所以这一行今天不可能让用例变红,它是一道前向守卫。它守的是 better-auth adapter 始终只按标量 id 删除(objectql-adapter.tsdelete/deleteMany/consumeOne 都是先 findOne/find 出行、再 delete(object, { where: { id } }));哪天升级把 session/verification 的清理改成裸谓词,挂的会是这条单测,而不是线上的 500。这句话写进了代码注释,免得后人把 gate 的绿读成「这里被覆盖了」。

本地验证

node scripts/check-engine-double-contract.mjs --self-test → OK
node scripts/check-engine-double-contract.mjs            → OK — 10 pinned, 30 DEBT, 1 exempt
   pinned  packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts

pnpm --filter @objectstack/plugin-auth exec vitest run src/auth-manager.jwt-eddsa-fallback.test.ts
   → Test Files 1 passed (1), Tests 8 passed (8)
pnpm --filter @objectstack/plugin-auth test      → Test Files 31 passed (31), Tests 699 passed (699)
pnpm --filter @objectstack/plugin-auth typecheck → tsc --noEmit,无输出
pnpm check:published-files / check:type-check-coverage → 均 OK
eslint 该测试文件 → 无输出

CI 上 ESLint job 已转绿(run 30866949063),TypeScript Type Check、Test Core、Build Core/Docs、Dogfood Regression Gate 等全绿。

一个副作用:Validate Package Dependencies 这次会跑,并且红

该 workflow 的触发条件是 paths: ['**/package.json', 'pnpm-lock.yaml', …]。本 PR 之前没碰过这两个文件,所以它从来没在这条 PR 上跑过;2. 补 devDependency 之后它第一次被触发,然后红在 OSV 扫描上:

Total 4 packages affected by 8 known vulnerabilities
fast-uri 3.1.4 / hono 4.12.32 / hono 4.12.33 / undici 7.28.0

这 8 条全部来自 origin/main 已有的锁文件版本,与本次改动无关 —— 本 PR 对 pnpm-lock.yaml 的 diff 只有三行:

+      '@objectstack/objectql':
+        specifier: workspace:*
+        version: link:../../objectql

git show origin/main:pnpm-lock.yaml 里这四个包就是这些版本;近期所有碰锁文件的 PR(issue-4311-runtime-typecheckissue-4762-static-rule-publish-gateissue-4965-osv-exemption-conventions …)在这个 workflow 上同样是 failure。已有 issue 记录:#5032(Validate Package Dependencies is red on main — 8 FIXABLE OSV advisories)。升级这四个包属于供应链维护,不该搭在一个 JWT 算法修复上,故本 PR 不动它;合并前需要 #5032 先落地,或由维护者决定走 osv-scanner.toml 豁免。

范围守则照旧:packages/spec/**content/docs/releases/ 零改动;scripts/check-engine-double-contract.mjspackages/objectql/** 未动;JWT 回退逻辑、keyring seam、其余测试一律未重构。本轮是纯测试基建修复,不新增 changeset(用户可见变更仍由 .changeset/jwt-eddsa-host-fallback.md 承载)。


Generated by Claude Code

@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

PM 复核(返工轮):返工通过;落地顺序裁定 A —— 等 #5032 落 main 后重跑,本 PR 不再改动

返工验收

落地顺序:A(排序问题,PM 直接裁定,不上升)

现状:22 绿,唯一的红是 Validate Package Dependencies —— 该 workflow 按路径触发,本 PR 首次触碰 package.json/lockfile 才使它开跑;它红在 main 既有的 8 条 OSV 公告(#5032,已认领在修),本 PR 的 lockfile diff 仅 3 行 workspace link,零解析版本变化。

兜底:若 #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
@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 02:27
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 9fa6bab Aug 4, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-3585-jwt-eddsa-fallback branch August 4, 2026 02:38
os-zhuang pushed a commit that referenced this pull request Aug 4, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: JWT plugin's EdDSA keygen breaks login on hosts without Ed25519 (WebContainer/StackBlitz)

2 participants