diff --git a/.changeset/23772-principal-session-mint.md b/.changeset/23772-principal-session-mint.md new file mode 100644 index 00000000000..d5486fbc947 --- /dev/null +++ b/.changeset/23772-principal-session-mint.md @@ -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('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(); +} +``` diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 3b733ffa6a7..6019e6f9f72 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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 diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 594852aa011..00e04f1c266 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -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, @@ -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'; @@ -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; @@ -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, @@ -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 { + 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 } = {}, diff --git a/packages/plugins/plugin-auth/src/principal-session-mint-test-support.ts b/packages/plugins/plugin-auth/src/principal-session-mint-test-support.ts new file mode 100644 index 00000000000..2a00ce24b0b --- /dev/null +++ b/packages/plugins/plugin-auth/src/principal-session-mint-test-support.ts @@ -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>>; +} + +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; + /** The `sys_session` row whose token is `token`, or `undefined`. */ + row(token: string): Record | undefined; + /** `GET /get-session` carrying `headers`, answered by the real request seam. */ + getSession(headers: Record): Promise<{ status: number; body: any }>; +} + +export async function bootMintFixture( + createEngine: () => MintFixtureEngine, + options: { origin?: string; advanced?: Record } = {}, +): Promise { + 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 | undefined): number { + if (!row) throw new Error('pin: the session row is gone'); + return new Date(row.expires_at as string | Date).getTime(); +} diff --git a/packages/plugins/plugin-auth/src/principal-session-mint.bearer-lane.pin.test.ts b/packages/plugins/plugin-auth/src/principal-session-mint.bearer-lane.pin.test.ts new file mode 100644 index 00000000000..31145d043aa --- /dev/null +++ b/packages/plugins/plugin-auth/src/principal-session-mint.bearer-lane.pin.test.ts @@ -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(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/principal-session-mint.cookie-lane.pin.test.ts b/packages/plugins/plugin-auth/src/principal-session-mint.cookie-lane.pin.test.ts new file mode 100644 index 00000000000..c72546e0e63 --- /dev/null +++ b/packages/plugins/plugin-auth/src/principal-session-mint.cookie-lane.pin.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23772] The principal-session mint on the COOKIE lane: a browser (the print + * service's headless one, local or remote) carrying the minted session + * cookie and nothing else. + * + * better-auth's `get-session` renews any session past `updateAge` to + * `now + expiresIn`; a minted session is past it from its first read, and the + * cookie lane carries no dont-remember cookie (the mint hands out none). So + * the read below WOULD turn a 120-second credential into a 7-day one — the + * control proves it on a session minted the same way without the brand — and + * the pin is that the minted one does not move, in the row or in the answer. + * Revoke is pinned on the same lane: afterwards the cookie resolves nothing. + * + * 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, rawTokenOf, type MintFixture } from './principal-session-mint-test-support'; +import { PRINCIPAL_SESSION_MAX_TTL_SECONDS } from '@objectstack/spec/contracts'; + +/** Clock slack between the server's `now` and this file's, in ms. */ +const SLACK_MS = 5_000; + +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()); + +describe('[#23772] cookie lane — a minted session is never renewed', () => { + it('control: a short session WITHOUT the brand is renewed to now + expiresIn by the same read', 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 { makeSignature } = await import('better-auth/crypto'); + const value = encodeURIComponent(`${plain.token}.${await makeSignature(plain.token, ctx.secret)}`); + const before = expiresAtMs(fx.row(plain.token)); + + const res = await fx.getSession({ cookie: `${ctx.authCookies.sessionToken.name}=${value}` }); + + expect(res.status).toBe(200); + expect(res.body?.user?.id, 'the control cookie did not authenticate').toBe(fx.userId); + const after = expiresAtMs(fx.row(plain.token)); + expect(after - before, 'the fixture does not renew — the pin below would pass vacuously') + .toBeGreaterThan((expiresInSec - 3600) * 1000); + }); + + it('the minted cookie 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)); + expect(Math.abs(mintedAt - (Date.now() + PRINCIPAL_SESSION_MAX_TTL_SECONDS * 1000))).toBeLessThan(SLACK_MS); + expect(new Date(minted.expiresAt).getTime()).toBe(mintedAt); + + for (let i = 0; i < 3; i++) { + const res = await fx.getSession({ cookie: `${minted.cookie.name}=${minted.cookie.value}` }); + expect(res.status).toBe(200); + expect(res.body?.user?.id, 'the minted cookie 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('after revoke the cookie resolves no session, and a second revoke is a no-op', async () => { + const minted = await fx.manager.mintPrincipalSession(fx.human()); + const cookie = `${minted.cookie.name}=${minted.cookie.value}`; + expect((await fx.getSession({ cookie })).body?.user?.id).toBe(fx.userId); + + await minted.revoke(); + 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({ cookie }); + expect({ status: res.status, code: res.body?.error?.code }, 'a revoked cookie still resolved a session') + .toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/principal-session-mint.test.ts b/packages/plugins/plugin-auth/src/principal-session-mint.test.ts new file mode 100644 index 00000000000..28588a012bc --- /dev/null +++ b/packages/plugins/plugin-auth/src/principal-session-mint.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23772] The principal-session mint: its refusals, its TTL, its cookie and + * its active organization, on a real `AuthManager`. The two lanes' renewal + * and revoke pins are `principal-session-mint.{cookie,bearer}-lane.pin.test.ts`. + * + * Every refusal asserts the envelope (`code` + `status`), AND that nothing + * was minted: `internalAdapter.createSession` was never called and no + * `sys_session` row appeared — a refusal after a mint would leave a live + * credential behind. + */ + +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { SystemUserId } from '@objectstack/spec/system'; +import { PRINCIPAL_SESSION_MAX_TTL_SECONDS, type IAuthService } from '@objectstack/spec/contracts'; +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { bootMintFixture, expiresAtMs, rawTokenOf, type MintFixture } from './principal-session-mint-test-support'; +import { PRINCIPAL_SESSION_TOKEN_PREFIX, holdPrincipalSessionExpiry } from './principal-session-mint'; + +const SLACK_MS = 5_000; + +let fx: MintFixture; +let createSession: ReturnType; + +const sessionRows = () => (fx.engine.tables.get('sys_session') ?? []).length; +const userRow = () => (fx.engine.tables.get('sys_user') ?? []).find((r) => r.id === fx.userId)!; + +/** The refusal envelope, and the proof nothing was minted. */ +async function expectRefused(promise: Promise, code: string, status: number) { + const rows = sessionRows(); + const err: any = await promise.then( + () => { throw new Error(`expected a ${status} ${code} refusal, the mint succeeded`); }, + (e) => e, + ); + expect({ code: err?.code, status: err?.status }).toEqual({ code, status }); + expect(createSession, 'a session was minted before the refusal').not.toHaveBeenCalled(); + expect(sessionRows(), 'a session row appeared on a refusal').toBe(rows); +} + +beforeAll(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + fx = await bootMintFixture(createMemoryEngine as any); + const ctx: any = await fx.manager.getAuthContext(); + createSession = vi.spyOn(ctx.internalAdapter, 'createSession'); +}); + +beforeEach(() => createSession.mockClear()); + +afterAll(() => vi.restoreAllMocks()); + +describe('[#23772] refusals — before anything is minted', () => { + it('a context with no user → PERMISSION_DENIED / 403', async () => { + await expectRefused(fx.manager.mintPrincipalSession(fx.human({ userId: undefined })), 'PERMISSION_DENIED', 403); + }); + + it('a system context → PERMISSION_DENIED / 403', async () => { + await expectRefused(fx.manager.mintPrincipalSession(fx.human({ isSystem: true })), 'PERMISSION_DENIED', 403); + }); + + for (const kind of ['agent', 'service', 'guest', 'system'] as const) { + it(`principalKind '${kind}' → PERMISSION_DENIED / 403`, async () => { + await expectRefused(fx.manager.mintPrincipalSession(fx.human({ principalKind: kind })), 'PERMISSION_DENIED', 403); + }); + } + + for (const ttlSeconds of [0, -1, PRINCIPAL_SESSION_MAX_TTL_SECONDS + 1, 1.5, Number.NaN]) { + it(`ttlSeconds ${ttlSeconds} → VALIDATION_ERROR / 400 (refused, never clamped)`, async () => { + await expectRefused(fx.manager.mintPrincipalSession(fx.human(), { ttlSeconds }), 'VALIDATION_ERROR', 400); + }); + } + + it('a carried authGate → 403 with the posture code', async () => { + const held = fx.human({ authGate: { code: 'MFA_REQUIRED', message: 'Enrol a second factor.' } }); + await expectRefused(fx.manager.mintPrincipalSession(held), 'MFA_REQUIRED', 403); + }); + + it('a posture computed now (not carried on the context) → 403 with its code', async () => { + const live = vi.spyOn(fx.manager, 'resolveUserAuthGates') + .mockResolvedValueOnce([{ code: 'PASSWORD_EXPIRED', message: 'Change your password.' }] as any); + await expectRefused(fx.manager.mintPrincipalSession(fx.human({ tenantId: 'org_live' })), 'PASSWORD_EXPIRED', 403); + expect(live).toHaveBeenCalledWith(fx.userId, { organizationId: 'org_live' }); + }); + + it('an unknown user → PERMISSION_DENIED / 403', async () => { + await expectRefused(fx.manager.mintPrincipalSession(fx.human({ userId: 'usr_absent_23772' })), 'PERMISSION_DENIED', 403); + }); + + it('the system user row and a role=system row → PERMISSION_DENIED / 403 (isHumanUserRow)', async () => { + const users = fx.engine.tables.get('sys_user')!; + users.push({ ...userRow(), id: SystemUserId.SYSTEM, email: 'system.23772@example.com', role: 'user' }); + try { + await expectRefused(fx.manager.mintPrincipalSession(fx.human({ userId: SystemUserId.SYSTEM })), 'PERMISSION_DENIED', 403); + } finally { + users.splice(users.findIndex((r) => r.id === SystemUserId.SYSTEM), 1); + } + const row = userRow(); + row.role = 'system'; + try { + await expectRefused(fx.manager.mintPrincipalSession(fx.human()), 'PERMISSION_DENIED', 403); + } finally { + row.role = 'user'; + } + }); + + it('a banned user → PERMISSION_DENIED / 403; a lapsed ban mints', async () => { + const row = userRow(); + row.banned = true; + try { + await expectRefused(fx.manager.mintPrincipalSession(fx.human()), 'PERMISSION_DENIED', 403); + row.ban_expires = new Date(Date.now() - 60_000); + const minted = await fx.manager.mintPrincipalSession(fx.human()); + await minted.revoke(); + } finally { + row.banned = false; + row.ban_expires = null; + } + }); +}); + +describe('[#23772] the minted session', () => { + it("AuthManager's method IS the contract member (a drift fails the test layer's typecheck)", () => { + const member: Required['mintPrincipalSession'] = fx.manager.mintPrincipalSession.bind(fx.manager); + expect(typeof member).toBe('function'); + }); + + it('a human principal (principalKind absent or human) mints, branded, impersonatedBy unset', async () => { + for (const ctx of [fx.human(), fx.human({ principalKind: 'human' })]) { + const minted = await fx.manager.mintPrincipalSession(ctx); + const row = fx.row(rawTokenOf(minted))!; + expect(row.user_id).toBe(fx.userId); + expect(String(row.token).startsWith(PRINCIPAL_SESSION_TOKEN_PREFIX)).toBe(true); + expect(row.impersonated_by ?? null).toBeNull(); + await minted.revoke(); + } + }); + + it('TTL: the ceiling by default, a shorter ask honoured', async () => { + const byDefault = await fx.manager.mintPrincipalSession(fx.human()); + expect(Math.abs(expiresAtMs(fx.row(rawTokenOf(byDefault))) - (Date.now() + PRINCIPAL_SESSION_MAX_TTL_SECONDS * 1000))) + .toBeLessThan(SLACK_MS); + await byDefault.revoke(); + + const short = await fx.manager.mintPrincipalSession(fx.human(), { ttlSeconds: 30 }); + expect(Math.abs(new Date(short.expiresAt).getTime() - (Date.now() + 30_000))).toBeLessThan(SLACK_MS); + await short.revoke(); + }); + + it("carries the context's tenantId as the session's active organization", async () => { + const minted = await fx.manager.mintPrincipalSession(fx.human({ tenantId: 'org_23772' })); + expect(fx.row(rawTokenOf(minted))!.active_organization_id).toBe('org_23772'); + await minted.revoke(); + }); + + it('the cookie is the plugin session cookie: better-auth.session_token, not Secure on http', async () => { + const minted = await fx.manager.mintPrincipalSession(fx.human()); + expect(minted.cookie).toMatchObject({ name: 'better-auth.session_token', path: '/', httpOnly: true, secure: false }); + expect(minted.cookie.value).toBe(minted.token); + await minted.revoke(); + }); +}); + +describe('[#23772] cookie names under cookiePrefix and __Secure-', () => { + it('a prefixed secure deployment names the cookie __Secure-PREFIX.session_token, and it authenticates', async () => { + const secure = await bootMintFixture(createMemoryEngine as any, { + origin: 'https://app.example.com', + advanced: { cookiePrefix: 'acme', useSecureCookies: true }, + }); + const minted = await secure.manager.mintPrincipalSession(secure.human()); + expect(minted.cookie).toMatchObject({ name: '__Secure-acme.session_token', secure: true, httpOnly: true }); + + const res = await secure.getSession({ cookie: `${minted.cookie.name}=${minted.cookie.value}` }); + expect(res.body?.user?.id).toBe(secure.userId); + await minted.revoke(); + }); +}); + +describe('[#23772] holdPrincipalSessionExpiry — the renewal guard itself', () => { + const ceiling = new Date(Date.now() + 60_000); + const minted = { context: { session: { session: { token: `${PRINCIPAL_SESSION_TOKEN_PREFIX}x`, expiresAt: ceiling } } } }; + const plain = { context: { session: { session: { token: 'plain', expiresAt: ceiling } } } }; + const renewal = { expiresAt: new Date(Date.now() + 7 * 86_400_000), updatedAt: new Date() }; + + it('pins a raised expiry of a minted session back to its own', () => { + expect(holdPrincipalSessionExpiry(renewal, minted)?.expiresAt).toEqual(ceiling); + }); + + it('leaves alone any other session, an update without expiresAt, and a lowered expiry (a revocation)', () => { + expect(holdPrincipalSessionExpiry(renewal, plain)).toBeUndefined(); + expect(holdPrincipalSessionExpiry({ activeOrganizationId: 'o' }, minted)).toBeUndefined(); + expect(holdPrincipalSessionExpiry({ expiresAt: new Date(Date.now() - 1000) }, minted)).toBeUndefined(); + expect(holdPrincipalSessionExpiry(renewal, undefined)).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/principal-session-mint.ts b/packages/plugins/plugin-auth/src/principal-session-mint.ts new file mode 100644 index 00000000000..791dc5f1444 --- /dev/null +++ b/packages/plugins/plugin-auth/src/principal-session-mint.ts @@ -0,0 +1,285 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23772] The principal-session mint: a short-lived better-auth session for + * the REQUESTING user's own principal, implementing the optional + * `IAuthService.mintPrincipalSession` (`@objectstack/spec` contracts). + * + * Its consumer is an in-process service that has to act as the user through + * a door that only accepts a session — the print service's headless browser, + * which loads the console page the user may see, under the user's principal + * and never a system one (#8346 ruling B′, M2). The credential may leave the + * process (a remote browser endpoint), so every property below holds for the + * credential itself, not for a caller that handles it carefully. + * + * ## What this module owns + * + * 1. **The refusals, all before anything is minted.** Human principals only + * (#22269 seat ruling OQ9 A). Asked in this order, each answered with + * `code` + `status` on a {@link PrincipalSessionMintRefusalError}: + * - no user on the context, a system context (`isSystem`), or a + * `principalKind` other than `human` (absent reads as `human`, as the + * spec declares it) → `PERMISSION_DENIED` / `403`; + * - a `ttlSeconds` outside 1..{@link PRINCIPAL_SESSION_MAX_TTL_SECONDS} + * → `VALIDATION_ERROR` / `400`; + * - a carried `authGate` → `403` with the posture's own code, the + * refusal `judgeHeldSession` gives every held session; + * - a user row that is missing, not a human (`isHumanUserRow`, the one + * predicate the audience gate and the dev seed read) or banned → + * `PERMISSION_DENIED` / `403`; + * - a posture computed NOW (`resolveUserAuthGates`, for the context's + * organization) → `403` with that posture's code, so a context built + * before the user was held cannot outrun the policy. + * An API key is not told apart here, deliberately: ADR-0090 D10 rules a + * key a credential of its owner, so a key-authenticated context IS the + * owner's human principal (`principalKind` absent or `human`). + * + * 2. **The TTL.** A fixed ceiling ({@link PRINCIPAL_SESSION_MAX_TTL_SECONDS}), + * refused above rather than clamped, so a caller that asks for more + * learns it at once. better-auth's `createSession` writes `expiresAt` + * AFTER the override spread, so the short life holds only with + * `overrideAll` — the impersonation endpoint's precedent. + * + * 3. **The non-extension, on every lane.** better-auth's `get-session` + * renews any session whose `expiresAt - expiresIn + updateAge` is past — + * which a 120-second session is from its first read — to + * `now + expiresIn` (7 days). Only a signed dont-remember cookie or + * `disableRefresh` stops that, and a bearer request carries neither, so a + * client-side defence would hold the cookie lane alone. The defence is + * therefore server-side, on the ONE write every renewal makes + * (`internalAdapter.updateSession`, through the `session.update.before` + * database hook, which runs under secondary storage too): + * {@link holdPrincipalSessionExpiry} pins a minted session's `expiresAt` + * to the value it was minted with. A minted session is recognised by its + * token, which carries {@link PRINCIPAL_SESSION_TOKEN_PREFIX} — set here, + * by the server, and stored on the row, so every node agrees. + * + * 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. + * + * 5. **The cookie.** Name and attributes are better-auth's own + * `authCookies.sessionToken`, the same derivation the plugin's session + * cookie gets from `advanced.cookiePrefix` / `useSecureCookies` — so the + * prefix and `__Secure-` follow the deployment with nothing restated. + * + * ⛔ Not impersonation (`admin-impersonate-endpoint.ts`): that mints a session + * for a DIFFERENT principal and records `impersonatedBy`. This mints the + * caller's own, with `impersonatedBy` unset. + */ + +import { PRINCIPAL_SESSION_MAX_TTL_SECONDS } from '@objectstack/spec/contracts'; +import type { + PrincipalSession, + PrincipalSessionCookie, + PrincipalSessionMintOptions, +} from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { isHumanUserRow } from './audience-posture.js'; +import { judgeHeldSession } from './platform-admin-gate.js'; + +/** + * The brand every minted session token starts with. Never `osk_` (the API-key + * prefix the transports route on) and free of `.`, which better-auth's bearer + * hook reads as "already signed". + */ +export const PRINCIPAL_SESSION_TOKEN_PREFIX = 'psm_'; + +/** True when `token` is a minted principal session's token. */ +export function isPrincipalSessionToken(token: unknown): boolean { + return typeof token === 'string' && token.startsWith(PRINCIPAL_SESSION_TOKEN_PREFIX); +} + +/** + * 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 + * them unchanged. + */ +export class PrincipalSessionMintRefusalError extends Error { + /** `PERMISSION_DENIED`, `VALIDATION_ERROR`, `INTERNAL_ERROR`, or a held posture's own code (`AuthGateCodeSchema`). */ + readonly code: string; + readonly status: number; + + constructor(code: string, status: number, message: string) { + super(message); + this.name = 'PrincipalSessionMintRefusalError'; + this.code = code; + this.status = status; + } +} + +const deny = (message: string) => new PrincipalSessionMintRefusalError('PERMISSION_DENIED', 403, message); + +/** The posture refusal `judgeHeldSession` gives a held session, as a throw — or `null`. */ +function heldRefusal(authGate: unknown): PrincipalSessionMintRefusalError | null { + const held = judgeHeldSession({ authGate }); + if (!held) return null; + const { code, message } = held.body.error; + return new PrincipalSessionMintRefusalError( + code, + held.status, + `A session cannot be minted for this user while an authentication policy holds them: ${message}`, + ); +} + +/** The slice of better-auth's `$context` the mint uses. */ +export interface PrincipalSessionAuthContext { + secret: string; + authCookies: { + sessionToken: { + name: string; + attributes: { path?: string; httpOnly?: boolean; secure?: boolean; sameSite?: string; domain?: string }; + }; + }; + internalAdapter: { + findUserById(userId: string): Promise | null>; + createSession( + userId: string, + dontRememberMe: boolean, + override: Record, + overrideAll: boolean, + ): Promise<{ token: string; expiresAt: Date | string } | null>; + deleteSession(token: string): Promise; + }; +} + +/** What the mint needs from its host, `AuthManager`. */ +export interface PrincipalSessionMintDeps { + authContext(): Promise; + resolveUserAuthGates(userId: string, options: { organizationId?: string }): Promise; +} + +/** The refusals that need no read, in order: the principal, then the TTL. */ +function refuseUpFront(context: ExecutionContext | undefined, options: PrincipalSessionMintOptions | undefined): number { + const userId = context?.userId; + if (typeof userId !== 'string' || !userId) { + throw deny('A principal session is minted only for a signed-in user, and this context carries no user.'); + } + if (context!.isSystem === true) { + throw deny('A principal session is never minted for a system context: it acts as the requesting user only.'); + } + const kind = context!.principalKind ?? 'human'; + if (kind !== 'human') { + throw deny(`A principal session is minted for human principals only, and this principal is '${kind}'.`); + } + + const ttl = options?.ttlSeconds ?? PRINCIPAL_SESSION_MAX_TTL_SECONDS; + if (!Number.isInteger(ttl) || ttl < 1 || ttl > PRINCIPAL_SESSION_MAX_TTL_SECONDS) { + throw new PrincipalSessionMintRefusalError( + 'VALIDATION_ERROR', + 400, + `ttlSeconds must be a whole number of seconds from 1 to ${PRINCIPAL_SESSION_MAX_TTL_SECONDS}; got ${String(options?.ttlSeconds)}.`, + ); + } + + const carried = heldRefusal(context!.authGate); + if (carried) throw carried; + return ttl; +} + +function isBanned(user: Record): boolean { + if (user.banned !== true && user.banned !== 1) return false; + const until = user.banExpires ?? user.ban_expires; + if (until == null || until === '') return true; + const ms = new Date(until as string | Date).getTime(); + return !Number.isFinite(ms) || ms > Date.now(); +} + +/** better-call's signed-cookie value (`encodeURIComponent(value + '.' + HMAC-SHA256)`), what `setSignedCookie` writes. */ +async function signedValue(token: string, secret: string): Promise { + const { makeSignature } = await import('better-auth/crypto'); + return encodeURIComponent(`${token}.${await makeSignature(token, secret)}`); +} + +function cookieOf(ctx: PrincipalSessionAuthContext, value: string): PrincipalSessionCookie { + const { name, attributes } = ctx.authCookies.sessionToken; + const sameSite = typeof attributes.sameSite === 'string' ? attributes.sameSite.toLowerCase() : undefined; + return { + name, + value, + path: attributes.path || '/', + httpOnly: attributes.httpOnly !== false, + secure: attributes.secure === true, + ...(sameSite === 'lax' || sameSite === 'strict' || sameSite === 'none' ? { sameSite } : {}), + ...(attributes.domain ? { domain: attributes.domain } : {}), + }; +} + +/** + * Mint a short-lived session for `context`'s own user (see the module header + * for the refusals, the TTL, the non-extension and the revoke). + */ +export async function mintPrincipalSession( + deps: PrincipalSessionMintDeps, + context: ExecutionContext, + options?: PrincipalSessionMintOptions, +): Promise { + const ttl = refuseUpFront(context, options); + const userId = context.userId as string; + const organizationId = typeof context.tenantId === 'string' && context.tenantId ? context.tenantId : undefined; + + const ctx = await deps.authContext(); + const user = await ctx.internalAdapter.findUserById(userId); + if (!user) throw deny('A principal session cannot be minted: the requesting user does not exist.'); + if (!isHumanUserRow(user)) throw deny('A principal session is minted for human users only, and this user is a system identity.'); + if (isBanned(user)) throw deny('A principal session cannot be minted for a banned user.'); + + const live = await deps.resolveUserAuthGates(userId, organizationId ? { organizationId } : {}); + const held = heldRefusal(live[0]); + if (held) throw held; + + const { generateRandomString } = await import('better-auth/crypto'); + const token = `${PRINCIPAL_SESSION_TOKEN_PREFIX}${generateRandomString(32, 'a-z', 'A-Z', '0-9')}`; + const expiresAt = new Date(Date.now() + ttl * 1000); + const session = await ctx.internalAdapter.createSession( + userId, + false, + { token, expiresAt, ...(organizationId ? { activeOrganizationId: organizationId } : {}) }, + true, + ); + if (!session || session.token !== token) { + if (session?.token) await ctx.internalAdapter.deleteSession(session.token); + throw new PrincipalSessionMintRefusalError( + 'INTERNAL_ERROR', + 500, + 'A principal session could not be minted: the session store did not keep the minted token.', + ); + } + + const value = await signedValue(token, ctx.secret); + let revoked = false; + return { + token: value, + cookie: cookieOf(ctx, value), + expiresAt: new Date(session.expiresAt).toISOString(), + revoke: async () => { + if (revoked) return; + await ctx.internalAdapter.deleteSession(token); + revoked = true; + }, + }; +} + +const toMs = (v: unknown): number => (v instanceof Date ? v.getTime() : new Date(v as string).getTime()); + +/** + * The `session.update.before` guard: when better-auth renews a minted + * session, pin `expiresAt` back to the value the session holds. `endpointCtx` + * is the hook's second argument, the endpoint context whose + * `context.session` is the session `get-session` read and is renewing. + * Returns the update to write instead, or `undefined` to leave it alone (any + * other session, any update that does not raise `expiresAt`, a revocation + * that lowers it). + */ +export function holdPrincipalSessionExpiry( + update: Record | undefined, + endpointCtx: unknown, +): Record | undefined { + if (!update || update.expiresAt == null) return undefined; + const target = (endpointCtx as { context?: { session?: { session?: { token?: unknown; expiresAt?: unknown } } } }) + ?.context?.session?.session; + if (!isPrincipalSessionToken(target?.token)) return undefined; + const ceiling = toMs(target!.expiresAt); + if (!Number.isFinite(ceiling) || toMs(update.expiresAt) <= ceiling) return undefined; + return { ...update, expiresAt: new Date(ceiling) }; +} diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 801eef2cf11..4f2e8a74743 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -224,6 +224,7 @@ "NotificationResult (interface)", "ObjectDraft (interface)", "OwnershipFloorAlternate (interface)", + "PRINCIPAL_SESSION_MAX_TTL_SECONDS (const)", "PendingActionRow (interface)", "PendingActionStatus (type)", "PlanUpgradeInput (interface)", @@ -232,6 +233,9 @@ "PresignedDownloadDescriptor (interface)", "PresignedDownloadOptions (interface)", "PresignedUploadDescriptor (interface)", + "PrincipalSession (interface)", + "PrincipalSessionCookie (interface)", + "PrincipalSessionMintOptions (interface)", "ProposePendingActionInput (interface)", "PubSubHandler (type)", "PubSubMessage (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 77f65fbc1f2..24815be18b6 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -224,6 +224,7 @@ "NotificationResult": "src/contracts/notification-service.ts#NotificationResult (interface)", "ObjectDraft": "src/contracts/external-datasource-service.ts#ObjectDraft (interface)", "OwnershipFloorAlternate": "src/contracts/security-service.ts#OwnershipFloorAlternate (interface)", + "PRINCIPAL_SESSION_MAX_TTL_SECONDS": "src/contracts/auth-service.ts#PRINCIPAL_SESSION_MAX_TTL_SECONDS (const)", "PendingActionRow": "src/contracts/ai-service.ts#PendingActionRow (interface)", "PendingActionStatus": "src/contracts/ai-service.ts#PendingActionStatus (type)", "PlanUpgradeInput": "src/contracts/package-service.ts#PlanUpgradeInput (interface)", @@ -232,6 +233,9 @@ "PresignedDownloadDescriptor": "src/contracts/storage-service.ts#PresignedDownloadDescriptor (interface)", "PresignedDownloadOptions": "src/contracts/storage-service.ts#PresignedDownloadOptions (interface)", "PresignedUploadDescriptor": "src/contracts/storage-service.ts#PresignedUploadDescriptor (interface)", + "PrincipalSession": "src/contracts/auth-service.ts#PrincipalSession (interface)", + "PrincipalSessionCookie": "src/contracts/auth-service.ts#PrincipalSessionCookie (interface)", + "PrincipalSessionMintOptions": "src/contracts/auth-service.ts#PrincipalSessionMintOptions (interface)", "ProposePendingActionInput": "src/contracts/ai-service.ts#ProposePendingActionInput (interface)", "PubSubHandler": "src/contracts/cluster-service.ts#PubSubHandler (type)", "PubSubMessage": "src/contracts/cluster-service.ts#PubSubMessage (interface)", diff --git a/packages/spec/src/contracts/auth-service.test.ts b/packages/spec/src/contracts/auth-service.test.ts index d99147fdf08..8a046cc81a8 100644 --- a/packages/spec/src/contracts/auth-service.test.ts +++ b/packages/spec/src/contracts/auth-service.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import type { IAuthService, AuthResult, AuthUser } from './auth-service'; +import type { IAuthService, AuthResult, AuthUser, PrincipalSession } from './auth-service'; +import { PRINCIPAL_SESSION_MAX_TTL_SECONDS } from './auth-service'; describe('Auth Service Contract', () => { it('should allow a minimal IAuthService implementation with required methods', () => { @@ -114,4 +115,21 @@ describe('Auth Service Contract', () => { expect(service.getMcpResourceUrl).toBeUndefined(); expect(service.getMcpResourceMetadataUrl).toBeUndefined(); }); + // [#23772] The principal-session mint is OPTIONAL: an auth service that + // cannot mint a session keeps satisfying the contract unchanged, and a + // consumer reads its absence as "no mint here", never as a failed mint. + it('should keep mintPrincipalSession optional, with a fixed TTL ceiling', async () => { + const minimal: IAuthService = { handleRequest: async () => new Response('OK'), verify: async () => ({ success: false }) }; + expect(minimal.mintPrincipalSession).toBeUndefined(); + expect(PRINCIPAL_SESSION_MAX_TTL_SECONDS).toBe(120); + + const minted: PrincipalSession = { + token: 't.sig', + cookie: { name: 'better-auth.session_token', value: 't.sig', path: '/', httpOnly: true, secure: false, sameSite: 'lax' }, + expiresAt: '2099-01-01T00:00:00Z', + revoke: async () => {}, + }; + const service: IAuthService = { ...minimal, mintPrincipalSession: async () => minted }; + expect((await service.mintPrincipalSession!({ userId: 'u1', positions: [], permissions: [], isSystem: false }, { ttlSeconds: 60 })).cookie.name).toBe('better-auth.session_token'); + }); }); diff --git a/packages/spec/src/contracts/auth-service.ts b/packages/spec/src/contracts/auth-service.ts index 68913cc3e9e..393e17d79d0 100644 --- a/packages/spec/src/contracts/auth-service.ts +++ b/packages/spec/src/contracts/auth-service.ts @@ -86,6 +86,64 @@ export interface ApiKeyLifecyclePolicy { startedAt?: string; } +/** + * [#23772] The longest life, in seconds, of a session minted by + * {@link IAuthService.mintPrincipalSession}. A fixed ceiling, not a setting: + * a caller may ask for less, and an ask for more is refused rather than + * clamped. Long enough for one headless render (a browser launch, a page + * load and a print, measured at about 1.3 s on a local executable), short + * enough that a credential which leaves the process expires on its own. + */ +export const PRINCIPAL_SESSION_MAX_TTL_SECONDS = 120; + +/** [#23772] What a caller of {@link IAuthService.mintPrincipalSession} may ask. */ +export interface PrincipalSessionMintOptions { + /** + * The session's life in whole seconds, from 1 to + * {@link PRINCIPAL_SESSION_MAX_TTL_SECONDS}; the ceiling when omitted. A + * value outside that range is refused (`VALIDATION_ERROR` / `400`). + */ + ttlSeconds?: number; +} + +/** + * [#23772] The session cookie of a minted principal session, under the name + * and attributes the auth service gives its own session cookie (the + * deployment's cookie prefix and, on a secure deployment, `__Secure-`). + */ +export interface PrincipalSessionCookie { + /** The cookie name. */ + name: string; + /** The signed cookie value, ready to send. */ + value: string; + /** The cookie path. */ + path: string; + /** Whether the cookie is `HttpOnly`. */ + httpOnly: boolean; + /** Whether the cookie is `Secure`. */ + secure: boolean; + /** The `SameSite` attribute, when the auth service sets one. */ + sameSite?: 'lax' | 'strict' | 'none'; + /** The cookie domain, when the auth service sets one (cross-subdomain cookies). */ + domain?: string; +} + +/** [#23772] A short-lived session minted for the requesting user's own principal. */ +export interface PrincipalSession { + /** The session as a bearer credential: send it as `Authorization: Bearer `. */ + token: string; + /** The same session as the auth service's session cookie. */ + cookie: PrincipalSessionCookie; + /** When the session ends (ISO 8601). Reading the session never moves it. */ + expiresAt: string; + /** + * End the session now. Afterwards neither {@link token} nor + * {@link cookie} resolves a session. Bound to this session alone, and + * safe to call more than once; call it in a `finally`. + */ + revoke(): Promise; +} + export interface IAuthService { /** * Handle an incoming HTTP authentication request @@ -237,6 +295,32 @@ export interface IAuthService { * resolver has always called it through `any`. */ verifyMcpAccessToken?(token: string): Promise<{ userId: string; scopes: string[]; clientId?: string } | undefined>; + + /** + * [#23772] Mint a short-lived session for the requesting user's OWN + * principal, so an in-process service can act as that user through a + * door that only accepts a session (a headless browser rendering a page + * the user may see). In-process only; no HTTP route reaches it. + * + * Human principals only. Every refusal is thrown before anything is + * minted, carrying `code` and `status`: a context with no user, a system + * context, or a principal that is not `human` answers + * `PERMISSION_DENIED` / `403`, as does a user row that is not a human + * (the system user) or is banned; a principal an authentication policy + * holds (`authGate`, carried or computed now) answers `403` with the + * posture's own code; a `ttlSeconds` outside its range answers + * `VALIDATION_ERROR` / `400`. + * + * The session lives at most {@link PRINCIPAL_SESSION_MAX_TTL_SECONDS}, is + * never renewed by a read on either lane (cookie or bearer), carries the + * context's `tenantId` as its active organization, and ends at + * {@link PrincipalSession.revoke}. + * + * @param context - The requesting user's execution context + * @param options - The session's life, at most the ceiling + * @returns The session as a bearer token and a cookie, with its revoke + */ + mintPrincipalSession?(context: ExecutionContext, options?: PrincipalSessionMintOptions): Promise; } /**