Skip to content

Commit 9fa6bab

Browse files
os-zhuangclaudeos-zhuang
authored
fix(plugin-auth): choose a JWT signing algorithm the host supports (#3585) (#5044)
* fix(plugin-auth): choose a JWT signing algorithm the host supports (#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 * test(plugin-auth): pin the #3585 memory engine's delete to ObjectQL's 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 * test(plugin-auth): say what the delete pin does and does not currently 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 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: os-zhuang <support@objectstack.ai>
1 parent a1a855a commit 9fa6bab

9 files changed

Lines changed: 1258 additions & 7 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): sign JWTs with an algorithm the host can actually use (#3585)
6+
7+
On any host whose WebCrypto lacks Ed25519 — StackBlitz/WebContainer is the
8+
reported one — **every authenticated request 500'd as soon as the OIDC provider
9+
was enabled**, which is the default whenever the MCP server is on. Sign-in
10+
succeeded, then the first `/api/v1/auth/get-session` returned 500 with
11+
`OperationError … cfrgGenerateKey`. An app that never asked for OIDC got an
12+
unusable login, and the only escape was `OS_OIDC_PROVIDER_ENABLED=false`.
13+
14+
The cause was an inherited default: `plugin-auth` registered better-auth's `jwt`
15+
plugin without `jwks.keyPairConfig`, so better-auth's **EdDSA / Ed25519** default
16+
applied and jose asked WebCrypto for an algorithm the host does not have. It hit
17+
ordinary cookie login rather than just OAuth clients because the plugin's `after`
18+
hook signs a `set-auth-jwt` header for *every* session.
19+
20+
**Three changes, no configuration required:**
21+
22+
- **The signing algorithm is now chosen by capability, not by inheritance.** At
23+
instance build the plugin asks WebCrypto whether it can generate an Ed25519
24+
key pair — using the exact algorithm descriptor jose uses — and pins
25+
`keyPairConfig` to `EdDSA`/`Ed25519` when it can, or falls back to **ES256**
26+
when it cannot. Hosts with Ed25519 behave exactly as before.
27+
- **Deployments that already minted an EdDSA key keep working.** Choosing ES256
28+
for *new* keys is not sufficient on its own: better-auth's `resolveSigningKey`
29+
falls back to *any* stored key when none matches the configured algorithm, so
30+
an existing EdDSA key in `sys_jwks` would still be selected and then fail in
31+
`importJWK`. On a host without Ed25519 the plugin now installs better-auth's
32+
`adapter.getJwks` keyring seam and hides keys this host cannot import, so a
33+
fresh ES256 key is minted and the deployment converges on a working state.
34+
Hidden rows are **never deleted** — move back to a host with Ed25519 and they
35+
are used again. Such a host also stops advertising those keys in
36+
`/api/v1/auth/jwks`, since it can neither sign nor verify with them.
37+
- **A signing failure can no longer take down the session path.** If signing
38+
fails anyway (neither algorithm usable, an unwritable `sys_jwks`, or a rotated
39+
`OS_AUTH_SECRET` that cannot decrypt the stored key), `/get-session` now
40+
returns the session normally and simply omits the `set-auth-jwt` header,
41+
instead of 500ing. The failure is reported once with an error that names the
42+
algorithm, says what still works, and points at the opt-out — and is queryable
43+
via `getDegradedAuthFeatures()` under the new `jwtSigning` key.
44+
45+
No configuration changes and no migration. Deployments on hosts with Ed25519 are
46+
unaffected: the keyring override is installed only where it is needed.

packages/plugins/plugin-auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"jose": "^6.2.5"
3434
},
3535
"devDependencies": {
36+
"@objectstack/objectql": "workspace:*",
3637
"@types/node": "^26.1.2",
3738
"hono": "^4.12.34",
3839
"typescript": "^6.0.3",

0 commit comments

Comments
 (0)