Skip to content

Commit 860f002

Browse files
refactor(plugin-auth): move AuthManager's plugin assembly helpers into auth-manager-plugin-assembly.ts (#25224 cut 6) (#25400)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuMjbPDrsigsyqoKsV4WZQMz)_ Part of #25224 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 The basis for the `Clause-②: no` line: this is a move. No accept set, export, route, refusal, code or answer changes. The `.d.ts` bar is ruling `6348891160` item 1 (on #25222, covering this card). `@objectstack/plugin-auth`'s published `dist/index.d.ts` and `dist/index.d.mts` are raw byte-identical to base (0 diff lines, so the union-order normalisation has nothing to do), with the ruling's positive control. Its entry exports the same 155 names under ESM and CJS (measured below). Cut 6 of the `auth-manager.ts` split: **the plugin assembly helpers.** Three `AuthManager` method bodies (85 lines) move unedited, re-indented one level, into one new module, `packages/plugins/plugin-auth/src/auth-manager-plugin-assembly.ts`. #25224 stays open for the later cuts. This follows the amended claim `6361212912`, which amends `6359424739` on the file cut 5 left (#25395, landed note `6360455119`). The shape is cuts 1 to 5's, which is #23050's. Branch: `claude/project-thread-lg3wrj`, the harness-pinned branch for this session, restarted from `origin/main` `eca6b61922` after #25395 merged. It names no issue, so the card relation is this body's first line. `auth-manager.ts` goes from 8,968 to 8,916 lines. The cut is +202 / −89 over 2 files: 291 changed lines, under the 3,000-line line. It is not a governed surface (`check-governed-merges.mjs --branch HEAD`: 0 of the 2 paths hit the register). ## Why this seam These are the three helpers `buildPluginList` calls to assemble better-auth's plugins: - `addOptionalPlugin`: one optional plugin whose construction throws is skipped, recorded in `degradedFeatures` and logged, so it cannot take down core email/password and session auth (the 15.1.0 incident its docblock records); - `buildJwtPlugin`: the `jwt` plugin, built with a signing algorithm this host can use (the Ed25519 probe), the signing-key reader, and the `/get-session` header hook made non-fatal; - `reportJwtSigningFailure`: the record and once-per-build announcement of a JWT signing failure that hook catches. What stays, and why: - `resolvePasswordHasher` stays. Its body reads `isWebContainerRuntime`, a module-level function that `auth-manager.ts` does not export and that `installWebContainerRequestStatePolyfill` also reads. `index.ts` re-exports `auth-manager.ts` whole (`export * from './auth-manager.js'`), so exporting it there would publish it, and moving it would move a declaration the polyfill shares. Per the landed note `6360455119`, a body that reads an unexported module helper comes back as `needs_decision` before it is cut. - `getDegradedAuthFeatures` stays: it is the public, one-line read of the record `addOptionalPlugin` writes. - `buildPluginList`, which calls all three, is a later cut. - `createDatabaseConfig`, `resolveAuthSecret` and `generateSecret` are the database and secret `createAuthInstance` hands better-auth, a different area, so they take their own cut. The seat agreed both splits. ## What moved | method | base signature line | body lines | members it reads | |---|---:|---:|---| | `addOptionalPlugin` | `:3178` | 15 | `degradedFeatures` | | `buildJwtPlugin` | `:3242` | 55 | `config`, `ed25519ProbeResult` (read and written), `reportJwtSigningFailure` | | `reportJwtSigningFailure` | `:3308` | 15 | `degradedFeatures`, `jwtSigningFailureReported` (read and written) | Lines are base `eca6b61922`. The members come from a walk of every `this.X` in each body with the TypeScript compiler API. No body has a bare `this`, `this[...]`, `arguments` or `super`, or a `this` inside a `function` or a nested class. No body reads a static (`AuthManager.X`) or a module-level declaration of `auth-manager.ts`. The two assignments to a `this` member are `buildJwtPlugin`'s to `ed25519ProbeResult` and `reportJwtSigningFailure`'s to `jwtSigningFailureReported`. **Imports.** Six imported names are read only by the moved text, so they leave `auth-manager.ts` and the new module imports each from the same place: - `buildJwtPluginSchema` (`./auth-schema-config.js`, still imported by `auth-manager.ts` for ten other names); - `createSigningKeyReader` (`./signing-key-read-cache.js`, still imported there for `retireHeldKeyrings` and `UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS`); - `createHostUsableJwksReader`, `probeEd25519Support`, `protectGetSessionJwtHook` and `resolveJwtSigningAlgorithm` (`./jwt-key-algorithm.js`). **Evaluation order.** `auth-manager.ts` no longer imports `./jwt-key-algorithm.js` itself. Nothing observable moves: that module has no imports, and its top level is three `as const` object literals, a type alias, interfaces and function declarations, so the point at which it is evaluated cannot be observed. `signing-key-read-cache.ts` imports it type-only. ## The shape - **The class keeps all three methods** at their places, with their names, signatures and docblocks: the published `.d.ts` lists each one. Each body is now a host literal of exactly the members that body reads, typed as a `Pick` of `PluginAssemblyHost`, plus `.call`. - **`PluginAssemblyHost`** lives in the new module. It names the 5 members the bodies read, each typed as the class member (`AuthManager['degradedFeatures']`, …). The module imports `AuthManager` as a type only, so there is no runtime cycle. - **`reportJwtSigningFailure` goes as a late-bound closure** (`(...a) => this.reportJwtSigningFailure(...a)`). The `/get-session` guard calls it at request time, long after `buildJwtPlugin` returned, and still reaches the method on the manager, so a spy or double set there is still the one it reaches. - **`config` and `degradedFeatures` go as getters.** The class reassigns `config` after construction (`setRuntimeBaseUrl`, `applyConfigPatch`), so it is read off the manager when a body reads it, as before. `degradedFeatures` is read the same way. - **`ed25519ProbeResult` and `jwtSigningFailureReported` go as get/set pairs.** The bodies store to both on the manager. So the Ed25519 probe still runs once per manager, the `() => this.ed25519ProbeResult!` thunk still reads the manager's promise when better-auth calls it, and a signing failure is still announced once per plugin-list build, which resets the flag (`buildPluginList`, which stays). - **Two stubs are no longer `async`.** Each returns the moved async function's own promise, so a caller awaits the same promise the body settles. Building the host cannot throw, so no method now throws synchronously where it used to reject. `reportJwtSigningFailure` was synchronous and stays so. - **The cost:** one small host object per call. `buildJwtPlugin` and `addOptionalPlugin` run once per plugin-list build; `reportJwtSigningFailure` runs only on a signing failure. - **Nothing re-exports the new module**, so the published surface does not move. ## Path readers None needs migrating: no reader keys on a moved line. Measured as reading no moved line: - **`api-error-code.pin.test.ts`'s `authManagerFiles` floor**: no `new APIError(` sits in the moved bodies, so the floor reads the same sites over the same files at base and head, and the new module is not added to the list. - **Plugin-factory scans** in `better-auth-schema-parity.test.ts` and `managed-extension-fields.test.ts`: the moved text has no `await import(`. `buildJwtPlugin` takes the `jwt` factory as a parameter, and the factory's import stays in `buildPluginList`. - **`check-dispatcher-error-vocabulary.mjs`**, **`check-auth-mount-ledger.mjs`** and **`auth-manager-base-path.test.ts`**: none of the tokens they read sits in the moved text. `check-dispatcher-error-vocabulary` reads 56 sites found and 56 classified at base and at head. - **The tenant-audit census** and its two documents: the moved text holds no engine write. Compared per site, base and head read the same 237 sites with the same totals (below), so neither document is regenerated. - **The system-context census**: the moved text holds no `isSystem` read. The census reads 148 elevation read sites at base and at head. - **`scripts/adr-anchors/`**: the moved text cites no ADR, and no entry's invariant describes it. The `auth-manager.ts` entry (ADR-0093 and ADR-0108) is unchanged and still holds. - **Callers through the manager**: the suites that drive the three methods, among them `auth-manager.jwt-eddsa-fallback.test.ts` (the host without Ed25519, an existing EdDSA key hidden and never deleted, and the signing failure recorded and announced once) and `auth-manager.optional-plugin-isolation.test.ts` (a throwing optional plugin skipped while sign-up and get-session still answer, and the degraded state reset on rebuild), call each through the class method. All pass at head (below). - **Every symbol anchor into the file.** Before the cut, every `auth-manager.ts#symbol` anchor in the tracked tree outside changelogs was mapped with `symbolSegmentResolution` from `scripts/symbol-anchors.mjs`. There are 29, in ADR-0127, ADR-0134, ADR-0135 and four platform-checklist areas, and all 29 are `declaration`-class at base, with the 3 bodies blanked, and at head. None names a moved method. One names a token the moved text also carries: `#AuthManager` (ADR-0135), in the bodies' `[AuthManager]` log prefixes. It resolves on the class declaration, which stays. - **`check-adr-anchors`' decision-letter layer** (the seat asks each cut's report for this count at both ends): 2,016 citations at base and 2,016 at head. The moved text carries no `ADR-NNNN Dk` citation, so nothing leaves the anchored surface. The number layer reads 52,116 citations across 6,623 files at both. On `origin/main` `7c68b10825` (below) the layer reads 2,014 alone and 2,014 with this cut. ## Proof Builds and suites ran through `os-verify-lock`, and every exit code was captured before any pipe. Base `eca6b61922`, head `eda6277866`. A full `turbo run build` ran first. Then `plugin-auth` was built and its suite run at base, then built and run at head, on the same dependency dists. **1. The moved text.** - For each of the 3 methods, the base class body and the new module's function body are the same TypeScript token sequence (scanner tokens, kind and text, trivia skipped, each template literal re-scanned at its substitutions as the parser does). - Every line is the base line minus exactly two leading spaces, and the parameters and return type are the same text. - Each class method keeps its docblock and signature, with `async` dropped from the two that had it. - Result: 3 of 3 pass, 85 body lines. | method | body lines | tokens | |---|---:|---:| | `addOptionalPlugin` | 15 | 87 | | `buildJwtPlugin` | 55 | 168 | | `reportJwtSigningFailure` | 15 | 67 | - **Negative control:** one token changed in the module (`'jwtSigning'` → `'jwtSigned'` in `reportJwtSigningFailure`) makes that method fail (tokens and lines DIFFER), and the run exits 1. Restored afterwards; `cmp` against the saved copy was clean. - **An independent reading:** `git diff --color-moved=plain --color-moved-ws=allow-indentation-change eca6b61 eda6277` over the two source files marks 85 of the 89 deleted lines and 96 of the 202 added lines as moved. - The other 4 deleted lines are the two `async` signature lines and two import-name lines (`buildJwtPluginSchema`, `createSigningKeyReader`). The `./jwt-key-algorithm.js` import block is marked moved: the module carries it verbatim. - The other added lines are the stubs' host literals, `PluginAssemblyHost`, the module header, the per-function pointer docblocks and the import blocks. **2. `.d.ts` and exports (ruling `6348891160` items 1 and 2).** | file | raw base | raw head | raw diff lines | normalised base | normalised head | |---|---|---|---:|---|---| | `dist/index.d.ts` | `3b3aedb6551d6324c40d87afd1cdee50b23bcb2c3384c39a9f71b771deca0472` | `3b3aedb6551d6324c40d87afd1cdee50b23bcb2c3384c39a9f71b771deca0472` | 0 | `d4a901d16c32f4838c14dd7fdc52d88d9d446b4b1d44afd4012c81b365ef9783` | `d4a901d16c32f4838c14dd7fdc52d88d9d446b4b1d44afd4012c81b365ef9783` | | `dist/index.d.mts` | `e2cdb2bc530cc6a554107866875910a90031e42fc89574d9adcb00d38a1becf1` | `e2cdb2bc530cc6a554107866875910a90031e42fc89574d9adcb00d38a1becf1` | 0 | `b2c585e677d5ac57274c22d746f6213342fbf1e7c8061b56b94175e8fe21c19d` | `b2c585e677d5ac57274c22d746f6213342fbf1e7c8061b56b94175e8fe21c19d` | - The normaliser, verbatim (the ruling's one-liner): `node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/index.d.ts | sha256sum`. - The raw files are already byte-identical, so no union-order swap occurs in this cut: items 1(b) and 1(d) hold with nothing to account for. - **Positive control:** the head `dist/index.d.ts` with one private member made public (`private buildJwtPlugin;` → `buildJwtPlugin;`, line 1881, a one-line change) hashes raw `8eaebd38dabcfa33bbe0db93e6bd2c8836c91a169d81c8e6aa2ec4e78055b2de` and normalised `4c5bbc92792de5db2c8f1b89018fd45d4aea96fe43b7479e3c934db9069c6169`. Both differ from the readings above. - **Export lists:** ESM `dist/index.mjs` and CJS `dist/index.js` each have 155 names at base and at head, the same list (the sha256 of the sorted names is `ed3ac8b902031d30…` on all four). - **The bundle:** 20 files in `dist/` on both sides, the same list. Only `index.js`, `index.mjs`, their two `.map` files and the two build-input hash stamps differ, as a move of code inside the bundle must. **3. Tests and lint.** - `pnpm exec vitest run --maxWorkers=3` in `packages/plugins/plugin-auth`: base `Test Files 187 passed (187)`, `Tests 3528 passed | 10 skipped (3538)`, VERDICT command-exit 0. Head: the same counts, VERDICT command-exit 0. - `pnpm --filter @objectstack/plugin-auth typecheck` (both `tsc` projects and `check:test-typecheck`): exit 0. - `eslint --no-inline-config` (the root `lint` script's binary and flags) over the 2 files: exit 0. **4. The census and the anchors, at base and at head.** - The tenant-audit census (`node scripts/tenant-audit-census.mjs --json`), compared per site with the new module's file name mapped back to `auth-manager.ts`: 237 sites on both sides, 0 per-site differences in object, verb, tenancy, context and elevation, 0 unresolved and 0 unledgered on both, and every total the same. - These checkers were run by hand at base and at head, because `dispatch-gates` does not derive the ADR one. All six exit 0 at both, with the same reading at both: - `check-adr-symbol-anchors`: 2,384 anchors across 142 records, 466 symbol (440 declaration, 26 literal). - `check-adr-anchors`: 80 anchored files; the decision-letter layer as above. - `check-scripts-symbol-anchors` (4,061 anchors), `check-spec-docblock-symbol-anchors` (5,071 anchors, both read in the same built tree), `check-system-context-census` (148 elevation read sites) and `check-platform-checklist` (16 areas, 329 items). **5. Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 53 commands for the 2 paths. All 53 ran at `eda6277866` after the full build, and all 53 exited 0. - `--ran`: 53 derived, 53 run, 0 NOT-MEASURED. - `check-governed-merges.mjs --branch HEAD`: NOT governed, 291 changed lines. - **Docs drift** (`node scripts/docs-audit/affected-docs.mjs --json eca6b61`, exit 0): 0 pages name something this change touched (11 anchors: 7 symbol, 4 literal). - **Serial constraints** (in the claim): before the run, one open PR touched `auth-manager.ts`, #25398, in the docblock of the `enforceSessionControls` stub, not a line of this cut. It has since merged, with #25381, #25396 and #25397, and `origin/main` is `7c68b10825`. On that tree with this cut applied (no conflict): - the move proof against `7c68b10825` passes 3 of 3; - `check-tenant-audit-census`, `check-adr-anchors`, `check-adr-symbol-anchors`, `check-platform-checklist`, `check-system-context-census`, `check-auth-mount-ledger` and `check-dispatcher-error-vocabulary` exit 0; - the seven suites that drive the moved methods or read the file pass (386 tests). - One open PR now touches `auth-manager.ts`: #25399, inside `createAuthInstance` and `buildPluginList`, not a line of this cut. `git merge-tree` of this head with #25399's head is clean. ## Changeset None: `skip-changeset`, per ruling `6348891160` item 4. The published declarations and export lists are unchanged, and the bundle's bytes move only because code moved inside it. --- _Generated by [Claude Code](https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6bdcac commit 860f002

2 files changed

Lines changed: 202 additions & 89 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25224 cut 6] The plugin assembly helpers of `AuthManager` — the isolation
5+
* that keeps one optional better-auth plugin's construction failure from
6+
* taking down core auth, the construction of better-auth's `jwt` plugin with a
7+
* signing algorithm this host can use and a non-fatal `/get-session` header
8+
* hook, and the once-only report of a JWT signing failure that hook catches —
9+
* moved out of `auth-manager.ts` whole.
10+
*
11+
* A MOVE, not a redesign. Each function below is the body of the
12+
* `AuthManager` method of the same name as it stood, re-indented one level
13+
* and otherwise unedited: the same plugins pushed or skipped, the same
14+
* degraded-feature records, the same algorithm probe and keyring read, the
15+
* same log lines at the same levels.
16+
*
17+
* ## How the moved bodies reach the class
18+
*
19+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
20+
* header carries the full reasoning), which `auth-manager-actor-guards.ts`,
21+
* `auth-manager-sign-in-lockout.ts`, `auth-manager-password-session-policy.ts`,
22+
* `auth-manager-database-hooks.ts` and `auth-manager-email-sms-delivery.ts`
23+
* follow, applied per method. The class keeps all three methods — the
24+
* published `dist/*.d.ts` lists each one — and each body is one literal of
25+
* exactly the {@link PluginAssemblyHost} members that method reads, plus a
26+
* `.call`. The two `async` methods are no longer `async`: each returns the
27+
* moved function's own promise, so a caller awaits the same promise the body
28+
* settles, with no extra wrapper.
29+
*
30+
* `reportJwtSigningFailure` goes as a closure that calls it on the manager at
31+
* call time, so a test double or spy set on the manager is still the one the
32+
* `/get-session` guard reaches; that guard runs at request time, long after
33+
* `buildJwtPlugin` returned. `config` and `degradedFeatures` go as GETTERS:
34+
* the class reassigns `config` after construction (`setRuntimeBaseUrl` and
35+
* `applyConfigPatch`), so each is read off the manager when a body reads it,
36+
* as before. `ed25519ProbeResult` and `jwtSigningFailureReported` go as
37+
* get/set pairs, because the bodies store to both on the manager: the Ed25519
38+
* probe still runs once per manager, and a signing failure is still announced
39+
* once per plugin-list build, which resets the flag.
40+
*
41+
* Nothing re-exports this module, so the published surface does not move.
42+
*/
43+
44+
import type { AuthManager } from './auth-manager.js';
45+
import { buildJwtPluginSchema } from './auth-schema-config.js';
46+
import {
47+
createHostUsableJwksReader,
48+
probeEd25519Support,
49+
protectGetSessionJwtHook,
50+
resolveJwtSigningAlgorithm,
51+
} from './jwt-key-algorithm.js';
52+
import { createSigningKeyReader } from './signing-key-read-cache.js';
53+
54+
/**
55+
* The `AuthManager` members the functions below read — every one of them, and
56+
* no other; each function's `this` is the `Pick` of the members IT reads. Each
57+
* is typed AS the class member (an indexed access on the class), so a change
58+
* to a member's signature on the class is a change here too, and naming a
59+
* member the class does not have is a compile error. See the module header for
60+
* why `config` and `degradedFeatures` are getters and `ed25519ProbeResult` and
61+
* `jwtSigningFailureReported` accessors.
62+
*/
63+
export interface PluginAssemblyHost {
64+
readonly config: AuthManager['config'];
65+
readonly degradedFeatures: AuthManager['degradedFeatures'];
66+
ed25519ProbeResult: AuthManager['ed25519ProbeResult'];
67+
jwtSigningFailureReported: AuthManager['jwtSigningFailureReported'];
68+
reportJwtSigningFailure: AuthManager['reportJwtSigningFailure'];
69+
}
70+
71+
/** The body of `AuthManager#addOptionalPlugin`; the method's docblock there is its contract. */
72+
export async function addOptionalPlugin(
73+
this: Pick<PluginAssemblyHost, 'degradedFeatures'>,
74+
plugins: any[],
75+
feature: string,
76+
build: () => Promise<any | any[]>,
77+
): Promise<void> {
78+
try {
79+
const built = await build();
80+
for (const p of Array.isArray(built) ? built : [built]) plugins.push(p);
81+
} catch (err: any) {
82+
const message = err?.message ?? String(err);
83+
this.degradedFeatures.set(feature, message);
84+
console.error(
85+
`[AuthManager] Optional auth feature "${feature}" failed to initialize and is DISABLED ` +
86+
`for this process — its endpoints will 404. Core email/password + session auth is unaffected. ` +
87+
`Fix the underlying error (often a better-auth version mismatch) and restart. Cause: ${message}`,
88+
err,
89+
);
90+
}
91+
}
92+
93+
/** The body of `AuthManager#buildJwtPlugin`; the method's docblock there is its contract. */
94+
export async function buildJwtPlugin(this: Pick<PluginAssemblyHost, 'config' | 'ed25519ProbeResult' | 'reportJwtSigningFailure'>, jwt: (options: any) => any): Promise<any> {
95+
if (!this.ed25519ProbeResult) this.ed25519ProbeResult = probeEd25519Support();
96+
const { keyPairConfig, ed25519Supported } = await resolveJwtSigningAlgorithm(
97+
() => this.ed25519ProbeResult!,
98+
);
99+
100+
if (!ed25519Supported) {
101+
// Functional, not durability: the deployment is fully operational on
102+
// ES256. Worth one line because it changes what /jwks publishes and what
103+
// `alg` issued tokens carry, which an operator debugging a relying party
104+
// otherwise cannot correlate to anything.
105+
console.warn(
106+
`[AuthManager] This host's WebCrypto has no Ed25519 (crypto.subtle.generateKey({name:'Ed25519'}) fails), ` +
107+
`so JWTs are signed with ${keyPairConfig.alg} instead of better-auth's default EdDSA. ` +
108+
`Any existing EdDSA key in sys_jwks is hidden from the JWKS on this host — it cannot be imported here — ` +
109+
`and a fresh ${keyPairConfig.alg} key is minted on first use. The rows are not deleted.`,
110+
);
111+
}
112+
113+
const jwtPlugin = jwt({
114+
schema: buildJwtPluginSchema(),
115+
jwks: { keyPairConfig },
116+
// [#24061] The keyring read goes through `createSigningKeyReader` on
117+
// every host: it holds the rows across requests only while no
118+
// `sys_jwks` write has passed this engine's middleware since they were
119+
// read, and never past `SIGNING_KEY_CACHE_TTL_MS` — both halves of the
120+
// cross-request caching rule (see `signing-key-read-cache.ts`). A miss
121+
// reads exactly what it read before: better-auth's stock read, or, on a
122+
// host without Ed25519, the host-usable keyring that hides keys this
123+
// host cannot import (#3585).
124+
adapter: {
125+
getJwks: createSigningKeyReader({
126+
engine: this.config.dataEngine,
127+
read: ed25519Supported ? undefined : createHostUsableJwksReader(),
128+
}),
129+
},
130+
});
131+
132+
const protectedHook = protectGetSessionJwtHook(jwtPlugin, (error) =>
133+
this.reportJwtSigningFailure(error, keyPairConfig.alg),
134+
);
135+
if (!protectedHook) {
136+
// The guard could not attach — better-auth moved or renamed the hook.
137+
// Say so rather than shipping a build that looks protected and is not.
138+
console.error(
139+
'[AuthManager] Could not attach the JWT signing guard to better-auth\'s /get-session hook ' +
140+
'(its `hooks.after` shape changed). A JWT signing failure will now 500 every /get-session ' +
141+
'instead of degrading to a missing set-auth-jwt header, and every in-process session read ' +
142+
'signs a JWT it discards (two sys_jwks reads per authenticated request). Re-check ' +
143+
'the better-auth version in better-auth-schema-parity.test.ts.',
144+
);
145+
}
146+
147+
return jwtPlugin;
148+
}
149+
150+
/** The body of `AuthManager#reportJwtSigningFailure`; the method's docblock there is its contract. */
151+
export function reportJwtSigningFailure(this: Pick<PluginAssemblyHost, 'degradedFeatures' | 'jwtSigningFailureReported'>, error: unknown, alg: string): void {
152+
const message = (error as any)?.message ?? String(error);
153+
this.degradedFeatures.set('jwtSigning', message);
154+
if (this.jwtSigningFailureReported) return;
155+
this.jwtSigningFailureReported = true;
156+
console.error(
157+
`[AuthManager] JWT signing failed with alg "${alg}", so /get-session responses carry no ` +
158+
`set-auth-jwt header and OIDC/MCP token issuance will not work. The session itself is valid — ` +
159+
`sign-in and cookie auth are unaffected. Common causes: this host's WebCrypto cannot use the ` +
160+
`algorithm of the key in sys_jwks, or OS_AUTH_SECRET changed since the key was encrypted. ` +
161+
`Set OS_OIDC_PROVIDER_ENABLED=false if this deployment does not need to act as an IdP. ` +
162+
`Cause: ${message}`,
163+
error,
164+
);
165+
}

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 37 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -147,18 +147,10 @@ import {
147147
buildTwoFactorPluginSchema,
148148
buildOauthProviderPluginSchema,
149149
buildDeviceAuthorizationPluginSchema,
150-
buildJwtPluginSchema,
151150
buildAdminPluginSchema,
152151
buildPhoneNumberPluginSchema,
153152
} from './auth-schema-config.js';
154153
import {
155-
createHostUsableJwksReader,
156-
probeEd25519Support,
157-
protectGetSessionJwtHook,
158-
resolveJwtSigningAlgorithm,
159-
} from './jwt-key-algorithm.js';
160-
import {
161-
createSigningKeyReader,
162154
retireHeldKeyrings,
163155
UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS,
164156
} from './signing-key-read-cache.js';
@@ -230,6 +222,14 @@ import {
230222
storedRecipientLocale,
231223
type EmailSmsDeliveryHost,
232224
} from './auth-manager-email-sms-delivery.js';
225+
// [#25224 cut 6] The plugin assembly helpers; the methods of the same names
226+
// below hand each body the members it reads (`PluginAssemblyHost`).
227+
import {
228+
addOptionalPlugin,
229+
buildJwtPlugin,
230+
reportJwtSigningFailure,
231+
type PluginAssemblyHost,
232+
} from './auth-manager-plugin-assembly.js';
233233

234234
/**
235235
* Detect WebContainer (StackBlitz) environment.
@@ -3201,24 +3201,17 @@ export class AuthManager {
32013201
* `build` may return one plugin or an array that must land atomically
32023202
* (e.g. jwt + oauthProvider): on failure NOTHING from the unit is pushed.
32033203
*/
3204-
private async addOptionalPlugin(
3204+
private addOptionalPlugin(
32053205
plugins: any[],
32063206
feature: string,
32073207
build: () => Promise<any | any[]>,
32083208
): Promise<void> {
3209-
try {
3210-
const built = await build();
3211-
for (const p of Array.isArray(built) ? built : [built]) plugins.push(p);
3212-
} catch (err: any) {
3213-
const message = err?.message ?? String(err);
3214-
this.degradedFeatures.set(feature, message);
3215-
console.error(
3216-
`[AuthManager] Optional auth feature "${feature}" failed to initialize and is DISABLED ` +
3217-
`for this process — its endpoints will 404. Core email/password + session auth is unaffected. ` +
3218-
`Fix the underlying error (often a better-auth version mismatch) and restart. Cause: ${message}`,
3219-
err,
3220-
);
3221-
}
3209+
// [#25224 cut 6] The body moved to `auth-manager-plugin-assembly.ts` (see `PluginAssemblyHost`).
3210+
const readDegradedFeatures = () => this.degradedFeatures;
3211+
const host: Pick<PluginAssemblyHost, 'degradedFeatures'> = {
3212+
get degradedFeatures() { return readDegradedFeatures(); },
3213+
};
3214+
return addOptionalPlugin.call(host, plugins, feature, build);
32223215
}
32233216

32243217
/**
@@ -3265,60 +3258,18 @@ export class AuthManager {
32653258
* read discards it, so it no longer pays the key read and the signature
32663259
* (#24061; see `protectGetSessionJwtHook`).
32673260
*/
3268-
private async buildJwtPlugin(jwt: (options: any) => any): Promise<any> {
3269-
if (!this.ed25519ProbeResult) this.ed25519ProbeResult = probeEd25519Support();
3270-
const { keyPairConfig, ed25519Supported } = await resolveJwtSigningAlgorithm(
3271-
() => this.ed25519ProbeResult!,
3272-
);
3273-
3274-
if (!ed25519Supported) {
3275-
// Functional, not durability: the deployment is fully operational on
3276-
// ES256. Worth one line because it changes what /jwks publishes and what
3277-
// `alg` issued tokens carry, which an operator debugging a relying party
3278-
// otherwise cannot correlate to anything.
3279-
console.warn(
3280-
`[AuthManager] This host's WebCrypto has no Ed25519 (crypto.subtle.generateKey({name:'Ed25519'}) fails), ` +
3281-
`so JWTs are signed with ${keyPairConfig.alg} instead of better-auth's default EdDSA. ` +
3282-
`Any existing EdDSA key in sys_jwks is hidden from the JWKS on this host — it cannot be imported here — ` +
3283-
`and a fresh ${keyPairConfig.alg} key is minted on first use. The rows are not deleted.`,
3284-
);
3285-
}
3286-
3287-
const jwtPlugin = jwt({
3288-
schema: buildJwtPluginSchema(),
3289-
jwks: { keyPairConfig },
3290-
// [#24061] The keyring read goes through `createSigningKeyReader` on
3291-
// every host: it holds the rows across requests only while no
3292-
// `sys_jwks` write has passed this engine's middleware since they were
3293-
// read, and never past `SIGNING_KEY_CACHE_TTL_MS` — both halves of the
3294-
// cross-request caching rule (see `signing-key-read-cache.ts`). A miss
3295-
// reads exactly what it read before: better-auth's stock read, or, on a
3296-
// host without Ed25519, the host-usable keyring that hides keys this
3297-
// host cannot import (#3585).
3298-
adapter: {
3299-
getJwks: createSigningKeyReader({
3300-
engine: this.config.dataEngine,
3301-
read: ed25519Supported ? undefined : createHostUsableJwksReader(),
3302-
}),
3303-
},
3304-
});
3305-
3306-
const protectedHook = protectGetSessionJwtHook(jwtPlugin, (error) =>
3307-
this.reportJwtSigningFailure(error, keyPairConfig.alg),
3308-
);
3309-
if (!protectedHook) {
3310-
// The guard could not attach — better-auth moved or renamed the hook.
3311-
// Say so rather than shipping a build that looks protected and is not.
3312-
console.error(
3313-
'[AuthManager] Could not attach the JWT signing guard to better-auth\'s /get-session hook ' +
3314-
'(its `hooks.after` shape changed). A JWT signing failure will now 500 every /get-session ' +
3315-
'instead of degrading to a missing set-auth-jwt header, and every in-process session read ' +
3316-
'signs a JWT it discards (two sys_jwks reads per authenticated request). Re-check ' +
3317-
'the better-auth version in better-auth-schema-parity.test.ts.',
3318-
);
3319-
}
3320-
3321-
return jwtPlugin;
3261+
private buildJwtPlugin(jwt: (options: any) => any): Promise<any> {
3262+
// [#25224 cut 6] The body moved to `auth-manager-plugin-assembly.ts` (see `PluginAssemblyHost`).
3263+
const readConfig = () => this.config;
3264+
const readEd25519ProbeResult = () => this.ed25519ProbeResult;
3265+
const writeEd25519ProbeResult = (v: AuthManager['ed25519ProbeResult']) => { this.ed25519ProbeResult = v; };
3266+
const host: Pick<PluginAssemblyHost, 'config' | 'ed25519ProbeResult' | 'reportJwtSigningFailure'> = {
3267+
get config() { return readConfig(); },
3268+
get ed25519ProbeResult() { return readEd25519ProbeResult(); },
3269+
set ed25519ProbeResult(v) { writeEd25519ProbeResult(v); },
3270+
reportJwtSigningFailure: (...a) => this.reportJwtSigningFailure(...a),
3271+
};
3272+
return buildJwtPlugin.call(host, jwt);
33223273
}
33233274

33243275
/**
@@ -3332,19 +3283,16 @@ export class AuthManager {
33323283
* delivering, and the operator has to act.
33333284
*/
33343285
private reportJwtSigningFailure(error: unknown, alg: string): void {
3335-
const message = (error as any)?.message ?? String(error);
3336-
this.degradedFeatures.set('jwtSigning', message);
3337-
if (this.jwtSigningFailureReported) return;
3338-
this.jwtSigningFailureReported = true;
3339-
console.error(
3340-
`[AuthManager] JWT signing failed with alg "${alg}", so /get-session responses carry no ` +
3341-
`set-auth-jwt header and OIDC/MCP token issuance will not work. The session itself is valid — ` +
3342-
`sign-in and cookie auth are unaffected. Common causes: this host's WebCrypto cannot use the ` +
3343-
`algorithm of the key in sys_jwks, or OS_AUTH_SECRET changed since the key was encrypted. ` +
3344-
`Set OS_OIDC_PROVIDER_ENABLED=false if this deployment does not need to act as an IdP. ` +
3345-
`Cause: ${message}`,
3346-
error,
3347-
);
3286+
// [#25224 cut 6] The body moved to `auth-manager-plugin-assembly.ts` (see `PluginAssemblyHost`).
3287+
const readDegradedFeatures = () => this.degradedFeatures;
3288+
const readJwtSigningFailureReported = () => this.jwtSigningFailureReported;
3289+
const writeJwtSigningFailureReported = (v: AuthManager['jwtSigningFailureReported']) => { this.jwtSigningFailureReported = v; };
3290+
const host: Pick<PluginAssemblyHost, 'degradedFeatures' | 'jwtSigningFailureReported'> = {
3291+
get degradedFeatures() { return readDegradedFeatures(); },
3292+
get jwtSigningFailureReported() { return readJwtSigningFailureReported(); },
3293+
set jwtSigningFailureReported(v) { writeJwtSigningFailureReported(v); },
3294+
};
3295+
return reportJwtSigningFailure.call(host, error, alg);
33483296
}
33493297

33503298
/**

0 commit comments

Comments
 (0)