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
@@ -1,3 +1,3 @@
---
'@objectstack/types': minor
'@objectstack/rest': patch
Expand All @@ -19,4 +19,4 @@

`@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.
The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset.
19 changes: 19 additions & 0 deletions .changeset/22258-services-in-process-session-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/plugin-webhooks': patch
'@objectstack/plugin-sharing': patch
'@objectstack/service-storage': patch
'@objectstack/service-settings': patch
'@objectstack/service-datasource': patch
---

fix(auth,services): the remaining in-process session reads no longer renew 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. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`).

**The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`:

- **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `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.
- **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none.

**Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`.
9 changes: 5 additions & 4 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from '@objectstack/platform-objects/apps';
import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages';
import { PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types';
import { inProcessSessionReadInput } from '@objectstack/types';
import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security';
import type { IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts';
import {
Expand Down Expand Up @@ -2461,7 +2462,7 @@ export class AuthPlugin implements Plugin {
// Platform-admin gate (ADR-0068 D2) — one shared judge for every
// ObjectStack `/admin/*` mount; see platform-admin-gate.ts.
const authApi = await this.authManager!.getApi();
const session = await authApi.getSession({ headers: c.req.raw.headers });
const session = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
const verdict = judgePlatformAdmin(session);
if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status);

Expand Down Expand Up @@ -2524,7 +2525,7 @@ export class AuthPlugin implements Plugin {
// spelling instead of accreting per-mount copies.
const gateAdmin = async (c: any): Promise<PlatformAdminActor | Response> => {
const authApi = await this.authManager!.getApi();
const session = await (authApi as any).getSession({ headers: c.req.raw.headers });
const session = await (authApi as any).getSession(inProcessSessionReadInput(c.req.raw.headers));
const verdict = judgePlatformAdmin(session);
if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status);
return verdict.actor;
Expand Down Expand Up @@ -2591,7 +2592,7 @@ export class AuthPlugin implements Plugin {

// Platform-admin gate (ADR-0068 D2) — see platform-admin-gate.ts.
const authApi = await this.authManager!.getApi();
const session = await authApi.getSession({ headers: c.req.raw.headers });
const session = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
const verdict = judgePlatformAdmin(session);
if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status);

Expand Down Expand Up @@ -2909,7 +2910,7 @@ export class AuthPlugin implements Plugin {
rawApp.post(`${basePath}/admin/has-permission`, async (c: any) => {
try {
const authApi = await this.authManager!.getApi();
const session = await (authApi as any).getSession({ headers: c.req.raw.headers });
const session = await (authApi as any).getSession(inProcessSessionReadInput(c.req.raw.headers));
const user = (session as { user?: { id?: unknown } } | null | undefined)?.user;
if (user?.id && isPlatformAdminUser(user)) {
const { readEvaluatedPermissionQuery, answerPermissionQueryAsAdmin } = await import(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#22258] This plugin's admin doors read the session in-process without
* leaving 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. The raw `/admin/*` mounts below read the
* session in-process and answer with their own response, so the renewed
* 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). Each mount now hands
* better-auth `inProcessSessionReadInput(headers)` (`@objectstack/types`): a
* request carrying a session cookie reads without renewal; a bearer-only
* request renews as before.
*
* Pinned against REAL better-auth — the installed version, its `expiresIn` /
* `updateAge` read off the live instance — behind the plugin's REAL route
* registration on a real Hono app (the `admin-remove-user-gate-ordering`
* harness: a real `AuthManager` over the shared in-memory engine). The session
* is aged to `now + expiresIn − updateAge − 60 s`, past `updateAge`, and its
* `sys_session` row is read straight off the engine's table after each request:
*
* - each door, 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 to `now + expiresIn`,
* and 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 /get-session`: renews AND re-issues the cookie with
* `Max-Age = expiresIn`.
*
* Doors and the reader each one reaches (`auth-plugin.ts`):
* `POST /admin/oauth2/toggle-disabled` → its own platform-admin gate read
* `POST /admin/set-user-manager` → `gateAdmin`, the shared gate of every
* `/admin/*` mount that calls it
* `POST /admin/unlock-user` → its own platform-admin gate read
* `POST /admin/has-permission` → its own platform-admin branch read
* Each body is one the door answers right after its read, so no second session
* read follows the one under test.
*/

import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { Hono } from 'hono';
import { AuthManager } from './auth-manager';
import { AuthPlugin } from './auth-plugin';
import { createMemoryEngine } from './impersonation-bearer-rotation.test';
import { inviteForAudienceGate } from './audience-gate-test-support';
import type { PluginContext } from '@objectstack/core';

const SECRET = 'test-secret-at-least-32-chars-long!!';
const PASSWORD = 'S3cure!Passw0rd-22258';
const ORIGIN = 'http://localhost:3000';
const BASE = '/api/v1/auth';
const ADMIN_EMAIL = 'admin.22258@example.com';
const MEMBER_EMAIL = 'member.22258@example.com';
/** Clock slack between the server's `now` and this file's, in ms. */
const SLACK_MS = 5_000;

const mockCtx = (): PluginContext =>
({
registerService: vi.fn(),
getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)),
getServices: vi.fn(() => new Map()),
hook: vi.fn(),
trigger: vi.fn(),
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
getKernel: vi.fn(),
}) as any;

let engine: ReturnType<typeof createMemoryEngine>;
let manager: AuthManager;
let app: Hono;
let expiresInSec: number;
let updateAgeSec: number;
let memberId: string;
/** The admin's credentials, as a browser and as a bearer client hold them. */
let cookiePair: string;
let bearer: string;
let sessionToken: string;

/** The admin's `sys_session` row, read straight off the engine's table. */
function sessionRow(): Record<string, unknown> {
const row = ((engine.tables.get('sys_session') ?? []) as any[]).find((r) => r.token === sessionToken);
if (!row) throw new Error('pin: the signed-in session row is gone');
return row;
}

const storedExpiry = (): number => new Date(sessionRow().expires_at as any).getTime();

/** Age the session to just past `updateAge` — the card's `now + expiresIn − updateAge − 60 s`. */
function ageSession(): number {
const target = Date.now() + (expiresInSec - updateAgeSec - 60) * 1000;
const row = sessionRow();
row.expires_at = typeof row.expires_at === 'string' ? new Date(target).toISOString() : new Date(target);
const stored = 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<string, string> => ({ cookie: cookiePair });
const asBearer = (): Record<string, string> => ({ authorization: `Bearer ${bearer}` });

const fire = (path: string, body: unknown, credential: Record<string, string>) =>
app.request(`${ORIGIN}${BASE}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin: ORIGIN, ...credential },
body: JSON.stringify(body),
});

/**
* 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 () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

engine = createMemoryEngine();
manager = new AuthManager({
secret: SECRET,
baseUrl: ORIGIN,
dataEngine: engine,
plugins: { admin: true },
} as any);

const direct = (path: string, body: unknown) =>
manager.handleRequest(
new Request(`${ORIGIN}${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
);

for (const [email, name] of [
[ADMIN_EMAIL, 'Platform Admin'],
[MEMBER_EMAIL, 'Plain Member'],
]) {
// The default audience posture is invite_only: fixture users beyond the
// first enter through the invitation carve-out (audience-gate-test-support).
await inviteForAudienceGate(manager, email);
const res = await direct('/sign-up/email', { email, password: PASSWORD, name });
expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200);
}

const users = (engine.tables.get('sys_user') ?? []) as any[];
memberId = String(users.find((r) => r.email === MEMBER_EMAIL)!.id);
// The legacy scalar `isPlatformAdminUser` accepts as its documented
// back-compat signal — every door below admits this caller.
users.find((r) => r.email === ADMIN_EMAIL)!.role = 'admin';

// The version this package pins, read off the running instance — never assumed.
const authContext: any = await manager.getAuthContext();
expiresInSec = Number(authContext.sessionConfig.expiresIn);
updateAgeSec = Number(authContext.sessionConfig.updateAge);

const res = await direct('/sign-in/email', { email: ADMIN_EMAIL, password: PASSWORD });
expect(res.status, `sign-in: ${await res.clone().text()}`).toBe(200);
const staged = res.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0]));
if (!staged) throw new Error('pin sign-in staged no session cookie');
cookiePair = staged.split(';')[0];
bearer = String(res.headers.get('set-auth-token') ?? '');
if (!bearer) throw new Error('pin sign-in emitted no set-auth-token');
sessionToken = String(((await res.json()) as any).token);

// The REAL route registration — raw mounts ahead of the catch-all — on a
// real Hono app in front of the real AuthManager.
app = new Hono();
const ctx = mockCtx();
const plugin = new AuthPlugin({ secret: SECRET });
await plugin.init(ctx);
(plugin as any).authManager = manager;
(plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
});

afterAll(() => vi.restoreAllMocks());

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 = ageSession();
const api: any = await manager.getApi();
// No rule: the call every reader in this file used to make.
await api.getSession({ headers: new Headers({ cookie: cookiePair }) });
const after = 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; body: () => unknown }> = [
{
label: 'POST /admin/oauth2/toggle-disabled',
path: '/admin/oauth2/toggle-disabled',
body: () => ({ client_id: 'cl_pin_22258_absent', disabled: true }),
},
{
label: 'POST /admin/set-user-manager (gateAdmin)',
path: '/admin/set-user-manager',
body: () => ({}),
},
{
label: 'POST /admin/unlock-user',
path: '/admin/unlock-user',
body: () => ({ userId: memberId }),
},
{
label: 'POST /admin/has-permission (platform-admin branch)',
path: '/admin/has-permission',
body: () => ({ permissions: { user: ['list'] } }),
},
];

describe('[#22258] each admin door leaves cookie and session expiry aligned', () => {
for (const door of DOORS) {
it(`${door.label} — by cookie: no renewal, no cookie`, async () => {
const aged = ageSession();
const res = await fire(door.path, door.body(), asCookie());
// Admitted: the read under test resolved the admin (a refusal would be 401/403).
expect([401, 403], `${door.label} refused the admin: ${res.status}`).not.toContain(res.status);
expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500);
const after = 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 = ageSession();
const res = await fire(door.path, door.body(), asBearer());
expect([401, 403], `${door.label} refused the admin: ${res.status}`).not.toContain(res.status);
expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500);
const after = 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] 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 = ageSession();
const res = await app.request(`${ORIGIN}${BASE}/get-session`, { headers: { origin: ORIGIN, ...asCookie() } });
expect(res.status).toBe(200);
const after = 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);
});
});
Loading
Loading