Skip to content
30 changes: 30 additions & 0 deletions .changeset/23772-principal-session-mint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-auth': minor
---

feat(spec,plugin-auth): a short-lived principal-session mint, `IAuthService.mintPrincipalSession`, implemented by `AuthManager`

Clause-②: yes (widening)

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.

- **`@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.
- **`@objectstack/plugin-auth`**: `AuthManager.mintPrincipalSession(context, options?)`.

What the mint guarantees:

- **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.
- **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.
- **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`.
- **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`.

```ts
const auth = ctx.getService<IAuthService>('auth');
const session = await auth.mintPrincipalSession?.(context, { ttlSeconds: 60 });
try {
// send session.token as `Authorization: Bearer`, or set session.cookie in a browser
} finally {
await session?.revoke();
}
```
1 change: 1 addition & 0 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ One of the largest consumers of the flag;
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |

### 6. Reads that only carry the flag onward
Expand Down
65 changes: 64 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,14 @@ import {
resolveUserAuthzGrants,
} from '@objectstack/core';
import type { AuthGate } from '@objectstack/core';
import type { ApiKeyLifecyclePolicy, IEmailService, ISmsService } from '@objectstack/spec/contracts';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type {
ApiKeyLifecyclePolicy,
IEmailService,
ISmsService,
PrincipalSession,
PrincipalSessionMintOptions,
} from '@objectstack/spec/contracts';
import {
readEnvWithDeprecation,
resolveTenancyPosture,
Expand Down Expand Up @@ -95,6 +102,11 @@ import {
VENDOR_ROLE_SCALAR_ADMIN_PATHS,
} from './admin-impersonate-endpoint.js';
import { judgeHeldSession } from './platform-admin-gate.js';
import {
holdPrincipalSessionExpiry,
mintPrincipalSession,
type PrincipalSessionAuthContext,
} from './principal-session-mint.js';
import {
applyDeclaredInvitationVerificationToListing,
} from './list-user-invitations-verification.js';
Expand Down Expand Up @@ -7837,6 +7849,31 @@ export class AuthManager {
);
return result;
};
// [#23772] A minted principal session is NEVER renewed. better-auth's
// `get-session` renews any session past `updateAge` to `now + expiresIn`,
// a short session from its first read, and on the bearer lane nothing
// client-side can stop it (no dont-remember cookie rides a bearer). Every
// renewal is one `internalAdapter.updateSession`, so the guard sits on
// that write — the cookie lane, the bearer lane and the in-process
// `getSession` alike, under secondary storage too
// (`principal-session-mint.ts`, `holdPrincipalSessionExpiry`). A host
// hook chains FIRST and its `false` (abort) is honoured; the guard then
// judges what the host left.
const hostSessionUpdateBefore = (host as any)?.session?.update?.before;
const sessionUpdateBefore = async (update: any, ctx: any) => {
let draft = update;
if (hostSessionUpdateBefore) {
const hostResult = await hostSessionUpdateBefore(update, ctx);
if (hostResult === false) return false;
if (hostResult && typeof hostResult === 'object' && 'data' in hostResult) {
draft = { ...draft, ...(hostResult as any).data };
}
}
const held = holdPrincipalSessionExpiry(draft, ctx);
if (held) return { data: held };
return draft === update ? undefined : { data: draft };
};

const hostSessionDeleteAfter = (host as any)?.session?.delete?.after;
const sessionDeleteAfter = async (session: any, ctx: any) => {
const result = hostSessionDeleteAfter ? await hostSessionDeleteAfter(session, ctx) : undefined;
Expand Down Expand Up @@ -7938,6 +7975,10 @@ export class AuthManager {
...(sessionBefore ? { before: sessionBefore } : {}),
after: sessionCreateAfter,
},
update: {
...((host as any)?.session?.update ?? {}),
before: sessionUpdateBefore,
},
delete: {
...((host as any)?.session?.delete ?? {}),
after: sessionDeleteAfter,
Expand Down Expand Up @@ -8289,6 +8330,28 @@ export class AuthManager {
};
}

/**
* [#23772] `IAuthService.mintPrincipalSession`: a short-lived session for
* the requesting user's own principal, in-process only (no route reaches
* it). Human principals only, refused before anything is minted; at most
* `PRINCIPAL_SESSION_MAX_TTL_SECONDS`; never renewed by a read on either
* lane (the `session.update.before` guard in `composeDatabaseHooks`); ended
* by the returned `revoke`. `principal-session-mint.ts` owns all of it.
*/
public async mintPrincipalSession(
context: ExecutionContext,
options?: PrincipalSessionMintOptions,
): Promise<PrincipalSession> {
return mintPrincipalSession(
{
authContext: async () => (await this.getAuthContext()) as PrincipalSessionAuthContext,
resolveUserAuthGates: (userId, opts) => this.resolveUserAuthGates(userId, opts),
},
context,
options,
);
}

public async resolveUserAuthGates(
userId: string,
options: { organizationId?: string } = {},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#23772] The shared fixture of the principal-session mint pins: a REAL
* `AuthManager` (the installed better-auth) over the in-memory engine the
* impersonation pins use, one signed-up human user, and readers for what a
* pin judges — the `sys_session` row straight off the engine's table, and
* `GET /get-session` through the manager's real request seam on each lane.
* Test-only; nothing in the package imports it.
*/

import { AuthManager } from './auth-manager.js';
import { inviteForAudienceGate } from './audience-gate-test-support.js';
import type { PrincipalSession } from '@objectstack/spec/contracts';
import type { ExecutionContext } from '@objectstack/spec/kernel';

export const MINT_SECRET = 'test-secret-at-least-32-chars-long!!';
export const MINT_BASE = '/api/v1/auth';
const PASSWORD = 'S3cure!Passw0rd-23772';

/** The in-memory engine of `impersonation-bearer-rotation.test.ts`, as far as these pins read it. */
export interface MintFixtureEngine {
tables: Map<string, Array<Record<string, unknown>>>;
}

export interface MintFixture {
manager: AuthManager;
engine: MintFixtureEngine;
origin: string;
userId: string;
/** The user's own context, as the HTTP entry assembles it for a session. */
human(extra?: Partial<ExecutionContext>): ExecutionContext;
/** The `sys_session` row whose token is `token`, or `undefined`. */
row(token: string): Record<string, unknown> | undefined;
/** `GET /get-session` carrying `headers`, answered by the real request seam. */
getSession(headers: Record<string, string>): Promise<{ status: number; body: any }>;
}

export async function bootMintFixture(
createEngine: () => MintFixtureEngine,
options: { origin?: string; advanced?: Record<string, unknown> } = {},
): Promise<MintFixture> {
const origin = options.origin ?? 'http://localhost:3000';
const engine = createEngine();
const manager = new AuthManager({
secret: MINT_SECRET,
baseUrl: origin,
dataEngine: engine,
plugins: { admin: true },
...(options.advanced ? { advanced: options.advanced } : {}),
} as any);

const email = 'mint.23772@example.com';
await inviteForAudienceGate(manager, email);
const res = await manager.handleRequest(
new Request(`${origin}${MINT_BASE}/sign-up/email`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin },
body: JSON.stringify({ email, password: PASSWORD, name: 'Mint Human' }),
}),
);
if (res.status !== 200) throw new Error(`fixture sign-up answered ${res.status}: ${await res.text()}`);
const user = (engine.tables.get('sys_user') ?? []).find((r) => r.email === email);
if (!user) throw new Error('fixture sign-up wrote no sys_user row');
const userId = String(user.id);

return {
manager,
engine,
origin,
userId,
human: (extra = {}) => ({ userId, positions: [], permissions: [], isSystem: false, ...extra }),
row: (token) => (engine.tables.get('sys_session') ?? []).find((r) => r.token === token),
getSession: async (headers) => {
const r = await manager.handleRequest(
new Request(`${origin}${MINT_BASE}/get-session`, { method: 'GET', headers: { origin, ...headers } }),
);
const text = await r.text();
return { status: r.status, body: text ? JSON.parse(text) : null };
},
};
}

/** The raw token of a minted session, read back out of its signed value. */
export function rawTokenOf(minted: PrincipalSession): string {
return decodeURIComponent(minted.token).split('.')[0];
}

/** The stored `expires_at` of a row, in ms. */
export function expiresAtMs(row: Record<string, unknown> | undefined): number {
if (!row) throw new Error('pin: the session row is gone');
return new Date(row.expires_at as string | Date).getTime();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#23772] The principal-session mint on the BEARER lane: a client sending
* the minted token as `Authorization: Bearer`, over HTTP and through the
* in-process session read every transport makes for a bearer-only request
* (`inProcessSessionReadInput`, `@objectstack/types`, which reads WITH
* renewal on that lane).
*
* This is the lane a client-side defence cannot reach: better-auth's
* `bearer()` turns the token into the session cookie and no dont-remember
* cookie rides along, so `get-session` renews a short session to
* `now + expiresIn` on its first read — the control proves it on a session
* minted the same way without the brand. The pin is that the minted one does
* not move on either reader, and that after revoke the token resolves nothing.
*/

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

let fx: MintFixture;
let expiresInSec: number;

beforeAll(async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
fx = await bootMintFixture(createMemoryEngine as any);
expiresInSec = Number((await fx.manager.getAuthContext()).sessionConfig.expiresIn);
});

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

const inProcessRead = async (token: string) => {
const api: any = await fx.manager.getApi();
return api.getSession(inProcessSessionReadInput(new Headers({ authorization: `Bearer ${token}` })));
};

describe('[#23772] bearer lane — a minted session is never renewed', () => {
it('control: a short session WITHOUT the brand is renewed by a bearer get-session', async () => {
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 before = expiresAtMs(fx.row(plain.token));

const res = await fx.getSession({ authorization: `Bearer ${plain.token}` });

expect(res.body?.user?.id, 'the control bearer did not authenticate').toBe(fx.userId);
expect(expiresAtMs(fx.row(plain.token)) - before, 'the fixture does not renew — the pins below would pass vacuously')
.toBeGreaterThan((expiresInSec - 3600) * 1000);
});

it('over HTTP: the minted token authenticates as the user and its expiry does not move', async () => {
const minted = await fx.manager.mintPrincipalSession(fx.human());
const token = rawTokenOf(minted);
const mintedAt = expiresAtMs(fx.row(token));

for (let i = 0; i < 3; i++) {
const res = await fx.getSession({ authorization: `Bearer ${minted.token}` });
expect(res.status).toBe(200);
expect(res.body?.user?.id, 'the minted bearer did not authenticate').toBe(fx.userId);
expect(new Date(res.body.session.expiresAt).getTime(), 'get-session answered a renewed expiry').toBe(mintedAt);
expect(expiresAtMs(fx.row(token)), `read ${i + 1} renewed the minted session row`).toBe(mintedAt);
}
await minted.revoke();
});

it('in-process (the bearer-only read that renews): the expiry does not move', async () => {
const minted = await fx.manager.mintPrincipalSession(fx.human());
const token = rawTokenOf(minted);
const mintedAt = expiresAtMs(fx.row(token));

const read = await inProcessRead(minted.token);

expect(read?.user?.id, 'the in-process bearer read did not resolve the user').toBe(fx.userId);
expect(expiresAtMs(fx.row(token)), 'the in-process read renewed the minted session row').toBe(mintedAt);
await minted.revoke();
});

it('after revoke the token resolves no session, over HTTP and in-process', async () => {
const minted = await fx.manager.mintPrincipalSession(fx.human());
expect((await fx.getSession({ authorization: `Bearer ${minted.token}` })).body?.user?.id).toBe(fx.userId);

await minted.revoke();

expect(fx.row(rawTokenOf(minted)), 'revoke left the session row').toBeUndefined();
// The platform's answer for "no session" on this route (anonymous-session-refusal.ts).
const res = await fx.getSession({ authorization: `Bearer ${minted.token}` });
expect({ status: res.status, code: res.body?.error?.code }, 'a revoked token still resolved a session')
.toEqual({ status: 401, code: 'UNAUTHENTICATED' });
expect(await inProcessRead(minted.token)).toBeNull();
});
});
Loading
Loading