Skip to content

Commit eda6277

Browse files
committed
refactor(plugin-auth): move AuthManager's plugin assembly helpers into auth-manager-plugin-assembly.ts
Three method bodies move unedited: addOptionalPlugin, which keeps one optional better-auth plugin's construction failure from taking down core auth; buildJwtPlugin, which builds the jwt plugin with an algorithm this host can use and a non-fatal /get-session hook; and reportJwtSigningFailure, which records and announces a signing failure once. The class keeps all three methods, their docblocks and signatures, and hands each body the members it reads. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr
1 parent eca6b61 commit eda6277

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
@@ -144,18 +144,10 @@ import {
144144
buildTwoFactorPluginSchema,
145145
buildOauthProviderPluginSchema,
146146
buildDeviceAuthorizationPluginSchema,
147-
buildJwtPluginSchema,
148147
buildAdminPluginSchema,
149148
buildPhoneNumberPluginSchema,
150149
} from './auth-schema-config.js';
151150
import {
152-
createHostUsableJwksReader,
153-
probeEd25519Support,
154-
protectGetSessionJwtHook,
155-
resolveJwtSigningAlgorithm,
156-
} from './jwt-key-algorithm.js';
157-
import {
158-
createSigningKeyReader,
159151
retireHeldKeyrings,
160152
UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS,
161153
} from './signing-key-read-cache.js';
@@ -227,6 +219,14 @@ import {
227219
storedRecipientLocale,
228220
type EmailSmsDeliveryHost,
229221
} from './auth-manager-email-sms-delivery.js';
222+
// [#25224 cut 6] The plugin assembly helpers; the methods of the same names
223+
// below hand each body the members it reads (`PluginAssemblyHost`).
224+
import {
225+
addOptionalPlugin,
226+
buildJwtPlugin,
227+
reportJwtSigningFailure,
228+
type PluginAssemblyHost,
229+
} from './auth-manager-plugin-assembly.js';
230230

231231
/**
232232
* Detect WebContainer (StackBlitz) environment.
@@ -3175,24 +3175,17 @@ export class AuthManager {
31753175
* `build` may return one plugin or an array that must land atomically
31763176
* (e.g. jwt + oauthProvider): on failure NOTHING from the unit is pushed.
31773177
*/
3178-
private async addOptionalPlugin(
3178+
private addOptionalPlugin(
31793179
plugins: any[],
31803180
feature: string,
31813181
build: () => Promise<any | any[]>,
31823182
): Promise<void> {
3183-
try {
3184-
const built = await build();
3185-
for (const p of Array.isArray(built) ? built : [built]) plugins.push(p);
3186-
} catch (err: any) {
3187-
const message = err?.message ?? String(err);
3188-
this.degradedFeatures.set(feature, message);
3189-
console.error(
3190-
`[AuthManager] Optional auth feature "${feature}" failed to initialize and is DISABLED ` +
3191-
`for this process — its endpoints will 404. Core email/password + session auth is unaffected. ` +
3192-
`Fix the underlying error (often a better-auth version mismatch) and restart. Cause: ${message}`,
3193-
err,
3194-
);
3195-
}
3183+
// [#25224 cut 6] The body moved to `auth-manager-plugin-assembly.ts` (see `PluginAssemblyHost`).
3184+
const readDegradedFeatures = () => this.degradedFeatures;
3185+
const host: Pick<PluginAssemblyHost, 'degradedFeatures'> = {
3186+
get degradedFeatures() { return readDegradedFeatures(); },
3187+
};
3188+
return addOptionalPlugin.call(host, plugins, feature, build);
31963189
}
31973190

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

32983249
/**
@@ -3306,19 +3257,16 @@ export class AuthManager {
33063257
* delivering, and the operator has to act.
33073258
*/
33083259
private reportJwtSigningFailure(error: unknown, alg: string): void {
3309-
const message = (error as any)?.message ?? String(error);
3310-
this.degradedFeatures.set('jwtSigning', message);
3311-
if (this.jwtSigningFailureReported) return;
3312-
this.jwtSigningFailureReported = true;
3313-
console.error(
3314-
`[AuthManager] JWT signing failed with alg "${alg}", so /get-session responses carry no ` +
3315-
`set-auth-jwt header and OIDC/MCP token issuance will not work. The session itself is valid — ` +
3316-
`sign-in and cookie auth are unaffected. Common causes: this host's WebCrypto cannot use the ` +
3317-
`algorithm of the key in sys_jwks, or OS_AUTH_SECRET changed since the key was encrypted. ` +
3318-
`Set OS_OIDC_PROVIDER_ENABLED=false if this deployment does not need to act as an IdP. ` +
3319-
`Cause: ${message}`,
3320-
error,
3321-
);
3260+
// [#25224 cut 6] The body moved to `auth-manager-plugin-assembly.ts` (see `PluginAssemblyHost`).
3261+
const readDegradedFeatures = () => this.degradedFeatures;
3262+
const readJwtSigningFailureReported = () => this.jwtSigningFailureReported;
3263+
const writeJwtSigningFailureReported = (v: AuthManager['jwtSigningFailureReported']) => { this.jwtSigningFailureReported = v; };
3264+
const host: Pick<PluginAssemblyHost, 'degradedFeatures' | 'jwtSigningFailureReported'> = {
3265+
get degradedFeatures() { return readDegradedFeatures(); },
3266+
get jwtSigningFailureReported() { return readJwtSigningFailureReported(); },
3267+
set jwtSigningFailureReported(v) { writeJwtSigningFailureReported(v); },
3268+
};
3269+
return reportJwtSigningFailure.call(host, error, alg);
33223270
}
33233271

33243272
/**

0 commit comments

Comments
 (0)