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
14 changes: 14 additions & 0 deletions .changeset/23813-minted-session-no-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-auth': patch
---

Clause-②: no

A session minted by `IAuthService.mintPrincipalSession` is no longer refreshed by a read. Before,
`get-session` (and every endpoint that reads the session) still renewed it on each request: #23772's
guard held its expiry, but the row's `updatedAt` was rewritten, the session cookie was re-set with a
7-day `Max-Age`, and `bearer()` answered `set-auth-token`. The console reads that header as a token
rotation, so a browser carrying a minted cookie stayed on "Opening your console…" and the print
service could not render under the requesting user. A request that carries a minted session now
sets better-auth's own per-request skip-refresh switch, on the cookie lane, the bearer lane and
in-process. The mint's contract, TTL, refusals and revoke are unchanged.
14 changes: 13 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
} from './admin-impersonate-endpoint.js';
import { judgeHeldSession } from './platform-admin-gate.js';
import {
carriesPrincipalSession,
holdPrincipalSessionExpiry,
mintPrincipalSession,
type PrincipalSessionAuthContext,
Expand Down Expand Up @@ -1528,7 +1529,7 @@ export class AuthManager {
*/
private async createAuthInstance(): Promise<Auth<any>> {
const { betterAuth } = await import('better-auth');
const { createAuthMiddleware } = await import('better-auth/api');
const { createAuthMiddleware, setShouldSkipSessionRefresh } = await import('better-auth/api');
const plugins = await this.buildPluginList();
const passwordHasher = await this.resolvePasswordHasher();
const betterAuthConfig: BetterAuthOptions = {
Expand Down Expand Up @@ -2064,6 +2065,17 @@ export class AuthManager {
return actor?.userId ?? null;
});

// ── #23813: a minted principal session is never refreshed ───────
// Also FIRST: the switch must be set before anything in this
// request reads the session (the actor resolver above may, later).
// A renewal of a minted session writes the row and re-sets its
// cookie, so `bearer()` answers `set-auth-token` on every request
// and the console re-resolves identity forever. Request-scoped
// (better-auth's own state), so it reaches the nested session reads
// of every endpoint on both lanes and in-process — see
// `principal-session-mint.ts` (3b).
if (carriesPrincipalSession(ctx)) await setShouldSkipSessionRefresh(true);

// ── #2780: per-number OTP send guard (admission control) ─────
// MUST run BEFORE the phone-number endpoints: better-auth's
// send-otp handler stores a fresh code and only THEN invokes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#23813] A minted principal session is not REFRESHED by a read — not merely
* held to its expiry (#23772's pins), but never renewed at all.
*
* The renewal #23772 clamps still wrote the row (`updatedAt`), re-set the
* session cookie and so made `bearer()` answer `set-auth-token` on every
* request. The console reads that header as a token rotation and re-resolves
* identity, then re-reads its organizations, whose session reads renew again:
* measured on the real console, it never left "Opening your console...". So
* the pin is the wire a client sees: on both lanes, `get-session` and an
* endpoint that reads the session through better-auth's middleware answer
* with no `set-auth-token`, no session `Set-Cookie`, and the row unwritten.
* The controls prove the same reads DO refresh an unbranded short session,
* so the pins cannot pass on a fixture that never renews.
*
* Real better-auth, real request seam (`AuthManager.handleRequest`), the
* `sys_session` row read straight off the engine's table.
*/

import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { createMemoryEngine } from './impersonation-bearer-rotation.test';
import { bootMintFixture, expiresAtMs, MINT_BASE, rawTokenOf, type MintFixture } from './principal-session-mint-test-support';
import { carriesPrincipalSession, PRINCIPAL_SESSION_TOKEN_PREFIX } from './principal-session-mint';

let fx: MintFixture;
let cookieName: string;

beforeAll(async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
fx = await bootMintFixture(createMemoryEngine as any);
cookieName = (await fx.manager.getAuthContext()).authCookies.sessionToken.name;
});

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

/** What a client sees of one request: the status and whether it was handed a (re)issued session. */
async function wire(path: string, headers: Record<string, string>) {
const res = await fx.manager.handleRequest(
new Request(`${fx.origin}${MINT_BASE}${path}`, { method: 'GET', headers: { origin: fx.origin, ...headers } }),
);
const setCookie = res.headers.get('set-cookie') ?? '';
return {
status: res.status,
setAuthToken: res.headers.has('set-auth-token'),
setsSessionCookie: setCookie.split(/,(?=\s*[^;,=\s]+=)/).some((c) => c.trim().startsWith(`${cookieName}=`)),
};
}

const updatedAtMs = (token: string): number => {
const row = fx.row(token);
if (!row) throw new Error('pin: the session row is gone');
return new Date(row.updated_at as string | Date).getTime();
};

/** A short session WITHOUT the brand, signed as the cookie carries it. */
async function plainShortSession(): Promise<{ token: string; signed: string }> {
const ctx: any = await fx.manager.getAuthContext();
const plain = await ctx.internalAdapter.createSession(
fx.userId, false, { expiresAt: new Date(Date.now() + 120_000) }, true,
);
const { makeSignature } = await import('better-auth/crypto');
return { token: plain.token, signed: encodeURIComponent(`${plain.token}.${await makeSignature(plain.token, ctx.secret)}`) };
}

const LANES = {
cookie: (signed: string) => ({ cookie: `${cookieName}=${signed}` }),
bearer: (signed: string) => ({ authorization: `Bearer ${signed}` }),
} as const;

// `/get-session` is the console's boot read; `/list-sessions` reads the session
// through better-auth's `sessionMiddleware`, as the organization reads do.
const PATHS = ['/get-session', '/list-sessions'] as const;

describe('[#23813] a minted session is never refreshed by a read', () => {
for (const [lane, headersOf] of Object.entries(LANES)) {
for (const path of PATHS) {
it(`control (${lane}, ${path}): the same read refreshes a short session WITHOUT the brand`, async () => {
const plain = await plainShortSession();
const before = updatedAtMs(plain.token);
await new Promise((r) => setTimeout(r, 5));

const seen = await wire(path, headersOf(plain.signed));

expect(seen.status).toBe(200);
expect(
{ setAuthToken: seen.setAuthToken, setsSessionCookie: seen.setsSessionCookie },
'the fixture does not refresh — the pin below would pass vacuously',
).toEqual({ setAuthToken: true, setsSessionCookie: true });
expect(updatedAtMs(plain.token)).toBeGreaterThan(before);
});

it(`${lane}, ${path}: answers with no reissued session and leaves the row unwritten`, async () => {
const minted = await fx.manager.mintPrincipalSession(fx.human());
const token = rawTokenOf(minted);
const updatedAt = updatedAtMs(token);
const expiresAt = expiresAtMs(fx.row(token));

for (let i = 0; i < 3; i++) {
await new Promise((r) => setTimeout(r, 5));
const seen = await wire(path, headersOf(minted.token));
expect(seen, `read ${i + 1} reissued the minted session`).toEqual({
status: 200,
setAuthToken: false,
setsSessionCookie: false,
});
expect(updatedAtMs(token), `read ${i + 1} wrote the minted session row`).toBe(updatedAt);
expect(expiresAtMs(fx.row(token)), `read ${i + 1} moved the minted expiry`).toBe(expiresAt);
}
await minted.revoke();
});
}
}
});

describe('[#23813] carriesPrincipalSession', () => {
const ctxOf = (headers: Record<string, string>) => ({
headers: new Headers(headers),
context: { authCookies: { sessionToken: { name: 'better-auth.session_token' } } },
});
const minted = `${PRINCIPAL_SESSION_TOKEN_PREFIX}abc123`;

it('reads the brand on the cookie lane and on every bearer spelling', () => {
expect(carriesPrincipalSession(ctxOf({ cookie: `a=b; better-auth.session_token=${minted}.sig%3D` }))).toBe(true);
expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${minted}.sig` }))).toBe(true);
expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${minted}` }))).toBe(true);
expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${encodeURIComponent(`${minted}.sig=`)}` }))).toBe(true);
});

it('answers false for every other session, and for the brand under another cookie name', () => {
expect(carriesPrincipalSession(ctxOf({ cookie: 'better-auth.session_token=AbC123.sig' }))).toBe(false);
expect(carriesPrincipalSession(ctxOf({ authorization: 'Bearer AbC123.sig' }))).toBe(false);
expect(carriesPrincipalSession(ctxOf({ cookie: `other_cookie=${minted}` }))).toBe(false);
expect(carriesPrincipalSession(ctxOf({}))).toBe(false);
expect(carriesPrincipalSession(undefined)).toBe(false);
});
});
48 changes: 48 additions & 0 deletions packages/plugins/plugin-auth/src/principal-session-mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@
* token, which carries {@link PRINCIPAL_SESSION_TOKEN_PREFIX} — set here,
* by the server, and stored on the row, so every node agrees.
*
* 3b. **No renewal at all on a read** (#23813). Holding `expiresAt` is not
* enough: the renewal still writes the row (`updatedAt`), re-sets the
* session cookie with a 7-day `Max-Age`, and so makes `bearer()` answer
* `set-auth-token` on EVERY request — which the console reads as a token
* rotation and re-resolves identity on, then re-reads its organizations,
* whose reads renew again: the console never leaves boot. So a request
* that carries a minted session ({@link carriesPrincipalSession}) is told
* to skip the refresh outright, through better-auth's own per-request
* switch (`setShouldSkipSessionRefresh`, the one its Next.js plugin sets
* for RSC reads), from the global before-hook that every endpoint passes
* first — `get-session` and every endpoint that reads the session, on the
* cookie lane, the bearer lane and in-process. The hold above stays as
* the backstop for any renewal that does not ask that switch.
*
* 4. **The revoke.** Bound to the one session minted, idempotent, and a
* plain `internalAdapter.deleteSession`: afterwards the token and the
* cookie resolve no session on either lane.
Expand Down Expand Up @@ -90,6 +104,40 @@ export function isPrincipalSessionToken(token: unknown): boolean {
return typeof token === 'string' && token.startsWith(PRINCIPAL_SESSION_TOKEN_PREFIX);
}

/**
* [#23813] True when a better-auth endpoint context carries a minted principal
* session — as the session cookie (its signed value starts with the raw
* token) or as `Authorization: Bearer` (signed, URL-encoded or raw; the brand
* leads every spelling). Read off the request before `bearer()` folds the
* header into the cookie, so both lanes answer the same.
*
* Unverified on purpose: the one thing it decides is that this request does
* not renew the session, which grants nothing. A forged brand resolves no
* session at all (no other token carries it), and the signature is still
* checked where the session is read.
*/
export function carriesPrincipalSession(ctx: unknown): boolean {
const c = ctx as {
headers?: { get?(name: string): string | null };
request?: { headers?: { get?(name: string): string | null } };
context?: { authCookies?: { sessionToken?: { name?: string } } };
};
const header = (name: string): string => c?.headers?.get?.(name) ?? c?.request?.headers?.get?.(name) ?? '';

const bearer = /^Bearer\s+(\S+)/i.exec(header('authorization'));
if (bearer && isPrincipalSessionToken(bearer[1])) return true;

const cookieName = c?.context?.authCookies?.sessionToken?.name;
if (!cookieName) return false;
for (const pair of header('cookie').split(';')) {
const eq = pair.indexOf('=');
if (eq > 0 && pair.slice(0, eq).trim() === cookieName && isPrincipalSessionToken(pair.slice(eq + 1).trim())) {
return true;
}
}
return false;
}

/**
* A refusal of the mint, thrown before anything is minted. `code` and
* `status` follow the ADR-0112 envelope, so a door that relays it answers
Expand Down
1 change: 1 addition & 0 deletions packages/qa/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@objectstack/setup": "workspace:*",
"@objectstack/trigger-api": "workspace:*",
"@types/node": "^26.6.3",
"playwright-core": "1.63.0",
"typescript": "^6.0.3",
"vitest": "^4.1.11"
}
Expand Down
Loading
Loading