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
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/cloud-connection/src/cloud-connection-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
};
Expand Down
138 changes: 138 additions & 0 deletions packages/cloud-connection/src/in-process-session-read.pin.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };
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<string, (c: any) => Promise<any>>();
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<string, (...args: any[]) => 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);
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 */ }
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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: <T,>(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<string, string>) {
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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading