Skip to content

Commit f792290

Browse files
perf(plugin-auth, plugin-hono-server): an authenticated request reads 8 rows in 4 round trips before its handler, down from 11 in 7; /auth/me/* resolves grants once (#24185)
Part of #24061 — four of the five Done-when items land here. The remaining half is item 3's "one read (or one round)": this PR takes the session and user rows from 3 serial reads to 2, and one round trip is not reachable on this engine without a join primitive. That is a fork, set out in the report on the card. Clause-②: no ## What a request paid before its handler, and what it pays now Measured on a real `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, this plugin's identity objects, the grant tables `SecurityPlugin` declares, the activation ledger and a real `AuthManager`. The user is signed in by cookie, and their session has an active organization backed by a `sys_member` row. Each driver call is recorded below the engine and yields one macrotask, so reads issued together are in flight together. That gives the round-trip count, the same leg definition core's batch-equivalence test kit uses. The engine was warm (core's memo observer was already registered). BASE is `b0fe9fad4`. The BASE readings match the card's 11 and 17 exactly. **Shared prelude** (`resolveAuthzContext` with the in-process `auth.api.getSession`, as the REST door and the runtime dispatcher call it): | round trip | BASE `b0fe9fad4` | this PR | |:--|:--|:--| | 1 | `sys_session` by token | `sys_session` by token | | 2 | `sys_session` `token` readback (`select id, token … where id in (…)`) | `sys_user` (the session's user) | | 3 | `sys_user` (the session's user) | grant wave: `sys_user`, `sys_member` ×2, `sys_user_position`, `sys_user_permission_set` | | 4 | grant wave (same 5 reads) | `sys_metadata_activation` | | 5 | `sys_metadata_activation` | — | | 6 | `sys_jwks` key rows (jwt `/get-session` after-hook) | — | | 7 | `sys_jwks` `private_key` readback | — | | **total** | **11 reads, 7 round trips** | **8 reads, 4 round trips** | **`/auth/me/*` and `/me/apps`** (`makeExecutionContextResolver`): 17 reads in 9 round trips at BASE (the 11 above, plus a second grant wave and ledger read) → 8 in 4. `/auth/me/localization` adds its own one localization read on top, unchanged (18 → 9). **HTTP `GET /api/v1/auth/get-session`**: 11 in 7 at BASE → 10 in 6 when it reads the signing key, 8 in 4 while the key is held. It still carries `set-auth-jwt`. ## What changed 1. **An in-process principal read does not run the JWT after-hook** (`plugin-auth/src/jwt-key-algorithm.ts`, `protectGetSessionJwtHook`). better-auth runs a path's after-hooks for a server-side `auth.api.*` call exactly as for a routed request (`dist/api/dispatch.mjs`). So every in-process session read signed a `set-auth-jwt` header that landed on its own discarded response. The guard that already wraps that hook's handler now also narrows its matcher, to a dispatch that answers an HTTP request. The test is the dispatch's `request`, which better-call's router always passes and `inProcessSessionReadInput` callers never do. It mirrors better-auth's own `isRequestLike`. Measured alternatives: `disableSettingJwtHeader` is global, so it would drop the HTTP header too. Calling the raw endpoint skips every hook, but it needs an `AuthContext` that none of the ~20 in-process callers hold. 2. **The signing key is cached** (`plugin-auth/src/signing-key-read-cache.ts`, new and module-internal, wired through jwt's `adapter.getJwks` on every host). Both halves of #11633's rule are met. (a) A middleware on `sys_jwks` counts every write that enters the engine and every write that settles. The held keyring is served only while both counters are unchanged since its read opened and no write is in flight, so a write retires it the moment it starts, and a read that overlapped a write is never held. (b) `SIGNING_KEY_CACHE_TTL_MS` = 30 s, a constant and not a config key, bounds writes this process cannot see. An engine without `registerMiddleware` holds nothing. On a host without Ed25519 the miss still reads through `createHostUsableJwksReader`. The seat's premise, measured: after item 1, in-process reads read `sys_jwks` zero times, so the key is read only where a JWT is really signed. 3. **The session row's `token` is answered from the lookup** (`plugin-auth/src/internal-field-readback.ts`, `objectql-adapter.ts`). better-auth finds the session by `where token = …`, so the privileged readback of `token` had nothing to add. A read keyed on that column alone (one equality, not case-insensitive, a non-empty string) attaches the lookup value. Every other shape recovers through the accessor as before. A cookie's token is the HMAC-signed value the server issued, byte for byte. With no join primitive in the engine (`query.joins` retired, ADR-0049; `expand` resolves by a second batch read), session → user remains two serial reads. 4. **Grants are memoized per request on `/auth/me/*` and `/me/apps`** (`plugin-hono-server/src/current-user-endpoints.ts`). The second resolution was NOT in `packages/runtime` (the PM's hypothesis, falsified: runtime registers no `/me/*` route). It was in `makeExecutionContextResolver`, which called `resolveUserAuthzGrants` outside any request scope after the session read's `customSession` hook had already resolved the same grants. It now resolves through `resolveAuthzContext`, whose existing request-scoped memo serves the second resolution from the first, and the memo dies with the call. The face keeps its semantics. It is still session-only: `headers: undefined`, so no API-key admission runs, and the session is read by the face's own closure. No tenancy posture is passed, so the session's organization claim stands as before. `resolve-authz-context.ts` and `security-catalog.ts` are not touched. 5. **A test counts the queries of one authenticated request's prelude** (`plugin-auth/src/auth-prelude-query-count.pin.test.ts`): exact per-object counts and round trips for the shared prelude and the `/auth/me/*` resolver, the HTTP `/get-session` header, and the held keyring. Two more pins: an in-process read signs nothing even when it asks for headers, and a `sys_jwks` write through the engine is seen on the very next signing (new `kid`, one key minted). Landing sites: `packages/plugins/plugin-auth` (declared) and `packages/plugins/plugin-hono-server` (the measured producer of the me/* double resolution, outside the claim's listed surface: the seat declares the cross-lane). `packages/rest/src/rest-server.ts` and `packages/runtime` are untouched. 6. **The current-user faces answer an authorization-store outage as one** (seat's append, from patch round 1). Resolving through `resolveAuthzContext` puts `makeExecutionContextResolver` in the transport ledger of the 2026-08-30 ruling (commit `6a180e42d`; core's `authz-store-unavailable.test.ts`). Its fail-closed catch re-raises the outage (`rethrowAuthzStoreUnavailable`). So `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` answer `503 SERVICE_UNAVAILABLE` on a failed permission-store read instead of a body identical to a signed-out caller's. An unprovisioned store still answers the signed-in caller. Pinned by `current-user-endpoints-authz-outage.test.ts`. The ledger row is the one `packages/core` edit (`domain:engine`). ## Tests - `pnpm --filter @objectstack/plugin-auth exec vitest run` at `3e1faaeaf`: 155 files passed, 3062 tests passed, 10 skipped (the lock's verdict line: command-exit 0). Includes `session-of-record.test.ts`, green. - `pnpm --filter @objectstack/plugin-hono-server exec vitest run`: 30 files, 386 tests passed. - `pnpm --filter @objectstack/plugin-auth --filter @objectstack/plugin-hono-server run typecheck`: exit 0 (both packages' test layers compile; the plugin-auth debt ledger is unchanged). - `session-grants-resolved-once.test.ts` pinned that the first in-process read minted the signing key inside the request, and that the memo declined across that write. That premise is gone, since in-process reads no longer sign. The decline is now pinned across the write a session read still makes inside the request: the ADR-0069 D4 idle-timeout heartbeat. **Ablations** (each through `scripts/ablation-replace.mjs` in WRAP mode, anchor 1 → 0 on disk, restored with blob == HEAD and `git diff HEAD` empty; subjects resolve to source, so no build was involved): | removed work put back | red | |:--|:--| | jwt hook matcher no longer checks the request | the matcher unit case; "an in-process session read signs nothing"; the HTTP keyring-read count | | lookup-key attach deleted (token readback returns) | `sys_session: 2` in all three counted pins; the lookup unit case | | me/* face resolves the grants again outside the memo | the me/* resolver count (`sys_user_position` 2, `sys_member` 4, ledger 2) | | held keyring ignores the write counters | unit (a); "seen on the very next signing" (3 key rows, a key minted per signature); the HTTP keyring-read count | The first me/* ablation was refused by the tool as a no-op: the replacement contained the anchor, so the count stayed 1 → 1. It was re-run with a non-overlapping replacement, and that run is the one reported. **Local gates**, derived by `node scripts/pm/dispatch-gates.mjs --commands` at `3e1faaeaf` (13 paths, +971 / −74): 69 families. 68 exit 0. `pnpm check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET: it reads every package's `dist/`, and this worktree built only the closure). That is NOT MEASURED locally, and CI's `Lint & Repo Gates` measures it. `--ran` reconciliation: 69 derived, 68 run, 1 NOT-MEASURED, 0 UNRUN. **Clause-②**: the built `dist/index.d.ts` and `index.d.mts` of both packages, BASE vs this head, are identical once comments are stripped (TypeScript printer, `removeComments`). The control, a one-type change, reads DIFFERENT. No export, no config key and no schema change. ## Acceptance notes - When an ADR-0069 gate is active, `rest-server.ts` re-reads the session for the gate after `resolveAuthzContext`. That is a full second session read outside the memo scope, with its own `customSession` grant resolution. It is not in this card's Done-when, and it is not changed here. - The grant resolver's own `sys_user` read repeats the session's user row in the same request. That read is in `resolve-authz-context.ts`, #15194's declared territory, so it is not touched. - The in-process `/get-session` re-dispatches through `handleRequest` (SSO provider registration, send-verification-email) still sign a header they discard. They are rare user actions, not per-request. - **Seat's append, from the dev's report (read-only inference, not filed):** `packages/runtime/src/security/resolve-session-principal.ts` says the inbound rate limiter runs a full in-process session read on every request. With this PR that read no longer signs a JWT, but it still runs `customSession`'s grant resolution outside any memo scope. Whether the limiter is on by default was not measured. - **Seat's append:** the verifier's TTL-only JWKS cache (`verifyMcpAccessToken`) is filed as #24186. --- _Generated by [Claude Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f963d8e commit f792290

15 files changed

Lines changed: 1070 additions & 76 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/plugin-hono-server': patch
4+
---
5+
6+
Clause-②: no
7+
8+
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
9+
10+
Measured on a real engine (SQLite through `@objectstack/driver-sql`), for a signed-in user whose session has an active organization:
11+
12+
| before the handler | before | after |
13+
|:--|:--|:--|
14+
| REST and dispatcher doors (`resolveAuthzContext` with the in-process session read) | 11 reads, 7 round trips | 8 reads, 4 round trips |
15+
| `GET /auth/me/permissions`, `/auth/me/localization`, `/me/apps` | 17 reads, 9 round trips | 8 reads, 4 round trips |
16+
| `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 |
17+
18+
- **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`.
19+
- **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.
20+
- **`/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.
21+
- **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.
22+
- **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.
23+
24+
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.

‎packages/core/src/security/authz-store-unavailable.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ const TRANSPORT_LEDGER: Record<string, 'guarded' | 'propagates'> = {
186186
'packages/services/service-settings/src/settings-service-plugin.ts': 'guarded',
187187
'packages/services/service-storage/src/storage-service-plugin.ts': 'guarded',
188188
'packages/plugins/plugin-sharing/src/sharing-plugin.ts': 'guarded',
189+
'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts': 'guarded',
189190
'packages/cloud-connection/src/marketplace-install-local-plugin.ts': 'guarded',
190191
'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded',
191192
};

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

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ import {
191191
protectGetSessionJwtHook,
192192
resolveJwtSigningAlgorithm,
193193
} from './jwt-key-algorithm.js';
194+
import { createSigningKeyReader } from './signing-key-read-cache.js';
194195

195196
/**
196197
* Detect WebContainer (StackBlitz) environment.
@@ -3012,13 +3013,20 @@ export class AuthManager {
30123013
* 2. **Existing EdDSA keys.** `resolveSigningKey` falls back to *any* stored
30133014
* key when none matches the configured algorithm, so a deployment that
30143015
* already minted an EdDSA key would still die in `importJWK` after the
3015-
* fallback. On a host without Ed25519 we install better-auth's
3016-
* `adapter.getJwks` keyring seam so unusable keys are not offered at all
3017-
* and a fresh ES256 key is minted instead. Rows are hidden, never
3018-
* deleted — moving back to a host with Ed25519 restores them.
3016+
* fallback. On a host without Ed25519 the `adapter.getJwks` keyring seam
3017+
* hides unusable keys, so they are not offered at all and a fresh ES256
3018+
* key is minted instead. Rows are hidden, never deleted — moving back to
3019+
* a host with Ed25519 restores them.
3020+
*
3021+
* The same seam carries the signing-key read cache on every host (#24061,
3022+
* `signing-key-read-cache.ts`): the keyring is held across requests only
3023+
* until a `sys_jwks` write passes the engine or its TTL lapses.
30193024
*
30203025
* Belt-and-braces, on every host: a signing failure degrades the
3021-
* `set-auth-jwt` header instead of 500ing `/get-session`.
3026+
* `set-auth-jwt` header instead of 500ing `/get-session`, and the header is
3027+
* signed only for an HTTP `/get-session` — an in-process `auth.api.getSession`
3028+
* read discards it, so it no longer pays the key read and the signature
3029+
* (#24061; see `protectGetSessionJwtHook`).
30223030
*/
30233031
private async buildJwtPlugin(jwt: (options: any) => any): Promise<any> {
30243032
if (!this.ed25519ProbeResult) this.ed25519ProbeResult = probeEd25519Support();
@@ -3042,12 +3050,20 @@ export class AuthManager {
30423050
const jwtPlugin = jwt({
30433051
schema: buildJwtPluginSchema(),
30443052
jwks: { keyPairConfig },
3045-
// The keyring override is installed ONLY on a host that needs it, so a
3046-
// normal deployment runs better-auth's stock read path with no
3047-
// ObjectStack code in it and nothing to regress.
3048-
...(ed25519Supported
3049-
? {}
3050-
: { adapter: { getJwks: createHostUsableJwksReader() } }),
3053+
// [#24061] The keyring read goes through `createSigningKeyReader` on
3054+
// every host: it holds the rows across requests only while no
3055+
// `sys_jwks` write has passed this engine's middleware since they were
3056+
// read, and never past `SIGNING_KEY_CACHE_TTL_MS` — both halves of the
3057+
// cross-request caching rule (see `signing-key-read-cache.ts`). A miss
3058+
// reads exactly what it read before: better-auth's stock read, or, on a
3059+
// host without Ed25519, the host-usable keyring that hides keys this
3060+
// host cannot import (#3585).
3061+
adapter: {
3062+
getJwks: createSigningKeyReader({
3063+
engine: this.config.dataEngine,
3064+
read: ed25519Supported ? undefined : createHostUsableJwksReader(),
3065+
}),
3066+
},
30513067
});
30523068

30533069
const protectedHook = protectGetSessionJwtHook(jwtPlugin, (error) =>
@@ -3059,7 +3075,8 @@ export class AuthManager {
30593075
console.error(
30603076
'[AuthManager] Could not attach the JWT signing guard to better-auth\'s /get-session hook ' +
30613077
'(its `hooks.after` shape changed). A JWT signing failure will now 500 every /get-session ' +
3062-
'instead of degrading to a missing set-auth-jwt header. Re-check ' +
3078+
'instead of degrading to a missing set-auth-jwt header, and every in-process session read ' +
3079+
'signs a JWT it discards (two sys_jwks reads per authenticated request). Re-check ' +
30633080
'the better-auth version in better-auth-schema-parity.test.ts.',
30643081
);
30653082
}

0 commit comments

Comments
 (0)