Skip to content

Commit 4f4c4ed

Browse files
objectstack-fleet[bot]claudeos-elon-musk
authored
fix(auth,services): the services-lane in-process session reads stop renewing a cookie session (#22258) (#22396)
Fixes #22258 Clause-②: no ## What this changes This is the `domain:services` half of the split-session card. 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 own response. The nine in-process readers below answered with their own response, so the renewal landed in the database and its cookie was discarded, leaving a live bearer beside a dying cookie. Each reader now hands better-auth `inProcessSessionReadInput(headers)` from `@objectstack/types`, the rule PR #22367 landed (`a45d5d8ab7`): - A request carrying a session cookie reads with `query.disableRefresh`, so the session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie. - A bearer-only request reads exactly as before, renewal included. Each change is a one-expression substitution. The headers pass through untouched, so every reader resolves the same session it did before. - `@objectstack/plugin-webhooks` gains a workspace dependency on `@objectstack/types`, per the triage ruling (`6072029812`). There is no cycle: `@objectstack/types` depends only on `@objectstack/spec`, and `@objectstack/core` already pulled `types` in transitively. `pnpm-lock.yaml` was regenerated by `pnpm install` (+3 lines). - `check:test-source-alias` then required the matching anchored alias in `packages/plugins/plugin-webhooks/vitest.config.ts`. That registry is shrink-only, so the gate's own remedy is the alias, and that line is in this PR. - No `packages/types`, `rest`, `runtime`, `plugin-hono-server`, `cloud-connection` or `packages/spec` edit. ## Enumeration pin: the census over `packages/services/**` and `packages/plugins/**`, non-test sources Measured at the merge base `117d34de` and at this head `c09841ccf`. Excluded: `*.test.ts`, `__tests__/**`, `*.testkit.ts`, `*.md`. | reader (line at head) | spelling | before (`117d34de`) | after (`c09841ccf`) | |---|---|---|---| | plugin-auth `auth-plugin.ts:2465` (toggle-disabled gate) | `authApi.getSession(` | `{ headers: c.req.raw.headers }` | `inProcessSessionReadInput(c.req.raw.headers)` | | plugin-auth `auth-plugin.ts:2528` (`gateAdmin`) | `(authApi as any).getSession(` | `{ headers: c.req.raw.headers }` | `inProcessSessionReadInput(c.req.raw.headers)` | | plugin-auth `auth-plugin.ts:2595` (unlock-user gate) | `authApi.getSession(` | `{ headers: c.req.raw.headers }` | `inProcessSessionReadInput(c.req.raw.headers)` | | plugin-auth `auth-plugin.ts:2913` (has-permission branch) | `(authApi as any).getSession(` | `{ headers: c.req.raw.headers }` | `inProcessSessionReadInput(c.req.raw.headers)` | | plugin-webhooks `webhook-outbox-plugin.ts:483` | `api.getSession(` | `{ headers: c.req.raw.headers }` | `inProcessSessionReadInput(c.req.raw.headers)` | | service-storage `storage-service-plugin.ts:844` | `api.getSession(` | `{ headers }` | `inProcessSessionReadInput(headers)` | | plugin-sharing `sharing-plugin.ts:941` | `api?.getSession?.(` | `{ headers: h }` | `inProcessSessionReadInput(h)` | | service-settings `settings-service-plugin.ts:300` | `api?.getSession?.(` | `{ headers: h }` | `inProcessSessionReadInput(h)` | | service-datasource `admin-routes.ts:212` | `api?.getSession?.(` | `{ headers }` | `inProcessSessionReadInput(headers)` | | plugin-hono-server `current-user-endpoints.ts:412` | `api.getSession(` | already the helper (PR #22367) | unchanged | - **Counted per spelling, at `c09841ccf`:** - The fixed string `api.getSession(` catches 3 calls (webhooks, storage, hono) plus 2 doc comments (`anonymous-session-refusal.ts:63` and `platform-admin-gate.ts:90`). - The fixed string `api?.getSession?.(` catches 3 calls (sharing, settings, datasource). - The any-receiver pattern `[)a-zA-Z_]\??\.getSession\??\.?\(` catches all 10 calls above, the same 2 doc comments, and 5 hits that are not better-auth reads: `store.getSession(` ×4 in `service-storage/src/storage-routes.ts` and `this.getSession(` in `metadata-store.ts`, both the upload-session store. - **None left.** At `c09841ccf`, the pattern `getSession\??\.?\(\{ *headers` over the same scope matches only the doc comment at `platform-admin-gate.ts:90`. No call passes a bare `{ headers }`. - **The triage spelling misses plugin-auth.** As a fixed string, `api.getSession(` is case-sensitive. It matches neither `authApi.getSession(` (capital `A`) nor `(authApi as any).getSession(`, so it finds none of the four plugin-auth readers. The table rests on the any-receiver pattern. ## Pins, and the ablation of each - **plugin-auth: real better-auth.** `src/in-process-session-renewal.pin.test.ts` runs the installed better-auth, with `expiresIn` and `updateAge` read off the live instance. It uses a real `AuthManager` and the plugin's real `registerAuthRoutes` on a Hono app (the `admin-remove-user-gate-ordering` harness). - The session is aged to `now + expiresIn − updateAge − 60 s`, and `sys_session` is read back after every request. - Precondition: a bare in-process `getSession` renews. - Control: `GET /get-session` renews and re-issues the cookie with `Max-Age = expiresIn`. - **The other five: input pins** through each package's real door. They assert what the reader hands better-auth: `query: { disableRefresh: true }` for a cookie, and for cookie plus bearer; no `query` at all for bearer-only. What that input does against real better-auth is pinned by the plugin-auth file above and by `packages/runtime/src/in-process-session-renewal.pin.test.ts`. - **Ablation**, run at `c09841ccf` through `scripts/ablation-replace.mjs` in wrap mode (literal anchor that must hit exactly once, on-disk counts and blob hashes, restore proven against HEAD). Each leg put the old `{ headers }` call back for one reader and ran that reader's pin. Every mutated reader resolves from `src/` in its suite (relative imports), so no `dist/` leg applies. | reader | pin and door | by cookie | bearer-only control | ablation: failing output | restored | |---|---|---|---|---|---| | plugin-auth `:2465` | real better-auth, `POST /admin/oauth2/toggle-disabled` | 0 s, no cookie | renews to `now + expiresIn`, no cookie | 1 failed, 20 passed: exactly "toggle-disabled — by cookie" (`the session renewed (+86460 s) but its cookie was not re-issued`) | blob `32c004a7d80f` == HEAD | | plugin-auth `:2528` `gateAdmin` | real better-auth, `POST /admin/set-user-manager` | 0 s, no cookie | renews, no cookie | 1 failed, 20 passed: exactly "set-user-manager (gateAdmin) — by cookie" (+86460 s) | `32c004a7d80f` == HEAD | | plugin-auth `:2595` | real better-auth, `POST /admin/unlock-user` | 0 s, no cookie | renews, no cookie | 1 failed, 20 passed: exactly "unlock-user — by cookie" (+86460 s) | `32c004a7d80f` == HEAD | | plugin-auth `:2913` | real better-auth, `POST /admin/has-permission` | 0 s, no cookie | renews, no cookie | 1 failed, 20 passed: exactly "has-permission — by cookie" (+86460 s) | `32c004a7d80f` == HEAD | | plugin-webhooks `:483` | input, `POST /api/v1/webhooks/redeliver` | `disableRefresh` | no query | 2 failed, 1 passed: the cookie and cookie-plus-bearer cases (`expected undefined to deeply equal { disableRefresh: true }`) | `60dc0b99b985` == HEAD | | service-storage `:844` | input, `GET /api/v1/storage/upload/chunked/:uploadId/progress` via `mountStorageRoutes` | `disableRefresh` | no query | 2 failed, 1 passed: the two cookie cases | `b85d1c3f6fe1` == HEAD | | plugin-sharing `:941` | input, `GET /api/v1/share-links` (real plugin boot) | `disableRefresh` | no query | 2 failed, 1 passed: the two cookie cases | `026ac1febf6e` == HEAD | | service-settings `:300` | input, the routes' `contextFromRequest` (real plugin boot, pass-through capture) | `disableRefresh` | no query | 2 failed, 1 passed: the two cookie cases | `e9af2764acea` == HEAD | | service-datasource `:212` | input, `GET /api/v1/datasources/drivers` (real registrar on Hono) | `disableRefresh` | no query | 2 failed, 1 passed: the two cookie cases | `0868657715d2` == HEAD | Each plugin-auth leg reddened exactly the one door it ablated, so each door reaches exactly one reader. The bearer control stayed green in every leg. ## Verification at `c09841ccf` (this branch merged with `origin/main` `191543456`) - **Build:** `turbo run build --filter=!@objectstack/docs`: 72/72 tasks, exit 0. - **`pnpm --filter PKG test`, all exit 0:** - plugin-auth: 133 files, 2693 passed, 10 skipped; - plugin-webhooks: 16 files, 168 passed; - service-storage: 47 files, 776 passed; - plugin-sharing: 41 files, 1005 passed; - service-settings: 42 files, 755 passed; - service-datasource: 42 files, 763 passed. - **`pnpm --filter PKG typecheck`:** exit 0 for all six. Each new pin file is listed by a program the typecheck script runs (`tsc --listFilesOnly`): `tsconfig.json`, or `tsconfig.test.json` through `check:test-typecheck`. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 82 commands for this diff (the pre-derived 76, plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`). All 82 exit 0 at `c09841ccf`. `--ran` with the recorded exit codes prints: `Run reconciliation — 82 derived, 82 run, 0 NOT-MEASURED, 0 UNRUN.` - **Lint, narrowed and declared** (full `pnpm lint` is CI's run): - The population, read from eslint's own config: the 13 changed `.ts` files. The other three changed files (`.changeset/*.md`, `package.json`, `pnpm-lock.yaml`) answer "File ignored because no matching configuration was supplied". - `eslint --no-inline-config --format json` over the 13: 13 files linted, 0 errors, 0 warnings. - Invariance: the config sets no `parserOptions.project` and no type-aware rule, so this diff cannot move the verdict on an untouched file. ## Acceptance notes 1. **Residue measured, out of this census.** Four plugin-auth doors still split a cookie session after this change, through in-process reads that do not spell `getSession(`. - Measured on the same real-better-auth harness: each moved `expires_at` +86460 s by cookie and set no cookie. - `POST /api/v1/auth/admin/sso/register`: the `/get-session` re-dispatch through the better-auth handler in `register-sso-provider.ts:60`, behind `gateAdmin`. - `POST /api/v1/auth/send-verification-email`: the same re-dispatch in `send-verification-email.ts:63`. - `POST /api/v1/auth/organization/add-member`: `authApi.addMember({ ..., headers })` in `organization-add-member.ts:175`; the vendor's session read inside renews. - `POST /api/v1/auth/set-initial-password`: `authApi.setPassword({ ..., headers })` in `set-initial-password.ts:65`. - Same mechanism by source, not measured: `authApi.createOAuthClient({ ..., headers })` at `auth-plugin.ts:3175`, and the SSO bridges' inner re-dispatches (`register-sso-provider.ts:210, 306, 404, 454`). Each bridge returns only status and body, so a vendor `Set-Cookie` there is discarded. - These need a rule shape for a handler re-dispatch or a vendor endpoint call, not the one-expression `getSession` input, so they are reported to the seat rather than changed here. 2. **A correction to PR #22367's H5 table.** H5 attributed the `POST /admin/sso/register` split to `gateAdmin` alone. With `gateAdmin` converted, that door still splits through the re-dispatch in note 1. The `gateAdmin` pin therefore uses `POST /admin/set-user-manager`, which reads the session once. 3. **The cli half's pending changeset, amended in `8d9dcbb4`** (seat's edit of this note, after patch round 1). `.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md` ended by saying the services-lane readers "still renew a cookie session without re-issuing its cookie", which this PR makes false in the same release. Under the seat's ruling A (#22258, ACCEPT `6073337286`), its last paragraph now reads: "The same rule is applied to the in-process `auth.api.getSession` readers in … by their own changeset." No other sentence and no frontmatter changed. `Check Changeset` is red by design (the DELIBERATE CORRECTION class); the at-tier record `6073440660` on `8d9dcbb4` confirms it: do not restore the old sentence. 4. **Outside the planned surface: one line.** `packages/plugins/plugin-webhooks/vitest.config.ts` gains the anchored `@objectstack/types` alias that `check:test-source-alias` dictates for the new dependency. 5. **Imported fixture.** The plugin-auth pin imports `createMemoryEngine` from `impersonation-bearer-rotation.test.ts`, as twenty sibling files do, so that file's ten cases also run inside this pin (21 = 11 + 10). Reusing the pinned double adds no new engine double to the ledger. 6. **A doc comment left as is.** `platform-admin-gate.ts:90` still says the gate's `session` is what `auth.api.getSession({ headers })` returned. It describes the result's shape, which is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: os-elon-musk <elon-musk@objectstack.ai>
1 parent 440bed6 commit 4f4c4ed

17 files changed

Lines changed: 874 additions & 12 deletions

‎.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ fix(auth): a server-side session read no longer renews a browser session behind
1919

2020
`@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.
2121

22-
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.
22+
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.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/plugin-webhooks': patch
4+
'@objectstack/plugin-sharing': patch
5+
'@objectstack/service-storage': patch
6+
'@objectstack/service-settings': patch
7+
'@objectstack/service-datasource': patch
8+
---
9+
10+
fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258)
11+
12+
**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/*`).
13+
14+
**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`:
15+
16+
- **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.
17+
- **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.
18+
19+
**Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`.

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from '@objectstack/platform-objects/apps';
3030
import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages';
3131
import { PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types';
32+
import { inProcessSessionReadInput } from '@objectstack/types';
3233
import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security';
3334
import type { IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts';
3435
import {
@@ -2461,7 +2462,7 @@ export class AuthPlugin implements Plugin {
24612462
// Platform-admin gate (ADR-0068 D2) — one shared judge for every
24622463
// ObjectStack `/admin/*` mount; see platform-admin-gate.ts.
24632464
const authApi = await this.authManager!.getApi();
2464-
const session = await authApi.getSession({ headers: c.req.raw.headers });
2465+
const session = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers));
24652466
const verdict = judgePlatformAdmin(session);
24662467
if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status);
24672468

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

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

@@ -2909,7 +2910,7 @@ export class AuthPlugin implements Plugin {
29092910
rawApp.post(`${basePath}/admin/has-permission`, async (c: any) => {
29102911
try {
29112912
const authApi = await this.authManager!.getApi();
2912-
const session = await (authApi as any).getSession({ headers: c.req.raw.headers });
2913+
const session = await (authApi as any).getSession(inProcessSessionReadInput(c.req.raw.headers));
29132914
const user = (session as { user?: { id?: unknown } } | null | undefined)?.user;
29142915
if (user?.id && isPlatformAdminUser(user)) {
29152916
const { readEvaluatedPermissionQuery, answerPermissionQueryAsAdmin } = await import(
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22258] This plugin's admin doors read the session in-process without
5+
* leaving the browser's cookie behind.
6+
*
7+
* better-auth's `getSession` renews a session older than `updateAge` — it
8+
* moves `sys_session.expires_at` to `now + expiresIn` — and stages the renewed
9+
* cookie on THAT call's response. The raw `/admin/*` mounts below read the
10+
* session in-process and answer with their own response, so the renewed
11+
* cookie was thrown away: the session lived on as a bearer while the browser's
12+
* cookie died at its old `Max-Age` (a SPLIT session). Each mount now hands
13+
* better-auth `inProcessSessionReadInput(headers)` (`@objectstack/types`): a
14+
* request carrying a session cookie reads without renewal; a bearer-only
15+
* request renews as before.
16+
*
17+
* Pinned against REAL better-auth — the installed version, its `expiresIn` /
18+
* `updateAge` read off the live instance — behind the plugin's REAL route
19+
* registration on a real Hono app (the `admin-remove-user-gate-ordering`
20+
* harness: a real `AuthManager` over the shared in-memory engine). The session
21+
* is aged to `now + expiresIn − updateAge − 60 s`, past `updateAge`, and its
22+
* `sys_session` row is read straight off the engine's table after each request:
23+
*
24+
* - each door, by cookie: `expires_at` does not move and no session cookie
25+
* is set — cookie and session stay aligned;
26+
* - the same door, bearer only: `expires_at` renews to `now + expiresIn`,
27+
* and no cookie is set on a response to a request that sent none (this
28+
* half is also each door's positive control — it proves the door's reader
29+
* ran);
30+
* - the control, `GET /get-session`: renews AND re-issues the cookie with
31+
* `Max-Age = expiresIn`.
32+
*
33+
* Doors and the reader each one reaches (`auth-plugin.ts`):
34+
* `POST /admin/oauth2/toggle-disabled` → its own platform-admin gate read
35+
* `POST /admin/set-user-manager` → `gateAdmin`, the shared gate of every
36+
* `/admin/*` mount that calls it
37+
* `POST /admin/unlock-user` → its own platform-admin gate read
38+
* `POST /admin/has-permission` → its own platform-admin branch read
39+
* Each body is one the door answers right after its read, so no second session
40+
* read follows the one under test.
41+
*/
42+
43+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
44+
import { Hono } from 'hono';
45+
import { AuthManager } from './auth-manager';
46+
import { AuthPlugin } from './auth-plugin';
47+
import { createMemoryEngine } from './impersonation-bearer-rotation.test';
48+
import { inviteForAudienceGate } from './audience-gate-test-support';
49+
import type { PluginContext } from '@objectstack/core';
50+
51+
const SECRET = 'test-secret-at-least-32-chars-long!!';
52+
const PASSWORD = 'S3cure!Passw0rd-22258';
53+
const ORIGIN = 'http://localhost:3000';
54+
const BASE = '/api/v1/auth';
55+
const ADMIN_EMAIL = 'admin.22258@example.com';
56+
const MEMBER_EMAIL = 'member.22258@example.com';
57+
/** Clock slack between the server's `now` and this file's, in ms. */
58+
const SLACK_MS = 5_000;
59+
60+
const mockCtx = (): PluginContext =>
61+
({
62+
registerService: vi.fn(),
63+
getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)),
64+
getServices: vi.fn(() => new Map()),
65+
hook: vi.fn(),
66+
trigger: vi.fn(),
67+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
68+
getKernel: vi.fn(),
69+
}) as any;
70+
71+
let engine: ReturnType<typeof createMemoryEngine>;
72+
let manager: AuthManager;
73+
let app: Hono;
74+
let expiresInSec: number;
75+
let updateAgeSec: number;
76+
let memberId: string;
77+
/** The admin's credentials, as a browser and as a bearer client hold them. */
78+
let cookiePair: string;
79+
let bearer: string;
80+
let sessionToken: string;
81+
82+
/** The admin's `sys_session` row, read straight off the engine's table. */
83+
function sessionRow(): Record<string, unknown> {
84+
const row = ((engine.tables.get('sys_session') ?? []) as any[]).find((r) => r.token === sessionToken);
85+
if (!row) throw new Error('pin: the signed-in session row is gone');
86+
return row;
87+
}
88+
89+
const storedExpiry = (): number => new Date(sessionRow().expires_at as any).getTime();
90+
91+
/** Age the session to just past `updateAge` — the card's `now + expiresIn − updateAge − 60 s`. */
92+
function ageSession(): number {
93+
const target = Date.now() + (expiresInSec - updateAgeSec - 60) * 1000;
94+
const row = sessionRow();
95+
row.expires_at = typeof row.expires_at === 'string' ? new Date(target).toISOString() : new Date(target);
96+
const stored = storedExpiry();
97+
expect(Math.abs(stored - target), 'the aging write did not land').toBeLessThan(1_000);
98+
return stored;
99+
}
100+
101+
/** The session-token cookie a response stages, or `null`. */
102+
function sessionCookieOf(res: Response): { maxAgeSec: number | null } | null {
103+
const staged = res.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0]));
104+
if (!staged) return null;
105+
const m = /;\s*max-age=(\d+)/i.exec(staged);
106+
return { maxAgeSec: m ? Number(m[1]) : null };
107+
}
108+
109+
const asCookie = (): Record<string, string> => ({ cookie: cookiePair });
110+
const asBearer = (): Record<string, string> => ({ authorization: `Bearer ${bearer}` });
111+
112+
const fire = (path: string, body: unknown, credential: Record<string, string>) =>
113+
app.request(`${ORIGIN}${BASE}${path}`, {
114+
method: 'POST',
115+
headers: { 'content-type': 'application/json', origin: ORIGIN, ...credential },
116+
body: JSON.stringify(body),
117+
});
118+
119+
/**
120+
* The pin: cookie and session expiry stay ALIGNED — either the session did not
121+
* move and no cookie was staged, or it moved and its cookie was re-issued to
122+
* expire with it.
123+
*/
124+
function expectAligned(label: string, aged: number, after: number, res: Response) {
125+
const cookie = sessionCookieOf(res);
126+
if (after !== aged) {
127+
expect(cookie, `${label}: the session renewed (+${Math.round((after - aged) / 1000)} s) but its cookie was not re-issued`).not.toBeNull();
128+
const cookieExpiry = Date.now() + (cookie!.maxAgeSec ?? 0) * 1000;
129+
expect(Math.abs(cookieExpiry - after), `${label}: re-issued cookie and session expire apart`).toBeLessThan(SLACK_MS);
130+
} else {
131+
expect(cookie, `${label}: a cookie was staged for a session that did not move`).toBeNull();
132+
}
133+
}
134+
135+
beforeAll(async () => {
136+
vi.spyOn(console, 'warn').mockImplementation(() => {});
137+
vi.spyOn(console, 'error').mockImplementation(() => {});
138+
139+
engine = createMemoryEngine();
140+
manager = new AuthManager({
141+
secret: SECRET,
142+
baseUrl: ORIGIN,
143+
dataEngine: engine,
144+
plugins: { admin: true },
145+
} as any);
146+
147+
const direct = (path: string, body: unknown) =>
148+
manager.handleRequest(
149+
new Request(`${ORIGIN}${BASE}${path}`, {
150+
method: 'POST',
151+
headers: { 'Content-Type': 'application/json' },
152+
body: JSON.stringify(body),
153+
}),
154+
);
155+
156+
for (const [email, name] of [
157+
[ADMIN_EMAIL, 'Platform Admin'],
158+
[MEMBER_EMAIL, 'Plain Member'],
159+
]) {
160+
// The default audience posture is invite_only: fixture users beyond the
161+
// first enter through the invitation carve-out (audience-gate-test-support).
162+
await inviteForAudienceGate(manager, email);
163+
const res = await direct('/sign-up/email', { email, password: PASSWORD, name });
164+
expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200);
165+
}
166+
167+
const users = (engine.tables.get('sys_user') ?? []) as any[];
168+
memberId = String(users.find((r) => r.email === MEMBER_EMAIL)!.id);
169+
// The legacy scalar `isPlatformAdminUser` accepts as its documented
170+
// back-compat signal — every door below admits this caller.
171+
users.find((r) => r.email === ADMIN_EMAIL)!.role = 'admin';
172+
173+
// The version this package pins, read off the running instance — never assumed.
174+
const authContext: any = await manager.getAuthContext();
175+
expiresInSec = Number(authContext.sessionConfig.expiresIn);
176+
updateAgeSec = Number(authContext.sessionConfig.updateAge);
177+
178+
const res = await direct('/sign-in/email', { email: ADMIN_EMAIL, password: PASSWORD });
179+
expect(res.status, `sign-in: ${await res.clone().text()}`).toBe(200);
180+
const staged = res.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0]));
181+
if (!staged) throw new Error('pin sign-in staged no session cookie');
182+
cookiePair = staged.split(';')[0];
183+
bearer = String(res.headers.get('set-auth-token') ?? '');
184+
if (!bearer) throw new Error('pin sign-in emitted no set-auth-token');
185+
sessionToken = String(((await res.json()) as any).token);
186+
187+
// The REAL route registration — raw mounts ahead of the catch-all — on a
188+
// real Hono app in front of the real AuthManager.
189+
app = new Hono();
190+
const ctx = mockCtx();
191+
const plugin = new AuthPlugin({ secret: SECRET });
192+
await plugin.init(ctx);
193+
(plugin as any).authManager = manager;
194+
(plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
195+
});
196+
197+
afterAll(() => vi.restoreAllMocks());
198+
199+
describe('[#22258] precondition — this stack renews, and the defect is better-auth\'s own behaviour', () => {
200+
it('reads expiresIn / updateAge off the running better-auth', () => {
201+
expect(expiresInSec).toBeGreaterThan(updateAgeSec);
202+
expect(updateAgeSec).toBeGreaterThan(60);
203+
});
204+
205+
it('a bare in-process getSession on an aged session renews it and stages a cookie nobody sends', async () => {
206+
const aged = ageSession();
207+
const api: any = await manager.getApi();
208+
// No rule: the call every reader in this file used to make.
209+
await api.getSession({ headers: new Headers({ cookie: cookiePair }) });
210+
const after = storedExpiry();
211+
expect(after - aged, 'the fixture does not renew — every pin below would pass vacuously')
212+
.toBeGreaterThan((updateAgeSec - SLACK_MS / 1000) * 1000);
213+
});
214+
});
215+
216+
const DOORS: Array<{ label: string; path: string; body: () => unknown }> = [
217+
{
218+
label: 'POST /admin/oauth2/toggle-disabled',
219+
path: '/admin/oauth2/toggle-disabled',
220+
body: () => ({ client_id: 'cl_pin_22258_absent', disabled: true }),
221+
},
222+
{
223+
label: 'POST /admin/set-user-manager (gateAdmin)',
224+
path: '/admin/set-user-manager',
225+
body: () => ({}),
226+
},
227+
{
228+
label: 'POST /admin/unlock-user',
229+
path: '/admin/unlock-user',
230+
body: () => ({ userId: memberId }),
231+
},
232+
{
233+
label: 'POST /admin/has-permission (platform-admin branch)',
234+
path: '/admin/has-permission',
235+
body: () => ({ permissions: { user: ['list'] } }),
236+
},
237+
];
238+
239+
describe('[#22258] each admin door leaves cookie and session expiry aligned', () => {
240+
for (const door of DOORS) {
241+
it(`${door.label} — by cookie: no renewal, no cookie`, async () => {
242+
const aged = ageSession();
243+
const res = await fire(door.path, door.body(), asCookie());
244+
// Admitted: the read under test resolved the admin (a refusal would be 401/403).
245+
expect([401, 403], `${door.label} refused the admin: ${res.status}`).not.toContain(res.status);
246+
expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500);
247+
const after = storedExpiry();
248+
expectAligned(door.label, aged, after, res);
249+
expect(after, `${door.label}: a cookie request renewed in-process`).toBe(aged);
250+
});
251+
252+
it(`${door.label} — bearer only: renews as before, sets no cookie`, async () => {
253+
const aged = ageSession();
254+
const res = await fire(door.path, door.body(), asBearer());
255+
expect([401, 403], `${door.label} refused the admin: ${res.status}`).not.toContain(res.status);
256+
expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500);
257+
const after = storedExpiry();
258+
expect(after, `${door.label}: a bearer-only read no longer renews`).toBeGreaterThan(aged);
259+
expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), `${door.label}: the renewal is not to now + expiresIn`)
260+
.toBeLessThan(SLACK_MS);
261+
expect(sessionCookieOf(res), `${door.label}: a cookie was set on a response to a request that sent none`).toBeNull();
262+
});
263+
}
264+
});
265+
266+
describe('[#22258] control — the browser-facing get-session still renews and re-issues', () => {
267+
it('renews an aged session and re-issues the cookie with Max-Age = expiresIn', async () => {
268+
const aged = ageSession();
269+
const res = await app.request(`${ORIGIN}${BASE}/get-session`, { headers: { origin: ORIGIN, ...asCookie() } });
270+
expect(res.status).toBe(200);
271+
const after = storedExpiry();
272+
expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), 'get-session did not renew').toBeLessThan(SLACK_MS);
273+
expect(sessionCookieOf(res)?.maxAgeSec, 'get-session did not re-issue the cookie with Max-Age = expiresIn').toBe(expiresInSec);
274+
expectAligned('get-session', aged, after, res);
275+
});
276+
});

0 commit comments

Comments
 (0)