From e9ef6c660e97bfbacfceecf0a813daf8dc76a169 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:40:30 +0000 Subject: [PATCH 1/8] wip(plugin-auth): in-process session reads skip the jwt header hook; the session token readback is answered from the lookup; me/* resolves grants once per request Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 8 +- .../src/internal-field-readback.ts | 68 +++++++++++++ .../plugin-auth/src/jwt-key-algorithm.ts | 50 +++++++++- .../plugin-auth/src/objectql-adapter.ts | 5 + packages/plugins/plugin-auth/vitest.config.ts | 8 ++ .../src/current-user-endpoints.ts | 99 +++++++++++-------- 6 files changed, 193 insertions(+), 45 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 97e29613ed1..6ac18696dc0 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -3002,7 +3002,10 @@ export class AuthManager { * 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`. + * `set-auth-jwt` header instead of 500ing `/get-session`, and the header is + * signed only for an HTTP `/get-session` — an in-process `auth.api.getSession` + * read discards it, so it no longer pays the key read and the signature + * (#24061; see `protectGetSessionJwtHook`). */ private async buildJwtPlugin(jwt: (options: any) => any): Promise { if (!this.ed25519ProbeResult) this.ed25519ProbeResult = probeEd25519Support(); @@ -3043,7 +3046,8 @@ export class AuthManager { 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. Re-check ' + + 'instead of degrading to a missing set-auth-jwt header, and every in-process session read ' + + 'signs a JWT it discards (two sys_jwks reads per authenticated request). Re-check ' + 'the better-auth version in better-auth-schema-parity.test.ts.', ); } diff --git a/packages/plugins/plugin-auth/src/internal-field-readback.ts b/packages/plugins/plugin-auth/src/internal-field-readback.ts index 95c4d5d0a02..50dec6899e7 100644 --- a/packages/plugins/plugin-auth/src/internal-field-readback.ts +++ b/packages/plugins/plugin-auth/src/internal-field-readback.ts @@ -251,10 +251,51 @@ const FAIL_CLOSED_CONSEQUENCE: Readonly> = { 'better-auth two-factor verification would find no TOTP secret on such rows and refuse every code', }; +/** + * One condition of the read that produced the rows: better-auth's + * `CleanedWhere`, spelled with the adapter's storage field names. + */ +export interface ReadLookupCondition { + readonly field: string; + readonly value: unknown; + readonly operator?: string; + readonly connector?: string; + readonly mode?: string; +} + +/** + * The value a read's `where` fixes for `field`, when the read was keyed on + * that column alone: exactly one condition, an equality on `field`, not + * case-insensitive, on a non-empty string. `undefined` for every other shape. + */ +function lookupKeyValue( + lookup: readonly ReadLookupCondition[] | undefined, + field: string, +): string | undefined { + if (!lookup || lookup.length !== 1) return undefined; + const [condition] = lookup; + if (condition?.field !== field) return undefined; + if ((condition.operator ?? 'eq') !== 'eq') return undefined; + if (condition.mode === 'insensitive') return undefined; + return typeof condition.value === 'string' && condition.value.length > 0 ? condition.value : undefined; +} + /** * Re-attach an object's `internal: true` columns to adapter read results, in * place. * + * [#24061] A column the read was KEYED ON needs no privileged read: every row + * the engine returned matched `field = value`, so the lookup value is the + * column's value. better-auth's session lookup is that read — `findSession` + * finds `sys_session` by `token` — so the `token` readback, a second serial + * round trip on every session read, is answered from the lookup. Measured on a + * real engine before the change: `select id, token from sys_session where id in + * (…)` ran after every `select … from sys_session where token = …`, between the + * session row and the user row. A cookie's token is the signed value the + * server issued, byte for byte; whatever token a read matched is the value the + * same storage matches on every later write keyed by it (`updateSession`, + * `deleteSession`), which is all better-auth does with it. + * * @param engine The RAW data engine (privileged verb holder). * @param objectName Protocol object name of the model just read. * @param rows The row (findOne) or rows (findMany) about to be @@ -264,18 +305,45 @@ const FAIL_CLOSED_CONSEQUENCE: Readonly> = { * one keeps its projection (that column is not * attached). Applied per column, so a projection may * name one flagged column and not another. + * @param lookup The read's own `where`, when the caller has it. Only + * a single equality on a flagged column answers that + * column; any other shape is recovered as before. */ export async function reattachInternalFieldsOnRead( engine: InternalFieldResolvingEngine, objectName: string, rows: unknown, requestedFields?: readonly string[], + lookup?: readonly ReadLookupCondition[], ): Promise { const fields = READBACK_FIELDS[objectName]; if (!fields) return; + attachLookupKeyColumns(rows, fields, lookup, requestedFields); await recoverColumns(engine, objectName, rows, fields, requestedFields); } +/** Answer each flagged column the read was keyed on from the lookup itself. */ +function attachLookupKeyColumns( + rows: unknown, + fields: readonly ReadbackColumn[], + lookup: readonly ReadLookupCondition[] | undefined, + requestedFields: readonly string[] | undefined, +): void { + if (!lookup) return; + const projected = Array.isArray(requestedFields) && requestedFields.length > 0 ? requestedFields : null; + const list = (Array.isArray(rows) ? rows : [rows]).filter( + (r): r is Record => Boolean(r) && typeof r === 'object', + ); + for (const { field } of fields) { + const value = lookupKeyValue(lookup, field); + if (value === undefined) continue; + if (projected && !projected.includes(field)) continue; + for (const row of list) { + if (!(field in row)) row[field] = value; + } + } +} + /** * [commit d6e80b28b] Recover flagged columns for one of plugin-auth's OWN raw-engine * reads — the second seam, and the reason a bare flag was not enough. diff --git a/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts b/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts index 5c2e73f8dc9..ea886b27bc5 100644 --- a/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts +++ b/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts @@ -238,12 +238,36 @@ export function createHostUsableJwksReader( * "the hook moved in a better-auth upgrade" from "the hook is fine". */ interface AfterHookEntry { - matcher?: (context: { path?: string }) => boolean; + matcher?: (context: { path?: string; request?: unknown }) => boolean; handler?: ((ctx: unknown) => unknown) & Record; } /** - * Make a JWT signing failure degrade the **header**, not the **session**. + * Is this better-auth dispatch answering an HTTP request? + * + * The router hands every endpoint the incoming `Request` as `request` + * (better-call `router.mjs`), and `dispatchAuthEndpoint` keeps it on the + * context its after-hooks receive. A server-side `auth.api.getSession(...)` + * call passes `headers` (`inProcessSessionReadInput` in `@objectstack/types`) + * and no `request`. The test is better-auth's own `isRequestLike` + * (`utils/url.mjs`), the one its dispatch uses to decide whether a call + * answers with a `Response`. + */ +function answersHttpRequest(context: { request?: unknown } | undefined): boolean { + const request = context?.request; + if (typeof Request !== 'undefined' && request instanceof Request) return true; + if (typeof request !== 'object' || request === null) return false; + if (Object.prototype.toString.call(request) !== '[object Request]') return false; + const shaped = request as { url?: unknown; headers?: { get?: unknown } | null }; + return typeof shaped.url === 'string' && typeof shaped.headers?.get === 'function'; +} + +/** + * Make the jwt plugin's `/get-session` header hook do two things it does not + * do on its own: degrade a signing failure to "no header", and sign only for + * an HTTP `/get-session`. + * + * ## A signing failure degrades 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 @@ -254,6 +278,24 @@ interface AfterHookEntry { * the same spirit as `AuthManager.addOptionalPlugin`: one optional federation * feature must never take down core session auth. * + * ## Only an HTTP `/get-session` signs (#24061) + * + * better-auth runs a path's after-hooks for a server-side `auth.api.*` call + * exactly as for a routed request, so every in-process session read — the + * REST and dispatcher execution-context resolvers, `/auth/me/*`, every plugin + * door that asks "who is calling" — signed a JWT and discarded it: the header + * lands on that call's own response object, which the caller never sends. + * Measured on a real engine, that was two `sys_jwks` reads (the key rows, then + * the `private_key` readback) and a signature on every authenticated request + * before its handler ran. The matcher is therefore narrowed to a dispatch that + * answers an HTTP request ({@link answersHttpRequest}): the browser-facing + * `GET /api/v1/auth/get-session` still carries `set-auth-jwt`, byte for byte as + * before, and an in-process read skips the hook. Only the header is affected — + * the session payload, its renewal and every other hook are unchanged. A + * server-side caller that wants the header dispatches the request + * (`AuthManager.handleRequest`), as the in-process `/get-session` re-dispatches + * already do. + * * 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 @@ -276,6 +318,10 @@ export function protectGetSessionJwtHook( if (typeof original !== 'function') continue; if (entry.matcher && !entry.matcher({ path: '/get-session' })) continue; + const matchesPath = entry.matcher; + entry.matcher = (context) => + (matchesPath ? matchesPath(context) : true) && answersHttpRequest(context); + const guarded = async (ctx: any): Promise => { try { return await original(ctx); diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 61785b0bc40..9dd2e48c850 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -928,11 +928,16 @@ export function createObjectQLAdapterFactory( // injection parses `sys_sso_provider.oidc_config` off this row, and // the blob is itself `internal` now — injecting into a row the strip // emptied would find nothing to inject into. + // + // [#24061] The read's own `where` rides along: a column the read was + // keyed on (the session lookup by `token`) is answered from it, so + // only columns the caller cannot already know cost a privileged read. await reattachInternalFieldsOnRead( internalFieldEngine, objectName, result, bridged && select ? select.map(camelToSnake) : select, + bridged ? remapWhere(where) : where, ); // [#8009] Read half — MANDATORY. `/sso/callback` reads the provider back // and authenticates to the IdP with the plaintext; encrypt-on-write diff --git a/packages/plugins/plugin-auth/vitest.config.ts b/packages/plugins/plugin-auth/vitest.config.ts index bf0b4c0055a..29c09ae12cc 100644 --- a/packages/plugins/plugin-auth/vitest.config.ts +++ b/packages/plugins/plugin-auth/vitest.config.ts @@ -56,6 +56,14 @@ export default defineConfig({ find: /^@objectstack\/service-settings$/, replacement: path.resolve(here, '../../services/service-settings/src/index.ts'), }, + // [#24061] `auth-prelude-query-count.pin.test.ts` counts the reads the + // `/auth/me/*` faces' execution-context resolver issues — that resolver + // (`makeExecutionContextResolver`) is the subject, so it is read from + // SOURCE. Same reason and the same anchoring as the entries above. + { + find: /^@objectstack\/plugin-hono-server$/, + replacement: path.resolve(here, '../plugin-hono-server/src/index.ts'), + }, ], }, }); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index f30ec90107a..129aee15ce1 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -42,8 +42,8 @@ import { assembleExecutionContext, buildEffectiveObjectPermissions, IDataEngine, + resolveAuthzContext, resolveLocalizationContext, - resolveUserAuthzGrants, type ApiExposureSchemaLike, type ManagedSchemaLike, } from '@objectstack/core'; @@ -431,14 +431,14 @@ function permissionSetResolver( * position tables. * * So the session lookup — the genuinely transport-specific part — stays here and - * ALL grant aggregation delegates to `resolveUserAuthzGrants`, the canonical - * resolver's userId-driven core, exported for exactly this caller shape: a - * surface that already knows WHO the principal is and needs the SAME envelope - * with no HTTP request to resolve it from. `sys_user_position` (null org = - * global, active-org match, ADR-0091 validity windows), the implicit `everyone` - * position (ADR-0090 D5), `sys_position_permission_set`, `mapMembershipRole` - * normalization, the platform-admin derivation and the `ai_seat` synthesis - * arrive with it instead of being re-implemented — one copy fewer to drift. + * ALL grant aggregation delegates to the canonical resolver. `sys_user_position` + * (null org = global, active-org match, ADR-0091 validity windows), the implicit + * `everyone` position (ADR-0090 D5), `sys_position_permission_set`, + * `mapMembershipRole` normalization, the platform-admin derivation and the + * `ai_seat` synthesis arrive with it instead of being re-implemented — one copy + * fewer to drift. [#24061] It is reached through `resolveAuthzContext`, whose + * request-scoped grants memo lets the session read's own resolution serve this + * face, with the session read supplied here and no API-key door opened. */ export function makeExecutionContextResolver( ctx: CurrentUserEndpointsContext, @@ -449,7 +449,7 @@ export function makeExecutionContextResolver( * kernel's does), and an engine-less stack must still resolve the SESSION — * a multi-tenant environment kernel carrying `auth` but no `objectql` * answers `{authenticated:true}` with empty grants, never a fake anonymous - * (cloud#927). `resolveUserAuthzGrants` accepts `undefined` and yields an + * (cloud#927). `resolveAuthzContext` accepts `undefined` and yields an * empty-but-valid envelope, so the guard is all this needs. */ const getObjectQL = (): IDataEngine | undefined => { @@ -469,34 +469,52 @@ export function makeExecutionContextResolver( // session's expiry while its renewed cookie is discarded. A request // carrying a session cookie reads without renewal; a bearer-only // request renews as before. - const session = await api.getSession(inProcessSessionReadInput(c.req.raw.headers)); - if (!session?.user?.id) return undefined; - const userId = session.user.id; - const tenantId = session.session?.activeOrganizationId ?? undefined; - // THE delegation (#6334). Never throws — fail-closed by - // construction: a missing engine or an absent table yields an - // empty-but-valid envelope rather than an exception, so no read - // here needs its own guard. + const readSession = async () => { + const session = await api.getSession(inProcessSessionReadInput(c.req.raw.headers)); + return session?.user?.id ? session : null; + }; + // THE delegation (#6334), and ONE resolution per request (#24061). // - // No `seedEmail`: that option exists for a caller holding an email - // the resolver cannot read back (the API-key path, which has no - // session). Here the resolver's own `sys_user` read — the row it - // loads anyway for the `ai_seat` synthesis — answers it, and - // `sys_user.email` is unique by the auth invariant, so the two - // sources cannot disagree. `AuthSessionApi.getSession` declares - // `user: { id?: string }` and nothing more; reading an undeclared - // `email` off it through `any` is the #4127 shape, and widening - // that contract needs a call site that actually requires it. - const grants = await resolveUserAuthzGrants(getObjectQL(), userId, { tenantId }); + // The session read above is better-auth's `getSession`, whose + // `customSession` hook (plugin-auth) resolves this principal's + // grants for the payload's `positions[]`. This face then asked + // `resolveUserAuthzGrants` for the same principal again, outside any + // request scope, so every `/auth/me/*` and `/me/apps` request issued + // every grant read twice — measured on a real engine, 15 reads in 9 + // round trips before the handler, where the REST door paid 10 in 7. + // `resolveAuthzContext` runs both inside ONE request-scoped grants + // memo (`request-grants-memo.ts`): the hook's resolution and this + // one share the scope and the arguments, so the second is served + // the first. The memo closes when the call settles — nothing + // outlives the request — and it serves only while no write has + // started or landed on the engine since the first resolution + // opened, so a revocation inside the request is still read fresh. + // + // SESSION ONLY, as before: this face never opened an API-key door, + // so the request's headers are withheld from the resolver + // (`headers: undefined` — no key is extracted, no admission runs) + // and the session is read by `readSession` over the request's own + // headers. No tenancy posture is passed, so the session's claimed + // organization stands as the session made it, exactly as this face + // read `activeOrganizationId` before. Never throws for a missing + // engine or an absent table — an empty-but-valid envelope + // (cloud#927); a permission-store read that FAILS raises, and the + // `catch` below answers no context, as it did. + const authz = await resolveAuthzContext({ + ql: getObjectQL(), + headers: undefined, + getSession: readSession, + }); + if (!authz.userId) return undefined; // [#2408 / #3361] Open the per-request `Server-Timing` disclosure // gate for an admin/service principal — the standalone-surface analog // of the runtime dispatcher's `timedResolveExecutionContext`. The rung // is no longer re-derived here onto a throw-away object: - // `grants.posture` IS the authoritative derivation (ADR-0095 D2/D3 — + // `authz.posture` IS the authoritative derivation (ADR-0095 D2/D3 — // from held capability grants, never a better-auth role), so this // surface and the dispatcher hand the shared predicate the same value. // A no-op when perf-tuning is off (no ambient gate). - if (isPerfDisclosurePrincipal({ isSystem: false, posture: grants.posture } as ExecutionContext)) { + if (isPerfDisclosurePrincipal({ isSystem: false, posture: authz.posture } as ExecutionContext)) { allowPerfDisclosure(); } // [#15747] THE assembly (the second delegation). This used to be a @@ -513,7 +531,7 @@ export function makeExecutionContextResolver( // // The DEFAULT, fail-closed entry (commit f586f1a89, the ruled Option A), not the guest // one: a sessionless request never reaches this line (the - // `session?.user?.id` guard above already returned `undefined`) and + // `authz.userId` guard above already returned `undefined`) and // all three handlers answer their own no-session body. Adopting // `assembleExecutionContextOrGuest` here would convert those into // an authorization evaluation over a guest envelope, which is not @@ -547,16 +565,15 @@ export function makeExecutionContextResolver( // still reach, so carrying it here would be an unread copy on a // surface whose whole point is that the gate does not close it. return assembleExecutionContext({ - // `grants` IS the `ResolvedAuthzContext` core, minus the two - // values only the session knows. `positions`, not `roles`: the - // ExecutionContext field name every reader uses (ADR-0090 D3), - // carrying the ADR-0057 D4 `sys_user_position` assignments, the - // normalized org-membership names, the implicit `everyone` - // anchor and the derived `platform_admin`. [ADR-0105 D2] - // `accessible_org_ids` rides along: the `group` posture's Layer - // 0 wall is `organization_id IN (...)`, so a context without it - // fails every read closed on this surface. - authz: { ...grants, userId, tenantId }, + // The shared resolver's `ResolvedAuthzContext`. `positions`, + // not `roles`: the ExecutionContext field name every reader uses + // (ADR-0090 D3), carrying the ADR-0057 D4 `sys_user_position` + // assignments, the normalized org-membership names, the + // implicit `everyone` anchor and the derived `platform_admin`. + // [ADR-0105 D2] `accessible_org_ids` rides along: the `group` + // posture's Layer 0 wall is `organization_id IN (...)`, so a + // context without it fails every read closed on this surface. + authz, oauth: undefined, localization: undefined, requestLocale: undefined, From 6dbdb478a38950dab5c87d7bc8280a92678ccb00 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:54:38 +0000 Subject: [PATCH 2/8] wip(plugin-auth): hold the signing keyring across requests behind the engine write seam and a TTL; pin the prelude's query count Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 33 ++- .../src/auth-prelude-query-count.pin.test.ts | 268 ++++++++++++++++++ .../src/internal-field-readback.test.ts | 52 ++++ .../plugin-auth/src/jwt-key-algorithm.test.ts | 15 + .../plugin-auth/src/jwt-key-algorithm.ts | 7 +- .../src/signing-key-read-cache.test.ts | 166 +++++++++++ .../plugin-auth/src/signing-key-read-cache.ts | 179 ++++++++++++ 7 files changed, 708 insertions(+), 12 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts create mode 100644 packages/plugins/plugin-auth/src/signing-key-read-cache.test.ts create mode 100644 packages/plugins/plugin-auth/src/signing-key-read-cache.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 6ac18696dc0..4f74ae6c4da 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -189,6 +189,7 @@ import { protectGetSessionJwtHook, resolveJwtSigningAlgorithm, } from './jwt-key-algorithm.js'; +import { createSigningKeyReader } from './signing-key-read-cache.js'; /** * Detect WebContainer (StackBlitz) environment. @@ -2996,10 +2997,14 @@ export class AuthManager { * 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. + * fallback. On a host without Ed25519 the `adapter.getJwks` keyring seam + * hides unusable keys, so they 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. + * + * The same seam carries the signing-key read cache on every host (#24061, + * `signing-key-read-cache.ts`): the keyring is held across requests only + * until a `sys_jwks` write passes the engine or its TTL lapses. * * Belt-and-braces, on every host: a signing failure degrades the * `set-auth-jwt` header instead of 500ing `/get-session`, and the header is @@ -3029,12 +3034,20 @@ export class AuthManager { 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() } }), + // [#24061] The keyring read goes through `createSigningKeyReader` on + // every host: it holds the rows across requests only while no + // `sys_jwks` write has passed this engine's middleware since they were + // read, and never past `SIGNING_KEY_CACHE_TTL_MS` — both halves of the + // cross-request caching rule (see `signing-key-read-cache.ts`). A miss + // reads exactly what it read before: better-auth's stock read, or, on a + // host without Ed25519, the host-usable keyring that hides keys this + // host cannot import (#3585). + adapter: { + getJwks: createSigningKeyReader({ + engine: this.config.dataEngine, + read: ed25519Supported ? undefined : createHostUsableJwksReader(), + }), + }, }); const protectedHook = protectGetSessionJwtHook(jwtPlugin, (error) => diff --git a/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts new file mode 100644 index 00000000000..f3b684f7c87 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24061] The reads one authenticated request pays before its handler runs — + * counted, on a real engine. + * + * ## The composition + * + * A real `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, this + * plugin's identity objects, the grant tables `SecurityPlugin` declares and the + * activation ledger, and a real `AuthManager`. A signed-up user holds a session + * cookie whose session has an active organization (a `sys_member` row backs + * it), which is the shape the card measured: every grant read the resolver can + * issue is issued. + * + * ## The instrument + * + * The driver is wrapped, below the engine: every call is recorded with the + * object it reads, and each yields on a macrotask before it runs, so reads a + * caller issues together are in flight together. A call that starts while + * nothing else is in flight OPENS a round trip; one that starts while another + * is in flight JOINS it — the leg definition of + * `resolve-authz-context.batch-equivalence.testkit.ts` (in `@objectstack/core`), + * spelled here because a test may not import another package's test files. + * + * ## What is pinned + * + * 1. **The shared prelude** — `resolveAuthzContext` with the in-process + * session read, exactly as the REST door (`rest-server.ts`) and the runtime + * dispatcher call it: 8 reads in 4 round trips. Before #24061 it was 11 in + * 7: the jwt plugin's `/get-session` header hook signed a JWT the caller + * discarded (two `sys_jwks` reads), and the session row's `token` was read + * back although the session had just been found BY that token. + * 2. **The `/auth/me/*` faces' resolver** (`makeExecutionContextResolver`, + * `@objectstack/plugin-hono-server`) — the same 8 in 4. Before, 17 in 9: it + * resolved the grants a second time, outside any request scope. + * 3. **The HTTP `/get-session`** still carries `set-auth-jwt`, signed by the + * stored key; the keyring is read once and then held. + * 4. **A keyring write is seen on the very next signing** — the key the + * header was signed with is retired through the engine, and the next + * `/get-session` signs with a new one. + * + * The counts are exact on purpose: a change that issues a read the prelude did + * not issue turns 1 or 2 red. A change that REMOVES one turns them red too — + * lower the number in the same change and say which read went. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveAuthzContext } from '@objectstack/core'; +import { inProcessSessionReadInput } from '@objectstack/types'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { makeExecutionContextResolver } from '@objectstack/plugin-hono-server'; +import { AuthManager } from './auth-manager.js'; +import { authIdentityObjects } from './manifest.js'; + +const BASE = 'http://localhost:3000'; +const AUTH = `${BASE}/api/v1/auth`; +const SECRET = 'test-secret-at-least-32-chars-long-24061'; +const SYS = { context: { isSystem: true } } as const; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { + await (engines.pop() as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } +}); + +interface Recording { + /** The object each driver call read or wrote, in issue order. */ + objects: string[]; + /** Round trips, by the leg definition above. */ + legs: number; +} + +/** Wrap the driver's verbs; recording is off until {@link Probe.measure}. */ +function probeDriver(driver: Record) { + let on = false; + let inFlight = 0; + let current: Recording = { objects: [], legs: 0 }; + for (const verb of ['find', 'findOne', 'count', 'aggregate', 'create', 'update', 'upsert', 'delete', + 'bulkCreate', 'bulkUpdate', 'bulkDelete', 'updateMany', 'deleteMany']) { + const original = driver[verb]?.bind(driver); + if (!original) continue; + driver[verb] = async (object: string, ...rest: unknown[]) => { + if (!on) return original(object, ...rest); + if (inFlight === 0) current.legs += 1; + inFlight += 1; + current.objects.push(String(object)); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + return await original(object, ...rest); + } finally { + inFlight -= 1; + } + }; + } + return { + async measure(run: () => Promise): Promise<{ result: T } & Recording> { + current = { objects: [], legs: 0 }; + on = true; + try { + const result = await run(); + return { result, ...current }; + } finally { + on = false; + } + }, + }; +} + +/** The grant tables, as `SecurityPlugin` declares them. */ +async function grantTables(): Promise { + let manifest: { objects?: unknown[] } = {}; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (name === 'manifest') return { register: (m: typeof manifest) => { manifest = m; } }; + throw new Error(`service not registered: ${name}`); + }, + }; + await new SecurityPlugin({}).init(ctx); + return (manifest.objects ?? []).filter((o: any) => + ['sys_user_position', 'sys_user_permission_set'].includes(o?.name)); +} + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +/** The `kid` a compact JWS was signed with. */ +const kidOf = (jwt: string | null): string | undefined => + jwt ? JSON.parse(Buffer.from(jwt.split('.')[0]!, 'base64url').toString('utf8')).kid : undefined; + +/** Count per object, for a readable failure. */ +const tally = (objects: string[]): Record => + objects.reduce>((acc, o) => ({ ...acc, [o]: (acc[o] ?? 0) + 1 }), {}); + +/** A signed-in user whose session has an active organization, on a warmed engine. */ +async function signedInRig() { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const engine = new ObjectQL(); + engines.push(engine); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + engine.registerDriver(driver, true); + await engine.init(); + for (const object of [...authIdentityObjects, SysMetadataActivation, ...(await grantTables())]) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + vi.spyOn((engine as any).logger, 'info').mockImplementation(() => undefined); + const probe = probeDriver(driver as unknown as Record); + + const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never } as never); + const signedUp = await manager.handleRequest(new Request(`${AUTH}/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin: BASE }, + body: JSON.stringify({ email: 'prelude@example.com', password: 'password-24061-prelude', name: 'Prelude' }), + })); + expect(signedUp.status, await signedUp.clone().text()).toBe(200); + const cookie = cookieFrom(signedUp); + const first = await manager.handleRequest(new Request(`${AUTH}/get-session`, { headers: { cookie } })); + const session: any = await first.json(); + const now = new Date().toISOString(); + await engine.insert('sys_organization', { id: 'org_prelude', name: 'Prelude', slug: 'prelude', created_at: now }, SYS); + await engine.insert('sys_member', { id: 'mem_prelude', organization_id: 'org_prelude', user_id: session.user.id, role: 'owner', created_at: now }, SYS); + await engine.update('sys_session', { id: session.session.id, active_organization_id: 'org_prelude' }, SYS); + + const api: any = await manager.getApi(); + const headers = new Headers({ cookie }); + const prelude = () => resolveAuthzContext({ + ql: engine, + headers, + getSession: async (h: unknown) => api.getSession(inProcessSessionReadInput(h)), + } as never); + // The first resolution to name an engine registers the request memo's write + // observer and, by that memo's own rule, stores nothing — a cold engine pays + // once. Every request after it is the request this file measures. + await prelude(); + return { engine, manager, probe, cookie, headers, prelude, userId: session.user.id as string }; +} + +/** The 8 reads of the prelude: the session row, the user row, the grant wave, the activation ledger. */ +const PRELUDE_READS = { + sys_session: 1, + sys_user: 2, + sys_member: 2, + sys_user_position: 1, + sys_user_permission_set: 1, + sys_metadata_activation: 1, +}; + +describe('[#24061] one authenticated request\'s prelude, counted on a real engine', () => { + it('the shared prelude (REST, dispatcher): 8 reads in 4 round trips, no keyring read', async () => { + const { probe, prelude, userId } = await signedInRig(); + + const run = await probe.measure(prelude); + + expect((run.result as any).userId).toBe(userId); + expect((run.result as any).tenantId).toBe('org_prelude'); + expect(tally(run.objects)).toEqual(PRELUDE_READS); + expect(run.objects).toHaveLength(8); + expect(run.legs).toBe(4); + }, 60_000); + + it('the /auth/me/* resolver: the same 8 reads in 4 round trips — the grants resolved once', async () => { + const { engine, manager, probe, headers, userId } = await signedInRig(); + const resolveCtx = makeExecutionContextResolver({ + getService: (name: string) => ({ auth: manager, objectql: engine } as Record)[name] as T, + }); + + const run = await probe.measure(() => resolveCtx({ req: { raw: { headers } } })); + + expect(run.result?.userId).toBe(userId); + expect(run.result?.tenantId).toBe('org_prelude'); + expect(run.result?.positions).toContain('org_owner'); + expect(tally(run.objects)).toEqual(PRELUDE_READS); + expect(run.legs).toBe(4); + }, 60_000); + + it('the HTTP /get-session still carries set-auth-jwt, and reads the keyring once', async () => { + const { engine, manager, probe, cookie, userId } = await signedInRig(); + const getSession = () => manager.handleRequest(new Request(`${AUTH}/get-session`, { headers: { cookie } })); + const [stored] = (await engine.find('sys_jwks', { ...SYS })) as Array<{ id: string }>; + + // The rig's first signature MINTED the key — a keyring write — so the + // keyring held before it was retired. This signature reads it once: the + // key rows, then the `private_key` readback. + const first = await probe.measure(getSession); + expect(tally(first.objects).sys_jwks).toBe(2); + // …and holds it: the next signature reads no key at all. + const again = await probe.measure(getSession); + expect(tally(again.objects).sys_jwks ?? 0).toBe(0); + + for (const response of [first.result, again.result]) { + const jwt = response.headers.get('set-auth-jwt'); + expect(jwt, 'the browser-facing /get-session is signed').toBeTruthy(); + expect(kidOf(jwt)).toBe(stored!.id); + expect(JSON.parse(Buffer.from(jwt!.split('.')[1]!, 'base64url').toString('utf8')).sub).toBe(userId); + } + }, 60_000); + + it('a keyring write through the engine is seen on the very next signing', async () => { + const { engine, manager, cookie } = await signedInRig(); + const getSession = () => manager.handleRequest(new Request(`${AUTH}/get-session`, { headers: { cookie } })); + const before = kidOf((await getSession()).headers.get('set-auth-jwt')); + expect(before).toBeTruthy(); + + // Retire the signing key: it expired a moment ago. + await engine.update('sys_jwks', { id: before, expires_at: new Date(Date.now() - 1_000).toISOString() }, SYS); + + const after = kidOf((await getSession()).headers.get('set-auth-jwt')); + expect(after, 'the next signature uses a key minted after the write').toBeTruthy(); + expect(after).not.toBe(before); + expect(((await engine.find('sys_jwks', { ...SYS })) as unknown[]).length).toBe(2); + }, 60_000); +}); diff --git a/packages/plugins/plugin-auth/src/internal-field-readback.test.ts b/packages/plugins/plugin-auth/src/internal-field-readback.test.ts index 7855b599b66..46cdc362392 100644 --- a/packages/plugins/plugin-auth/src/internal-field-readback.test.ts +++ b/packages/plugins/plugin-auth/src/internal-field-readback.test.ts @@ -132,6 +132,58 @@ describe('#7823 reattachInternalFieldsOnRead — sys_session.token', () => { }); }); +describe('[#24061] a column the read was keyed on is answered from the lookup', () => { + // better-auth's `findSession(token)` is `findOne(session, where token = …)`. + // Every row that read returns matched the token, so the readback of `token` + // — a second serial round trip on every session read — has nothing to add. + const byToken = (value: unknown, extra: Record = {}) => [ + { field: 'token', value, operator: 'eq', connector: 'AND', mode: 'sensitive', ...extra }, + ]; + + it('the session lookup by token attaches the token and issues no privileged read', async () => { + const resolveInternalField = resolver({ s1: { token: 'tok-1' } }); + const row: any = { id: 's1', user_id: 'u1' }; + await reattachInternalFieldsOnRead({ resolveInternalField }, 'sys_session', row, undefined, byToken('tok-1')); + expect(row.token).toBe('tok-1'); + expect(resolveInternalField).not.toHaveBeenCalled(); + }); + + it('every other lookup shape still recovers the column through the accessor', async () => { + const shapes: Array<[string, unknown[]]> = [ + ['keyed on another column', [{ field: 'id', value: 's1', operator: 'eq', connector: 'AND' }]], + ['a second condition beside the token', [...byToken('tok-1'), { field: 'user_id', value: 'u1', operator: 'eq', connector: 'AND' }]], + ['not an equality', byToken(['tok-1'], { operator: 'in' })], + ['case-insensitive', byToken('tok-1', { mode: 'insensitive' })], + ['an empty value', byToken('')], + ['a non-string value', byToken(42)], + ['no lookup at all', []], + ]; + for (const [label, lookup] of shapes) { + const resolveInternalField = resolver({ s1: { token: 'stored' } }); + const row: any = { id: 's1', user_id: 'u1' }; + await reattachInternalFieldsOnRead({ resolveInternalField }, 'sys_session', row, undefined, lookup as any); + expect(row.token, label).toBe('stored'); + expect(resolveInternalField, label).toHaveBeenCalledTimes(1); + } + }); + + it('a projection that excluded the column keeps its projection, lookup or not', async () => { + const resolveInternalField = resolver({ s1: { token: 'tok-1' } }); + const row: any = { id: 's1' }; + await reattachInternalFieldsOnRead({ resolveInternalField }, 'sys_session', row, ['id', 'expires_at'], byToken('tok-1')); + expect('token' in row).toBe(false); + expect(resolveInternalField).not.toHaveBeenCalled(); + }); + + it('a row that still carries the column keeps its own value', async () => { + const resolveInternalField = resolver({}); + const row: any = { id: 's1', token: 'original' }; + await reattachInternalFieldsOnRead({ resolveInternalField }, 'sys_session', row, undefined, byToken('tok-1')); + expect(row.token).toBe('original'); + expect(resolveInternalField).not.toHaveBeenCalled(); + }); +}); + describe('#7987 reattachInternalFieldsOnRead — sys_account OAuth columns', () => { const ACCOUNT_ROW = () => ({ id: 'a1', user_id: 'u1', provider_id: 'google' }); diff --git a/packages/plugins/plugin-auth/src/jwt-key-algorithm.test.ts b/packages/plugins/plugin-auth/src/jwt-key-algorithm.test.ts index 624116abb77..a931a0519f6 100644 --- a/packages/plugins/plugin-auth/src/jwt-key-algorithm.test.ts +++ b/packages/plugins/plugin-auth/src/jwt-key-algorithm.test.ts @@ -279,6 +279,21 @@ describe('protectGetSessionJwtHook', () => { ).toBe(false); }); + it('[#24061] the guarded hook runs for an HTTP /get-session and not for an in-process read', () => { + const plugin = makePlugin(async () => 'signed'); + expect(protectGetSessionJwtHook(plugin, vi.fn())).toBe(true); + const matcher = plugin.hooks.after[0]!.matcher as (c: unknown) => boolean; + + // The router hands the endpoint the incoming Request (better-call router.mjs). + const routed = { path: '/get-session', request: new Request('http://localhost/api/v1/auth/get-session') }; + expect(matcher(routed)).toBe(true); + // `auth.api.getSession(inProcessSessionReadInput(headers))`: headers, no request. + expect(matcher({ path: '/get-session', headers: new Headers({ cookie: 'x' }) })).toBe(false); + expect(matcher({ path: '/get-session', request: { url: 'http://localhost/x', headers: {} } })).toBe(false); + // The path half is the original matcher's, unchanged. + expect(matcher({ path: '/sign-in/email', request: routed.request })).toBe(false); + }); + it('skips hooks whose matcher does not claim /get-session', () => { const plugin = { hooks: { after: [{ matcher: () => false, handler: async () => 'untouched' }] }, diff --git a/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts b/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts index ea886b27bc5..afa97f5f48b 100644 --- a/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts +++ b/packages/plugins/plugin-auth/src/jwt-key-algorithm.ts @@ -52,8 +52,11 @@ * 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. + * The filter applies **only when the host lacks Ed25519**: every normal + * deployment reads the stock keyring, unfiltered. [#24061] On every host the + * keyring read is wrapped by the signing-key read cache + * (`signing-key-read-cache.ts`), whose miss performs this module's read on such + * a host and better-auth's stock read on any other. * * @see https://github.com/objectstack-ai/objectstack/issues/3585 */ diff --git a/packages/plugins/plugin-auth/src/signing-key-read-cache.test.ts b/packages/plugins/plugin-auth/src/signing-key-read-cache.test.ts new file mode 100644 index 00000000000..74bb3ae5a1e --- /dev/null +++ b/packages/plugins/plugin-auth/src/signing-key-read-cache.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#24061] The signing-key read, held across requests under both halves of the +// cross-request caching rule: (a) a write that passes the engine's middleware +// retires the held keyring in the same operation, and (b) a TTL bounds what +// that seam cannot see. The end-to-end pin — a key write through a real engine +// seen on the very next signing — is `auth-prelude-query-count.pin.test.ts`; +// this file pins the reader's own contract, one rule per case. + +import { describe, it, expect } from 'vitest'; +import { createSigningKeyReader, SIGNING_KEY_CACHE_TTL_MS } from './signing-key-read-cache.js'; + +type Middleware = (ctx: { operation?: string }, next: () => Promise) => Promise; + +/** An engine double exposing the middleware seam, with a way to drive a write through it. */ +function engineWithSeam() { + const registered: Array<{ fn: Middleware; object?: string }> = []; + return { + registered, + registerMiddleware(fn: Middleware, options?: { object?: string }) { + registered.push({ fn, object: options?.object }); + }, + /** Run one operation through every middleware registered for `object`. */ + async run(object: string, operation: string, body: () => Promise = async () => {}) { + const chain = registered.filter((m) => !m.object || m.object === object); + const step = async (i: number): Promise => { + if (i === chain.length) return body(); + return chain[i]!.fn({ operation }, () => step(i + 1)); + }; + await step(0); + }, + }; +} + +/** A better-auth endpoint context whose adapter answers the keyring and counts the reads. */ +function keyring(rows: Array>) { + const state = { rows, reads: 0 }; + const ctx = { + context: { + adapter: { + findMany: async ({ model }: { model: string }) => { + expect(model).toBe('jwks'); + state.reads += 1; + return state.rows.map((r) => ({ ...r })); + }, + }, + }, + }; + return { state, ctx }; +} + +describe('[#24061] createSigningKeyReader', () => { + it('serves a repeat read from the held keyring', async () => { + const engine = engineWithSeam(); + const { state, ctx } = keyring([{ id: 'k1' }]); + const read = createSigningKeyReader({ engine }); + + expect(await read(ctx)).toEqual([{ id: 'k1' }]); + expect(await read(ctx)).toEqual([{ id: 'k1' }]); + expect(state.reads).toBe(1); + }); + + it('registers ONE observer per engine, on the keyring object only', () => { + const engine = engineWithSeam(); + createSigningKeyReader({ engine }); + createSigningKeyReader({ engine }); + expect(engine.registered.map((m) => m.object)).toEqual(['sys_jwks']); + }); + + it('(a) a keyring write through the engine retires the held keyring — the next read sees it', async () => { + const engine = engineWithSeam(); + const { state, ctx } = keyring([{ id: 'k1' }]); + const read = createSigningKeyReader({ engine }); + await read(ctx); + + await engine.run('sys_jwks', 'insert', async () => { + state.rows = [...state.rows, { id: 'k2' }]; + }); + + expect((await read(ctx)).map((r) => r.id)).toEqual(['k1', 'k2']); + expect(state.reads).toBe(2); + }); + + it('(a) a read that overlapped a write is answered but never held', async () => { + const engine = engineWithSeam(); + const { state, ctx } = keyring([{ id: 'k1' }]); + const read = createSigningKeyReader({ engine }); + let release!: () => void; + const writing = engine.run('sys_jwks', 'update', () => new Promise((r) => { release = r; })); + + // The write is in flight: answered from the table, not held. + await read(ctx); + release(); + await writing; + await read(ctx); + expect(state.reads).toBe(2); + // …and once nothing is in flight, the next read is held again. + await read(ctx); + expect(state.reads).toBe(2); + }); + + it('a keyring READ through the engine does not retire the held keyring', async () => { + const engine = engineWithSeam(); + const { state, ctx } = keyring([{ id: 'k1' }]); + const read = createSigningKeyReader({ engine }); + await read(ctx); + await engine.run('sys_jwks', 'find'); + await read(ctx); + expect(state.reads).toBe(1); + }); + + it('(b) the TTL bounds a keyring held with no write observed', async () => { + const engine = engineWithSeam(); + const { state, ctx } = keyring([{ id: 'k1' }]); + let now = 1_000_000; + const read = createSigningKeyReader({ engine, now: () => now }); + await read(ctx); + + now += SIGNING_KEY_CACHE_TTL_MS - 1; + await read(ctx); + expect(state.reads).toBe(1); + + now += 1; + await read(ctx); + expect(state.reads).toBe(2); + }); + + it('an engine with no write seam holds nothing — every read goes to the table', async () => { + const { state, ctx } = keyring([{ id: 'k1' }]); + const read = createSigningKeyReader({ engine: {} }); + await read(ctx); + await read(ctx); + expect(state.reads).toBe(2); + }); + + it('an engine whose registration throws holds nothing', async () => { + const { state, ctx } = keyring([{ id: 'k1' }]); + const engine = { registerMiddleware() { throw new Error('no middleware here'); } }; + const read = createSigningKeyReader({ engine }); + await read(ctx); + await read(ctx); + expect(state.reads).toBe(2); + }); + + it('the inner read decides the answer — a host-usable reader hides what it hides from the held keyring too', async () => { + const engine = engineWithSeam(); + const { state, ctx } = keyring([{ id: 'k1' }, { id: 'k2' }]); + const read = createSigningKeyReader({ + engine, + read: async (c) => ((await c.context.adapter.findMany({ model: 'jwks' })) as any[]).filter((r) => r.id !== 'k1'), + }); + expect((await read(ctx)).map((r) => r.id)).toEqual(['k2']); + expect((await read(ctx)).map((r) => r.id)).toEqual(['k2']); + expect(state.reads).toBe(1); + }); + + it('hands out copies — a caller mutating its answer does not change the next one', async () => { + const engine = engineWithSeam(); + const { ctx } = keyring([{ id: 'k1', alg: 'EdDSA' }]); + const read = createSigningKeyReader({ engine }); + const first = (await read(ctx)) as any[]; + first[0].alg = 'tampered'; + first.push({ id: 'k9' }); + expect(await read(ctx)).toEqual([{ id: 'k1', alg: 'EdDSA' }]); + }); +}); diff --git a/packages/plugins/plugin-auth/src/signing-key-read-cache.ts b/packages/plugins/plugin-auth/src/signing-key-read-cache.ts new file mode 100644 index 00000000000..375ee10b3c9 --- /dev/null +++ b/packages/plugins/plugin-auth/src/signing-key-read-cache.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The signing-key read, held across requests under BOTH halves of the + * cross-request caching rule (#24061). + * + * ## What is read, and how often + * + * better-auth's `jwt` plugin reads the `sys_jwks` keyring every time it signs: + * the `set-auth-jwt` header of an HTTP `/get-session`, `/token`, and the OIDC / + * MCP provider's tokens. Each read is two serial round trips on a real engine — + * the key rows, then the `private_key` readback (`internal-field-readback.ts`) + * — for a table that changes when a key is minted or rotated, which is almost + * never. In-process session reads stopped signing altogether + * (`protectGetSessionJwtHook`), so this is the read that is left. + * + * ## The rule this cache is held to + * + * The accepted cross-request caching design for the authenticated request path + * (#11633, ruled) admits no cached answer on this path unless (a) every write + * that can change it passes a seam this process observes, and (b) a TTL bounds + * the case where (a) fails. Both. So: + * + * - **(a) The write seam.** The engine's middleware chain is the one road a + * write to `sys_jwks` takes in this process: better-auth mints and rotates + * keys through its storage adapter, which writes with `engine.insert` / + * `update` / `delete` (`objectql-adapter.ts`), and the data API writes + * through the same engine. A middleware registered on `sys_jwks` counts + * every write that ENTERS it and, in a `finally` around `next()`, every + * write whose driver step has SETTLED. An entry is served only while both + * counters read what they read when the entry's read OPENED and no write is + * in flight — so a write retires the entry the moment it starts, and a read + * that overlapped a write is never stored. + * - **(b) The bound.** {@link SIGNING_KEY_CACHE_TTL_MS}: a write this process + * cannot see — another node minting its own key, a key row edited at the + * driver, a restore — is read at most that long after it lands. A fixed + * constant, deliberately not a configuration key. + * + * An engine without `registerMiddleware` (a test double), or one whose + * registration threw, has no observable write seam, so half (a) cannot hold + * and NOTHING is cached: every read goes to the table, as before. + * + * ## What it is not + * + * ⛔ Not a session, grant or other authorization cache. It holds the keyring + * rows the signer reads — the rows `/jwks` publishes and the encrypted private + * key better-auth decrypts per signature — and nothing derived from a caller. + * It is one entry per reader (one reader per better-auth instance), so a + * rebuilt instance starts empty. + */ + +import type { StoredJwk } from './jwt-key-algorithm.js'; + +/** + * How long a keyring read is served without a write this process observed — + * the (b) half above. Thirty seconds, the repo's established bound for a + * cross-request read on this path (`rest-server.ts`'s hostname cache, the + * localization failure memo). + */ +export const SIGNING_KEY_CACHE_TTL_MS = 30_000; + +/** The keyring object, as the jwt plugin's schema maps it (`buildJwtPluginSchema`). */ +const KEYRING_OBJECT = 'sys_jwks'; + +/** Engine operations that read; every other operation on the keyring is counted as a write. */ +const READ_OPERATIONS: ReadonlySet = new Set(['find', 'findOne', 'count', 'aggregate']); + +interface KeyringWriteObserver { + started: number; + completed: number; +} + +/** + * One observer per engine. `null` records an engine whose registration threw: + * it is never cached behind. + */ +const keyringWriteObservers = new WeakMap(); + +type Middleware = (ctx: { operation?: unknown }, next: () => Promise) => Promise; + +/** The observer for `engine`, registered on first sight; `undefined` when the engine offers no seam. */ +function observeKeyringWrites(engine: unknown): KeyringWriteObserver | undefined { + if (!engine || typeof engine !== 'object') return undefined; + const existing = keyringWriteObservers.get(engine); + if (existing !== undefined) return existing ?? undefined; + const register = (engine as { registerMiddleware?: unknown }).registerMiddleware; + if (typeof register !== 'function') return undefined; + const observer: KeyringWriteObserver = { started: 0, completed: 0 }; + const middleware: Middleware = async (ctx, next) => { + const operation = ctx?.operation; + if (typeof operation === 'string' && READ_OPERATIONS.has(operation)) return next(); + observer.started += 1; + try { + await next(); + } finally { + observer.completed += 1; + } + }; + try { + (register as (fn: Middleware, options: { object: string }) => void).call(engine, middleware, { + object: KEYRING_OBJECT, + }); + } catch { + keyringWriteObservers.set(engine, null); + return undefined; + } + keyringWriteObservers.set(engine, observer); + return observer; +} + +/** better-auth's stock keyring read — the read its jwks adapter makes with no `getJwks` override. */ +async function readKeyring(ctx: any): Promise { + return (await ctx?.context?.adapter?.findMany({ model: 'jwks' })) ?? []; +} + +export interface SigningKeyReaderOptions { + /** The engine better-auth's storage adapter writes through — the (a) seam. */ + readonly engine: unknown; + /** + * The read a miss performs. Omitted: better-auth's stock keyring read. A host + * without Ed25519 passes `createHostUsableJwksReader()`, so the rows it hides + * are hidden from the cached answer too. + */ + readonly read?: (ctx: any) => Promise; + /** Clock, for tests. */ + readonly now?: () => number; +} + +interface CachedKeyring { + readonly rows: StoredJwk[]; + readonly readAtMs: number; + readonly startedAtOpen: number; + readonly completedAtOpen: number; +} + +/** + * The `jwt({ adapter: { getJwks } })` reader: better-auth routes all four of + * its keyring reads through it (`getAllKeys`, `getLatestKey`, + * `getLatestKeyByAlg`, `getKeyById`), so every signer and `/jwks` share it. + * + * The observer is registered HERE, when the reader is built — before the + * first request — so a key write that precedes the first read is counted. + */ +export function createSigningKeyReader( + options: SigningKeyReaderOptions, +): (ctx: any) => Promise { + const observer = observeKeyringWrites(options.engine); + const read = options.read ?? readKeyring; + const now = options.now ?? (() => Date.now()); + let entry: CachedKeyring | undefined; + + return async (ctx: any): Promise => { + if ( + observer + && entry + && entry.startedAtOpen === observer.started + && entry.completedAtOpen === observer.completed + && observer.started === observer.completed + && now() - entry.readAtMs < SIGNING_KEY_CACHE_TTL_MS + ) { + return structuredClone(entry.rows); + } + + entry = undefined; + const startedAtOpen = observer?.started ?? 0; + const completedAtOpen = observer?.completed ?? 0; + const readAtMs = now(); + const rows = await read(ctx); + if ( + observer + && observer.started === startedAtOpen + && observer.completed === completedAtOpen + && observer.started === observer.completed + ) { + entry = { rows: structuredClone(rows), readAtMs, startedAtOpen, completedAtOpen }; + } + return rows; + }; +} From d816cc203cecbabc2c789b96ee812414abbf51ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:02:11 +0000 Subject: [PATCH 3/8] test(plugin-auth): the grants-once suite declines across the idle-timeout heartbeat, the write an in-process session read still makes; changeset Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .changeset/24061-auth-prelude-queries.md | 23 +++++++++++ .../src/auth-prelude-query-count.pin.test.ts | 6 ++- .../src/session-grants-resolved-once.test.ts | 38 +++++++++++-------- 3 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 .changeset/24061-auth-prelude-queries.md diff --git a/.changeset/24061-auth-prelude-queries.md b/.changeset/24061-auth-prelude-queries.md new file mode 100644 index 00000000000..ce96388b34b --- /dev/null +++ b/.changeset/24061-auth-prelude-queries.md @@ -0,0 +1,23 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/plugin-hono-server': patch +--- + +Clause-②: no + +perf(plugin-auth, plugin-hono-server): an authenticated request reads 8 rows in 4 round trips before its handler, down from 11 in 7, and `/auth/me/*` resolves the caller's grants once + +Measured on a real engine (SQLite through `@objectstack/driver-sql`), for a signed-in user whose session has an active organization: + +| before the handler | before | after | +|:--|:--|:--| +| REST and dispatcher doors (`resolveAuthzContext` with the in-process session read) | 11 reads, 7 round trips | 8 reads, 4 round trips | +| `GET /auth/me/permissions`, `/auth/me/localization`, `/me/apps` | 17 reads, 9 round trips | 8 reads, 4 round trips | +| `GET /api/v1/auth/get-session` | 11 reads, 7 round trips | 10 reads, 6 round trips when it reads the signing key; 8 in 4 while the key is held | + +- **An in-process session read no longer signs a JWT.** better-auth runs `/get-session`'s after-hooks for a server-side `auth.api.getSession(...)` call too, so every door that asked "who is calling" had the jwt plugin read the signing key (two `sys_jwks` round trips) and sign a `set-auth-jwt` header the caller then discarded. The hook now runs only when the call answers an HTTP request. `GET /api/v1/auth/get-session` still carries `set-auth-jwt`, signed exactly as before. A server-side caller that wants the header dispatches the request through `AuthManager.handleRequest`. +- **The session token is not read back after the session was found by it.** `sys_session.token` is withheld from ordinary reads and recovered for better-auth through a privileged read. better-auth finds the session by its token, so that read had nothing to add. It was a second round trip between the session row and the user row on every session read. A read keyed on any other column still recovers the token. +- **`/auth/me/*` and `/me/apps` resolve the grants once.** These routes resolved the caller's grants twice per request: once inside the session read (for the payload's `positions[]`) and once more for their own context. They now resolve through `resolveAuthzContext`, whose request-scoped memo serves the second resolution from the first while no write has landed in between. The memo ends with the request. The routes still admit a session only, and the session's active organization stands as before. +- **The signing key is held across requests, under two bounds.** The keyring the jwt plugin signs with is kept after a read. Any write to `sys_jwks` that passes this process's engine drops it at once, and it is never kept longer than 30 seconds, which bounds a write this process cannot see (another node minting a key). An engine without a middleware seam keeps nothing. On a host without Ed25519 the keyring is still filtered to keys the host can use. + +Unchanged: the session and user every door resolves, every grant, the `/get-session` body and its renewal, every option and environment variable, and the exports. No new configuration key. diff --git a/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts index f3b684f7c87..549028e1862 100644 --- a/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts @@ -238,10 +238,12 @@ describe('[#24061] one authenticated request\'s prelude, counted on a real engin // keyring held before it was retired. This signature reads it once: the // key rows, then the `private_key` readback. const first = await probe.measure(getSession); - expect(tally(first.objects).sys_jwks).toBe(2); + expect(tally(first.objects)).toEqual({ ...PRELUDE_READS, sys_jwks: 2 }); + expect(first.legs).toBe(6); // …and holds it: the next signature reads no key at all. const again = await probe.measure(getSession); - expect(tally(again.objects).sys_jwks ?? 0).toBe(0); + expect(tally(again.objects)).toEqual(PRELUDE_READS); + expect(again.legs).toBe(4); for (const response of [first.result, again.result]) { const jwt = response.headers.get('set-auth-jwt'); diff --git a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts index 436408251f7..a10b7828435 100644 --- a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts +++ b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts @@ -166,10 +166,10 @@ function instrument(engine: any) { }; } -const arrange = async () => { +const arrange = async (config: Record = {}) => { const engine: any = createMemoryEngine(); const probe = instrument(engine); - const manager = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine } as any); + const manager = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...config } as any); await signUp(manager, 'owner@example.com', 'Org Owner'); await signUp(manager, 'member@example.com', 'Org Member'); @@ -210,19 +210,21 @@ const arrange = async () => { }; /** - * A WARM kernel: the first session read on a fresh auth instance generates the - * JWT signing key (`sys_jwks` insert) — a write inside the request, which the - * memo must and does decline across (pinned below). Every later request finds - * the key, writes nothing, and is the case the count is about. + * A WARM kernel: the first request through the resolver registers core's write + * observer on the engine, and that request stores nothing for the engine (it + * reads twice). Every later request is the case the count is about. + * + * [#24061] An in-process session read signs no JWT any more, so it no longer + * mints the signing key either: the warm-up request writes nothing. A write + * inside the request is still declined across — pinned below with the + * idle-timeout heartbeat, the write a session read still makes. */ const arrangeWarm = async () => { const a = await arrange(); - // One request through the resolver: it generates the signing key AND - // registers core's write observer on the engine (that request stores nothing - // for the engine, and reads twice). Every later request is the warm one. + const observersBefore = a.probe.middlewareCount(); const warm = await resolveRequest(a, a.bearers.owner); - expect(warm.writes).toContain('insert sys_jwks'); - expect(a.probe.middlewareCount()).toBe(1); + expect(warm.writes).toEqual([]); + expect(a.probe.middlewareCount()).toBe(observersBefore + 1); return a; }; @@ -371,17 +373,21 @@ describe.concurrent('the real customSession hook and resolveAuthzContext resolve expect(countOf(reads, 'sys_user_position')).toBe(1); }); - it('the kernel\'s first request writes its signing key inside the request: the memo declines, the envelope is unchanged', async () => { - const a = await arrange(); + it('a write inside the request (the idle-timeout heartbeat): the memo declines, the envelope is unchanged', async () => { + // [ADR-0069 D4] With an idle timeout configured, the hook stamps the + // session's `last_activity_at` AFTER it resolved the grants — at most once + // a minute, and on a session's first request since sign-in. + const a = await arrange({ sessionIdleTimeoutMinutes: 30 }); // Register core's write observer with an anonymous request first (no - // session, no key generated), so the ONLY reason left for the member's + // session, nothing written), so the ONLY reason left for the member's // first request to read twice is the write inside it. + const observersBefore = a.probe.middlewareCount(); const anonymous = await resolveRequest(a, null); expect(anonymous.writes).toEqual([]); - expect(a.probe.middlewareCount()).toBe(1); + expect(a.probe.middlewareCount()).toBe(observersBefore + 1); const first = await resolveRequest(a, a.bearers.member); // The write happened between the hook's resolution and step 2… - expect(first.writes).toContain('insert sys_jwks'); + expect(first.writes).toContain('update sys_session'); // …so step 2 read afresh: two resolutions, the pre-memo count. expect(countOf(first.reads, 'sys_user_position')).toBe(2); const warm = await resolveRequestWithoutMemo(a, a.bearers.member); From 5fea80e5b0f90b7ee913a270e9bbc2fd1638e656 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:07:32 +0000 Subject: [PATCH 4/8] fix(plugin-hono-server): pass the auth service's bound gate and lifecycle sources to the shared resolver Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugin-hono-server/src/current-user-endpoints.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 129aee15ce1..4274bfb89ca 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -39,11 +39,13 @@ */ import { + apiKeyLifecyclePolicySourceOf, assembleExecutionContext, buildEffectiveObjectPermissions, IDataEngine, resolveAuthzContext, resolveLocalizationContext, + userAuthGateSourceOf, type ApiExposureSchemaLike, type ManagedSchemaLike, } from '@objectstack/core'; @@ -463,14 +465,15 @@ export function makeExecutionContextResolver( if (!api && typeof authService.getApi === 'function') { api = await authService.getApi(); } - if (!api?.getSession) return undefined; + const getSession = api?.getSession?.bind(api); + if (!getSession) return undefined; // [#22258] The in-process session-read rule: these routes answer // with their own response, so a renewal here would move the // session's expiry while its renewed cookie is discarded. A request // carrying a session cookie reads without renewal; a bearer-only // request renews as before. const readSession = async () => { - const session = await api.getSession(inProcessSessionReadInput(c.req.raw.headers)); + const session = await getSession(inProcessSessionReadInput(c.req.raw.headers)); return session?.user?.id ? session : null; }; // THE delegation (#6334), and ONE resolution per request (#24061). @@ -504,6 +507,11 @@ export function makeExecutionContextResolver( ql: getObjectQL(), headers: undefined, getSession: readSession, + // Bound from the auth service, as the resolver requires + // wherever one is reachable. Both are read by the API-key + // admission seam alone, which no request here reaches. + userAuthGates: userAuthGateSourceOf(authService), + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), }); if (!authz.userId) return undefined; // [#2408 / #3361] Open the per-request `Server-Timing` disclosure From 3e1faaeafe6c831f834ce114abc8e539f236cd86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:10:27 +0000 Subject: [PATCH 5/8] test(plugin-auth): pin that an in-process session read signs nothing, the half a held keyring hides from the count Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../src/auth-prelude-query-count.pin.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts index 549028e1862..1ac6f5c35d4 100644 --- a/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts @@ -36,7 +36,9 @@ * resolved the grants a second time, outside any request scope. * 3. **The HTTP `/get-session`** still carries `set-auth-jwt`, signed by the * stored key; the keyring is read once and then held. - * 4. **A keyring write is seen on the very next signing** — the key the + * 4. **An in-process read signs nothing**, even one that asks for the + * response headers — the half of 1 a held keyring hides from the count. + * 5. **A keyring write is seen on the very next signing** — the key the * header was signed with is retired through the engine, and the next * `/get-session` signs with a new one. * @@ -253,6 +255,18 @@ describe('[#24061] one authenticated request\'s prelude, counted on a real engin } }, 60_000); + it('an in-process session read signs nothing, even when it asks for the response headers', async () => { + // The count above cannot see this half: once the keyring is held, a + // signature reads no key. The header can. + const { manager, headers, userId } = await signedInRig(); + const api: any = await manager.getApi(); + + const read = await api.getSession({ ...inProcessSessionReadInput(headers), returnHeaders: true }); + + expect(read.response?.user?.id).toBe(userId); + expect(read.headers?.get('set-auth-jwt') ?? null).toBeNull(); + }, 60_000); + it('a keyring write through the engine is seen on the very next signing', async () => { const { engine, manager, cookie } = await signedInRig(); const getSession = () => manager.handleRequest(new Request(`${AUTH}/get-session`, { headers: { cookie } })); From 7ac7fd4730da1660503ff4a48b9e353a4bdb973e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:33:08 +0000 Subject: [PATCH 6/8] fix(plugin-hono-server): the current-user faces re-raise an authorization-store outage; the #13279 transport ledger classifies them The faces resolve through resolveAuthzContext since this branch, which puts them in that ruling's transport ledger: an unlisted transport reds the core suite's set-equality audit, and a guarded one re-raises the outage to the host's error seam instead of answering a signed-out body. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../security/authz-store-unavailable.test.ts | 1 + ...urrent-user-endpoints-authz-outage.test.ts | 87 +++++++++++++++++++ .../src/current-user-endpoints.ts | 16 +++- 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/plugin-hono-server/src/current-user-endpoints-authz-outage.test.ts diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index 53a9cf5651f..bbbc8397b29 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -186,6 +186,7 @@ const TRANSPORT_LEDGER: Record = { 'packages/services/service-settings/src/settings-service-plugin.ts': 'guarded', 'packages/services/service-storage/src/storage-service-plugin.ts': 'guarded', 'packages/plugins/plugin-sharing/src/sharing-plugin.ts': 'guarded', + 'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts': 'guarded', 'packages/cloud-connection/src/marketplace-install-local-plugin.ts': 'guarded', 'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded', }; diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-authz-outage.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-authz-outage.test.ts new file mode 100644 index 00000000000..9709117a20c --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-authz-outage.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#13279 / #24061] An authorization store that cannot be read is an OUTAGE on +// the current-user faces, never a signed-out caller. +// +// Since #24061 these faces resolve their caller through `resolveAuthzContext`, +// which places them in the #13279 ruling's transport ledger +// (`packages/core/src/security/authz-store-unavailable.test.ts`). The ruling: +// a transport's fail-closed net re-raises `AuthzStoreUnavailableError` rather +// than degrading it, because the degraded answer here — `{ authenticated: +// false }` — is byte-identical to a genuine anonymous caller. These cases pin +// the behaviour at the route, not the spelling the ledger greps for: +// +// - a permission-store read that FAILS escapes to the host's error seam as the +// branded outage (on `HonoHttpServer` that seam answers `503 +// SERVICE_UNAVAILABLE`); +// - CONTROL: a store that was never PROVISIONED (missing tables) is not an +// outage — the face still answers its authenticated, empty-grant body. + +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import { isAuthzStoreUnavailableError } from '@objectstack/core'; +import { registerCurrentUserEndpoints } from './current-user-endpoints'; + +const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; +const USER = 'usr_outage'; + +/** A data engine whose every read throws `fault`. */ +const failingQl = (fault: () => Error) => ({ + find: async () => { throw fault(); }, + registry: { getAllApps: () => [], getAllObjects: () => [] }, + getSchema: () => undefined, +}); + +function mount(fault: () => Error) { + const escaped: unknown[] = []; + const services: Record = { + auth: { + api: { + getSession: async () => ({ + user: { id: USER, email: 'outage@example.com' }, + session: { activeOrganizationId: null }, + }), + }, + }, + objectql: failingQl(fault), + }; + const app = new Hono(); + app.onError((err, c) => { + escaped.push(err); + return c.json({ escaped: true }, 503); + }); + registerCurrentUserEndpoints({ + rawApp: app, + ctx: { + logger: { debug() {}, warn() {} }, + getService: (name: string): T => { + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); + return services[name] as T; + }, + }, + }); + return { app, escaped }; +} + +describe('[#13279] the current-user faces re-raise an authorization-store outage', () => { + it('a failed permission-store read escapes as the branded outage, not as a signed-out body', async () => { + const { app, escaped } = mount(() => new Error('connection refused')); + + const res = await app.request(`http://localhost${ME_PERMISSIONS}`); + + expect(res.status).toBe(503); + expect(await res.json()).not.toMatchObject({ authenticated: false }); + expect(escaped).toHaveLength(1); + expect(isAuthzStoreUnavailableError(escaped[0])).toBe(true); + }); + + it('CONTROL — an unprovisioned store is not an outage: the face answers the signed-in caller', async () => { + const { app, escaped } = mount(() => new Error('no such table: sys_member')); + + const res = await app.request(`http://localhost${ME_PERMISSIONS}`); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ authenticated: true, userId: USER }); + expect(escaped).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 4274bfb89ca..5fc6c30deb7 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -45,6 +45,7 @@ import { IDataEngine, resolveAuthzContext, resolveLocalizationContext, + rethrowAuthzStoreUnavailable, userAuthGateSourceOf, type ApiExposureSchemaLike, type ManagedSchemaLike, @@ -502,7 +503,7 @@ export function makeExecutionContextResolver( // read `activeOrganizationId` before. Never throws for a missing // engine or an absent table — an empty-but-valid envelope // (cloud#927); a permission-store read that FAILS raises, and the - // `catch` below answers no context, as it did. + // `catch` below re-raises that outage (#13279). const authz = await resolveAuthzContext({ ql: getObjectQL(), headers: undefined, @@ -588,8 +589,17 @@ export function makeExecutionContextResolver( accessToken: undefined, authGate: undefined, }); - } catch { - return undefined; + } catch (err) { + // [#13279 — maintainer ruling 2026-08-30] Fail closed, with ONE + // exception: an authorization store that could not be read is an + // outage, not an anonymous caller. Since #24061 this face resolves + // through `resolveAuthzContext`, which puts it in that ruling's + // transport ledger (`authz-store-unavailable.test.ts` in core): the + // outage is re-raised to the host's error seam — `503 + // SERVICE_UNAVAILABLE` on `HonoHttpServer` — instead of answering + // `{ authenticated: false }`, a body byte-identical to a signed-out + // caller's. Every other fault still answers no context. + return rethrowAuthzStoreUnavailable(err); } }; return resolveCtx; From 6d7f066f3c88b84f4c3840963dc1b35ce44cccdf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:46:13 +0000 Subject: [PATCH 7/8] test(plugin-hono-server): pin each current-user route's outage answer on the real host seam (503 SERVICE_UNAVAILABLE); cite the ruling by its live record Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- ...urrent-user-endpoints-authz-outage.test.ts | 64 +++++++++---------- .../src/current-user-endpoints.ts | 20 +++--- 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-authz-outage.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-authz-outage.test.ts index 9709117a20c..6e747cb4a96 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-authz-outage.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-authz-outage.test.ts @@ -1,39 +1,41 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#13279 / #24061] An authorization store that cannot be read is an OUTAGE on +// [#24061] An authorization store that cannot be read is an OUTAGE on // the current-user faces, never a signed-out caller. // // Since #24061 these faces resolve their caller through `resolveAuthzContext`, -// which places them in the #13279 ruling's transport ledger -// (`packages/core/src/security/authz-store-unavailable.test.ts`). The ruling: +// which places them in the transport ledger of the 2026-08-30 maintainer +// ruling (commit 6a180e42d; its text is quoted in the header of +// `packages/core/src/security/authz-store-unavailable.test.ts`). The ruling: // a transport's fail-closed net re-raises `AuthzStoreUnavailableError` rather // than degrading it, because the degraded answer here — `{ authenticated: -// false }` — is byte-identical to a genuine anonymous caller. These cases pin -// the behaviour at the route, not the spelling the ledger greps for: +// false }`, or `{ apps: [] }` — is byte-identical to a genuine anonymous +// caller's. These cases pin what each route ANSWERS, on the real host seam +// (`HonoHttpServer`'s error envelope), not the spelling the ledger greps for: // -// - a permission-store read that FAILS escapes to the host's error seam as the -// branded outage (on `HonoHttpServer` that seam answers `503 -// SERVICE_UNAVAILABLE`); +// - a permission-store read that FAILS answers `503` with the declared +// `SERVICE_UNAVAILABLE` code on all three routes; // - CONTROL: a store that was never PROVISIONED (missing tables) is not an -// outage — the face still answers its authenticated, empty-grant body. +// outage — the face still answers its signed-in caller. import { describe, it, expect } from 'vitest'; -import { Hono } from 'hono'; -import { isAuthzStoreUnavailableError } from '@objectstack/core'; +import { HonoHttpServer } from './adapter'; import { registerCurrentUserEndpoints } from './current-user-endpoints'; -const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; const USER = 'usr_outage'; +const ROUTES = ['/api/v1/auth/me/permissions', '/api/v1/auth/me/localization', '/api/v1/me/apps'] as const; -/** A data engine whose every read throws `fault`. */ -const failingQl = (fault: () => Error) => ({ - find: async () => { throw fault(); }, +/** A data engine whose every read throws `fault(object)`. */ +const failingQl = (fault: (object: string) => Error) => ({ + find: async (object: string) => { throw fault(object); }, registry: { getAllApps: () => [], getAllObjects: () => [] }, getSchema: () => undefined, }); -function mount(fault: () => Error) { - const escaped: unknown[] = []; +function mount(fault: (object: string) => Error) { + const server = new HonoHttpServer(0); + const quiet = { debug() {}, info() {}, warn() {}, error() {}, fatal() {}, child() { return quiet; } }; + server.setLogger(quiet as never); const services: Record = { auth: { api: { @@ -45,13 +47,8 @@ function mount(fault: () => Error) { }, objectql: failingQl(fault), }; - const app = new Hono(); - app.onError((err, c) => { - escaped.push(err); - return c.json({ escaped: true }, 503); - }); registerCurrentUserEndpoints({ - rawApp: app, + rawApp: server.getRawApp(), ctx: { logger: { debug() {}, warn() {} }, getService: (name: string): T => { @@ -60,28 +57,27 @@ function mount(fault: () => Error) { }, }, }); - return { app, escaped }; + return (path: string) => server.getRawApp().fetch(new Request(`http://localhost${path}`)); } -describe('[#13279] the current-user faces re-raise an authorization-store outage', () => { - it('a failed permission-store read escapes as the branded outage, not as a signed-out body', async () => { - const { app, escaped } = mount(() => new Error('connection refused')); +describe('[#24061] the current-user faces answer an authorization-store outage as one', () => { + it.each(ROUTES)('%s: a failed permission-store read answers 503 SERVICE_UNAVAILABLE', async (route) => { + const request = mount(() => new Error('connection refused')); - const res = await app.request(`http://localhost${ME_PERMISSIONS}`); + const res = await request(route); + const body: any = await res.json(); expect(res.status).toBe(503); - expect(await res.json()).not.toMatchObject({ authenticated: false }); - expect(escaped).toHaveLength(1); - expect(isAuthzStoreUnavailableError(escaped[0])).toBe(true); + expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE'); }); it('CONTROL — an unprovisioned store is not an outage: the face answers the signed-in caller', async () => { - const { app, escaped } = mount(() => new Error('no such table: sys_member')); + // The spelling SQLite answers, naming the very table each read asked for. + const request = mount((object) => new Error(`no such table: ${object}`)); - const res = await app.request(`http://localhost${ME_PERMISSIONS}`); + const res = await request(ROUTES[0]); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ authenticated: true, userId: USER }); - expect(escaped).toEqual([]); }); }); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 5fc6c30deb7..c0e104d2cae 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -503,7 +503,7 @@ export function makeExecutionContextResolver( // read `activeOrganizationId` before. Never throws for a missing // engine or an absent table — an empty-but-valid envelope // (cloud#927); a permission-store read that FAILS raises, and the - // `catch` below re-raises that outage (#13279). + // `catch` below re-raises that outage. const authz = await resolveAuthzContext({ ql: getObjectQL(), headers: undefined, @@ -590,15 +590,17 @@ export function makeExecutionContextResolver( authGate: undefined, }); } catch (err) { - // [#13279 — maintainer ruling 2026-08-30] Fail closed, with ONE - // exception: an authorization store that could not be read is an - // outage, not an anonymous caller. Since #24061 this face resolves - // through `resolveAuthzContext`, which puts it in that ruling's - // transport ledger (`authz-store-unavailable.test.ts` in core): the - // outage is re-raised to the host's error seam — `503 + // [commit 6a180e42d — maintainer ruling 2026-08-30, quoted verbatim + // in the header of core's `authz-store-unavailable.test.ts`] Fail + // closed, with ONE exception: an authorization store that could + // not be read is an outage, not an anonymous caller, at every + // transport that authorizes through `resolveAuthzContext`. Since + // #24061 this face is one, so it is in that test's transport + // ledger: the outage is re-raised to the host's error seam — `503 // SERVICE_UNAVAILABLE` on `HonoHttpServer` — instead of answering - // `{ authenticated: false }`, a body byte-identical to a signed-out - // caller's. Every other fault still answers no context. + // `{ authenticated: false }` or `{ apps: [] }`, bodies + // byte-identical to a signed-out caller's. Every other fault still + // answers no context. return rethrowAuthzStoreUnavailable(err); } }; From 978ce6133f3eb9332b3aae99564553d99803540f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:51:40 +0000 Subject: [PATCH 8/8] docs(changeset): the current-user routes' outage answer Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .changeset/24061-auth-prelude-queries.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/24061-auth-prelude-queries.md b/.changeset/24061-auth-prelude-queries.md index ce96388b34b..fcf06d2a0d5 100644 --- a/.changeset/24061-auth-prelude-queries.md +++ b/.changeset/24061-auth-prelude-queries.md @@ -18,6 +18,7 @@ Measured on a real engine (SQLite through `@objectstack/driver-sql`), for a sign - **An in-process session read no longer signs a JWT.** better-auth runs `/get-session`'s after-hooks for a server-side `auth.api.getSession(...)` call too, so every door that asked "who is calling" had the jwt plugin read the signing key (two `sys_jwks` round trips) and sign a `set-auth-jwt` header the caller then discarded. The hook now runs only when the call answers an HTTP request. `GET /api/v1/auth/get-session` still carries `set-auth-jwt`, signed exactly as before. A server-side caller that wants the header dispatches the request through `AuthManager.handleRequest`. - **The session token is not read back after the session was found by it.** `sys_session.token` is withheld from ordinary reads and recovered for better-auth through a privileged read. better-auth finds the session by its token, so that read had nothing to add. It was a second round trip between the session row and the user row on every session read. A read keyed on any other column still recovers the token. - **`/auth/me/*` and `/me/apps` resolve the grants once.** These routes resolved the caller's grants twice per request: once inside the session read (for the payload's `positions[]`) and once more for their own context. They now resolve through `resolveAuthzContext`, whose request-scoped memo serves the second resolution from the first while no write has landed in between. The memo ends with the request. The routes still admit a session only, and the session's active organization stands as before. +- **An unreadable authorization store is an outage on `/auth/me/*` and `/me/apps`, not a signed-out caller.** Because these routes now authorize through `resolveAuthzContext`, they follow the rule every transport through it follows: a permission-store read that fails answers `503` with the code `SERVICE_UNAVAILABLE` (on `HonoHttpServer`'s error seam) instead of `{ authenticated: false }` or `{ apps: [] }`, which a client cannot tell from an anonymous caller. A store whose tables were never created is not an outage: those routes answer the signed-in caller with no grants, as before. - **The signing key is held across requests, under two bounds.** The keyring the jwt plugin signs with is kept after a read. Any write to `sys_jwks` that passes this process's engine drops it at once, and it is never kept longer than 30 seconds, which bounds a write this process cannot see (another node minting a key). An engine without a middleware seam keeps nothing. On a host without Ed25519 the keyring is still filtered to keys the host can use. -Unchanged: the session and user every door resolves, every grant, the `/get-session` body and its renewal, every option and environment variable, and the exports. No new configuration key. +Unchanged: the session and user every door resolves, every grant, the `/get-session` body and its renewal, every option and environment variable, and the exports. No new configuration key. The one answer that changes is the outage answer above.