From 27e595bfe2f7cfd3ca1e7d1f33c942c2d33cd08f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:41:27 +0000 Subject: [PATCH 1/3] fix(plugin-auth): choose a JWT signing algorithm the host supports (#3585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- .changeset/jwt-eddsa-host-fallback.md | 46 ++ .../auth-manager.jwt-eddsa-fallback.test.ts | 402 ++++++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 132 +++++- .../src/better-auth-schema-parity.test.ts | 63 +++ .../plugin-auth/src/jwt-key-algorithm.test.ts | 291 +++++++++++++ .../plugin-auth/src/jwt-key-algorithm.ts | 307 +++++++++++++ 6 files changed, 1236 insertions(+), 5 deletions(-) create mode 100644 .changeset/jwt-eddsa-host-fallback.md create mode 100644 packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts create mode 100644 packages/plugins/plugin-auth/src/jwt-key-algorithm.test.ts create mode 100644 packages/plugins/plugin-auth/src/jwt-key-algorithm.ts diff --git a/.changeset/jwt-eddsa-host-fallback.md b/.changeset/jwt-eddsa-host-fallback.md new file mode 100644 index 0000000000..a045d700f1 --- /dev/null +++ b/.changeset/jwt-eddsa-host-fallback.md @@ -0,0 +1,46 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(plugin-auth): sign JWTs with an algorithm the host can actually use (#3585) + +On any host whose WebCrypto lacks Ed25519 — StackBlitz/WebContainer is the +reported one — **every authenticated request 500'd as soon as the OIDC provider +was enabled**, which is the default whenever the MCP server is on. Sign-in +succeeded, then the first `/api/v1/auth/get-session` returned 500 with +`OperationError … cfrgGenerateKey`. An app that never asked for OIDC got an +unusable login, and the only escape was `OS_OIDC_PROVIDER_ENABLED=false`. + +The cause was an inherited default: `plugin-auth` registered better-auth's `jwt` +plugin without `jwks.keyPairConfig`, so better-auth's **EdDSA / Ed25519** default +applied and jose asked WebCrypto for an algorithm the host does not have. It hit +ordinary cookie login rather than just OAuth clients because the plugin's `after` +hook signs a `set-auth-jwt` header for *every* session. + +**Three changes, no configuration required:** + +- **The signing algorithm is now chosen by capability, not by inheritance.** At + instance build the plugin asks WebCrypto whether it can generate an Ed25519 + key pair — using the exact algorithm descriptor jose uses — and pins + `keyPairConfig` to `EdDSA`/`Ed25519` when it can, or falls back to **ES256** + when it cannot. Hosts with Ed25519 behave exactly as before. +- **Deployments that already minted an EdDSA key keep working.** Choosing ES256 + for *new* keys is not sufficient on its own: better-auth's `resolveSigningKey` + falls back to *any* stored key when none matches the configured algorithm, so + an existing EdDSA key in `sys_jwks` would still be selected and then fail in + `importJWK`. On a host without Ed25519 the plugin now installs better-auth's + `adapter.getJwks` keyring seam and hides keys this host cannot import, so a + fresh ES256 key is minted and the deployment converges on a working state. + Hidden rows are **never deleted** — move back to a host with Ed25519 and they + are used again. Such a host also stops advertising those keys in + `/api/v1/auth/jwks`, since it can neither sign nor verify with them. +- **A signing failure can no longer take down the session path.** If signing + fails anyway (neither algorithm usable, an unwritable `sys_jwks`, or a rotated + `OS_AUTH_SECRET` that cannot decrypt the stored key), `/get-session` now + returns the session normally and simply omits the `set-auth-jwt` header, + instead of 500ing. The failure is reported once with an error that names the + algorithm, says what still works, and points at the opt-out — and is queryable + via `getDegradedAuthFeatures()` under the new `jwtSigning` key. + +No configuration changes and no migration. Deployments on hosts with Ed25519 are +unaffected: the keyring override is installed only where it is needed. diff --git a/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts b/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts new file mode 100644 index 0000000000..b071310882 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts @@ -0,0 +1,402 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// End-to-end regression tests for #3585 — the JWT plugin's EdDSA keygen broke +// login on hosts without Ed25519 (StackBlitz/WebContainer). +// +// These run the REAL better-auth pipeline (like +// auth-manager.optional-plugin-isolation.test.ts, and unlike auth-manager.test.ts +// which mocks better-auth itself), against a WebCrypto that has had Ed25519 +// removed exactly the way WebContainer's has: `crypto.subtle.generateKey` / +// `importKey` reject `{ name: 'Ed25519' }` with an OperationError, and every +// other algorithm keeps working. Nothing about the plugin wiring is stubbed — +// if the fallback did not reach jose, these tests would 500 like the bug report. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AuthManager } from './auth-manager'; + +const createMemoryEngine = () => { + const tables = new Map(); + const rows = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: any, b: any) => + a instanceof Date || b instanceof Date + ? new Date(a as any).getTime() === new Date(b as any).getTime() + : a === b; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + const actual = row[k]; + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) { + if ('$ne' in v) return !eq(actual, v.$ne); + if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x)); + if ('$gt' in v) return actual > v.$gt; + if ('$gte' in v) return actual >= v.$gte; + if ('$lt' in v) return actual < v.$lt; + if ('$lte' in v) return actual <= v.$lte; + if ('$regex' in v) return new RegExp(String(v.$regex)).test(String(actual ?? '')); + } + return eq(actual, v); + }); + let seq = 0; + return { + tables, + async insert(name: string, data: any) { + const row = { id: data.id ?? `row_${++seq}`, ...data }; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, q: any = {}) { + return rows(name).find((r) => matches(r, q.where)) ?? null; + }, + async find(name: string, q: any = {}) { + let out = rows(name).filter((r) => matches(r, q.where)); + const order = q.orderBy?.[0]; + if (order) { + out = [...out].sort( + (a, b) => (a[order.field] > b[order.field] ? 1 : -1) * (order.order === 'desc' ? -1 : 1), + ); + } + if (q.offset) out = out.slice(q.offset); + if (q.limit) out = out.slice(0, q.limit); + return out.map((r) => ({ ...r })); + }, + async count(name: string, q: any = {}) { + return rows(name).filter((r) => matches(r, q.where)).length; + }, + async update(name: string, patch: any) { + const row = rows(name).find((r) => r.id === patch.id); + if (!row) return null; + Object.assign(row, patch); + return { ...row }; + }, + async delete(name: string, q: any = {}) { + const table = rows(name); + const keep = table.filter((r) => !matches(r, q.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +}; + +/** + * Remove Ed25519 from this process's WebCrypto, the way WebContainer's lacks it. + * + * This is deliberately stronger than stubbing our own probe. If the probe were + * the only thing stubbed, real WebCrypto would still happily import an Ed25519 + * key — so a regression that let better-auth select a stored EdDSA key would + * pass the test and still 500 on the real host. Patching WebCrypto itself makes + * the test fail for the same reason production would. + * + * `generateKey` is where the reported stack died (`cfrgGenerateKey`), and + * `importKey` is patched with it because that is what a deployment with an + * ALREADY-MINTED EdDSA key hits first, via `importJWK` in resolveSigningKey. + * Every other algorithm delegates to the real implementation, so ES256 keygen, + * AES-GCM private-key encryption and session hashing all still work. + */ +const removeEd25519 = (): { restore: () => void } => { + const subtle = globalThis.crypto.subtle as any; + const originalGenerateKey = subtle.generateKey.bind(subtle); + const originalImportKey = subtle.importKey.bind(subtle); + const isEd25519 = (algorithm: any) => + (typeof algorithm === 'string' ? algorithm : algorithm?.name) === 'Ed25519'; + const operationError = () => + Object.assign(new Error('The operation failed for an operation-specific reason'), { + name: 'OperationError', + }); + + subtle.generateKey = async (algorithm: any, ...rest: any[]) => { + if (isEd25519(algorithm)) throw operationError(); + return originalGenerateKey(algorithm, ...rest); + }; + subtle.importKey = async (format: any, keyData: any, algorithm: any, ...rest: any[]) => { + if (isEd25519(algorithm)) throw operationError(); + return originalImportKey(format, keyData, algorithm, ...rest); + }; + + return { + restore: () => { + subtle.generateKey = originalGenerateKey; + subtle.importKey = originalImportKey; + }, + }; +}; + +/** Decode a compact JWS header without verifying — we only assert `alg`. */ +const jwtHeader = (token: string): Record => + JSON.parse(Buffer.from(token.split('.')[0]!, 'base64url').toString('utf8')); + +const SECRET = 'test-secret-at-least-32-chars-long!!'; + +const makeManager = (engine: any) => + new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + plugins: { oidcProvider: true }, + }); + +const signUp = (manager: AuthManager, email: string) => + manager.handleRequest( + new Request('http://localhost:3000/api/v1/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: 'S3cure!Passw0rd-3585', name: 'Ed25519 Test' }), + }), + ); + +const getSession = (manager: AuthManager, cookie: string) => + manager.handleRequest( + new Request('http://localhost:3000/api/v1/auth/get-session', { headers: { cookie } }), + ); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +describe('#3585 — JWT signing on a host without Ed25519', () => { + let warnSpy: ReturnType; + let patched: { restore: () => void } | undefined; + + beforeEach(() => { + patched = undefined; + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + patched?.restore(); + vi.restoreAllMocks(); + }); + + it('control: on a host WITH Ed25519 nothing changes — get-session 200, EdDSA header', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine as any); + + const signed = await signUp(manager, 'healthy@example.com'); + expect(signed.status).toBe(200); + + const session = await getSession(manager, cookieFrom(signed)); + expect(session.status).toBe(200); + + const token = session.headers.get('set-auth-jwt'); + expect(token).toBeTruthy(); + expect(jwtHeader(token!).alg).toBe('EdDSA'); + expect(manager.getDegradedAuthFeatures()).toEqual([]); + }); + + it('the reported bug: sign-in + get-session both 200 on a host without Ed25519', async () => { + const engine = createMemoryEngine(); + patched = removeEd25519(); + const manager = makeManager(engine as any); + + const signed = await signUp(manager, 'webcontainer@example.com'); + expect(signed.status).toBe(200); + + const session = await getSession(manager, cookieFrom(signed)); + + // The bug: this was a 500 with `OperationError ... cfrgGenerateKey`. + expect(session.status).toBe(200); + const token = session.headers.get('set-auth-jwt'); + expect(token).toBeTruthy(); + expect(jwtHeader(token!).alg).toBe('ES256'); + expect(manager.getDegradedAuthFeatures()).toEqual([]); + }); + + it('mints an ES256 key into sys_jwks, and says which algorithm it chose and why', async () => { + const engine = createMemoryEngine(); + patched = removeEd25519(); + const manager = makeManager(engine as any); + + const signed = await signUp(manager, 'keys@example.com'); + await getSession(manager, cookieFrom(signed)); + + const keys = engine.tables.get('sys_jwks') ?? []; + expect(keys).toHaveLength(1); + expect(keys[0]!.alg).toBe('ES256'); + + // The operator has to be able to correlate "my tokens are ES256" with a + // cause; the message names the algorithm, not just "keygen failed". + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("WebCrypto has no Ed25519"), + ); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('ES256')); + }); + + it('the /jwks endpoint serves the ES256 key rather than 500ing', async () => { + const engine = createMemoryEngine(); + patched = removeEd25519(); + const manager = makeManager(engine as any); + + const signed = await signUp(manager, 'jwks@example.com'); + await getSession(manager, cookieFrom(signed)); + + const response = await manager.handleRequest( + new Request('http://localhost:3000/api/v1/auth/jwks'), + ); + expect(response.status).toBe(200); + const body: any = await response.json(); + expect(body.keys.map((k: any) => k.alg)).toEqual(['ES256']); + }); +}); + +describe('#3585 — an EXISTING EdDSA key when the host loses Ed25519', () => { + // The upgrade path, and the part configuring `keyPairConfig` alone does not + // fix: better-auth's resolveSigningKey falls back to `getLatestKey()` (ANY + // algorithm) when no key matches the configured one, so a stored EdDSA key + // would be selected and then die in `importJWK`. + let patched: { restore: () => void } | undefined; + + beforeEach(() => { + patched = undefined; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + patched?.restore(); + vi.restoreAllMocks(); + }); + + it('a deployment that already minted EdDSA keeps working after moving to such a host', async () => { + const engine = createMemoryEngine(); + + // ── Phase 1: a normal host mints a REAL EdDSA key (better-auth's own + // createJwk, correctly encrypted under the real secret — not a fixture). + const healthy = makeManager(engine as any); + const signed = await signUp(healthy, 'migrating@example.com'); + const cookie = cookieFrom(signed); + const before = await getSession(healthy, cookie); + expect(jwtHeader(before.headers.get('set-auth-jwt')!).alg).toBe('EdDSA'); + expect(engine.tables.get('sys_jwks')!.map((k: any) => k.alg)).toEqual(['EdDSA']); + + // ── Phase 2: same database, host without Ed25519. + patched = removeEd25519(); + const degraded = makeManager(engine as any); + + const after = await getSession(degraded, cookie); + + // Not a 500, and not merely "no header": the deployment CONVERGES on a + // usable algorithm instead of sitting broken. + expect(after.status).toBe(200); + const token = after.headers.get('set-auth-jwt'); + expect(token).toBeTruthy(); + expect(jwtHeader(token!).alg).toBe('ES256'); + expect(degraded.getDegradedAuthFeatures()).toEqual([]); + }); + + it('the stored EdDSA row is HIDDEN, never deleted — moving back restores it', async () => { + const engine = createMemoryEngine(); + + const healthy = makeManager(engine as any); + const signed = await signUp(healthy, 'preserve@example.com'); + const cookie = cookieFrom(signed); + await getSession(healthy, cookie); + const eddsaId = engine.tables.get('sys_jwks')![0]!.id; + + patched = removeEd25519(); + const degraded = makeManager(engine as any); + await getSession(degraded, cookie); + + // Both rows are still on disk… + const stored = engine.tables.get('sys_jwks')!; + expect(stored.map((k: any) => k.alg).sort()).toEqual(['ES256', 'EdDSA']); + expect(stored.some((k: any) => k.id === eddsaId)).toBe(true); + + // …but this host does not advertise the one it cannot sign or verify with. + const jwks: any = await ( + await degraded.handleRequest(new Request('http://localhost:3000/api/v1/auth/jwks')) + ).json(); + expect(jwks.keys.map((k: any) => k.alg)).toEqual(['ES256']); + + // Move back to a host with Ed25519: the original key is visible again. + patched.restore(); + patched = undefined; + const restored = makeManager(engine as any); + const restoredJwks: any = await ( + await restored.handleRequest(new Request('http://localhost:3000/api/v1/auth/jwks')) + ).json(); + expect(restoredJwks.keys.map((k: any) => k.alg).sort()).toEqual(['ES256', 'EdDSA']); + }); +}); + +describe('#3585 — degradation when signing cannot work at all', () => { + let errorSpy: ReturnType; + + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('a session is still returned when JWT signing throws — no 500 on the session path', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine as any); + + const signed = await signUp(manager, 'nosign@example.com'); + const cookie = cookieFrom(signed); + + // Break signing after sign-up: every jwks write fails, so there is no key + // to sign with and no way to mint one. (Models a host where neither + // algorithm works, or an unwritable sys_jwks.) + const originalInsert = engine.insert.bind(engine); + engine.insert = async (name: string, data: any) => { + if (name === 'sys_jwks') throw new Error('sys_jwks is not writable (simulated)'); + return originalInsert(name, data); + }; + + const session = await getSession(manager, cookie); + + // The session itself is valid and must be returned — the JWT header is an + // optional enrichment, not the session. + expect(session.status).toBe(200); + const body: any = await session.json(); + expect(body?.user?.email).toBe('nosign@example.com'); + expect(session.headers.get('set-auth-jwt')).toBeNull(); + }); + + it('the degradation is recorded and announced once, naming the algorithm and the remedy', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine as any); + + const signed = await signUp(manager, 'announce@example.com'); + const cookie = cookieFrom(signed); + + const originalInsert = engine.insert.bind(engine); + engine.insert = async (name: string, data: any) => { + if (name === 'sys_jwks') throw new Error('sys_jwks is not writable (simulated)'); + return originalInsert(name, data); + }; + + await getSession(manager, cookie); + + expect(manager.getDegradedAuthFeatures()).toEqual([ + expect.objectContaining({ feature: 'jwtSigning' }), + ]); + + const announcement = errorSpy.mock.calls.find((call) => + String(call[0]).includes('JWT signing failed'), + ); + expect(announcement).toBeTruthy(); + // Names the algorithm (the issue's complaint was that the error pointed at + // better-auth rather than the algorithm choice)… + expect(String(announcement![0])).toContain('EdDSA'); + // …says what still works, so nobody reads it as "auth is down"… + expect(String(announcement![0])).toContain('sign-in and cookie auth are unaffected'); + // …and names the opt-out. + expect(String(announcement![0])).toContain('OS_OIDC_PROVIDER_ENABLED=false'); + + // Said ONCE, not once per request. + await getSession(manager, cookie); + await getSession(manager, cookie); + const announcements = errorSpy.mock.calls.filter((call) => + String(call[0]).includes('JWT signing failed'), + ); + expect(announcements).toHaveLength(1); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 162bf4af81..5b0991b00d 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -46,6 +46,12 @@ import { buildAdminPluginSchema, buildPhoneNumberPluginSchema, } from './auth-schema-config.js'; +import { + createHostUsableJwksReader, + probeEd25519Support, + protectGetSessionJwtHook, + resolveJwtSigningAlgorithm, +} from './jwt-key-algorithm.js'; /** * Detect WebContainer (StackBlitz) environment. @@ -737,6 +743,17 @@ export class AuthManager { // by every buildPluginList() run; see addOptionalPlugin(). private degradedFeatures = new Map(); + // #3585 — "can this host do Ed25519?", asked of WebCrypto once per manager. + // Memoized because the answer is a fixed property of the runtime, not of a + // registry that is still filling: nothing can register Ed25519 support later + // in the boot, so caching the verdict cannot go stale (contrast the + // startup-registry-verdict rule in AGENTS.md, which is about the opposite + // case). + private ed25519ProbeResult?: Promise; + // Emitted at most once per instance build: a host that cannot sign says so on + // the first /get-session failure, not on every request. + private jwtSigningFailureReported = false; + /** * Result of the dev-only admin seed (set by `AuthPlugin.maybeSeedDevAdmin` * when it provisions the well-known admin on an empty DB). The `serve` @@ -1554,15 +1571,115 @@ export class AuthManager { } /** - * Optional auth features skipped by the last plugin-list build because - * their better-auth plugin threw during initialization. Empty when the - * instance is healthy (or not built yet). Keys match the - * `AuthPluginConfig` flag names (`oidcProvider`, `sso`, `scim`, …). + * Optional auth features that are not working on this instance. Empty when + * the instance is healthy (or not built yet). Reset by every plugin-list + * build. + * + * Most keys match the `AuthPluginConfig` flag names (`oidcProvider`, `sso`, + * `scim`, …) and mean "the better-auth plugin threw during initialization + * and was skipped" (see addOptionalPlugin). One key is finer-grained: + * `jwtSigning` is recorded at REQUEST time when the jwt plugin constructed + * fine but could not actually sign (#3585) — the plugin's endpoints are + * mounted, they just cannot mint a token. */ getDegradedAuthFeatures(): Array<{ feature: string; error: string }> { return Array.from(this.degradedFeatures, ([feature, error]) => ({ feature, error })); } + /** + * Construct better-auth's `jwt` plugin with an algorithm this host can + * actually use, and with the `/get-session` header hook made non-fatal. + * + * Two independent defects are closed here; see `jwt-key-algorithm.ts` for + * the full analysis of better-auth's key selection. + * + * 1. **Algorithm choice.** better-auth defaults to EdDSA/Ed25519, which + * `crypto.subtle.generateKey` rejects on hosts without it (WebContainer). + * We probe the capability once and pin `keyPairConfig` explicitly. + * 2. **Existing EdDSA keys.** `resolveSigningKey` falls back to *any* stored + * key when none matches the configured algorithm, so a deployment that + * already minted an EdDSA key would still die in `importJWK` after the + * fallback. On a host without Ed25519 we install better-auth's + * `adapter.getJwks` keyring seam so unusable keys are not offered at all + * and a fresh ES256 key is minted instead. Rows are hidden, never + * deleted — moving back to a host with Ed25519 restores them. + * + * Belt-and-braces, on every host: a signing failure degrades the + * `set-auth-jwt` header instead of 500ing `/get-session`. + */ + private async buildJwtPlugin(jwt: (options: any) => any): Promise { + if (!this.ed25519ProbeResult) this.ed25519ProbeResult = probeEd25519Support(); + const { keyPairConfig, ed25519Supported } = await resolveJwtSigningAlgorithm( + () => this.ed25519ProbeResult!, + ); + + if (!ed25519Supported) { + // Functional, not durability: the deployment is fully operational on + // ES256. Worth one line because it changes what /jwks publishes and what + // `alg` issued tokens carry, which an operator debugging a relying party + // otherwise cannot correlate to anything. + console.warn( + `[AuthManager] This host's WebCrypto has no Ed25519 (crypto.subtle.generateKey({name:'Ed25519'}) fails), ` + + `so JWTs are signed with ${keyPairConfig.alg} instead of better-auth's default EdDSA. ` + + `Any existing EdDSA key in sys_jwks is hidden from the JWKS on this host — it cannot be imported here — ` + + `and a fresh ${keyPairConfig.alg} key is minted on first use. The rows are not deleted.`, + ); + } + + const jwtPlugin = jwt({ + schema: buildJwtPluginSchema(), + jwks: { keyPairConfig }, + // The keyring override is installed ONLY on a host that needs it, so a + // normal deployment runs better-auth's stock read path with no + // ObjectStack code in it and nothing to regress. + ...(ed25519Supported + ? {} + : { adapter: { getJwks: createHostUsableJwksReader() } }), + }); + + const protectedHook = protectGetSessionJwtHook(jwtPlugin, (error) => + this.reportJwtSigningFailure(error, keyPairConfig.alg), + ); + if (!protectedHook) { + // The guard could not attach — better-auth moved or renamed the hook. + // Say so rather than shipping a build that looks protected and is not. + console.error( + '[AuthManager] Could not attach the JWT signing guard to better-auth\'s /get-session hook ' + + '(its `hooks.after` shape changed). A JWT signing failure will now 500 every /get-session ' + + 'instead of degrading to a missing set-auth-jwt header. See objectstack#3585 and re-check ' + + 'the better-auth version in better-auth-schema-parity.test.ts.', + ); + } + + return jwtPlugin; + } + + /** + * Record + announce a `/get-session` JWT signing failure exactly once. + * + * Functional degradation (AGENTS.md degradation-log-levels): nothing that + * claimed to persist was lost, the session itself is valid and returned + * normally — only the optional `set-auth-jwt` enrichment header is missing. + * It is logged at `error` rather than `warn` for the same reason + * addOptionalPlugin does: an auth feature the deployment asked for is not + * delivering, and the operator has to act. + */ + private reportJwtSigningFailure(error: unknown, alg: string): void { + const message = (error as any)?.message ?? String(error); + this.degradedFeatures.set('jwtSigning', message); + if (this.jwtSigningFailureReported) return; + this.jwtSigningFailureReported = true; + console.error( + `[AuthManager] JWT signing failed with alg "${alg}", so /get-session responses carry no ` + + `set-auth-jwt header and OIDC/MCP token issuance will not work. The session itself is valid — ` + + `sign-in and cookie auth are unaffected. Common causes: this host's WebCrypto cannot use the ` + + `algorithm of the key in sys_jwks, or OS_AUTH_SECRET changed since the key was encrypted. ` + + `Set OS_OIDC_PROVIDER_ENABLED=false if this deployment does not need to act as an IdP. ` + + `Cause: ${message}`, + error, + ); + } + /** * Build the list of better-auth plugins based on AuthPluginConfig flags. * @@ -1589,6 +1706,11 @@ export class AuthManager { */ private async buildPluginList(): Promise { this.degradedFeatures.clear(); + // Same lifecycle as degradedFeatures: an operator who fixes the cause and + // triggers a rebuild gets a fresh verdict, and a fresh log line if it is + // still broken. (The Ed25519 probe itself is NOT reset — it is a property + // of the runtime, which a rebuild cannot change.) + this.jwtSigningFailureReported = false; const pluginConfig: Partial = this.config.plugins ?? {}; const plugins: any[] = []; @@ -2172,7 +2294,7 @@ export class AuthManager { // automatically — it is otherwise an internal implementation detail // and forcing every consumer to opt in would be poor DX. const { jwt } = await import('better-auth/plugins'); - const jwtPlugin = jwt({ schema: buildJwtPluginSchema() }); + const jwtPlugin = await this.buildJwtPlugin(jwt); const { oauthProvider } = await import('@better-auth/oauth-provider'); const dcr = resolveDcrEnabled(pluginConfig); diff --git a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts index f99264efd1..95c89696e9 100644 --- a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts +++ b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts @@ -265,3 +265,66 @@ describe('@better-auth/sso + @better-auth/scim schema ↔ platform-objects parit } } }); + +/** + * Upgrade tripwires for the #3585 fix. + * + * `AuthManager.buildJwtPlugin` reaches into two things better-auth does not + * version as public API: the EdDSA default it applies when `keyPairConfig` is + * absent, and the `/get-session` `after` hook whose handler is wrapped so a + * signing failure cannot 500 the session path. Both are pinned here so a + * better-auth bump that moves either one fails a fast unit test instead of a + * production login. + */ +describe('better-auth jwt plugin contract (#3585)', () => { + it('still defaults to EdDSA/Ed25519 — the reason the fallback exists', async () => { + // If this ever fails because better-auth changed its default to something + // universally supported, the probe is no longer load-bearing and + // buildJwtPlugin can be simplified. Read utils.mjs `generateExportedKeyPair` + // before deleting anything. + const { generateExportedKeyPair } = (await import( + 'better-auth/plugins/jwt' + )) as unknown as { + generateExportedKeyPair: (o?: unknown) => Promise<{ alg: string; cfg: { crv?: string } }>; + }; + const generated = await generateExportedKeyPair(undefined); + expect(generated.alg).toBe('EdDSA'); + expect(generated.cfg.crv).toBe('Ed25519'); + }); + + it('honours an explicit ES256 keyPairConfig', async () => { + const { generateExportedKeyPair } = (await import( + 'better-auth/plugins/jwt' + )) as unknown as { + generateExportedKeyPair: (o?: unknown) => Promise<{ alg: string }>; + }; + const generated = await generateExportedKeyPair({ jwks: { keyPairConfig: { alg: 'ES256' } } }); + expect(generated.alg).toBe('ES256'); + }); + + it('still exposes exactly one /get-session after-hook for the guard to wrap', () => { + const plugin = jwt({ schema: buildJwtPluginSchema() }) as unknown as { + hooks?: { after?: Array<{ matcher?: (c: { path?: string }) => boolean; handler?: unknown }> }; + }; + const after = plugin.hooks?.after ?? []; + const getSessionHooks = after.filter((h) => h.matcher?.({ path: '/get-session' })); + + expect(getSessionHooks).toHaveLength(1); + expect(typeof getSessionHooks[0]!.handler).toBe('function'); + // Not matched for other paths — the guard must not silently wrap unrelated + // hooks if the matcher is ever broadened. + expect(after.filter((h) => h.matcher?.({ path: '/sign-in/email' }))).toHaveLength(0); + }); + + it('exposes the adapter.getJwks keyring seam the ES256 fallback relies on', () => { + // The seam is what lets a host without Ed25519 hide keys it cannot import, + // so resolveSigningKey's any-algorithm `getLatestKey()` fallback cannot + // hand it a stored EdDSA key. Removing the option would silently reinstate + // the crash for existing deployments. + const getJwks = async () => []; + const plugin = jwt({ schema: buildJwtPluginSchema(), adapter: { getJwks } } as never) as { + options?: { adapter?: { getJwks?: unknown } }; + }; + expect(plugin.options?.adapter?.getJwks).toBe(getJwks); + }); +}); diff --git a/packages/plugins/plugin-auth/src/jwt-key-algorithm.test.ts b/packages/plugins/plugin-auth/src/jwt-key-algorithm.test.ts new file mode 100644 index 0000000000..624116abb7 --- /dev/null +++ b/packages/plugins/plugin-auth/src/jwt-key-algorithm.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// Unit tests for the #3585 Ed25519 probe / ES256 fallback decision and the +// host-usable JWKS filter. +// +// A real WebContainer is not needed (and would not be reproducible in CI): the +// only thing that makes a host "without Ed25519" is `crypto.subtle.generateKey` +// rejecting `{ name: 'Ed25519' }`, so that is what these tests simulate. + +import { describe, it, expect, vi } from 'vitest'; +import { + ED25519_KEY_PAIR_CONFIG, + ES256_KEY_PAIR_CONFIG, + createHostUsableJwksReader, + isEd25519Jwk, + probeEd25519Support, + protectGetSessionJwtHook, + resolveJwtSigningAlgorithm, + selectHostUsableJwks, +} from './jwt-key-algorithm'; + +/** The exact DOMException WebContainer throws out of `cfrgGenerateKey`. */ +const webContainerOperationError = () => + Object.assign(new Error('The operation failed for an operation-specific reason'), { + name: 'OperationError', + }); + +describe('probeEd25519Support', () => { + it('asks WebCrypto for the SAME descriptor jose uses for EdDSA', async () => { + const generateKey = vi.fn().mockResolvedValue({}); + + await probeEd25519Support({ generateKey } as any); + + // jose resolves `EdDSA` to `{ name: 'Ed25519' }` (lib/jws_algorithms.js). + // A probe that asked for anything else could pass while signing fails. + expect(generateKey).toHaveBeenCalledWith({ name: 'Ed25519' }, true, ['sign', 'verify']); + }); + + it('true when the host can generate an Ed25519 key pair', async () => { + await expect(probeEd25519Support({ generateKey: async () => ({}) } as any)).resolves.toBe(true); + }); + + it('false — never throws — when generateKey REJECTS (the WebContainer case)', async () => { + const generateKey = vi.fn().mockRejectedValue(webContainerOperationError()); + await expect(probeEd25519Support({ generateKey } as any)).resolves.toBe(false); + }); + + it('false when generateKey throws SYNCHRONOUSLY', async () => { + const generateKey = vi.fn(() => { + throw new Error('NotSupportedError'); + }); + await expect(probeEd25519Support({ generateKey } as any)).resolves.toBe(false); + }); + + it('false when the host has no usable crypto.subtle at all', async () => { + // This is the shape `globalThis.crypto?.subtle` degrades to on a host + // without WebCrypto — the probe must answer, not throw on `undefined?.`. + await expect(probeEd25519Support(null)).resolves.toBe(false); + await expect(probeEd25519Support({} as any)).resolves.toBe(false); + await expect(probeEd25519Support({ generateKey: 'nope' } as any)).resolves.toBe(false); + }); + + it('the real host running these tests is probed truthfully', async () => { + // Node 22 has Ed25519; this pins that the probe works against real + // WebCrypto and is not accidentally always-false. + await expect(probeEd25519Support()).resolves.toBe(true); + }); +}); + +describe('resolveJwtSigningAlgorithm', () => { + it('keeps better-auth\'s EdDSA default when the host supports it', async () => { + const decision = await resolveJwtSigningAlgorithm(async () => true); + + expect(decision.ed25519Supported).toBe(true); + expect(decision.keyPairConfig).toEqual({ alg: 'EdDSA', crv: 'Ed25519' }); + expect(decision.keyPairConfig).toBe(ED25519_KEY_PAIR_CONFIG); + }); + + it('falls back to ES256 on a host without Ed25519', async () => { + const decision = await resolveJwtSigningAlgorithm(async () => false); + + expect(decision.ed25519Supported).toBe(false); + expect(decision.keyPairConfig).toEqual({ alg: 'ES256' }); + expect(decision.keyPairConfig).toBe(ES256_KEY_PAIR_CONFIG); + }); + + it('pins the EdDSA config explicitly rather than leaving it to better-auth', async () => { + // better-auth's `generateExportedKeyPair` defaults to + // `{ alg: 'EdDSA', crv: 'Ed25519' }` when keyPairConfig is absent. We pass + // it explicitly so the deployment's algorithm is visible at the call site + // and cannot change under us on a dependency bump. + const { keyPairConfig } = await resolveJwtSigningAlgorithm(async () => true); + expect(keyPairConfig.alg).toBe('EdDSA'); + expect((keyPairConfig as { crv?: string }).crv).toBe('Ed25519'); + }); +}); + +describe('isEd25519Jwk', () => { + it('detects the modern row shape via alg', () => { + expect(isEd25519Jwk({ alg: 'EdDSA', crv: 'Ed25519' })).toBe(true); + }); + + it('detects via crv alone', () => { + expect(isEd25519Jwk({ crv: 'Ed25519' })).toBe(true); + }); + + it('LEGACY row (alg/crv null) is classified from the stored key material', () => { + // Rows minted before better-auth 1.7 have no alg/crv columns populated. + // `getLatestKeyByAlg` treats `alg == null` as "the configured default", so + // after the ES256 fallback such a row would be selected AS IF it were + // ES256 and then blow up in importJWK. Reading kty/crv off the JWK is what + // makes this correct rather than merely well-typed. + const legacyEdDSA = { + alg: null, + crv: null, + publicKey: JSON.stringify({ kty: 'OKP', crv: 'Ed25519', x: 'abc' }), + }; + expect(isEd25519Jwk(legacyEdDSA)).toBe(true); + }); + + it('legacy EC row is NOT mistaken for Ed25519', () => { + const legacyEc = { + alg: null, + crv: null, + publicKey: JSON.stringify({ kty: 'EC', crv: 'P-256', x: 'a', y: 'b' }), + }; + expect(isEd25519Jwk(legacyEc)).toBe(false); + }); + + it('ES256 rows are never hidden', () => { + expect(isEd25519Jwk({ alg: 'ES256', crv: 'P-256' })).toBe(false); + expect(isEd25519Jwk({ alg: 'RS256' })).toBe(false); + }); + + it('unparseable key material is left VISIBLE — better a loud better-auth error than a silently hidden key', () => { + expect(isEd25519Jwk({ alg: null, crv: null, publicKey: 'not-json' })).toBe(false); + }); + + it('tolerates null/undefined rows', () => { + expect(isEd25519Jwk(null)).toBe(false); + expect(isEd25519Jwk(undefined)).toBe(false); + }); +}); + +describe('selectHostUsableJwks', () => { + const eddsa = { id: 'k_eddsa', alg: 'EdDSA', crv: 'Ed25519' }; + const es256 = { id: 'k_es256', alg: 'ES256' }; + + it('is IDENTITY on a host with Ed25519 — the filter must be inert in production', () => { + expect(selectHostUsableJwks([eddsa, es256], true)).toEqual([eddsa, es256]); + }); + + it('hides Ed25519 keys on a host without Ed25519', () => { + expect(selectHostUsableJwks([eddsa, es256], false)).toEqual([es256]); + }); + + it('an all-EdDSA keyring becomes empty, which is what makes better-auth mint a fresh usable key', () => { + // resolveSigningKey: `getLatestKeyByAlg(ES256) ?? getLatestKey()` — with + // both empty it falls through to createJwk(), minting ES256. That is the + // convergence this filter exists to enable. + expect(selectHostUsableJwks([eddsa], false)).toEqual([]); + }); +}); + +describe('createHostUsableJwksReader', () => { + const ctxWith = (rows: unknown[]) => ({ + context: { adapter: { findMany: vi.fn().mockResolvedValue(rows) } }, + }); + + it('reads the jwks model and filters unusable keys', async () => { + const ctx = ctxWith([ + { id: 'a', alg: 'EdDSA', crv: 'Ed25519' }, + { id: 'b', alg: 'ES256' }, + ]); + + const keys = await createHostUsableJwksReader()(ctx); + + expect(ctx.context.adapter.findMany).toHaveBeenCalledWith({ model: 'jwks' }); + expect(keys.map((k) => k.id)).toEqual(['b']); + }); + + it('reports how many keys it hid, so the caller can log once', async () => { + const onHidden = vi.fn(); + await createHostUsableJwksReader(onHidden)( + ctxWith([ + { id: 'a', alg: 'EdDSA' }, + { id: 'b', alg: 'EdDSA' }, + { id: 'c', alg: 'ES256' }, + ]), + ); + expect(onHidden).toHaveBeenCalledWith(2); + }); + + it('does not report when nothing was hidden', async () => { + const onHidden = vi.fn(); + await createHostUsableJwksReader(onHidden)(ctxWith([{ id: 'c', alg: 'ES256' }])); + expect(onHidden).not.toHaveBeenCalled(); + }); + + it('an empty / missing table reads as an empty keyring, not a crash', async () => { + await expect(createHostUsableJwksReader()(ctxWith([]))).resolves.toEqual([]); + await expect( + createHostUsableJwksReader()({ context: { adapter: { findMany: async () => null } } }), + ).resolves.toEqual([]); + }); +}); + +describe('protectGetSessionJwtHook', () => { + const makePlugin = (handler: any) => ({ + hooks: { + after: [ + { + matcher: (context: { path?: string }) => context.path === '/get-session', + handler, + }, + ], + }, + }); + + it('a throwing handler degrades to "no header" instead of propagating', async () => { + const boom = new Error('The operation failed for an operation-specific reason'); + const plugin = makePlugin(async () => { + throw boom; + }); + const onFailure = vi.fn(); + + expect(protectGetSessionJwtHook(plugin, onFailure)).toBe(true); + + await expect(plugin.hooks.after[0]!.handler!({} as any)).resolves.toBeUndefined(); + expect(onFailure).toHaveBeenCalledWith(boom); + }); + + it('returns the {headers,response} shape runAfterHooks reads, not a bare undefined', async () => { + // better-auth's runAfterHooks does an UNGUARDED `result.headers` read on + // every hook result (api/dispatch.mjs). Swallowing the error and returning + // nothing just moves the 500 one frame up — this exact bug cost a debug + // cycle while building the fix, so it is pinned. + const plugin = makePlugin(async () => { + throw new Error('signing exploded'); + }); + protectGetSessionJwtHook(plugin, vi.fn()); + + const result: any = await plugin.hooks.after[0]!.handler!({ returnHeaders: true } as any); + + expect(result).toEqual({ headers: undefined, response: undefined }); + // `response: undefined` is what leaves the real session payload in place: + // runAfterHooks only overwrites `context.returned` when response !== undefined. + expect(result.response).toBeUndefined(); + // `headers: undefined` is safe — mergeResponseHeaders early-returns on falsy. + expect('headers' in result).toBe(true); + }); + + it('a healthy handler is passed through untouched — return value and ctx', async () => { + const inner = vi.fn().mockResolvedValue('ok'); + const plugin = makePlugin(inner); + const onFailure = vi.fn(); + protectGetSessionJwtHook(plugin, onFailure); + + const ctx = { marker: 1 }; + await expect(plugin.hooks.after[0]!.handler!(ctx as any)).resolves.toBe('ok'); + expect(inner).toHaveBeenCalledWith(ctx); + expect(onFailure).not.toHaveBeenCalled(); + }); + + it('preserves the better-call `options` property the router reads', async () => { + const inner = Object.assign(async () => undefined, { options: { use: ['x'] } }); + const plugin = makePlugin(inner); + protectGetSessionJwtHook(plugin, vi.fn()); + + expect((plugin.hooks.after[0]!.handler as any).options).toEqual({ use: ['x'] }); + }); + + it('returns FALSE when the hook shape is not what we expect — absence must be loud', () => { + expect(protectGetSessionJwtHook({} as any, vi.fn())).toBe(false); + expect(protectGetSessionJwtHook({ hooks: {} } as any, vi.fn())).toBe(false); + expect(protectGetSessionJwtHook({ hooks: { after: [] } } as any, vi.fn())).toBe(false); + expect( + protectGetSessionJwtHook({ hooks: { after: [{ handler: undefined }] } } as any, vi.fn()), + ).toBe(false); + }); + + it('skips hooks whose matcher does not claim /get-session', () => { + const plugin = { + hooks: { after: [{ matcher: () => false, handler: async () => 'untouched' }] }, + }; + const original = plugin.hooks.after[0]!.handler; + + expect(protectGetSessionJwtHook(plugin as any, vi.fn())).toBe(false); + expect(plugin.hooks.after[0]!.handler).toBe(original); + }); +}); diff --git a/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts b/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts new file mode 100644 index 0000000000..5c2e73f8dc --- /dev/null +++ b/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Host-aware signing-algorithm selection for better-auth's `jwt` plugin (#3585). + * + * ## The failure this exists to prevent + * + * better-auth's `jwt` plugin defaults to **EdDSA / Ed25519** — `utils.mjs` + * reads `options?.jwks?.keyPairConfig ?? { alg: 'EdDSA', crv: 'Ed25519' }` and + * hands it to jose's `generateKeyPair`, which resolves `EdDSA` to the WebCrypto + * algorithm `{ name: 'Ed25519' }` (see jose `lib/jws_algorithms.js`). Hosts + * whose WebCrypto lacks Ed25519 — StackBlitz/WebContainer is the reported one — + * throw `OperationError` out of `crypto.subtle.generateKey`. + * + * That would be a contained problem if it only broke the IdP, but the plugin + * installs an `after` hook on **`/get-session`** that signs a `set-auth-jwt` + * header for *every* session. So on such a host the first authenticated request + * after sign-in 500s, and the OIDC provider is on by default whenever the MCP + * server is (`resolveOidcProviderEnabled` → `isMcpServerEnabled()`). An app that + * never asked for OIDC gets an unusable login. + * + * ## Why a capability probe rather than a host sniff + * + * `isWebContainerRuntime()` (auth-manager.ts) answers "which host is this", + * which is a proxy for the question that actually matters — "can this host do + * Ed25519". The proxy is wrong on both sides: it misses every *other* runtime + * without Ed25519, and it would downgrade a WebContainer that gained support. + * {@link probeEd25519Support} asks WebCrypto the real question, using the exact + * algorithm descriptor jose uses, so the answer cannot drift from the operation + * it predicts. + * + * ## The part that bites existing deployments + * + * Picking ES256 for *new* keys is not sufficient on its own. better-auth's + * `resolveSigningKey` (jwt/sign.mjs) selects an existing key like this: + * + * ```js + * const primaryAlg = options?.jwks?.keyPairConfig?.alg ?? 'EdDSA'; + * key = await adapter.getLatestKeyByAlg(ctx, primaryAlg) ?? await adapter.getLatestKey(ctx); + * ``` + * + * The second half is an **any-algorithm** fallback. A deployment that already + * minted an EdDSA key into `sys_jwks` — then moved to a host without Ed25519 — + * finds no ES256 key, falls back to the stored EdDSA row, and dies one line + * later in `importJWK(privateWebKey, 'EdDSA')`. Configuring `keyPairConfig` + * alone therefore leaves the *upgrade* path broken while fixing the fresh one. + * + * {@link createHostUsableJwksReader} closes that by installing better-auth's + * documented `adapter.getJwks` keyring seam and hiding keys this host cannot + * use. `getLatestKey()` then also returns nothing, `resolveSigningKey` mints a + * fresh ES256 key, and the deployment converges on a working state instead of + * sitting broken. The EdDSA row is never deleted — move back to a host with + * Ed25519 and it is simply visible again. + * + * The reader is installed **only when the host lacks Ed25519**, so every normal + * deployment runs better-auth's stock code path with no ObjectStack code in it. + * + * @see https://github.com/objectstack-ai/objectstack/issues/3585 + */ + +/** + * better-auth's own default. Kept explicit rather than inherited so the + * algorithm this deployment signs with is visible at the call site instead of + * hidden in a dependency's `??` default. + */ +export const ED25519_KEY_PAIR_CONFIG = { alg: 'EdDSA', crv: 'Ed25519' } as const; + +/** + * Fallback for hosts without Ed25519. ES256 (ECDSA P-256 + SHA-256) is the + * other algorithm every OIDC relying party is required to support (OpenID + * Connect Core §15.1 lists RS256 as mandatory and ES256 as the standard EC + * choice), and its WebCrypto primitives (`ECDSA` / `P-256`) predate Ed25519 in + * every runtime we have seen. RS256 would also work but costs a 2048-bit RSA + * keygen on a host that is, by construction, already slow. + */ +export const ES256_KEY_PAIR_CONFIG = { alg: 'ES256' } as const; + +export type JwtKeyPairConfig = + | typeof ED25519_KEY_PAIR_CONFIG + | typeof ES256_KEY_PAIR_CONFIG; + +/** + * The WebCrypto algorithm descriptor jose uses for `EdDSA`. + * + * Single-sourced here because the probe is only meaningful if it asks for the + * *same* thing the later `generateKeyPair` / `importJWK` will ask for — a probe + * that tested a different descriptor could report success and still let signing + * throw. + */ +const ED25519_SUBTLE_ALGORITHM = { name: 'Ed25519' } as const; + +/** Minimal shape of a persisted `sys_jwks` row, as better-auth hands it back. */ +export interface StoredJwk { + readonly id?: string; + readonly alg?: string | null; + readonly crv?: string | null; + readonly publicKey?: string | null; + readonly privateKey?: string | null; + readonly createdAt?: Date; + readonly expiresAt?: Date | null; +} + +/** + * Ask WebCrypto whether this host can actually generate an Ed25519 key pair. + * + * Returns `false` — never throws — for every way a host can lack the + * capability: no `crypto.subtle` at all, a `generateKey` that rejects + * (`OperationError` on WebContainer, `NotSupportedError` elsewhere), or one + * that throws synchronously. + * + * @param subtle - Injectable for tests. Omitted (or explicitly `undefined`) + * means "ask this host"; `null` models a host with no WebCrypto at all. + */ +export async function probeEd25519Support( + subtle: Pick | null | undefined = (globalThis as any)?.crypto + ?.subtle, +): Promise { + if (typeof subtle?.generateKey !== 'function') return false; + try { + // `extractable: true` matches better-auth's generateExportedKeyPair — it + // exports the pair to JWK immediately after minting, and a host could in + // principle allow non-extractable keys only. + await subtle.generateKey(ED25519_SUBTLE_ALGORITHM as any, true, ['sign', 'verify']); + return true; + } catch { + return false; + } +} + +/** Outcome of the startup probe. */ +export interface JwtSigningAlgorithmDecision { + /** Pass verbatim as better-auth's `jwks.keyPairConfig`. */ + readonly keyPairConfig: JwtKeyPairConfig; + /** `false` when this host cannot do Ed25519 and the fallback was taken. */ + readonly ed25519Supported: boolean; +} + +/** + * Decide which key-pair algorithm this host should sign JWTs with. + * + * @param probe - Injectable capability probe; defaults to + * {@link probeEd25519Support}. Tests stub this to simulate a host without + * Ed25519 without needing WebContainer. + */ +export async function resolveJwtSigningAlgorithm( + probe: () => Promise = probeEd25519Support, +): Promise { + const ed25519Supported = await probe(); + return { + ed25519Supported, + keyPairConfig: ed25519Supported ? ED25519_KEY_PAIR_CONFIG : ES256_KEY_PAIR_CONFIG, + }; +} + +/** + * Is this stored key an Ed25519 key? + * + * Three sources, in order of trust, because rows written at different times + * carry different amounts of metadata: + * + * 1. `alg === 'EdDSA'` — written by better-auth 1.7+. + * 2. `crv === 'Ed25519'` — likewise. + * 3. the serialized public JWK's own `kty`/`crv` — the only signal on **legacy + * rows minted before the `alg`/`crv` columns existed**, which is not a + * theoretical case: `getLatestKeyByAlg` treats `alg == null` as "the + * configured default", so after the ES256 fallback a legacy *EdDSA* row + * would otherwise be selected *as if it were ES256* and blow up in + * `importJWK`. Reading the key material is what makes the answer true + * rather than merely well-typed. + */ +export function isEd25519Jwk(key: StoredJwk | null | undefined): boolean { + if (!key) return false; + if (key.alg === 'EdDSA') return true; + if (key.crv === 'Ed25519') return true; + // Legacy row: alg/crv unset. Fall back to the key material itself. + if (key.alg == null && key.crv == null && typeof key.publicKey === 'string') { + try { + const jwk = JSON.parse(key.publicKey) as { kty?: unknown; crv?: unknown }; + return jwk?.kty === 'OKP' && jwk?.crv === 'Ed25519'; + } catch { + // Unparseable key material is not evidence of Ed25519; leave it visible + // and let better-auth fail on it loudly rather than silently hiding a + // key for the wrong reason. + return false; + } + } + return false; +} + +/** + * Drop keys this host cannot import, sign with, or verify. + * + * Identity when `ed25519Supported` — the filter exists only for the degraded + * host, and must be provably inert everywhere else. + */ +export function selectHostUsableJwks( + keys: readonly T[], + ed25519Supported: boolean, +): T[] { + if (ed25519Supported) return [...keys]; + return keys.filter((key) => !isEd25519Jwk(key)); +} + +/** + * Build the `jwt({ adapter: { getJwks } })` reader for a host without Ed25519. + * + * better-auth routes *all four* of its keyring reads (`getAllKeys`, + * `getLatestKey`, `getLatestKeyByAlg`, `getKeyById`) through this one hook when + * it is present, which is what makes it sufficient: there is no read path left + * that can hand `resolveSigningKey` a key this host cannot import. + * + * It also means the published `/api/v1/auth/jwks` document loses the hidden + * keys. That is the honest answer rather than a side effect — this host is the + * issuer, it cannot sign or verify with those keys, and advertising them would + * be a machine-readable surface claiming a capability the runtime does not have + * (AGENTS.md "Machine-readable surfaces must not lie"). + * + * @param onHidden - Called once per read that actually hid something, with the + * count. Used to emit exactly one operator-facing line. + */ +export function createHostUsableJwksReader( + onHidden?: (hiddenCount: number) => void, +): (ctx: any) => Promise { + return async (ctx: any): Promise => { + // Same read better-auth's default adapter performs — the model name is + // mapped to `sys_jwks` by buildJwtPluginSchema(), so this stays a single + // source of truth for the table. + const all: StoredJwk[] = (await ctx?.context?.adapter?.findMany({ model: 'jwks' })) ?? []; + const usable = selectHostUsableJwks(all, false); + if (usable.length !== all.length) onHidden?.(all.length - usable.length); + return usable; + }; +} + +/** + * Shape better-auth's jwt plugin uses for its `/get-session` `after` hook. + * Narrow on purpose: {@link protectGetSessionJwtHook} must be able to tell + * "the hook moved in a better-auth upgrade" from "the hook is fine". + */ +interface AfterHookEntry { + matcher?: (context: { path?: string }) => boolean; + handler?: ((ctx: unknown) => unknown) & Record; +} + +/** + * Make a JWT signing failure degrade the **header**, not the **session**. + * + * better-auth's hook is `await getJwtToken(ctx, options)` with no `catch`, so + * any signing error — an algorithm this host cannot use, a key encrypted under + * a rotated secret, a `sys_jwks` write that fails — turns every + * `/get-session` into a 500 and locks the user out of an otherwise healthy app. + * Signing the `set-auth-jwt` header is an *optional* enrichment; the session it + * describes is already valid. So the failure is downgraded to "no header", in + * the same spirit as `AuthManager.addOptionalPlugin`: one optional federation + * feature must never take down core session auth. + * + * Returns `false` when the hook could not be found, so the caller can say so + * out loud instead of silently shipping an unprotected build — a guard that + * quietly stops guarding is worse than no guard (AGENTS.md "Absence must be + * loud"). `better-auth-schema-parity.test.ts` pins the shape so a better-auth + * upgrade that moves the hook fails a test rather than a production login. + * + * @param onFailure - Invoked with the signing error. Called on every failure; + * the caller is responsible for logging it only once. + */ +export function protectGetSessionJwtHook( + plugin: { hooks?: { after?: AfterHookEntry[] } }, + onFailure: (error: unknown) => void, +): boolean { + const after = plugin?.hooks?.after; + if (!Array.isArray(after)) return false; + + let wrapped = 0; + for (const entry of after) { + const original = entry?.handler; + if (typeof original !== 'function') continue; + if (entry.matcher && !entry.matcher({ path: '/get-session' })) continue; + + const guarded = async (ctx: any): Promise => { + try { + return await original(ctx); + } catch (error) { + onFailure(error); + // Return what a no-op run of this hook returns, NOT a bare `undefined`. + // better-auth's `runAfterHooks` (api/dispatch.mjs) does an unguarded + // `result.headers` read on every hook result, so swallowing the error + // and returning nothing just moves the 500 one frame up. better-call + // shapes a middleware's return by the caller's `returnHeaders` flag + // (`middleware.mjs`), so mirror that: `{ headers, response }` when the + // caller asked for headers, plain `undefined` otherwise. + // + // `headers: undefined` is explicitly safe — `mergeResponseHeaders` + // early-returns on falsy — and `response: undefined` leaves + // `context.returned` (the real session payload) untouched, which is + // the entire point: the session survives, only the header is missing. + return ctx?.returnHeaders ? { headers: undefined, response: undefined } : undefined; + } + }; + // better-call middlewares carry an own `options` property that the router + // reads (better-call `middleware.mjs`); copy whatever is there so the + // wrapper is indistinguishable from the original to better-auth. + entry.handler = Object.assign(guarded, original) as AfterHookEntry['handler']; + wrapped++; + } + + return wrapped > 0; +} From ad4d5ced5b72ffc91741e9926a812cadc18b3240 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:50:39 +0000 Subject: [PATCH 2/3] test(plugin-auth): pin the #3585 memory engine's delete to ObjectQL's dispatch predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- packages/plugins/plugin-auth/package.json | 1 + .../src/auth-manager.jwt-eddsa-fallback.test.ts | 9 +++++++++ pnpm-lock.yaml | 3 +++ scripts/engine-double-contract.baseline.json | 4 ++-- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index e5b6dcea97..abd7a171ad 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -33,6 +33,7 @@ "jose": "^6.2.5" }, "devDependencies": { + "@objectstack/objectql": "workspace:*", "@types/node": "^26.1.2", "hono": "^4.12.32", "typescript": "^6.0.3", diff --git a/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts b/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts index b071310882..249e3938b4 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts @@ -12,6 +12,7 @@ // if the fallback did not reach jose, these tests would 500 like the bug report. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineDeleteDispatch } from '@objectstack/objectql'; import { AuthManager } from './auth-manager'; const createMemoryEngine = () => { @@ -71,6 +72,14 @@ const createMemoryEngine = () => { return { ...row }; }, async delete(name: string, q: any = {}) { + // [#4550] Pinned to ObjectQL.delete's own dispatch predicate rather than a + // hand-written approximation of it: a fake that accepts a call the real + // engine refuses is how #4434 shipped a dead REST route with its suite + // green. better-auth's adapter only ever deletes by scalar id + // (`objectql-adapter.ts` — `delete`/`deleteMany`/`consumeOne` all resolve + // the row first, then call `delete(object, { where: { id } })`), so this + // assertion is what proves that stays true as the pipeline evolves. + assertEngineDeleteDispatch(q); const table = rows(name); const keep = table.filter((r) => !matches(r, q.where)); tables.set(name, keep); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9939e71ae7..3657af983c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1375,6 +1375,9 @@ importers: specifier: ^6.2.5 version: 6.2.7 devDependencies: + '@objectstack/objectql': + specifier: workspace:* + version: link:../../objectql '@types/node': specifier: ^26.1.2 version: 26.1.2 diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index c884e01341..521cb1c03d 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -105,8 +105,8 @@ "file": "packages/plugins/plugin-auth/src/auth-manager.optional-plugin-isolation.test.ts", "unguarded": 1, "kind": "DEBT", - "why": "@objectstack/plugin-auth does not depend on @objectstack/objectql. Pinning needs a devDependency + lockfile change, which is a separate reviewable act.", - "closes": "add @objectstack/objectql to devDependencies, then open the fake's delete with assertEngineDeleteDispatch(opts)" + "why": "MEASURED (#3585): the devDependency this entry used to cite as the blocker now EXISTS — @objectstack/objectql was added to @objectstack/plugin-auth's devDependencies when the sibling auth-manager.jwt-eddsa-fallback.test.ts was pinned, so what is left here is a one-line pin. Deferred only because #3585's PR is a JWT-algorithm fix and flipping an unmeasured suite red belongs in its own PR, not because anything structural stands in the way.", + "closes": "open the fake's delete with assertEngineDeleteDispatch(opts) and run the package's suite — the devDependency is already declared" }, { "file": "packages/plugins/plugin-reports/src/report-export-axis.test.ts", From c9c60145773a51c7ce60adf8726aa3a0f3819e96 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:52:18 +0000 Subject: [PATCH 3/3] test(plugin-auth): say what the delete pin does and does not currently prove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- .../src/auth-manager.jwt-eddsa-fallback.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts b/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts index 249e3938b4..727f01fb80 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.jwt-eddsa-fallback.test.ts @@ -75,10 +75,17 @@ const createMemoryEngine = () => { // [#4550] Pinned to ObjectQL.delete's own dispatch predicate rather than a // hand-written approximation of it: a fake that accepts a call the real // engine refuses is how #4434 shipped a dead REST route with its suite - // green. better-auth's adapter only ever deletes by scalar id - // (`objectql-adapter.ts` — `delete`/`deleteMany`/`consumeOne` all resolve - // the row first, then call `delete(object, { where: { id } })`), so this - // assertion is what proves that stays true as the pipeline evolves. + // green. + // + // Measured, so the claim stays honest: the paths this file drives + // (sign-up → get-session → /jwks) never reach a delete today, so this + // line cannot currently flip the suite red. It is a forward guard. What + // it guards is that better-auth's adapter only ever deletes by SCALAR id + // — `objectql-adapter.ts`'s `delete`/`deleteMany`/`consumeOne` each + // resolve the row first, then call `delete(object, { where: { id } })` — + // so the day an upgrade routes a session/verification purge through here + // as a bare predicate, this fails in a unit test instead of 500ing on a + // real server. assertEngineDeleteDispatch(q); const table = rows(name); const keep = table.filter((r) => !matches(r, q.where));