|
| 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 | +} |
0 commit comments