Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/24061-auth-prelude-queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@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.
- **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. The one answer that changes is the outage answer above.
1 change: 1 addition & 0 deletions packages/core/src/security/authz-store-unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ const TRANSPORT_LEDGER: Record<string, 'guarded' | 'propagates'> = {
'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',
};
Expand Down
41 changes: 29 additions & 12 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ import {
protectGetSessionJwtHook,
resolveJwtSigningAlgorithm,
} from './jwt-key-algorithm.js';
import { createSigningKeyReader } from './signing-key-read-cache.js';

/**
* Detect WebContainer (StackBlitz) environment.
Expand Down Expand Up @@ -2996,13 +2997,20 @@ 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`.
* `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<any> {
if (!this.ed25519ProbeResult) this.ed25519ProbeResult = probeEd25519Support();
Expand All @@ -3026,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) =>
Expand All @@ -3043,7 +3059,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.',
);
}
Expand Down
Loading
Loading