Skip to content

Commit 9cb9d95

Browse files
feat(spec,plugin-auth): a short-lived principal-session mint, IAuthService.mintPrincipalSession, implemented by AuthManager (#23786)
Fixes #23772 Clause-②: yes ## What this does S2 of #22269: a short-lived session for the requesting user's OWN principal, so an in-process service (the print service's headless browser, local or remote) can load a page as that user, never as a system principal (#8346 ruling B′, M2). - **`@objectstack/spec`** (`contracts/auth-service.ts`): one optional `IAuthService` member, `mintPrincipalSession(context, options?)`, its types (`PrincipalSession`, `PrincipalSessionCookie`, `PrincipalSessionMintOptions`), and `PRINCIPAL_SESSION_MAX_TTL_SECONDS = 120`. The `api-surface/` and `export-origins/` shards are regenerated: 4 exports added, 0 removed. - **`@objectstack/plugin-auth`**: `AuthManager.mintPrincipalSession`, which delegates to the new `principal-session-mint.ts`. That module owns the refusals, the TTL, the non-extension guard and the revoke. `composeDatabaseHooks` gains one `session.update.before` hook. A host hook runs first and its `false` is honoured. - No HTTP route. The impersonation paths are untouched. ## The mechanism, measured against better-auth 1.7.3 (`node_modules`, not the range) | Zone 2 hypothesis | Verdict | |:--|:--| | 1. Only two `internalAdapter.createSession` callers | **Holds, re-measured.** `admin-impersonate-endpoint.ts:325` (it was `:228`) and `impersonation-bearer-rotation.ts:251`. Both were read for the shape. The mint calls `createSession(userId, false, { token, expiresAt, activeOrganizationId }, true)`. `overrideAll` is required because `createSession` writes `expiresAt` after the override spread (`dist/db/internal-adapter.mjs:261-277`). Neither impersonation path is used. `impersonatedBy` stays unset. | | 2. The refresh trap, on both lanes | **Holds, and the bearer lane is worse than the card said.** `get-session` renews when `expiresAt - expiresIn + updateAge` is in the past (`dist/api/routes/session.mjs:180`), and a 120 s session meets that on its first read. It then writes `now + expiresIn` (`:200`). Only a signed dont-remember cookie or `disableRefresh` skips this (`:170`). `bearer()` rewrites only the session-token cookie (`dist/plugins/bearer/index.mjs:46`), and the in-process bearer read renews by design (`inProcessSessionReadInput`). So a client-side defence covers the cookie lane only. **The defence is server-side, on the one write every renewal makes**: `internalAdapter.updateSession` runs `session.update.before` before it writes, under secondary storage too (`dist/db/with-hooks.mjs:43-60`). `holdPrincipalSessionExpiry` pins a minted session's `expiresAt` to its stored value. A minted session is identified by its token prefix `psm_`, which the server sets and stores on the row. The non-extension is pinned on each lane: cookie over HTTP, bearer over HTTP, and the in-process bearer read. | | 3. TTL short, fixed maximum | **120 s, a ceiling the caller cannot raise.** A larger `ttlSeconds` is **refused** (`VALIDATION_ERROR` / `400`), not clamped (axis 3: a loud contract over silent tolerance). 120 s is the design report's number: a local render measured about 1.3 s (launch 954 ms, render 341 ms), and the rest is slack for the remote placement and page load. | | 4. Revoke reads as no session on both lanes | **Holds.** `revoke()` is `deleteSession`, bound to the one token and idempotent. Afterwards both HTTP lanes answer `401 UNAUTHENTICATED`, the platform's ruled no-session answer (`anonymous-session-refusal.ts`). The in-process read answers `null`, and the row is gone. | | 5. Refusals before any mint; the predicate reused | **Holds.** No `ExecutionContext`-level "is human" predicate exists. `git grep` finds only `isGuestPrincipal` (plugin-security and plugin-sharing) and `isPerfDisclosurePrincipal`. The judgement is therefore the spec's own declaration: `principalKind` absent means `human`, plus `isSystem`. On the row it reuses `isHumanUserRow` (the predicate the audience gate and dev seed read). On posture it reuses `judgeHeldSession`, the same judge the impersonation endpoint uses, over the carried `authGate` **and** a live `resolveUserAuthGates`. An API key is not told apart: ADR-0090 D10 rules a key a credential of its owner, so its context is the owner's human principal (see the open questions). | | 6. Cookie names honour `cookiePrefix` / `__Secure-` | **Holds.** Name and attributes are `authContext.authCookies.sessionToken`, better-auth's derivation from the `advanced.cookiePrefix` / `useSecureCookies` that `AuthManager` passes (`auth-manager.ts` `advanced` block). Nothing is restated. Pinned: `better-auth.session_token` on http; `__Secure-acme.session_token` (secure) under prefix `acme`, and that cookie authenticates. | | 7. Clause-② on two surfaces; existing implementers compile | **Holds.** No class in `packages/` declares `implements IAuthService`. The 8 files that name the type are readers (`IAuthService \| undefined`) or the slot map (`core-service-contracts.ts`), and an optional member changes nothing for them. A compile-time pin assigns `AuthManager.mintPrincipalSession` to the contract's own member type (`IAuthService` with every member required, indexed at `mintPrincipalSession`), so the two cannot drift. | Refusal order, all thrown as `PrincipalSessionMintRefusalError` (`code` + `status`) before `createSession` is reached: 1. No user, `isSystem`, or a non-`human` `principalKind`: `PERMISSION_DENIED` / `403`. 2. `ttlSeconds` outside 1..120, or not an integer: `VALIDATION_ERROR` / `400`. 3. A carried `authGate`: `403`, with the posture code. 4. User row missing, not human, or banned with no lapse: `PERMISSION_DENIED` / `403`. 5. A live posture: `403`, with the posture code. ## Pins - `principal-session-mint.cookie-lane.pin.test.ts`: a control (an unbranded 120 s session IS renewed by the same read); the minted cookie authenticates and its expiry does not move over 3 reads, in the row and in the answer; revoke. - `principal-session-mint.bearer-lane.pin.test.ts`: a control; HTTP bearer over 3 reads; the in-process bearer read; revoke on both. - `principal-session-mint.test.ts`: every refusal asserts `code` + `status`, that `createSession` was never called, and that no `sys_session` row appeared. Also: TTL default and a shorter ask, the active organization, the brand, the cookie on http, the prefixed `__Secure-` cookie, the guard function itself, and the contract-assignability pin. - `packages/spec/src/contracts/auth-service.test.ts`: the member stays optional; the ceiling is 120. **Ablation** (one-shot, `scripts/ablation-replace.mjs`, on the committed fix). `const held = holdPrincipalSessionExpiry(draft, ctx);` was replaced with `const held: any = undefined;`. The anchor went 1 to 0, blob `00e04f1c266a` to `eb8c5dc5e4fc`. The tests import the module from `src` (relative), so no dist is involved. Result: **3 failed / 24 passed**. All three non-extension pins went red (cookie, bearer HTTP, bearer in-process); the controls and revokes stayed green. Restore was proven: the blob equals HEAD `00e04f1c266a` and `git diff HEAD` is empty. ## Tests and gates All readings are on this branch. Code and tests were measured at `e3db58a8`, and the final head `53e173d2` adds one docs row on top. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` (whole package): **149 files, 2921 passed, 10 skipped, 0 failed**. - The three mint files alone: 60/60, then 61/61 after the contract-assignability pin. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. That covers `tsc` on src, the examples config, and `check:test-typecheck` (OK; the debt is unchanged at 10 files / 88 errors / 23 pinned signatures, and the new tests add none). - `pnpm --filter @objectstack/spec exec vitest run src/contracts/auth-service.test.ts`: 9/9. `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/spec build && … check:generated`, re-run after merging `origin/main`: all 14 artifacts are up to date. Before that, `--fix` regenerated only the two shards it proved stale. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final head: 111 families. `--ran` reconciliation: **111 derived, 107 run, 4 NOT MEASURED, 0 unrun**. Every one of the 107 that ran exited 0. - `node scripts/check-system-context-census.mjs` first exited 1 with a real finding: the mint's system-context refusal had no row on `content/docs/permissions/system-context.mdx`. Row 61b was added and the census now passes. That docs page is outside the claimed file surface; the gate requires it. - NOT MEASURED, exit 3 (the gate refused its own prerequisite): `check-plugin-teardown-shape.mjs --self-test` (its fixture commit is outside this shallow clone), `pnpm check:dual-build-cjs-loads` (41 workspace packages have no local dist), and `pnpm --filter @objectstack/spec run check:skill-examples` (another package's declarations are absent). CI runs all three. - NOT MEASURED, exit 124: `pnpm check:type-check-debt` hit a 300 s local timeout before the whole-workspace ratchet reached a verdict. CI runs it. - Local-only `pnpm check:dev-prereqs`: exit 1, because 2 unrelated packages (`cli`, `organizations`) have no local dist. It does not flag spec, and it is not a reading of this diff. ## Acceptance notes - A minted session fires the existing `session.create.after` audit writer, so each mint leaves a `login` row for the user with no endpoint in its context. This is an observation; the consumer card may want to say whether a render should be audited differently. - While a minted session lives (at most 120 s), ADR-0069 D4's concurrent-session cap sweep counts it if the user signs in elsewhere in that window. This is an observation. - The line `[AuthManager] better-auth returned error: 500 {"message":"Failed to find admin session"}` appears once per fixture boot in these suites and 14 times across the existing plugin-auth suite. It predates this PR. - Line budget: +963 / -2 (965) against the card's estimate of about 560–640. The excess is test and module prose: three pin files plus a shared fixture (about 480 lines), and a module header that states each measured trap. --- _Generated by [Claude Code](https://claude.ai/code/session_01QBUMRzs2CSSq7QCTU1gT2j)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 46ca793 commit 9cb9d95

12 files changed

Lines changed: 964 additions & 2 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
feat(spec,plugin-auth): a short-lived principal-session mint, `IAuthService.mintPrincipalSession`, implemented by `AuthManager`
7+
8+
Clause-②: yes (widening)
9+
10+
An in-process service can now act as the requesting user through a door that only accepts a session, such as a headless browser rendering a page the user may see, under the user's own principal and never a system one. Nothing is removed and no HTTP route is added.
11+
12+
- **`@objectstack/spec`** (`contracts`): an optional `IAuthService` member, `mintPrincipalSession(context, options?)`, its types `PrincipalSession`, `PrincipalSessionCookie` and `PrincipalSessionMintOptions`, and the constant `PRINCIPAL_SESSION_MAX_TTL_SECONDS` (`120`). The member is optional, so every existing `IAuthService` implementation compiles unchanged.
13+
- **`@objectstack/plugin-auth`**: `AuthManager.mintPrincipalSession(context, options?)`.
14+
15+
What the mint guarantees:
16+
17+
- **Human principals only, refused before anything is minted.** A context with no user, a system context, or a `principalKind` other than `human` gets `PERMISSION_DENIED` / `403`. So does a user row that is missing, the system user or banned. A user an authentication policy holds gets `403` with the policy's own code (`MFA_REQUIRED`, `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED`), whether the posture rides on the context or is computed at the call. A `ttlSeconds` outside 1 to 120 gets `VALIDATION_ERROR` / `400`: it is refused, never clamped.
18+
- **Short and never renewed.** The session lives at most 120 seconds. Reading it never extends it, on the cookie lane, the bearer lane or the in-process session read alike.
19+
- **Revocable.** `revoke()` ends the session. Afterwards neither the token nor the cookie resolves one. It is bound to that session alone and safe to call twice, so call it in a `finally`.
20+
- **The deployment's own cookie.** The cookie carries the name and attributes of the plugin's session cookie, the cookie prefix and, on a secure deployment, `__Secure-` included. The session's active organization is the context's `tenantId`.
21+
22+
```ts
23+
const auth = ctx.getService<IAuthService>('auth');
24+
const session = await auth.mintPrincipalSession?.(context, { ttlSeconds: 60 });
25+
try {
26+
// send session.token as `Authorization: Bearer`, or set session.cookie in a browser
27+
} finally {
28+
await session?.revoke();
29+
}
30+
```

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ One of the largest consumers of the flag;
191191
| 59 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` |
192192
| 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` |
193193
| 61 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `packages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient` |
194+
| 61b | **The principal-session mint refuses a system context** | plugin-auth | Get: nothing — `mintPrincipalSession` answers `PERMISSION_DENIED` / `403` before anything is minted. A principal session acts as the requesting user and never as the system, so a render or any other in-process act that needs a session must carry the user's own context | `packages/plugins/plugin-auth/src/principal-session-mint.ts#refuseUpFront` |
194195
| 62 | **`organization_id` is not auto-stamped on INSERT** — the organization-axis twin of the `owner_id` gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the fill-only stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ Neither path rewrites a supplied `organization_id`: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall, which refuses a forged one `403 PERMISSION_DENIED` exactly as it refuses an update re-pointing a row. Elevation skips the stamp and the wall alike, which is why it is the seam the legitimate cross-organization writers use | `packages/plugins/organizations/src/organizations-plugin.ts#start` |
195196

196197
### 6. Reads that only carry the flag onward

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

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,14 @@ import {
5151
resolveUserAuthzGrants,
5252
} from '@objectstack/core';
5353
import type { AuthGate } from '@objectstack/core';
54-
import type { ApiKeyLifecyclePolicy, IEmailService, ISmsService } from '@objectstack/spec/contracts';
54+
import type { ExecutionContext } from '@objectstack/spec/kernel';
55+
import type {
56+
ApiKeyLifecyclePolicy,
57+
IEmailService,
58+
ISmsService,
59+
PrincipalSession,
60+
PrincipalSessionMintOptions,
61+
} from '@objectstack/spec/contracts';
5562
import {
5663
readEnvWithDeprecation,
5764
resolveTenancyPosture,
@@ -95,6 +102,11 @@ import {
95102
VENDOR_ROLE_SCALAR_ADMIN_PATHS,
96103
} from './admin-impersonate-endpoint.js';
97104
import { judgeHeldSession } from './platform-admin-gate.js';
105+
import {
106+
holdPrincipalSessionExpiry,
107+
mintPrincipalSession,
108+
type PrincipalSessionAuthContext,
109+
} from './principal-session-mint.js';
98110
import {
99111
applyDeclaredInvitationVerificationToListing,
100112
} from './list-user-invitations-verification.js';
@@ -7837,6 +7849,31 @@ export class AuthManager {
78377849
);
78387850
return result;
78397851
};
7852+
// [#23772] A minted principal session is NEVER renewed. better-auth's
7853+
// `get-session` renews any session past `updateAge` to `now + expiresIn`,
7854+
// a short session from its first read, and on the bearer lane nothing
7855+
// client-side can stop it (no dont-remember cookie rides a bearer). Every
7856+
// renewal is one `internalAdapter.updateSession`, so the guard sits on
7857+
// that write — the cookie lane, the bearer lane and the in-process
7858+
// `getSession` alike, under secondary storage too
7859+
// (`principal-session-mint.ts`, `holdPrincipalSessionExpiry`). A host
7860+
// hook chains FIRST and its `false` (abort) is honoured; the guard then
7861+
// judges what the host left.
7862+
const hostSessionUpdateBefore = (host as any)?.session?.update?.before;
7863+
const sessionUpdateBefore = async (update: any, ctx: any) => {
7864+
let draft = update;
7865+
if (hostSessionUpdateBefore) {
7866+
const hostResult = await hostSessionUpdateBefore(update, ctx);
7867+
if (hostResult === false) return false;
7868+
if (hostResult && typeof hostResult === 'object' && 'data' in hostResult) {
7869+
draft = { ...draft, ...(hostResult as any).data };
7870+
}
7871+
}
7872+
const held = holdPrincipalSessionExpiry(draft, ctx);
7873+
if (held) return { data: held };
7874+
return draft === update ? undefined : { data: draft };
7875+
};
7876+
78407877
const hostSessionDeleteAfter = (host as any)?.session?.delete?.after;
78417878
const sessionDeleteAfter = async (session: any, ctx: any) => {
78427879
const result = hostSessionDeleteAfter ? await hostSessionDeleteAfter(session, ctx) : undefined;
@@ -7938,6 +7975,10 @@ export class AuthManager {
79387975
...(sessionBefore ? { before: sessionBefore } : {}),
79397976
after: sessionCreateAfter,
79407977
},
7978+
update: {
7979+
...((host as any)?.session?.update ?? {}),
7980+
before: sessionUpdateBefore,
7981+
},
79417982
delete: {
79427983
...((host as any)?.session?.delete ?? {}),
79437984
after: sessionDeleteAfter,
@@ -8289,6 +8330,28 @@ export class AuthManager {
82898330
};
82908331
}
82918332

8333+
/**
8334+
* [#23772] `IAuthService.mintPrincipalSession`: a short-lived session for
8335+
* the requesting user's own principal, in-process only (no route reaches
8336+
* it). Human principals only, refused before anything is minted; at most
8337+
* `PRINCIPAL_SESSION_MAX_TTL_SECONDS`; never renewed by a read on either
8338+
* lane (the `session.update.before` guard in `composeDatabaseHooks`); ended
8339+
* by the returned `revoke`. `principal-session-mint.ts` owns all of it.
8340+
*/
8341+
public async mintPrincipalSession(
8342+
context: ExecutionContext,
8343+
options?: PrincipalSessionMintOptions,
8344+
): Promise<PrincipalSession> {
8345+
return mintPrincipalSession(
8346+
{
8347+
authContext: async () => (await this.getAuthContext()) as PrincipalSessionAuthContext,
8348+
resolveUserAuthGates: (userId, opts) => this.resolveUserAuthGates(userId, opts),
8349+
},
8350+
context,
8351+
options,
8352+
);
8353+
}
8354+
82928355
public async resolveUserAuthGates(
82938356
userId: string,
82948357
options: { organizationId?: string } = {},
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23772] The shared fixture of the principal-session mint pins: a REAL
5+
* `AuthManager` (the installed better-auth) over the in-memory engine the
6+
* impersonation pins use, one signed-up human user, and readers for what a
7+
* pin judges — the `sys_session` row straight off the engine's table, and
8+
* `GET /get-session` through the manager's real request seam on each lane.
9+
* Test-only; nothing in the package imports it.
10+
*/
11+
12+
import { AuthManager } from './auth-manager.js';
13+
import { inviteForAudienceGate } from './audience-gate-test-support.js';
14+
import type { PrincipalSession } from '@objectstack/spec/contracts';
15+
import type { ExecutionContext } from '@objectstack/spec/kernel';
16+
17+
export const MINT_SECRET = 'test-secret-at-least-32-chars-long!!';
18+
export const MINT_BASE = '/api/v1/auth';
19+
const PASSWORD = 'S3cure!Passw0rd-23772';
20+
21+
/** The in-memory engine of `impersonation-bearer-rotation.test.ts`, as far as these pins read it. */
22+
export interface MintFixtureEngine {
23+
tables: Map<string, Array<Record<string, unknown>>>;
24+
}
25+
26+
export interface MintFixture {
27+
manager: AuthManager;
28+
engine: MintFixtureEngine;
29+
origin: string;
30+
userId: string;
31+
/** The user's own context, as the HTTP entry assembles it for a session. */
32+
human(extra?: Partial<ExecutionContext>): ExecutionContext;
33+
/** The `sys_session` row whose token is `token`, or `undefined`. */
34+
row(token: string): Record<string, unknown> | undefined;
35+
/** `GET /get-session` carrying `headers`, answered by the real request seam. */
36+
getSession(headers: Record<string, string>): Promise<{ status: number; body: any }>;
37+
}
38+
39+
export async function bootMintFixture(
40+
createEngine: () => MintFixtureEngine,
41+
options: { origin?: string; advanced?: Record<string, unknown> } = {},
42+
): Promise<MintFixture> {
43+
const origin = options.origin ?? 'http://localhost:3000';
44+
const engine = createEngine();
45+
const manager = new AuthManager({
46+
secret: MINT_SECRET,
47+
baseUrl: origin,
48+
dataEngine: engine,
49+
plugins: { admin: true },
50+
...(options.advanced ? { advanced: options.advanced } : {}),
51+
} as any);
52+
53+
const email = 'mint.23772@example.com';
54+
await inviteForAudienceGate(manager, email);
55+
const res = await manager.handleRequest(
56+
new Request(`${origin}${MINT_BASE}/sign-up/email`, {
57+
method: 'POST',
58+
headers: { 'content-type': 'application/json', origin },
59+
body: JSON.stringify({ email, password: PASSWORD, name: 'Mint Human' }),
60+
}),
61+
);
62+
if (res.status !== 200) throw new Error(`fixture sign-up answered ${res.status}: ${await res.text()}`);
63+
const user = (engine.tables.get('sys_user') ?? []).find((r) => r.email === email);
64+
if (!user) throw new Error('fixture sign-up wrote no sys_user row');
65+
const userId = String(user.id);
66+
67+
return {
68+
manager,
69+
engine,
70+
origin,
71+
userId,
72+
human: (extra = {}) => ({ userId, positions: [], permissions: [], isSystem: false, ...extra }),
73+
row: (token) => (engine.tables.get('sys_session') ?? []).find((r) => r.token === token),
74+
getSession: async (headers) => {
75+
const r = await manager.handleRequest(
76+
new Request(`${origin}${MINT_BASE}/get-session`, { method: 'GET', headers: { origin, ...headers } }),
77+
);
78+
const text = await r.text();
79+
return { status: r.status, body: text ? JSON.parse(text) : null };
80+
},
81+
};
82+
}
83+
84+
/** The raw token of a minted session, read back out of its signed value. */
85+
export function rawTokenOf(minted: PrincipalSession): string {
86+
return decodeURIComponent(minted.token).split('.')[0];
87+
}
88+
89+
/** The stored `expires_at` of a row, in ms. */
90+
export function expiresAtMs(row: Record<string, unknown> | undefined): number {
91+
if (!row) throw new Error('pin: the session row is gone');
92+
return new Date(row.expires_at as string | Date).getTime();
93+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23772] The principal-session mint on the BEARER lane: a client sending
5+
* the minted token as `Authorization: Bearer`, over HTTP and through the
6+
* in-process session read every transport makes for a bearer-only request
7+
* (`inProcessSessionReadInput`, `@objectstack/types`, which reads WITH
8+
* renewal on that lane).
9+
*
10+
* This is the lane a client-side defence cannot reach: better-auth's
11+
* `bearer()` turns the token into the session cookie and no dont-remember
12+
* cookie rides along, so `get-session` renews a short session to
13+
* `now + expiresIn` on its first read — the control proves it on a session
14+
* minted the same way without the brand. The pin is that the minted one does
15+
* not move on either reader, and that after revoke the token resolves nothing.
16+
*/
17+
18+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
19+
import { inProcessSessionReadInput } from '@objectstack/types';
20+
import { createMemoryEngine } from './impersonation-bearer-rotation.test';
21+
import { bootMintFixture, expiresAtMs, rawTokenOf, type MintFixture } from './principal-session-mint-test-support';
22+
23+
let fx: MintFixture;
24+
let expiresInSec: number;
25+
26+
beforeAll(async () => {
27+
vi.spyOn(console, 'warn').mockImplementation(() => {});
28+
vi.spyOn(console, 'error').mockImplementation(() => {});
29+
fx = await bootMintFixture(createMemoryEngine as any);
30+
expiresInSec = Number((await fx.manager.getAuthContext()).sessionConfig.expiresIn);
31+
});
32+
33+
afterAll(() => vi.restoreAllMocks());
34+
35+
const inProcessRead = async (token: string) => {
36+
const api: any = await fx.manager.getApi();
37+
return api.getSession(inProcessSessionReadInput(new Headers({ authorization: `Bearer ${token}` })));
38+
};
39+
40+
describe('[#23772] bearer lane — a minted session is never renewed', () => {
41+
it('control: a short session WITHOUT the brand is renewed by a bearer get-session', async () => {
42+
const ctx: any = await fx.manager.getAuthContext();
43+
const plain = await ctx.internalAdapter.createSession(
44+
fx.userId, false, { expiresAt: new Date(Date.now() + 120_000) }, true,
45+
);
46+
const before = expiresAtMs(fx.row(plain.token));
47+
48+
const res = await fx.getSession({ authorization: `Bearer ${plain.token}` });
49+
50+
expect(res.body?.user?.id, 'the control bearer did not authenticate').toBe(fx.userId);
51+
expect(expiresAtMs(fx.row(plain.token)) - before, 'the fixture does not renew — the pins below would pass vacuously')
52+
.toBeGreaterThan((expiresInSec - 3600) * 1000);
53+
});
54+
55+
it('over HTTP: the minted token authenticates as the user and its expiry does not move', async () => {
56+
const minted = await fx.manager.mintPrincipalSession(fx.human());
57+
const token = rawTokenOf(minted);
58+
const mintedAt = expiresAtMs(fx.row(token));
59+
60+
for (let i = 0; i < 3; i++) {
61+
const res = await fx.getSession({ authorization: `Bearer ${minted.token}` });
62+
expect(res.status).toBe(200);
63+
expect(res.body?.user?.id, 'the minted bearer did not authenticate').toBe(fx.userId);
64+
expect(new Date(res.body.session.expiresAt).getTime(), 'get-session answered a renewed expiry').toBe(mintedAt);
65+
expect(expiresAtMs(fx.row(token)), `read ${i + 1} renewed the minted session row`).toBe(mintedAt);
66+
}
67+
await minted.revoke();
68+
});
69+
70+
it('in-process (the bearer-only read that renews): the expiry does not move', async () => {
71+
const minted = await fx.manager.mintPrincipalSession(fx.human());
72+
const token = rawTokenOf(minted);
73+
const mintedAt = expiresAtMs(fx.row(token));
74+
75+
const read = await inProcessRead(minted.token);
76+
77+
expect(read?.user?.id, 'the in-process bearer read did not resolve the user').toBe(fx.userId);
78+
expect(expiresAtMs(fx.row(token)), 'the in-process read renewed the minted session row').toBe(mintedAt);
79+
await minted.revoke();
80+
});
81+
82+
it('after revoke the token resolves no session, over HTTP and in-process', async () => {
83+
const minted = await fx.manager.mintPrincipalSession(fx.human());
84+
expect((await fx.getSession({ authorization: `Bearer ${minted.token}` })).body?.user?.id).toBe(fx.userId);
85+
86+
await minted.revoke();
87+
88+
expect(fx.row(rawTokenOf(minted)), 'revoke left the session row').toBeUndefined();
89+
// The platform's answer for "no session" on this route (anonymous-session-refusal.ts).
90+
const res = await fx.getSession({ authorization: `Bearer ${minted.token}` });
91+
expect({ status: res.status, code: res.body?.error?.code }, 'a revoked token still resolved a session')
92+
.toEqual({ status: 401, code: 'UNAUTHENTICATED' });
93+
expect(await inProcessRead(minted.token)).toBeNull();
94+
});
95+
});

0 commit comments

Comments
 (0)