Skip to content

Commit 0cfd0c4

Browse files
fix(plugin-auth): a minted principal session is never refreshed by a read, so it boots the console (#23828)
Fixes #23813 Clause-②: no ## What was wrong better-auth refreshed a session minted by `mintPrincipalSession` on **every** read. better-auth renews a session once `expiresAt - expiresIn + updateAge` has passed, and a 120-second session is past that from its first read. #23772's `holdPrincipalSessionExpiry` kept `expiresAt` where it was, but the refresh still ran. On each request it: - rewrote the row's `updatedAt`, - re-set the session cookie with a 7-day `Max-Age`, - caused `bearer()` to answer `set-auth-token`. The console's auth client (objectui `TokenStorage.subscribeRotation` in `AuthProvider`) treats `set-auth-token` as a token rotation and runs `loadSession` again. The new `user` object then re-runs `refreshOrganizations`, and the organization reads refresh the session again. The console never leaves "Opening your console…". ## Measured cause Both of the card's hypotheses were tested on the same stack. The only variable toggled was the refresh skip; the minted `activeOrganizationId` stayed the same. | Stack (the e2e below, real console at `.objectui-sha` `daeb77fc84`, Chromium 141) | admin, minted | member, minted | admin, normal sign-in | |---|---|---|---| | defect restored (ablation) | never booted: 553 `get-session` in 30 s, 1238 `set-auth-token` responses, 65 more `get-session` in the 3 s after | never booted: 651 / 1433 / 64 | boots | | this PR | boots, 1 `get-session`, 0 `set-auth-token`, 0 after | boots, same | boots | - **Hypothesis 1 (the read-time renewal write) is the cause.** Restoring only the refresh brings the loop back, and removing it ends the loop. - **Hypothesis 2 (the active organization) is ruled out.** The same `activeOrganizationId` boots once the refresh is gone. At the wire, a scratch probe on the real better-auth (`AuthManager.handleRequest`) before the fix showed every minted `get-session` on both lanes answering `set-auth-token` plus a session `Set-Cookie`, with `updated_at` advancing on every read and `expires_at` held. After the fix: no `set-auth-token`, no `Set-Cookie`, and `updated_at` unchanged. ## The fix (`packages/plugins/plugin-auth`) - `principal-session-mint.ts`: adds `carriesPrincipalSession(ctx)`. It answers true when the request carries a minted session, either as the session cookie or as `Authorization: Bearer` (every spelling). It reads only the token brand and verifies nothing. That is deliberate: all it decides is "do not renew", which grants nothing. The module header gains section 3b. - `auth-manager.ts`: the global `hooks.before` is the first hook every endpoint passes, before `bearer()`. When the request carries a minted session, it calls better-auth's own per-request switch `setShouldSkipSessionRefresh(true)`. better-auth's Next.js plugin sets the same switch for RSC reads. The switch is request-scoped, so it covers `get-session` and every endpoint that reads the session through better-auth's middleware (the organization reads included), on the cookie lane, the bearer lane and in-process. - `holdPrincipalSessionExpiry` stays as the backstop for any renewal that does not ask the switch. No `IAuthService` contract change, nothing in `packages/spec`. The built declaration closure of `@objectstack/plugin-auth` (`index.d.ts`, `index.d.mts`, `rate-limit-storage.d.ts`, `rate-limit-storage.d.mts`) is byte-identical at base `d7957199` and at head (sha256 compared after a build of each). Hence `Clause-②: no`. ### Alternatives (four axes, in short) - **A signed dont-remember cookie.** This needs a second cookie in `PrincipalSession`, which is a `packages/spec` contract change. It would also hold only on the cookie lane: a bearer request carries no cookie. Rejected. - **Returning `false` from `session.update.before`.** `updateSession` would then answer null, and `get-session` deletes the cookie and throws 401. Rejected. - **Changing the console's rotation handling.** That works around the problem in the consumer. The producer (the server renewing a credential it promised never to extend) is where the defect lives. Rejected. ## Pins - `plugin-auth/src/principal-session-mint.no-refresh.pin.test.ts`: a real better-auth through the real request seam, cookie and bearer lanes × `/get-session` and `/list-sessions` (which reads the session through `sessionMiddleware`, as the organization reads do). Each read must answer with no `set-auth-token`, no session `Set-Cookie`, and `updated_at` / `expires_at` unchanged. A control proves the same read does refresh an unbranded short session, so the pin cannot pass vacuously. Unit cases cover `carriesPrincipalSession`. - `qa/dogfood/test/principal-session-console-boot.dogfood.test.ts`: the end-to-end pin. The showcase is booted in-process, with the built console composed into its plugins by the CLI's own `createConsoleStaticPlugin` (what `os serve` mounts). Each browser request goes to the real Hono app from a real Chromium. The minted cookie is set exactly as handed out. The test pins boot complete, no `get-session` in the settle window, and no `set-auth-token`, for an administrator and a member. A normal sign-in is the control. It needs the built console and a Chromium, and says `SKIPPED` without them. `playwright-core` `1.63.0` (already in the lockfile) becomes a devDependency of the private `@objectstack/dogfood`. - #23772's pins (`principal-session-mint.test.ts`, `.cookie-lane.pin`, `.bearer-lane.pin`: TTL, no extension, revoke, refusals) are unchanged and green. ### Ablation The defect was put back with `scripts/ablation-replace.mjs` on the `auth-manager.ts` line, gating the switch on an env var that is never set: injected 1, removed 0. The marker was confirmed in `dist/` by `ablation-dist-preflight` (exit 0). Results: - No-refresh pin: 4 red, `read 1 reissued the minted session`. #23772's lane pins stayed green, as they should: the hold still holds. - E2E: both minted cases red with the numbers in the table above; the control stayed green. Restored with `git checkout HEAD --`. The HEAD-blob hash matched, `git diff HEAD` was empty, and after a rebuild the marker was absent from `dist/` (exit 0 on the dist reading). ## Tests and gates (head `d4839df3ad`) - `@objectstack/plugin-auth`: `pnpm --filter @objectstack/plugin-auth test` → 150 files, 2942 passed, 10 skipped (pre-existing), exit 0. `typecheck` exit 0. - `@objectstack/dogfood`: `test/principal-session-console-boot.dogfood.test.ts` → 3/3 passed with the built console (`daeb77fc84`) and Chromium (`/opt/pw-browsers/chromium`). `typecheck` exit 0. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gave 82 commands. Each was run with its exit code recorded before any pipe and stdin from `/dev/null`: 82 × exit 0. `--ran`: "82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED". One real red on the first pass was fixed: `check:test-source-alias` asked for `@objectstack/cli/console` to be aliased to source in dogfood's vitest config. Two first-pass exit-3 results were prerequisites, now met: the shallow clone was deepened (`git fetch --unshallow origin main`) for `check-plugin-teardown-shape --self-test`, and the 8 packages `check:dual-build-cjs-loads` needed were built. ## Acceptance notes - The e2e pin skips wherever the console is not built or no Chromium exists, which likely includes `Dogfood Regression Gate` on CI. The wire half of the pin (`no-refresh.pin`) runs everywhere. - `plugin-auth`'s `typecheck` excludes `**/*.test.ts`, so the new unit pin is type-checked by nothing local. Vitest transpiles it. `@objectstack/dogfood`'s typecheck does cover the e2e file. - `pnpm install` also re-resolved `packages/apps/account`'s vitest peer to `esbuild 0.28.1`. That is pnpm's own output and was not hand-edited; #22269's dev recorded the same churn. - #22269's PR #23811 binds the mint after this lands. `packages/services/service-print/**` was not touched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA --- _Generated by [Claude Code](https://claude.ai/code/session_0138Lk1GC4xpkss8esKzWVFA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 43567a8 commit 0cfd0c4

8 files changed

Lines changed: 509 additions & 2 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
Clause-②: no
6+
7+
A session minted by `IAuthService.mintPrincipalSession` is no longer refreshed by a read. Before,
8+
`get-session` (and every endpoint that reads the session) still renewed it on each request: #23772's
9+
guard held its expiry, but the row's `updatedAt` was rewritten, the session cookie was re-set with a
10+
7-day `Max-Age`, and `bearer()` answered `set-auth-token`. The console reads that header as a token
11+
rotation, so a browser carrying a minted cookie stayed on "Opening your console…" and the print
12+
service could not render under the requesting user. A request that carries a minted session now
13+
sets better-auth's own per-request skip-refresh switch, on the cookie lane, the bearer lane and
14+
in-process. The mint's contract, TTL, refusals and revoke are unchanged.

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
} from './admin-impersonate-endpoint.js';
104104
import { judgeHeldSession } from './platform-admin-gate.js';
105105
import {
106+
carriesPrincipalSession,
106107
holdPrincipalSessionExpiry,
107108
mintPrincipalSession,
108109
type PrincipalSessionAuthContext,
@@ -1528,7 +1529,7 @@ export class AuthManager {
15281529
*/
15291530
private async createAuthInstance(): Promise<Auth<any>> {
15301531
const { betterAuth } = await import('better-auth');
1531-
const { createAuthMiddleware } = await import('better-auth/api');
1532+
const { createAuthMiddleware, setShouldSkipSessionRefresh } = await import('better-auth/api');
15321533
const plugins = await this.buildPluginList();
15331534
const passwordHasher = await this.resolvePasswordHasher();
15341535
const betterAuthConfig: BetterAuthOptions = {
@@ -2064,6 +2065,17 @@ export class AuthManager {
20642065
return actor?.userId ?? null;
20652066
});
20662067

2068+
// ── #23813: a minted principal session is never refreshed ───────
2069+
// Also FIRST: the switch must be set before anything in this
2070+
// request reads the session (the actor resolver above may, later).
2071+
// A renewal of a minted session writes the row and re-sets its
2072+
// cookie, so `bearer()` answers `set-auth-token` on every request
2073+
// and the console re-resolves identity forever. Request-scoped
2074+
// (better-auth's own state), so it reaches the nested session reads
2075+
// of every endpoint on both lanes and in-process — see
2076+
// `principal-session-mint.ts` (3b).
2077+
if (carriesPrincipalSession(ctx)) await setShouldSkipSessionRefresh(true);
2078+
20672079
// ── #2780: per-number OTP send guard (admission control) ─────
20682080
// MUST run BEFORE the phone-number endpoints: better-auth's
20692081
// send-otp handler stores a fresh code and only THEN invokes
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23813] A minted principal session is not REFRESHED by a read — not merely
5+
* held to its expiry (#23772's pins), but never renewed at all.
6+
*
7+
* The renewal #23772 clamps still wrote the row (`updatedAt`), re-set the
8+
* session cookie and so made `bearer()` answer `set-auth-token` on every
9+
* request. The console reads that header as a token rotation and re-resolves
10+
* identity, then re-reads its organizations, whose session reads renew again:
11+
* measured on the real console, it never left "Opening your console...". So
12+
* the pin is the wire a client sees: on both lanes, `get-session` and an
13+
* endpoint that reads the session through better-auth's middleware answer
14+
* with no `set-auth-token`, no session `Set-Cookie`, and the row unwritten.
15+
* The controls prove the same reads DO refresh an unbranded short session,
16+
* so the pins cannot pass on a fixture that never renews.
17+
*
18+
* Real better-auth, real request seam (`AuthManager.handleRequest`), the
19+
* `sys_session` row read straight off the engine's table.
20+
*/
21+
22+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
23+
import { createMemoryEngine } from './impersonation-bearer-rotation.test';
24+
import { bootMintFixture, expiresAtMs, MINT_BASE, rawTokenOf, type MintFixture } from './principal-session-mint-test-support';
25+
import { carriesPrincipalSession, PRINCIPAL_SESSION_TOKEN_PREFIX } from './principal-session-mint';
26+
27+
let fx: MintFixture;
28+
let cookieName: string;
29+
30+
beforeAll(async () => {
31+
vi.spyOn(console, 'warn').mockImplementation(() => {});
32+
vi.spyOn(console, 'error').mockImplementation(() => {});
33+
fx = await bootMintFixture(createMemoryEngine as any);
34+
cookieName = (await fx.manager.getAuthContext()).authCookies.sessionToken.name;
35+
});
36+
37+
afterAll(() => vi.restoreAllMocks());
38+
39+
/** What a client sees of one request: the status and whether it was handed a (re)issued session. */
40+
async function wire(path: string, headers: Record<string, string>) {
41+
const res = await fx.manager.handleRequest(
42+
new Request(`${fx.origin}${MINT_BASE}${path}`, { method: 'GET', headers: { origin: fx.origin, ...headers } }),
43+
);
44+
const setCookie = res.headers.get('set-cookie') ?? '';
45+
return {
46+
status: res.status,
47+
setAuthToken: res.headers.has('set-auth-token'),
48+
setsSessionCookie: setCookie.split(/,(?=\s*[^;,=\s]+=)/).some((c) => c.trim().startsWith(`${cookieName}=`)),
49+
};
50+
}
51+
52+
const updatedAtMs = (token: string): number => {
53+
const row = fx.row(token);
54+
if (!row) throw new Error('pin: the session row is gone');
55+
return new Date(row.updated_at as string | Date).getTime();
56+
};
57+
58+
/** A short session WITHOUT the brand, signed as the cookie carries it. */
59+
async function plainShortSession(): Promise<{ token: string; signed: string }> {
60+
const ctx: any = await fx.manager.getAuthContext();
61+
const plain = await ctx.internalAdapter.createSession(
62+
fx.userId, false, { expiresAt: new Date(Date.now() + 120_000) }, true,
63+
);
64+
const { makeSignature } = await import('better-auth/crypto');
65+
return { token: plain.token, signed: encodeURIComponent(`${plain.token}.${await makeSignature(plain.token, ctx.secret)}`) };
66+
}
67+
68+
const LANES = {
69+
cookie: (signed: string) => ({ cookie: `${cookieName}=${signed}` }),
70+
bearer: (signed: string) => ({ authorization: `Bearer ${signed}` }),
71+
} as const;
72+
73+
// `/get-session` is the console's boot read; `/list-sessions` reads the session
74+
// through better-auth's `sessionMiddleware`, as the organization reads do.
75+
const PATHS = ['/get-session', '/list-sessions'] as const;
76+
77+
describe('[#23813] a minted session is never refreshed by a read', () => {
78+
for (const [lane, headersOf] of Object.entries(LANES)) {
79+
for (const path of PATHS) {
80+
it(`control (${lane}, ${path}): the same read refreshes a short session WITHOUT the brand`, async () => {
81+
const plain = await plainShortSession();
82+
const before = updatedAtMs(plain.token);
83+
await new Promise((r) => setTimeout(r, 5));
84+
85+
const seen = await wire(path, headersOf(plain.signed));
86+
87+
expect(seen.status).toBe(200);
88+
expect(
89+
{ setAuthToken: seen.setAuthToken, setsSessionCookie: seen.setsSessionCookie },
90+
'the fixture does not refresh — the pin below would pass vacuously',
91+
).toEqual({ setAuthToken: true, setsSessionCookie: true });
92+
expect(updatedAtMs(plain.token)).toBeGreaterThan(before);
93+
});
94+
95+
it(`${lane}, ${path}: answers with no reissued session and leaves the row unwritten`, async () => {
96+
const minted = await fx.manager.mintPrincipalSession(fx.human());
97+
const token = rawTokenOf(minted);
98+
const updatedAt = updatedAtMs(token);
99+
const expiresAt = expiresAtMs(fx.row(token));
100+
101+
for (let i = 0; i < 3; i++) {
102+
await new Promise((r) => setTimeout(r, 5));
103+
const seen = await wire(path, headersOf(minted.token));
104+
expect(seen, `read ${i + 1} reissued the minted session`).toEqual({
105+
status: 200,
106+
setAuthToken: false,
107+
setsSessionCookie: false,
108+
});
109+
expect(updatedAtMs(token), `read ${i + 1} wrote the minted session row`).toBe(updatedAt);
110+
expect(expiresAtMs(fx.row(token)), `read ${i + 1} moved the minted expiry`).toBe(expiresAt);
111+
}
112+
await minted.revoke();
113+
});
114+
}
115+
}
116+
});
117+
118+
describe('[#23813] carriesPrincipalSession', () => {
119+
const ctxOf = (headers: Record<string, string>) => ({
120+
headers: new Headers(headers),
121+
context: { authCookies: { sessionToken: { name: 'better-auth.session_token' } } },
122+
});
123+
const minted = `${PRINCIPAL_SESSION_TOKEN_PREFIX}abc123`;
124+
125+
it('reads the brand on the cookie lane and on every bearer spelling', () => {
126+
expect(carriesPrincipalSession(ctxOf({ cookie: `a=b; better-auth.session_token=${minted}.sig%3D` }))).toBe(true);
127+
expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${minted}.sig` }))).toBe(true);
128+
expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${minted}` }))).toBe(true);
129+
expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${encodeURIComponent(`${minted}.sig=`)}` }))).toBe(true);
130+
});
131+
132+
it('answers false for every other session, and for the brand under another cookie name', () => {
133+
expect(carriesPrincipalSession(ctxOf({ cookie: 'better-auth.session_token=AbC123.sig' }))).toBe(false);
134+
expect(carriesPrincipalSession(ctxOf({ authorization: 'Bearer AbC123.sig' }))).toBe(false);
135+
expect(carriesPrincipalSession(ctxOf({ cookie: `other_cookie=${minted}` }))).toBe(false);
136+
expect(carriesPrincipalSession(ctxOf({}))).toBe(false);
137+
expect(carriesPrincipalSession(undefined)).toBe(false);
138+
});
139+
});

‎packages/plugins/plugin-auth/src/principal-session-mint.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@
5454
* token, which carries {@link PRINCIPAL_SESSION_TOKEN_PREFIX} — set here,
5555
* by the server, and stored on the row, so every node agrees.
5656
*
57+
* 3b. **No renewal at all on a read** (#23813). Holding `expiresAt` is not
58+
* enough: the renewal still writes the row (`updatedAt`), re-sets the
59+
* session cookie with a 7-day `Max-Age`, and so makes `bearer()` answer
60+
* `set-auth-token` on EVERY request — which the console reads as a token
61+
* rotation and re-resolves identity on, then re-reads its organizations,
62+
* whose reads renew again: the console never leaves boot. So a request
63+
* that carries a minted session ({@link carriesPrincipalSession}) is told
64+
* to skip the refresh outright, through better-auth's own per-request
65+
* switch (`setShouldSkipSessionRefresh`, the one its Next.js plugin sets
66+
* for RSC reads), from the global before-hook that every endpoint passes
67+
* first — `get-session` and every endpoint that reads the session, on the
68+
* cookie lane, the bearer lane and in-process. The hold above stays as
69+
* the backstop for any renewal that does not ask that switch.
70+
*
5771
* 4. **The revoke.** Bound to the one session minted, idempotent, and a
5872
* plain `internalAdapter.deleteSession`: afterwards the token and the
5973
* cookie resolve no session on either lane.
@@ -90,6 +104,40 @@ export function isPrincipalSessionToken(token: unknown): boolean {
90104
return typeof token === 'string' && token.startsWith(PRINCIPAL_SESSION_TOKEN_PREFIX);
91105
}
92106

107+
/**
108+
* [#23813] True when a better-auth endpoint context carries a minted principal
109+
* session — as the session cookie (its signed value starts with the raw
110+
* token) or as `Authorization: Bearer` (signed, URL-encoded or raw; the brand
111+
* leads every spelling). Read off the request before `bearer()` folds the
112+
* header into the cookie, so both lanes answer the same.
113+
*
114+
* Unverified on purpose: the one thing it decides is that this request does
115+
* not renew the session, which grants nothing. A forged brand resolves no
116+
* session at all (no other token carries it), and the signature is still
117+
* checked where the session is read.
118+
*/
119+
export function carriesPrincipalSession(ctx: unknown): boolean {
120+
const c = ctx as {
121+
headers?: { get?(name: string): string | null };
122+
request?: { headers?: { get?(name: string): string | null } };
123+
context?: { authCookies?: { sessionToken?: { name?: string } } };
124+
};
125+
const header = (name: string): string => c?.headers?.get?.(name) ?? c?.request?.headers?.get?.(name) ?? '';
126+
127+
const bearer = /^Bearer\s+(\S+)/i.exec(header('authorization'));
128+
if (bearer && isPrincipalSessionToken(bearer[1])) return true;
129+
130+
const cookieName = c?.context?.authCookies?.sessionToken?.name;
131+
if (!cookieName) return false;
132+
for (const pair of header('cookie').split(';')) {
133+
const eq = pair.indexOf('=');
134+
if (eq > 0 && pair.slice(0, eq).trim() === cookieName && isPrincipalSessionToken(pair.slice(eq + 1).trim())) {
135+
return true;
136+
}
137+
}
138+
return false;
139+
}
140+
93141
/**
94142
* A refusal of the mint, thrown before anything is minted. `code` and
95143
* `status` follow the ADR-0112 envelope, so a door that relays it answers

‎packages/qa/dogfood/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@objectstack/setup": "workspace:*",
5353
"@objectstack/trigger-api": "workspace:*",
5454
"@types/node": "^26.6.3",
55+
"playwright-core": "1.63.0",
5556
"typescript": "^6.0.3",
5657
"vitest": "^4.1.11"
5758
}

0 commit comments

Comments
 (0)