diff --git a/.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md b/.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md new file mode 100644 index 00000000000..f7c03b9437d --- /dev/null +++ b/.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md @@ -0,0 +1,22 @@ +--- +'@objectstack/types': minor +'@objectstack/rest': patch +'@objectstack/runtime': patch +'@objectstack/plugin-hono-server': patch +'@objectstack/cloud-connection': patch +--- + +fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + +**What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + +**The rule now, decided by what the request carries.** + +- **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. +- **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + +**Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + +`@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + +Not changed here: the in-process readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` still renew a cookie session without re-issuing its cookie. diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts index 22cf214415d..b749e06e2f3 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -78,6 +78,7 @@ interface Plugin { } import { hostname } from 'node:os'; +import { inProcessSessionReadInput } from '@objectstack/types'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js'; @@ -202,7 +203,10 @@ export class CloudConnectionPlugin implements Plugin { const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<{ userId?: string } | null> => { const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc; - const session = await api?.getSession?.({ headers: rawReq.headers }); + // [#22258] The in-process session-read rule: a request carrying a + // session cookie reads without renewal, because this route's + // response never carries a renewed cookie. + const session = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers)); const userId = session?.user?.id ? String(session.user.id) : undefined; return userId ? { userId } : null; }; diff --git a/packages/cloud-connection/src/in-process-session-read.pin.test.ts b/packages/cloud-connection/src/in-process-session-read.pin.test.ts new file mode 100644 index 00000000000..7872778eba0 --- /dev/null +++ b/packages/cloud-connection/src/in-process-session-read.pin.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] All three of this package's in-process `auth.api.getSession` + * readers hand better-auth the in-process session-read rule's input + * (`inProcessSessionReadInput`, `@objectstack/types`): + * + * ① `CloudConnectionPlugin`'s session bridge behind `/api/v1/cloud-connection/*`; + * ② `MarketplaceInstallLocalPlugin.resolveActiveOrgId` (the scoping read); + * ③ `MarketplaceInstallLocalPlugin.resolveInstallPrincipal`'s session getter, + * handed to `resolveAuthzContext` (the admission read). + * + * A request carrying a session cookie reads with `query.disableRefresh`: these + * routes answer with their own response, so a renewal here would move the + * session's expiry while its renewed cookie is discarded. A bearer-only request + * reads exactly as before, renewal included. What `disableRefresh` then DOES + * against real better-auth is pinned end to end in + * `packages/runtime/src/in-process-session-renewal.pin.test.ts`. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; + +const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; +const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; +const USER = 'usr_22258'; + +type Shape = { name: string; headers: Record }; +const COOKIE: Shape = { name: 'a session cookie', headers: { cookie: SESSION_COOKIE } }; +const BOTH: Shape = { name: 'cookie AND bearer (the console)', headers: { cookie: SESSION_COOKIE, authorization: BEARER } }; +const BEARER_ONLY: Shape = { name: 'a bearer only', headers: { authorization: BEARER } }; + +/** An auth service whose session API records every input it is handed. */ +function recordingAuth() { + const calls: any[] = []; + return { + calls, + service: { + api: { + getSession: async (input: any) => { + calls.push(input); + return { user: { id: USER }, session: { activeOrganizationId: 'org_22258' } }; + }, + }, + }, + }; +} + +/** The rule, stated once: a cookie request never renews in-process; a bearer-only one is untouched. */ +function expectTheRule(calls: any[], shape: Shape) { + expect(calls.length, `${shape.name}: the reader never ran`).toBeGreaterThan(0); + for (const input of calls) { + if (shape.headers.cookie) { + expect(input.query, `${shape.name}: a cookie request renewed in-process`).toEqual({ disableRefresh: true }); + } else { + expect('query' in input, `${shape.name}: a bearer-only read lost its renewal`).toBe(false); + } + const h = input.headers; + expect(h.get('authorization') ?? undefined).toBe(shape.headers.authorization); + expect(h.get('cookie') ?? undefined).toBe(shape.headers.cookie); + } +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('[#22258] ① the cloud-connection routes read the session by the in-process rule', () => { + async function readInstalled(shape: Shape) { + const routes = new Map Promise>(); + const rawApp = { + get: (path: string, h: any) => routes.set(`GET ${path}`, h), + post: (path: string, h: any) => routes.set(`POST ${path}`, h), + }; + const auth = recordingAuth(); + const hooks = new Map any>(); + const ctx = { + hook: (event: string, handler: (...args: any[]) => any) => hooks.set(event, handler), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (name === 'auth') return auth.service; + throw new Error(`service ${name} not registered`); + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + // No control-plane credential: the route answers locally after the + // session read, so nothing leaves the process. + await new CloudConnectionPlugin({ singleEnvironment: true, environmentId: 'env-22258', controlPlaneUrl: '' }) + .start(ctx as any); + await hooks.get('kernel:ready')?.(); + const url = 'http://localhost:3000/api/v1/cloud-connection/installed'; + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + const res = await routes.get('GET /api/v1/cloud-connection/installed')!({ + req: { url, raw: new Request(url, { headers: shape.headers }), json: async () => ({}) }, + json, + }); + return { res, calls: auth.calls }; + } + + for (const shape of [COOKIE, BOTH, BEARER_ONLY]) { + it(`${shape.name}`, async () => { + const { res, calls } = await readInstalled(shape); + expect(res.status, 'the session resolved — the route answered past its 401').toBe(200); + expectTheRule(calls, shape); + }); + } +}); + +describe('[#22258] ② ③ the install-local doors read the session by the in-process rule', () => { + function pluginWith(shape: Shape) { + const auth = recordingAuth(); + const ctx: any = { + getService: (name: string) => { + if (name === 'auth') return auth.service; + throw new Error(`service ${name} not registered`); + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + const c = { req: { raw: new Request('http://localhost/api/v1/marketplace/install-local', { headers: shape.headers }) } }; + return { plugin: new MarketplaceInstallLocalPlugin() as any, ctx, c, calls: auth.calls }; + } + + for (const shape of [COOKIE, BOTH, BEARER_ONLY]) { + it(`② resolveActiveOrgId — ${shape.name}`, async () => { + const { plugin, ctx, c, calls } = pluginWith(shape); + expect(await plugin.resolveActiveOrgId(c, ctx), 'the scoping read resolved the session').toBe('org_22258'); + expectTheRule(calls, shape); + }); + + it(`③ resolveInstallPrincipal — ${shape.name}`, async () => { + const { plugin, ctx, c, calls } = pluginWith(shape); + const principal = await plugin.resolveInstallPrincipal(c, ctx); + expect(principal?.userId, 'the admission read resolved the session').toBe(USER); + expectTheRule(calls, shape); + }); + } +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 083c38d6e8a..7f0b160b747 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -98,6 +98,8 @@ import { postureGatesGlobalUniques, GLOBAL_UNIQUE_CONFIRMATION_REQUIRED, type GlobalUniqueFinding, + // [#22258] The in-process session-read rule — both session reads below. + inProcessSessionReadInput, } from '@objectstack/types'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { ManifestSchema, manifestIdRefusal } from '@objectstack/spec/kernel'; @@ -2617,7 +2619,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { let api: any = authService?.api; if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); if (!api?.getSession) return null; - const session = await api.getSession({ headers: c.req.raw.headers }); + // [#22258] A request carrying a session cookie reads without + // renewal: this route's response never carries a renewed cookie. + const session = await api.getSession(inProcessSessionReadInput(c.req.raw.headers)); const direct = session?.session?.activeOrganizationId ?? session?.activeOrganizationId ?? null; if (direct) return String(direct); } catch { /* ignore */ } @@ -2785,7 +2789,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (!api && typeof authService?.getApi === 'function') { api = await authService.getApi(); } - return await api?.getSession?.({ headers: h }); + // [#22258] The in-process session-read rule, as in + // `resolveActiveOrgId` above. + return await api?.getSession?.(inProcessSessionReadInput(h)); } catch { return undefined; } 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 3f5a36b42ec..74dc764f84c 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -57,6 +57,7 @@ import type { Logger, } from '@objectstack/spec/contracts'; import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; +import { inProcessSessionReadInput } from '@objectstack/types'; /** API prefix these endpoints mount under unless the host overrides it. */ export const DEFAULT_CURRENT_USER_PREFIX = '/api/v1'; @@ -403,7 +404,12 @@ export function makeExecutionContextResolver( api = await authService.getApi(); } if (!api?.getSession) return undefined; - const session = await api.getSession({ headers: c.req.raw.headers }); + // [#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 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; diff --git a/packages/plugins/plugin-hono-server/src/in-process-session-read.pin.test.ts b/packages/plugins/plugin-hono-server/src/in-process-session-read.pin.test.ts new file mode 100644 index 00000000000..e5c1d367ac5 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/in-process-session-read.pin.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] The current-user endpoints' in-process `auth.api.getSession` read + * (`makeExecutionContextResolver`, behind `GET /api/v1/auth/me/permissions` and + * its siblings) hands better-auth the in-process session-read rule's input + * (`inProcessSessionReadInput`, `@objectstack/types`). + * + * A request carrying a session cookie reads with `query.disableRefresh`: these + * routes answer with their own response, so a renewal here would move the + * session's expiry while its renewed cookie is discarded. A bearer-only request + * reads exactly as before, renewal included. What `disableRefresh` then DOES + * against real better-auth is pinned end to end through this door in + * `packages/runtime/src/in-process-session-renewal.pin.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import { registerCurrentUserEndpoints } from './current-user-endpoints'; + +const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; +const USER = 'usr_22258'; +const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; +const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; + +function mount() { + const calls: any[] = []; + const services: Record = { + auth: { + api: { + getSession: async (input: any) => { + calls.push(input); + return { user: { id: USER }, session: {} }; + }, + }, + }, + objectql: { find: async () => [], registry: { getAllApps: () => [], getAllObjects: () => [] } }, + metadata: { list: async () => [] as unknown[] }, + security: { resolvePermissionSetsForContext: async () => [] }, + }; + const app = new Hono(); + 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, calls }; +} + +async function readMePermissions(headers: Record) { + const { app, calls } = mount(); + const res = await app.request(`http://localhost${ME_PERMISSIONS}`, { headers }); + return { calls, status: res.status, body: (await res.json()) as any }; +} + +describe('[#22258] the current-user endpoints read the session by the in-process rule', () => { + it('a request carrying a session cookie reads without renewal', async () => { + const { calls, status } = await readMePermissions({ cookie: SESSION_COOKIE }); + expect(status, 'the fixture resolves the caller — the door really ran').toBe(200); + expect(calls.length).toBeGreaterThan(0); + for (const input of calls) { + expect(input.query, 'a cookie request renewed in-process').toEqual({ disableRefresh: true }); + expect(input.headers.get('cookie')).toBe(SESSION_COOKIE); + } + }); + + it('the console sends cookie AND bearer — still no renewal in-process', async () => { + const { calls } = await readMePermissions({ cookie: SESSION_COOKIE, authorization: BEARER }); + expect(calls.length).toBeGreaterThan(0); + for (const input of calls) expect(input.query).toEqual({ disableRefresh: true }); + }); + + it('a bearer-only request reads exactly as before — renewal stays on, no query at all', async () => { + const { calls, status } = await readMePermissions({ authorization: BEARER }); + expect(status).toBe(200); + expect(calls.length).toBeGreaterThan(0); + for (const input of calls) { + expect('query' in input, 'a bearer-only read lost its renewal').toBe(false); + expect(input.headers.get('authorization')).toBe(BEARER); + } + }); +}); diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index f29e9b10be5..d3514601e8a 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -257,7 +257,10 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/); // The re-read is loud, and it is the RAW api call — ⛔ not the swallowing // `getSession` closure, which would re-collapse the very same two facts. - expect(body).toMatch(/gatedSession = await api\.getSession\(\{ headers \}\);/); + // [#22258] Its argument is the in-process session-read rule's input (a + // cookie request reads without renewal); the call itself is still the raw, + // throwing one this pin exists for. + expect(body).toMatch(/gatedSession = await api\.getSession\(inProcessSessionReadInput\(headers\)\);/); expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/); // ⛔ NARROWNESS CONTROL: the probe-throws leg must STAY absorbed — a host // whose probe faults never declared a gate. If this ever flips, the repair diff --git a/packages/rest/src/in-process-session-read.pin.test.ts b/packages/rest/src/in-process-session-read.pin.test.ts new file mode 100644 index 00000000000..2af0a7bb8bc --- /dev/null +++ b/packages/rest/src/in-process-session-read.pin.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] Both of this package's in-process `auth.api.getSession` readers hand + * better-auth the in-process session-read rule's input + * (`inProcessSessionReadInput`, `@objectstack/types`): + * + * ① `computeExecCtx`'s session getter, handed to `resolveAuthzContext`; + * ② the auth-gate re-read below it (reached only when a gate is active, so + * the fixture's auth service declares one). + * + * A request carrying a session cookie reads with `query.disableRefresh` — the + * door's response never carries a renewed cookie, so a renewal here would leave + * the browser's cookie to die before its session. A bearer-only request reads + * exactly as before, renewal included. What `disableRefresh` then DOES against + * real better-auth (an aged session's `sys_session.expires_at` read back) is + * pinned end to end through this door in + * `packages/runtime/src/in-process-session-renewal.pin.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const TASK = { + name: 'task', + label: 'Task', + fields: { id: { type: 'text', label: 'ID' }, title: { type: 'text', label: 'Title' } }, +}; + +const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; +const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; + +const makeServer = () => ({ + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), +}); + +const makeQl = () => ({ + find: async (object: string, opts: any) => { + if (object === 'sys_user') return [{ id: opts?.where?.id, email: 'member@example.com' }]; + return []; + }, +}); + +function makeRes() { + let status = 200; + const res: any = { + write: () => true, + end: () => {}, + header: () => res, + status: (code: number) => { status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return { res, getStatus: () => status }; +} + +/** Drive `GET /api/v1/data/task` and return every input `getSession` was handed. */ +async function readThroughDataDoor(headers: Record) { + const calls: any[] = []; + const auth = { + // Declared ACTIVE so the gate re-read (②) runs too; the user carries no + // gate, so the request is admitted. + isAuthGateActive: () => true, + api: { + getSession: async (input: any) => { + calls.push(input); + return { user: { id: 'member1' } }; + }, + }, + }; + const protocol: any = { + getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }), + findData: vi.fn(async () => ({ object: 'task', records: [] })), + }; + const rest = new RestServer( + makeServer() as any, + protocol as any, + {} as any, + undefined, // kernelManager + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + async () => auth, // authServiceProvider + async () => makeQl(), // objectQLProvider + ); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object'); + expect(route, 'the data door is registered').toBeDefined(); + const out = makeRes(); + await route!.handler({ + method: 'GET', + path: '/api/v1/data/task', + params: { object: 'task' }, query: {}, headers, + } as any, out.res); + return { calls, status: out.getStatus(), findData: protocol.findData }; +} + +describe('[#22258] the REST data door reads the session by the in-process rule', () => { + it('a request carrying a session cookie reaches BOTH readers, and each reads without renewal', async () => { + const { calls, status, findData } = await readThroughDataDoor({ cookie: SESSION_COOKIE }); + expect(status, 'the fixture admits the caller — the door really ran').toBe(200); + expect(findData).toHaveBeenCalledTimes(1); + expect(calls.length, 'the session getter AND the gate re-read both ran').toBe(2); + for (const input of calls) { + expect(input.query, 'a cookie request renewed in-process').toEqual({ disableRefresh: true }); + // The request's own credential is what better-auth reads — the rule + // decides renewal only, never which session resolves. + expect(input.headers.get('cookie')).toBe(SESSION_COOKIE); + } + }); + + it('the console sends cookie AND bearer — still no renewal in-process', async () => { + const { calls } = await readThroughDataDoor({ cookie: SESSION_COOKIE, authorization: BEARER }); + expect(calls.length).toBe(2); + for (const input of calls) expect(input.query).toEqual({ disableRefresh: true }); + }); + + it('a bearer-only request reads exactly as before — renewal stays on, no query at all', async () => { + const { calls, status } = await readThroughDataDoor({ authorization: BEARER }); + expect(status).toBe(200); + expect(calls.length).toBe(2); + for (const input of calls) { + expect('query' in input, 'a bearer-only read lost its renewal').toBe(false); + expect(input.headers.get('authorization')).toBe(BEARER); + } + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 1498026e865..89fae0b5e5f 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -40,6 +40,8 @@ import { // [#20061] The thrown `VALIDATION_FAILED` + `fields[]` shape every catch in // this file already maps to `400` — see `readDeclaredQueryNumber` below. validationFailure, + // [#22258] The in-process session-read rule — see `computeExecCtx`. + inProcessSessionReadInput, } from '@objectstack/types'; import { allowPerfDisclosure, @@ -3023,8 +3025,16 @@ export class RestServer { // never drift on authorization. (This path previously kept its own copy that // silently omitted sys_user_position / sys_position_permission_set / platform_admin / // ai_seat — see the resolver's module doc.) + // + // [#22258] Read through the in-process session rule: this request + // answers with its OWN response, so a renewal here would move + // `sys_session.expires_at` while the renewed cookie is discarded — + // the browser's cookie then dies before its session. A request + // carrying a session cookie therefore reads without renewal (only + // `/get-session`, which re-issues the cookie, renews it); a + // bearer-only request renews as before. const getSession = async (h: any) => { - try { return await api.getSession({ headers: h }); } catch { return undefined; } + try { return await api.getSession(inProcessSessionReadInput(h)); } catch { return undefined; } }; // [#8287] The EFFECTIVE tenancy posture, from the kernel's `tenancy` // service — the same source plugin-security reconciles for the Layer 0 @@ -3210,7 +3220,8 @@ export class RestServer { // precisely the collapse being repaired. A session that // RESOLVES carrying no gate is not a failure — that user is // simply not gated, and still admits. - gatedSession = await api.getSession({ headers }); + // [#22258] Same in-process read rule as the closure above. + gatedSession = await api.getSession(inProcessSessionReadInput(headers)); } catch (err) { throw new AuthzStoreUnavailableError('auth_gate', err); } diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 946bc08a022..58b3f264a82 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -11,7 +11,7 @@ import { // when its own read cannot answer. AuthzStoreUnavailableError, } from '@objectstack/core'; -import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage } from '@objectstack/types'; +import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage, inProcessSessionReadInput } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -1356,7 +1356,10 @@ export class HttpDispatcher { } else { return null; } - const session: any = await api.getSession({ headers }).catch(() => undefined); + // [#22258] The in-process session-read rule (`@objectstack/types`): + // a cookie request reads without renewal, because this door's + // response never carries a renewed cookie. + const session: any = await api.getSession(inProcessSessionReadInput(headers)).catch(() => undefined); const gate = evaluateAuthGate(session?.user, cleanPath); if (!gate) return null; return this.error(gate.message, 403, { code: gate.code }); @@ -1435,9 +1438,8 @@ export class HttpDispatcher { // this was specifically the signed-in non-member case. const authService = await this.resolveService(this.requestKernel(context), CoreServiceName.enum.auth); const api = authService?.api ?? (typeof authService?.getApi === 'function' ? await authService.getApi() : undefined); - const sessionData = await api?.getSession?.({ - headers: context.request?.headers, - }); + // [#22258] The in-process session-read rule, as in `enforceAuthGate`. + const sessionData = await api?.getSession?.(inProcessSessionReadInput(context.request?.headers)); userId = sessionData?.user?.id ?? sessionData?.session?.userId; activeOrganizationId = sessionData?.session?.activeOrganizationId; } catch { diff --git a/packages/runtime/src/in-process-session-renewal.pin.test.ts b/packages/runtime/src/in-process-session-renewal.pin.test.ts new file mode 100644 index 00000000000..89d3c6cea55 --- /dev/null +++ b/packages/runtime/src/in-process-session-renewal.pin.test.ts @@ -0,0 +1,267 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] An in-process session read never leaves the browser's cookie behind. + * + * better-auth's `getSession` renews a session older than `updateAge` — it moves + * `sys_session.expires_at` to `now + expiresIn` — and stages the renewed cookie + * on THAT call's response. A door reading in-process answers with its own + * response, so the cookie was thrown away: the session lived on as a bearer + * while the browser's cookie died at its old `Max-Age` (a SPLIT session). The + * rule every in-process reader now applies (`inProcessSessionReadInput`, + * `@objectstack/types`): a request carrying a session cookie reads without + * renewal; a bearer-only request renews as before. + * + * Pinned here against REAL better-auth, the version this repo pins, its + * `expiresIn` / `updateAge` read off the running instance. A session is aged to + * `now + expiresIn − updateAge − 60 s` — past `updateAge`, the method the card + * measured with — and read back from `sys_session` at driver level below every + * hook after each request: + * + * - each of this lane's doors, by cookie: `expires_at` does not move and no + * session cookie is set — cookie and session stay aligned; + * - the same door, bearer only: `expires_at` renews as before, and still no + * cookie is set on a response to a request that sent none (this half is + * also each door's positive control — it proves the door's reader ran); + * - the control, `GET /auth/get-session`: renews AND re-issues the cookie with + * `Max-Age = expiresIn`, and still does so right after the inbound rate + * limiter's reader (`resolveSessionPrincipalId`, which runs on EVERY request + * ahead of the route) has read the same aged session. + * + * Doors and the reader each one reaches: + * `GET /data/:object` → `@objectstack/rest` `computeExecCtx` + * `GET /auth/me/permissions` → `@objectstack/plugin-hono-server` current-user endpoints + * `GET /i18n/locales` → this package's dispatcher, `resolveExecutionContext` + * `resolveSessionPrincipalId` → this package's `resolve-session-principal` (rate limiter, + * concrete route mounts), called as the limiter calls it + * The remaining in-process readers — the dispatcher's auth-gate and membership + * reads, REST's gate re-read, cloud-connection's three — are pinned on the + * input they hand better-auth in their own packages' `in-process-session-read` + * pins; this file is what that input DOES. + * + * Composition: an in-process `ObjectKernel` in the order `@objectstack/verify`'s + * `bootStack` uses (engine, sqlite-wasm default datasource, HTTP server, the + * app, platform objects, auth, security, REST, dispatcher), requests injected + * through the HTTP app, signed in as the dev-seeded administrator. The boot is + * paid in `beforeAll`, never inside a case. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/rest'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { AppPlugin } from './app-plugin.js'; +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { resolveSessionPrincipalId } from './security/resolve-session-principal.js'; + +const BOOT_TIMEOUT = 180_000; +const ORIGIN = 'http://localhost:3000'; +const API = '/api/v1'; +const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; +/** Clock slack between the server's `now` and this file's, in ms. */ +const SLACK_MS = 5_000; + +const PIN_APP: any = { + manifest: { id: 'com.pin.session22258', name: 'Session renewal pins', version: '1.0.0' }, + objects: [{ name: 'pin_session_note', label: 'Pin note', fields: { title: { type: 'text', label: 'Title' } } }], +}; + +let kernel: any; +let httpServer: any; +let app: any; +let prevNodeEnv: string | undefined; +let expiresInSec: number; +let updateAgeSec: number; +/** The admin's credentials, as a browser and as a bearer client hold them. */ +let cookiePair: string; +let bearer: string; +let sessionToken: string; + +const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init); + +/** The `sys_session` row's expiry, read at driver level below every hook. */ +async function storedExpiry(): Promise { + const engine: any = await kernel.getServiceAsync('objectql'); + const driver = engine.getDriver('sys_session'); + const found = await driver.find('sys_session', { where: { token: sessionToken } }); + const row = (Array.isArray(found) ? found : [found]).find(Boolean) as Record | undefined; + if (!row) throw new Error('pin: the signed-in session row is gone'); + return new Date(String(row.expires_at)).getTime(); +} + +/** Age the session to just past `updateAge` — the card's `now + expiresIn − updateAge − 60 s`. */ +async function ageSession(): Promise { + const target = Date.now() + (expiresInSec - updateAgeSec - 60) * 1000; + const engine: any = await kernel.getServiceAsync('objectql'); + const driver = engine.getDriver('sys_session'); + const found = await driver.find('sys_session', { where: { token: sessionToken } }); + const row = (Array.isArray(found) ? found : [found]).find(Boolean) as Record; + await driver.update('sys_session', String(row.id), { expires_at: new Date(target).toISOString() }); + const stored = await storedExpiry(); + expect(Math.abs(stored - target), 'the aging write did not land').toBeLessThan(1_000); + return stored; +} + +/** The session-token cookie a response stages, or `null`. */ +function sessionCookieOf(res: Response): { maxAgeSec: number | null } | null { + const staged = res.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0])); + if (!staged) return null; + const m = /;\s*max-age=(\d+)/i.exec(staged); + return { maxAgeSec: m ? Number(m[1]) : null }; +} + +const asCookie = (): Record => ({ cookie: cookiePair }); +const asBearer = (): Record => ({ authorization: `Bearer ${bearer}` }); + +/** + * The pin: cookie and session expiry stay ALIGNED — either the session did not + * move and no cookie was staged, or it moved and its cookie was re-issued to + * expire with it. + */ +function expectAligned(label: string, aged: number, after: number, res: Response) { + const cookie = sessionCookieOf(res); + if (after !== aged) { + expect(cookie, `${label}: the session renewed (+${Math.round((after - aged) / 1000)} s) but its cookie was not re-issued`).not.toBeNull(); + const cookieExpiry = Date.now() + (cookie!.maxAgeSec ?? 0) * 1000; + expect(Math.abs(cookieExpiry - after), `${label}: re-issued cookie and session expire apart`).toBeLessThan(SLACK_MS); + } else { + expect(cookie, `${label}: a cookie was staged for a session that did not move`).toBeNull(); + } +} + +beforeAll(async () => { + prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` / bootStack arm it + + kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); + await kernel.use(new HonoServerPlugin({ port: 0 })); + await kernel.use(new AppPlugin(PIN_APP)); + await kernel.use(new PlatformObjectsPlugin()); + await kernel.use(new AuthPlugin({ secret: 'session-renewal-22258-secret', autoDefaultOrganization: false })); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); + await kernel.use(createRestApiPlugin({})); + await kernel.use(createDispatcherPlugin({})); + await kernel.bootstrap(); + + httpServer = await kernel.getServiceAsync('http-server'); + app = httpServer.getRawApp(); + + // The version this repo pins, read off the running instance — never assumed. + const authService: any = await kernel.getServiceAsync('auth'); + const authContext: any = await authService.getAuthContext(); + expiresInSec = Number(authContext.sessionConfig.expiresIn); + updateAgeSec = Number(authContext.sessionConfig.updateAge); + + const res = await req('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ADMIN), + }); + if (!res.ok) throw new Error(`pin signIn failed: ${res.status} ${await res.text()}`); + const staged = res.headers.getSetCookie().find((c: string) => /(?:^|\.)session_token=/.test(c.split(';')[0])); + if (!staged) throw new Error('pin signIn staged no session cookie'); + cookiePair = staged.split(';')[0]; + bearer = String(res.headers.get('set-auth-token') ?? ''); + if (!bearer) throw new Error('pin signIn emitted no set-auth-token'); + sessionToken = String(((await res.json()) as any).token); +}, BOOT_TIMEOUT); + +afterAll(async () => { + try { await httpServer?.close?.(); } catch { /* best-effort */ } + try { await kernel?.shutdown?.(); } catch { /* best-effort */ } + if (prevNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prevNodeEnv; +}, 60_000); + +describe('[#22258] precondition — this stack renews, and the defect is better-auth\'s own behaviour', () => { + it('reads expiresIn / updateAge off the running better-auth', () => { + expect(expiresInSec).toBeGreaterThan(updateAgeSec); + expect(updateAgeSec).toBeGreaterThan(60); + }); + + it('a bare in-process getSession on an aged session renews it and stages a cookie nobody sends', async () => { + const aged = await ageSession(); + const authService: any = await kernel.getServiceAsync('auth'); + const api: any = await authService.getApi(); + // No rule: the call every reader used to make. + await api.getSession({ headers: new Headers({ cookie: cookiePair }) }); + const after = await storedExpiry(); + expect(after - aged, 'the fixture does not renew — every pin below would pass vacuously') + .toBeGreaterThan((updateAgeSec - SLACK_MS / 1000) * 1000); + }); +}); + +const DOORS: Array<{ label: string; path: string }> = [ + { label: 'GET /data/:object (rest computeExecCtx)', path: '/data/pin_session_note?limit=1' }, + { label: 'GET /auth/me/permissions (hono current-user endpoints)', path: '/auth/me/permissions' }, + { label: 'GET /i18n/locales (dispatcher resolveExecutionContext)', path: '/i18n/locales' }, +]; + +describe('[#22258] each door of this lane leaves cookie and session expiry aligned', () => { + for (const door of DOORS) { + it(`${door.label} — by cookie: no renewal, no cookie`, async () => { + const aged = await ageSession(); + const res = await req(door.path, { headers: asCookie() }); + expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500); + const after = await storedExpiry(); + expectAligned(door.label, aged, after, res); + expect(after, `${door.label}: a cookie request renewed in-process`).toBe(aged); + }); + + it(`${door.label} — bearer only: renews as before, sets no cookie`, async () => { + const aged = await ageSession(); + const res = await req(door.path, { headers: asBearer() }); + expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500); + const after = await storedExpiry(); + expect(after, `${door.label}: a bearer-only read no longer renews`).toBeGreaterThan(aged); + expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), `${door.label}: the renewal is not to now + expiresIn`) + .toBeLessThan(SLACK_MS); + expect(sessionCookieOf(res), `${door.label}: a cookie was set on a response to a request that sent none`).toBeNull(); + }); + } +}); + +describe('[#22258] the rate limiter\'s reader (resolveSessionPrincipalId)', () => { + it('by cookie: resolves the principal without renewal — and get-session then still renews AND re-issues', async () => { + const aged = await ageSession(); + const authService: any = await kernel.getServiceAsync('auth'); + // Exactly the limiter's call: the request's raw header record. + const principal = await resolveSessionPrincipalId(authService, asCookie()); + expect(principal, 'the limiter resolved no principal from the cookie').toBeTruthy(); + expect(await storedExpiry(), 'the limiter renewed a cookie session in-process').toBe(aged); + + // The route behind it: renewal still happens where the cookie is re-issued. + const res = await req('/auth/get-session', { headers: asCookie() }); + expect(res.status).toBe(200); + const after = await storedExpiry(); + expect(after, 'get-session found nothing left to renew').toBeGreaterThan(aged); + expectAligned('get-session after the limiter', aged, after, res); + }); + + it('bearer only: resolves the principal AND renews as before', async () => { + await ageSession(); + const authService: any = await kernel.getServiceAsync('auth'); + expect(await resolveSessionPrincipalId(authService, asBearer())).toBeTruthy(); + expect(Math.abs((await storedExpiry()) - (Date.now() + expiresInSec * 1000)), 'a bearer-only read no longer renews') + .toBeLessThan(SLACK_MS); + }); +}); + +describe('[#22258] control — the browser-facing get-session still renews and re-issues', () => { + it('renews an aged session and re-issues the cookie with Max-Age = expiresIn', async () => { + const aged = await ageSession(); + const res = await req('/auth/get-session', { headers: asCookie() }); + expect(res.status).toBe(200); + const after = await storedExpiry(); + expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), 'get-session did not renew').toBeLessThan(SLACK_MS); + expect(sessionCookieOf(res)?.maxAgeSec, 'get-session did not re-issue the cookie with Max-Age = expiresIn').toBe(expiresInSec); + expectAligned('get-session', aged, after, res); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 38358ad5349..c570e6e4cd8 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -29,6 +29,8 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; import { scopesToAgentPermissionSets, MCP_OAUTH_SCOPE_ACTIONS } from '@objectstack/spec/ai'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; +// [#22258] The in-process session-read rule — see the session getter below. +import { inProcessSessionReadInput } from '@objectstack/types'; import { resolveAuthzContext, @@ -157,7 +159,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise | Headers; @@ -48,7 +49,12 @@ export async function resolveSessionData( api = await (authService as any).getApi(); } if (!api?.getSession) return undefined; - return await api.getSession({ headers: toHeaders(headers) }); + // [#22258] The in-process session-read rule: a request carrying a + // session cookie reads without renewal. Load-bearing here twice over — + // the inbound rate limiter runs this on EVERY request, `/get-session` + // included, so a renewal here would also leave the very route that + // re-issues the cookie with nothing left to renew. + return await api.getSession(inProcessSessionReadInput(toHeaders(headers))); } catch { return undefined; } diff --git a/packages/types/src/in-process-session-read.test.ts b/packages/types/src/in-process-session-read.test.ts new file mode 100644 index 00000000000..deb418e6729 --- /dev/null +++ b/packages/types/src/in-process-session-read.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22258] The in-process session-read rule, pinned at its one home. Every + * in-process `auth.api.getSession` reader in `rest`, `runtime`, + * `plugin-hono-server` and `cloud-connection` hands better-auth what + * `inProcessSessionReadInput` returns, so the cookie test below IS the line + * between "renewal stays where the cookie is re-issued" (a browser) and + * "renewal as before" (a bearer-only client). The end-to-end half — real + * better-auth, real doors, `sys_session.expires_at` read back — lives in + * `packages/runtime/src/in-process-session-renewal.pin.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { carriesSessionCookie, inProcessSessionReadInput } from './in-process-session-read.js'; + +const SIGNED = 'tok3nValue.c2lnbmF0dXJl'; + +describe('[#22258] carriesSessionCookie — what counts as a browser session cookie', () => { + it('recognises better-auth\'s session cookie in every spelling better-auth writes', () => { + // default prefix + expect(carriesSessionCookie(new Headers({ cookie: `better-auth.session_token=${SIGNED}` }))).toBe(true); + // secure cookies + expect(carriesSessionCookie(new Headers({ cookie: `__Secure-better-auth.session_token=${SIGNED}` }))).toBe(true); + // an author-configured `advanced.cookiePrefix` + expect(carriesSessionCookie(new Headers({ cookie: `acme.session_token=${SIGNED}` }))).toBe(true); + expect(carriesSessionCookie(new Headers({ cookie: `__Secure-acme-console.session_token=${SIGNED}` }))).toBe(true); + // among other cookies, in any position + expect(carriesSessionCookie(new Headers({ + cookie: `os_locale=en; better-auth.session_token=${SIGNED}; theme=dark`, + }))).toBe(true); + }); + + it('a bearer-only request carries none — the bearer is NOT a cookie, however it is spelled', () => { + expect(carriesSessionCookie(new Headers({ authorization: `Bearer ${SIGNED}` }))).toBe(false); + expect(carriesSessionCookie(new Headers())).toBe(false); + }); + + it('other cookies are not a session cookie', () => { + expect(carriesSessionCookie(new Headers({ cookie: 'os_locale=en; theme=dark' }))).toBe(false); + // better-auth's OTHER cookies: renewal is the session token's question only + expect(carriesSessionCookie(new Headers({ cookie: 'better-auth.dont_remember=x; better-auth.session_data=y' }))).toBe(false); + // a name that merely ENDS like it, with no prefix separator + expect(carriesSessionCookie(new Headers({ cookie: `xsession_token=${SIGNED}` }))).toBe(false); + // a session-token-shaped string inside another cookie's VALUE + expect(carriesSessionCookie(new Headers({ cookie: `note=better-auth.session_token` }))).toBe(false); + }); + + it('an empty session cookie value is no session cookie', () => { + expect(carriesSessionCookie(new Headers({ cookie: 'better-auth.session_token=' }))).toBe(false); + expect(carriesSessionCookie(new Headers({ cookie: 'better-auth.session_token=; theme=dark' }))).toBe(false); + }); + + it('reads plain header records the way adapters deliver them', () => { + expect(carriesSessionCookie({ cookie: `better-auth.session_token=${SIGNED}` })).toBe(true); + expect(carriesSessionCookie({ Cookie: `better-auth.session_token=${SIGNED}` })).toBe(true); + expect(carriesSessionCookie({ cookie: ['os_locale=en', `better-auth.session_token=${SIGNED}`] })).toBe(true); + expect(carriesSessionCookie({ authorization: `Bearer ${SIGNED}` })).toBe(false); + expect(carriesSessionCookie({ cookie: undefined })).toBe(false); + }); + + it('no headers at all is no cookie', () => { + expect(carriesSessionCookie(undefined)).toBe(false); + expect(carriesSessionCookie(null)).toBe(false); + expect(carriesSessionCookie('better-auth.session_token=x')).toBe(false); + }); +}); + +describe('[#22258] inProcessSessionReadInput — the getSession input every reader hands better-auth', () => { + it('a cookie request reads WITHOUT renewal', () => { + const headers = new Headers({ cookie: `better-auth.session_token=${SIGNED}` }); + expect(inProcessSessionReadInput(headers)).toEqual({ headers, query: { disableRefresh: true } }); + }); + + it('a cookie AND bearer request (the console sends both) reads without renewal', () => { + const headers = new Headers({ + cookie: `better-auth.session_token=${SIGNED}`, + authorization: `Bearer ${SIGNED}`, + }); + expect(inProcessSessionReadInput(headers).query).toEqual({ disableRefresh: true }); + }); + + it('a bearer-only request reads exactly as before — no query key at all', () => { + const headers = new Headers({ authorization: `Bearer ${SIGNED}` }); + const input = inProcessSessionReadInput(headers); + expect(input).toEqual({ headers }); + expect('query' in input).toBe(false); + }); + + it('hands the SAME headers object through, never a copy', () => { + const webHeaders = new Headers({ cookie: `better-auth.session_token=${SIGNED}` }); + const record = { authorization: `Bearer ${SIGNED}` }; + expect(inProcessSessionReadInput(webHeaders).headers).toBe(webHeaders); + expect(inProcessSessionReadInput(record).headers).toBe(record); + }); +}); diff --git a/packages/types/src/in-process-session-read.ts b/packages/types/src/in-process-session-read.ts new file mode 100644 index 00000000000..01a35389559 --- /dev/null +++ b/packages/types/src/in-process-session-read.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * THE rule for an in-process better-auth session read — `auth.api.getSession` + * called by a door on the server, as opposed to the browser-facing + * `GET /api/v1/auth/get-session` route. + * + * ## Why a read needs a rule (#22258) + * + * better-auth's `getSession` is not a pure read. Once a session is older than + * `session.updateAge`, the call RENEWS it — it moves `sys_session.expires_at` + * to `now + expiresIn` — and stages the renewed session cookie on THAT call's + * own response (better-auth 1.7.3, `dist/api/routes/session.mjs:198-214`). A + * door reading in-process answers with its own response, so the renewal lands + * in the database and the cookie is thrown away. The browser keeps its old + * cookie and its old `Max-Age`, and later `/get-session` calls find a fresh row + * and re-issue nothing. The cookie dies first: a SPLIT session, a dead cookie + * beside a live bearer, and every cookie-only path then reads a signed-in user + * as signed out. + * + * Measured through the public doors on a fresh dev stack, a session aged to + * `now + expiresIn − updateAge − 60 s`: `GET /data/:object`, + * `GET /auth/me/permissions`, `GET /meta/object` and the dispatcher's doors + * each moved `expires_at` by +86460 s and answered no session cookie, by cookie + * and by bearer alike; `GET /auth/get-session` renewed AND re-issued the cookie + * with `Max-Age = expiresIn`. + * + * ## The rule — decided by what the request carries + * + * - **A session cookie** (a browser): read with `query.disableRefresh`. The + * session renews only where its cookie is re-issued — the `/get-session` + * route — so cookie expiry and session expiry cannot split. + * - **No session cookie** (a bearer-only client — the SDK outside a browser, + * the CLI): read exactly as before, renewal included. No cookie exists to fall + * behind; the bearer IS the session token and renewal does not change it; and + * these clients reach `/get-session` only at sign-in (`os login`, + * `os cloud whoami`), so without renewal on their data reads their session + * would end `expiresIn` after sign-in however active they were. + * + * Forwarding the renewed `Set-Cookie` from every door was measured and not + * taken: several readers run with no response in hand (the inbound rate + * limiter, the dispatcher's scope resolution, the REST execution-context + * resolver, which is cached per request), and a forward would need this same + * cookie test anyway so that no cookie is ever set on a response to a request + * that sent none. better-auth applies the same rule to its own server-side + * reads that cannot write a cookie (React Server Components, + * `dist/integrations/next-js.mjs:62-69`). + * + * ⛔ The rule only ever ADDS `disableRefresh`. It never sets a cookie, never + * forwards one, and never changes which session a request resolves to. + */ + +/** + * The input a reader hands `getSession`: the request's own headers, unchanged, + * plus `query.disableRefresh` when the request carries a session cookie. + */ +export interface InProcessSessionReadInput { + headers: H; + query?: { disableRefresh: true }; +} + +/** + * The better-auth session cookie, whatever its prefix. + * + * better-auth names it `${cookiePrefix}.session_token`, behind `__Secure-` when + * cookies are secure (1.7.3 `dist/cookies/index.mjs:23-29`, default prefix + * `better-auth`). The prefix is author-configurable + * (`AuthConfig.advanced.cookiePrefix`), so the test reads the name's SHAPE, not + * one spelling. A cookie with an empty value is no session cookie. + */ +const SESSION_TOKEN_COOKIE = /(?:^|;)\s*(?:__Secure-|__Host-)?[^\s=;]+\.session_token=[^;\s]/; + +/** The `Cookie` header of a Web `Headers` or of a plain header record. */ +function cookieHeaderOf(headers: unknown): string { + if (!headers || typeof headers !== 'object') return ''; + const get = (headers as { get?: unknown }).get; + if (typeof get === 'function') { + const value: unknown = get.call(headers, 'cookie'); + return typeof value === 'string' ? value : ''; + } + for (const [name, value] of Object.entries(headers as Record)) { + if (name.toLowerCase() !== 'cookie' || value == null) continue; + return Array.isArray(value) ? value.map(String).join('; ') : String(value); + } + return ''; +} + +/** + * Does this request carry a better-auth session cookie? + * + * Accepts the shapes the doors are handed: a Web `Headers`, or a plain record + * (adapters deliver either; a value may be a string array). + */ +export function carriesSessionCookie(headers: unknown): boolean { + return SESSION_TOKEN_COOKIE.test(cookieHeaderOf(headers)); +} + +/** + * The `getSession` input for an in-process session read — call as + * `api.getSession(inProcessSessionReadInput(headers))`. + * + * The headers pass through untouched, so the reader resolves exactly the + * session it resolved before; only renewal is decided here. + */ +export function inProcessSessionReadInput(headers: H): InProcessSessionReadInput { + return carriesSessionCookie(headers) ? { headers, query: { disableRefresh: true } } : { headers }; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0e902ffde1f..640e3100e46 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -6,6 +6,13 @@ export * from './degraded-boot.js'; // (plugin-auth) both read — see the module doc for why it must be one. export * from './email-verified.js'; export * from './env.js'; +// [#22258] The one rule for an in-process better-auth `getSession` read: a +// request carrying a session cookie reads without renewal, so a renewal never +// lands where its cookie cannot be re-issued. Its readers live in `rest`, +// `runtime`, `plugin-hono-server` and `cloud-connection` — `rest` and the hono +// server cannot import `runtime` — and every one of them already depends on +// this package, so adopting the rule adds no edge. +export * from './in-process-session-read.js'; export * from './error-leak.js'; // [#17681] The SIBLING question, kept deliberately separate: `error-leak.js` // asks "is this message a driver dump?", this asks "did the JS RUNTIME raise