diff --git a/.changeset/sys-session-revoke-by-id.md b/.changeset/sys-session-revoke-by-id.md new file mode 100644 index 00000000000..f7fbc993817 --- /dev/null +++ b/.changeset/sys-session-revoke-by-id.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/platform-objects': patch +--- + +fix(plugin-auth,platform-objects): Revoke Session on a `sys_session` row ends the session it names + +Clause-②: no + +- **The action.** The `sys_session` `revoke_session` row action (Setup → Sessions, Account → Active Sessions) posts the row's `id` as `sessionId` to a new plugin-auth route, `POST /api/v1/auth/sys-session/revoke`. The server resolves the session from the id. The session token stays `internal: true`: it is not served to the console or to any other client, and no request to or answer from this route carries it. +- **Who may revoke.** The session's owner: the route hands the request to better-auth's own `POST /api/v1/auth/revoke-session`, so that route's checks and its `user_revoked` record apply exactly as before. Another user's session: a platform administrator only (the ADR-0068 D2 standing every `/api/v1/auth/admin/*` mount judges). The session is ended in place with `revoke_reason: admin` (ADR-0069 D4), stays listed under the Revoked view, and its next request is unauthenticated. +- **Refusals.** An anonymous caller: `401 UNAUTHENTICATED`. A body without `sessionId`: `400 INVALID_REQUEST`. Every other caller, and an id that names no live session: `404 RESOURCE_NOT_FOUND`, one answer for every cause. A platform administrator whose own sign-in is held by the authentication policy: `403` with the policy's code. +- **Unchanged.** `POST /api/v1/auth/revoke-session`, "Sign out other devices", and every `sys_session` read path. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 3b1e71896d9..9bdb3e093c8 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **50 of the 231 sites are spelled that way**. A +forwarding shim cannot, and **50 of the 232 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **231** | +| 175 write call sites | quoted in the merged changeset | **232** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 231** decidable, **75** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 130 decidably elevated, 0 decidably not, 93 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 232** decidable, **76** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 131 decidably elevated, 0 decidably not, 93 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -207,11 +207,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -130 of 231 (56%) as decidably elevated, with 93 more whose elevation is a +131 of 232 (56%) as decidably elevated, with 93 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 231`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -223,23 +223,23 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **231** | +| write call sites on the application surface | **232** | | …whose object name is statically decidable | 156 | -| …whose object name is chosen at run time | 75 | +| …whose object name is chosen at run time | 76 | | …against an object with tenancy ENABLED | 155 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 173 | +| threading a tenant context | 174 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 50 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 130 | +| threading a decidably ELEVATED (`isSystem`) context | 131 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 93 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 190 | +| receiver carried a readable engine type | 191 | | receiver erased, placed by the object NAME | 31 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | @@ -247,7 +247,7 @@ cannot read, and they are neither in nor out. | object name spelled inline | 107 | | object name spelled through a `const` | 49 | | object name is an `object: string` parameter | 7 | -| object name is some other run-time expression | 68 | +| object name is some other run-time expression | 69 | ### Subtractions the census could NOT defend — enforced @@ -297,11 +297,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-20 at `1f2b0cb73`. +Measured on 2026-10-20 at `300e9d436`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 674 | +| tracked non-test sources scanned | 675 | | engine-shaped types recognised | 80 | | declared objects in the registry | 118 | | same-named calls subtracted as non-engine | 166 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index eaa6d70fd3f..61122fb75b6 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 231 | +| Write call sites | 232 | | Object name statically decidable | 156 | -| Object name chosen at run time | 75 | +| Object name chosen at run time | 76 | | Against a tenancy-enabled object | 155 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 173 | +| Threading a tenant context | 174 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 50 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 130 | +| Threading a decidably elevated context | 131 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 93 | @@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-20 at `1f2b0cb73`. +Measured on 2026-10-20 at `300e9d436`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 674 | +| tracked non-test sources scanned | 675 | | engine-shaped types recognised | 80 | | declared objects in the registry | 118 | | same-named calls subtracted as non-engine | 166 | @@ -158,6 +158,7 @@ Measured on 2026-10-20 at `1f2b0cb73`. | `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 | +| `packages/plugins/plugin-auth/src/sys-session-revoke.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 | | `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `object` | undecidable | elevated | 1 | diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index d91da28e014..804084ba101 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -92,10 +92,15 @@ export const SysSession = ObjectSchema.create({ mode: 'delete', locations: ['list_item'], type: 'api', - target: '/api/v1/auth/revoke-session', - // better-auth `revoke-session` keys off the session token, not the id. - recordIdParam: 'token', - recordIdField: 'token', + // Keyed on the row's `id` — every list row carries it — and resolved to + // the session SERVER-side by plugin-auth's `sys-session/revoke` door, so + // the `internal` token below is never sent to or from the console. That + // door admits the session's owner (better-auth's own `/revoke-session`) + // and, for another user's session, a platform administrator (ADR-0068 + // D2). ⛔ Never key this action on `token`: no read path serves it, so a + // row can never supply it. + target: '/api/v1/auth/sys-session/revoke', + recordIdParam: 'sessionId', confirmText: 'Revoke this session? The user will be signed out from that device.', successMessage: 'Session revoked', refreshAfter: true, diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 7c4e59b33c5..82526cdf497 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -327,6 +327,53 @@ describe('@objectstack/platform-objects', () => { expect(role.description).toContain('OS_PLATFORM_OWNER_EMAIL'); expect(role.description).toContain('`single`'); }); + + it('SysSession revoke_session identifies its session by the row id, resolved server-side', () => { + const revoke = (SysSession.actions ?? []).find((a) => a.name === 'revoke_session'); + expect(revoke).toBeDefined(); + expect(revoke?.type).toBe('api'); + expect(revoke?.locations).toEqual(['list_item']); + expect(revoke?.target).toBe('/api/v1/auth/sys-session/revoke'); + expect(revoke?.recordIdParam).toBe('sessionId'); + // The row field that seeds `recordIdParam` defaults to `id` — the one + // column every list row carries. + expect(revoke?.recordIdField ?? 'id').toBe('id'); + expect(SysSession.fields.id).toBeDefined(); + }); + + it('SysSession keeps its session token internal: no read path serves it', () => { + const token = SysSession.fields.token as { internal?: unknown; hidden?: unknown; readonly?: unknown }; + expect(token.internal).toBe(true); + expect(token.hidden).toBe(true); + expect(token.readonly).toBe(true); + }); + + it('no identity action identifies its record by a field the server never serves', () => { + // An action seeds `recordIdParam` from the ROW the console holds. An + // `internal` field is omitted from every read, so it can never be on that + // row, and an action keyed on one can never name its record. + const objects = [ + SysAccount, SysApiKey, SysBusinessUnit, SysBusinessUnitMember, SysDeviceCode, + SysInvitation, SysJwks, SysMember, SysOauthAccessToken, SysOauthApplication, + SysOauthRefreshToken, SysOrganization, SysSession, SysSsoProvider, SysTeam, + SysTeamMember, SysTwoFactor, SysUser, SysUserPreference, SysVerification, + ]; + const offenders: string[] = []; + let seeded = 0; + for (const object of objects) { + for (const action of object.actions ?? []) { + if (!action.recordIdParam && !action.recordIdField) continue; + seeded += 1; + const field = action.recordIdField ?? 'id'; + const declared = (object.fields as Record)[field]; + if (field !== 'id' && !declared) offenders.push(`${object.name}.${action.name}: '${field}' is not a field`); + if (declared?.internal === true) offenders.push(`${object.name}.${action.name}: '${field}' is internal`); + } + } + // Positive control: the sweep reaches real declarations. + expect(seeded).toBeGreaterThan(0); + expect(offenders).toEqual([]); + }); }); describe('data portability — derived, not declared (#3025 / #3543)', () => { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 4de01344a9d..e184030fbee 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -71,6 +71,7 @@ import { MANAGED_EXTENSION_EDITABLE_FIELDS } from './managed-extension-fields.js import { runSetInitialPassword } from './set-initial-password.js'; import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js'; import { runResendVerificationEmail } from './send-verification-email.js'; +import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js'; import { readJsonRequestBody } from './request-json-body.js'; import type { CounterStore } from './rate-limit-storage.js'; import { authIdentityManifest } from './manifest.js'; @@ -3550,6 +3551,30 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // Revoke ONE session, named by its `sys_session.id` — the door behind the + // `sys_session` Revoke Session row action (Setup → Sessions, Account → + // Active Sessions). The server resolves the session from the id, so the + // session token never travels to or from the console. The owner lane + // re-dispatches to better-auth's own `/revoke-session`; another user's + // session is revocable by a platform administrator only (ADR-0068 D2 + // judge). Same family as `sys-oauth-application/register` above: an + // object-scoped mount outside `/admin/`, because it admits the owner. + // See `sys-session-revoke.ts` for the lanes and refusals. + rawApp.post(`${basePath}/sys-session/revoke`, async (c: any) => { + try { + const { status, body } = await runSysSessionRevoke( + sysSessionRevokeDepsFor(this.authManager!, basePath), + c.req.raw, + ); + return c.json(body, status as any); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] sys-session/revoke failed', err); + return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500); + } + }); + // ──────────────────────────────────────────────────────────────────── // Self-service resend of the email-verification link. SHADOWS better-auth's // native `/send-verification-email` (registered before the catch-all below). diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts index e69a16ff881..f5b625b5c1f 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts @@ -232,6 +232,11 @@ describe('auth route ledger hygiene', () => { 'POST /api/v1/auth/organization/add-member', 'POST /api/v1/auth/set-initial-password', 'POST /api/v1/auth/sys-oauth-application/register', + // The fourteenth, on this pin's own terms: `auth-plugin.ts` mounts it + // itself (a `rawApp.post` on the `${basePath}/sys-session/revoke` + // template, ahead of the catch-all), and the `live.has(route)` loop + // below holds it to the same proof — better-auth serves no by-id revoke. + 'POST /api/v1/auth/sys-session/revoke', ]); for (const route of own) { expect(live.has(route), `${route} should NOT come from better-auth`).toBe(false); diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index 6b437f9b765..75e17329bd3 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -243,6 +243,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ { route: 'POST /api/v1/auth/admin/set-user-manager', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user set_user_manager action posts it directly; sets or clears sys_user.manager_id, which drives the approvals `manager` rung and the own_and_reports scope and had no product write surface at all (the ADR-0092 D2 managed-update whitelist is {name,image,locale}). Runs under a system context so it reaches the column BY CONTEXT, not by whitelist — no Tier-1 list moves and the column keeps readonly: true; platform-admin gated (ADR-0068), ADR-0057 / ADR-0092' }, { route: 'POST /api/v1/auth/admin/unlock-user', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user unlock_user action posts it directly; clears a brute-force lockout (sys_user.locked_until / failed_login_count), a custom per-identity mechanism with no better-auth endpoint; platform-admin gated (ADR-0068), ADR-0069 D2' }, { route: 'POST /api/v1/auth/sys-oauth-application/register', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application create action posts it directly; session-required self-service wrapper over better-auth /oauth2/create-client that splits the Console\'s newline-separated redirect-URL textarea into the redirect_uris array the vendor schema requires' }, + { route: 'POST /api/v1/auth/sys-session/revoke', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_session revoke_session row action posts it directly with the row id; the server resolves the session from the id, so the session token never travels to or from the console. The session owner is re-dispatched to better-auth /revoke-session; another user\'s session is revocable by a platform admin only (ADR-0068 D2), tombstoned with reason admin (ADR-0069 D4)' }, { route: 'POST /api/v1/auth/organization/accept-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.accept', requires: 'organization' }, // #9941 — better-auth declares `addMember` with NO HTTP path (server-only // `auth.api.addMember`; measured on the installed 1.7.1), so the catch-all diff --git a/packages/plugins/plugin-auth/src/session-tombstone.ts b/packages/plugins/plugin-auth/src/session-tombstone.ts index 32288cdbaad..ffba135c2f9 100644 --- a/packages/plugins/plugin-auth/src/session-tombstone.ts +++ b/packages/plugins/plugin-auth/src/session-tombstone.ts @@ -185,8 +185,10 @@ import { SystemObjectName } from '@objectstack/spec/system'; * the session. Recording `admin` for a user clicking "sign out my other * devices" would not be a vague audit record, it would be a **wrong** one — it * names an actor class that took no action, which is worse for an auditor than - * no record at all. `admin` therefore means exactly the two `/admin/*` routes, - * and `user_revoked` means the session's own owner did it. + * no record at all. `admin` therefore means exactly the two `/admin/*` routes + * — plus the administrator lane of `sys-session-revoke.ts`, which writes this + * ledger's `admin` value itself — and `user_revoked` means the session's own + * owner did it. */ export const INTERACTIVE_REVOKE_REASON: Readonly> = { // Self-service — the session owner acting on their own sessions. diff --git a/packages/plugins/plugin-auth/src/sys-session-revoke.test.ts b/packages/plugins/plugin-auth/src/sys-session-revoke.test.ts new file mode 100644 index 00000000000..d3a03272166 --- /dev/null +++ b/packages/plugins/plugin-auth/src/sys-session-revoke.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `POST /api/v1/auth/sys-session/revoke` — the door behind the `sys_session` +// Revoke Session row action. The invariant: a session named by its row id +// ends, its next request is refused, and only a caller entitled to end it can +// end it — the session's owner, or a platform administrator. The session token +// is never part of any request to or answer from this door. +// +// Every revoke assertion pins BOTH directions, following +// `session-tombstone.test.ts`: the cookie STOPS authenticating, and the row +// SURVIVES carrying its cause (ADR-0069 D4). Every refusal pins the opposite: +// the target cookie STILL authenticates and the row is untouched. Refusals are +// asserted as `status` AND `code` (ADR-0112). +// +// Real pipeline throughout: the plugin's REAL route registration on a real +// Hono app in front of a real `AuthManager` (the +// `in-process-session-renewal.pin.test.ts` harness), cookies minted by +// better-auth's own sign-in, and "is this session alive?" asked of +// better-auth's own `/get-session`. + +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; +import { Hono } from 'hono'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import type { PluginContext } from '@objectstack/core'; +import { AuthManager } from './auth-manager'; +import { AuthPlugin } from './auth-plugin'; +// ⚠️ Imported from a sibling TEST file on purpose, as +// `admin-impersonate-endpoint.test.ts` and `in-process-session-renewal.pin.test.ts` +// do: that file's pinned engine double is reused rather than minting a second +// one with its own `check:engine-double-contract` ledger entries. The cost is +// known — its rotation cases also run inside this file. +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; +import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; +import { INTERACTIVE_REVOKE_REASON } from './session-tombstone'; +import { + ADMIN_SESSION_REVOKE_REASON, + SYS_SESSION_REVOKE_NOT_FOUND_CODE, + SYS_SESSION_REVOKE_NOT_FOUND_MESSAGE, + SYS_SESSION_REVOKE_PATH, +} from './sys-session-revoke'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-revoke-by-id'; +const ORIGIN = 'http://localhost:3000'; +const BASE = '/api/v1/auth'; +const PS_ADMIN = 'ps_admin_full_access'; +const ADMIN_EMAIL = 'padmin@revoke-by-id.example.com'; +const ALICE_EMAIL = 'alice@revoke-by-id.example.com'; +const BOB_EMAIL = 'bob@revoke-by-id.example.com'; + +const mockCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as any; + +const makeManager = (engine: any) => + new AuthManager({ secret: SECRET, baseUrl: ORIGIN, dataEngine: engine } as any); + +const direct = (manager: AuthManager, path: string, body: unknown) => + manager.handleRequest( + new Request(`${ORIGIN}${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +const sessionRows = (engine: any) => (engine.tables.get('sys_session') ?? []) as any[]; +const userRows = (engine: any) => (engine.tables.get('sys_user') ?? []) as any[]; + +const userIdFor = (engine: any, email: string): string => { + const row = userRows(engine).find((r) => r.email === email); + if (!row) throw new Error(`no sys_user row for ${email}`); + return String(row.id); +}; + +/** ADR-0068 D2 standing — the unscoped `admin_full_access` grant, never the legacy `role` scalar. */ +const grantPlatformAdmin = async (engine: any, userId: string) => { + if (!(engine.tables.get('sys_permission_set') ?? []).some((r: any) => r.id === PS_ADMIN)) { + await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); + } + await engine.insert('sys_user_permission_set', { + user_id: userId, + permission_set_id: PS_ADMIN, + permission_set: 'admin_full_access', + organization_id: null, + }); +}; + +/** The three sign-ups every case starts from, made once per file (one scrypt derivation each). */ +let signedUpTables: Promise> | undefined; +const signedUpOnce = (): Promise> => + (signedUpTables ??= (async () => { + const template = createMemoryEngine(); + const manager = makeManager(template); + for (const [email, name] of [ + [ADMIN_EMAIL, 'Platform Admin'], + [ALICE_EMAIL, 'Alice'], + [BOB_EMAIL, 'Bob'], + ]) { + await inviteForAudienceGate(manager, email); + const res = await direct(manager, '/sign-up/email', { email, password: PASSWORD, name }); + expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200); + } + return template.tables as Map; + })()); + +interface Signed { + cookie: string; + row: any; +} + +const arrange = async () => { + const engine = createMemoryEngine(); + for (const [name, rows] of await signedUpOnce()) engine.tables.set(name, structuredClone(rows)); + const manager = makeManager(engine); + await grantPlatformAdmin(engine, userIdFor(engine, ADMIN_EMAIL)); + + // The REAL route registration — raw mounts ahead of the catch-all. + const app = new Hono(); + const ctx = mockCtx(); + const plugin = new AuthPlugin({ secret: SECRET }); + await plugin.init(ctx); + (plugin as any).authManager = manager; + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + + const signIn = async (email: string): Promise => { + const res = await direct(manager, '/sign-in/email', { email, password: PASSWORD }); + expect(res.status, `sign-in ${email}: ${await res.clone().text()}`).toBe(200); + const token = String(((await res.clone().json()) as any).token); + const row = sessionRows(engine).find((r) => r.token === token); + if (!row) throw new Error(`no sys_session row for ${email}'s sign-in`); + return { cookie: cookieFrom(res), row }; + }; + + /** The live session payload for a cookie, or `null`. */ + const sessionOf = async (cookie: string): Promise => { + const res = await manager.handleRequest(new Request(`${ORIGIN}${BASE}/get-session`, { headers: { cookie } })); + if (res.status !== 200) return null; + const body = (await res.json().catch(() => null)) as any; + return body?.user?.id ? body : null; + }; + const isAuthenticated = async (cookie: string) => (await sessionOf(cookie)) !== null; + + /** POST the door through the Hono app, as the console does: cookie, origin, JSON body. */ + const revoke = (cookie: string | undefined, body: unknown) => + app.request(`${ORIGIN}${BASE}${SYS_SESSION_REVOKE_PATH}`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN, ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body), + }); + + /** No session token, of any row, appears anywhere in `text`. */ + const expectNoToken = (text: string) => { + for (const row of sessionRows(engine)) { + expect(text.includes(String(row.token)), 'a session token appeared in an answer').toBe(false); + } + }; + + return { engine, manager, signIn, sessionOf, isAuthenticated, revoke, expectNoToken }; +}; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +const scrypt = memoizeScrypt(); +afterAll(scrypt.restore); + +describe('sys-session/revoke — a session named by its id ends', () => { + it("an administrator revoking another user's session by id ends it: that session's next request is refused", async () => { + const { engine, signIn, sessionOf, isAuthenticated, revoke, expectNoToken } = await arrange(); + const alice = await signIn(ALICE_EMAIL); + const admin = await signIn(ADMIN_EMAIL); + expect(await isAuthenticated(alice.cookie), 'precondition: the target session is live').toBe(true); + expect((await sessionOf(admin.cookie))?.user?.isPlatformAdmin, 'precondition: platform-admin standing').toBe(true); + + // The console adds the active organization to every auth-namespace body. + const res = await revoke(admin.cookie, { sessionId: alice.row.id, organizationId: 'org_any' }); + const text = await res.text(); + expect(res.status, text).toBe(200); + expect(JSON.parse(text)).toEqual({ success: true, data: { sessionId: alice.row.id } }); + expectNoToken(text); + + expect(await isAuthenticated(alice.cookie), 'the revoked session still authenticates').toBe(false); + const row = sessionRows(engine).find((r) => r.id === alice.row.id); + expect(row, 'the row is kept as the record of its ending').toBeDefined(); + expect(row.revoke_reason).toBe('admin'); + expect(row.revoked_at).toBeTruthy(); + expect(await isAuthenticated(admin.cookie), "the administrator's own session is untouched").toBe(true); + }); + + it("a session's owner revoking their own other session by id ends that one and keeps the current one", async () => { + const { engine, signIn, isAuthenticated, revoke, expectNoToken } = await arrange(); + const current = await signIn(ALICE_EMAIL); + const other = await signIn(ALICE_EMAIL); + + const res = await revoke(current.cookie, { sessionId: other.row.id }); + const text = await res.text(); + expect(res.status, text).toBe(200); + expect(JSON.parse(text)).toEqual({ success: true, data: { sessionId: other.row.id } }); + expectNoToken(text); + + expect(await isAuthenticated(other.cookie), 'the revoked session still authenticates').toBe(false); + expect(await isAuthenticated(current.cookie), 'the current session must survive').toBe(true); + const row = sessionRows(engine).find((r) => r.id === other.row.id); + expect(row.revoke_reason).toBe('user_revoked'); + expect(row.revoked_at).toBeTruthy(); + }); + + it('an already-ended session reads not-found, and its record keeps the first ending', async () => { + const { engine, signIn, revoke } = await arrange(); + const alice = await signIn(ALICE_EMAIL); + const admin = await signIn(ADMIN_EMAIL); + expect((await revoke(admin.cookie, { sessionId: alice.row.id })).status).toBe(200); + const first = sessionRows(engine).find((r) => r.id === alice.row.id).revoked_at; + + const again = await revoke(admin.cookie, { sessionId: alice.row.id }); + const body = (await again.json()) as any; + expect(again.status).toBe(404); + expect(body.error.code).toBe(SYS_SESSION_REVOKE_NOT_FOUND_CODE); + expect(sessionRows(engine).find((r) => r.id === alice.row.id).revoked_at).toEqual(first); + }); +}); + +describe('sys-session/revoke — only a caller entitled to end a session can end it', () => { + it("a signed-in caller without platform-admin standing cannot revoke another user's session", async () => { + const { engine, signIn, sessionOf, isAuthenticated, revoke, expectNoToken } = await arrange(); + const alice = await signIn(ALICE_EMAIL); + const bob = await signIn(BOB_EMAIL); + expect((await sessionOf(bob.cookie))?.user?.isPlatformAdmin, 'precondition: no platform-admin standing').not.toBe(true); + + const res = await revoke(bob.cookie, { sessionId: alice.row.id }); + const text = await res.text(); + expect(res.status, text).toBe(404); + const body = JSON.parse(text); + expect(body.success).toBe(false); + expect(body.error.code).toBe(SYS_SESSION_REVOKE_NOT_FOUND_CODE); + expect(body.error.message).toBe(SYS_SESSION_REVOKE_NOT_FOUND_MESSAGE); + expectNoToken(text); + + expect(await isAuthenticated(alice.cookie), "another user's session must survive").toBe(true); + const row = sessionRows(engine).find((r) => r.id === alice.row.id); + expect(row.revoked_at ?? null).toBeNull(); + + // The answer is the one an id naming no session gets, byte for byte. + const missing = await revoke(bob.cookie, { sessionId: 'ses_names_nothing' }); + expect(missing.status).toBe(res.status); + expect(await missing.text()).toBe(text); + }); + + it('an anonymous caller is refused before anything is revoked', async () => { + const { engine, signIn, isAuthenticated, revoke, expectNoToken } = await arrange(); + const alice = await signIn(ALICE_EMAIL); + + const res = await revoke(undefined, { sessionId: alice.row.id }); + const text = await res.text(); + expect(res.status, text).toBe(401); + expect(JSON.parse(text).error.code).toBe('UNAUTHENTICATED'); + expectNoToken(text); + + expect(await isAuthenticated(alice.cookie)).toBe(true); + expect(sessionRows(engine).find((r) => r.id === alice.row.id).revoked_at ?? null).toBeNull(); + }); + + it('a request that names no session is refused, and nothing is revoked', async () => { + const { engine, signIn, revoke } = await arrange(); + const admin = await signIn(ADMIN_EMAIL); + const before = sessionRows(engine).filter((r) => r.revoked_at).length; + + const res = await revoke(admin.cookie, { organizationId: 'org_any' }); + const body = (await res.json()) as any; + expect(res.status).toBe(400); + expect(body.error.code).toBe('INVALID_REQUEST'); + expect(sessionRows(engine).filter((r) => r.revoked_at).length).toBe(before); + }); +}); + +describe('sys-session/revoke — the administrator lane records the ledger value', () => { + it("records the interactive-revoke ledger's own `admin` reason", () => { + expect(ADMIN_SESSION_REVOKE_REASON).toBe(INTERACTIVE_REVOKE_REASON['/admin/revoke-user-session']); + expect(ADMIN_SESSION_REVOKE_REASON).toBe('admin'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/sys-session-revoke.ts b/packages/plugins/plugin-auth/src/sys-session-revoke.ts new file mode 100644 index 00000000000..c6f8029af07 --- /dev/null +++ b/packages/plugins/plugin-auth/src/sys-session-revoke.ts @@ -0,0 +1,284 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST /api/v1/auth/sys-session/revoke` — end ONE session, named by its + * `sys_session.id`. + * + * ## The invariant this route holds + * + * A session row's Revoke Session action — Setup → Sessions for an + * administrator, Account → Active Sessions for the session's owner — ends the + * session it names, and that session's next request is refused. The action is + * keyed on the row's `id`, which every list row carries; the server resolves + * the session from the id, server-side, and the session token never leaves the + * server in either direction. `sys_session.token` stays `internal: true`: no + * read path serves it, and nothing here needs one to. + * + * ## Who may revoke what + * + * Two lanes, decided by the row the id resolves to: + * + * 1. **The session's owner** — re-dispatched to better-auth's own + * `POST /revoke-session` with the resolved token and the caller's own + * credentials (`in-process-redispatch.ts`). Everything that route enforces + * still applies, unchanged and not restated here: the vendor's ownership + * and freshness checks, the `revoke-session-match-guard.ts` admission gate, + * and the ADR-0069 D4 tombstone (`session-tombstone.ts`, reason + * `user_revoked`) with the session-delete hooks it keeps running. + * 2. **A platform administrator, on another user's session** — admitted by + * the ADR-0068 D2 judge every ObjectStack `/admin/*` mount uses + * (`judgePlatformAdmin`, held sessions refused per ADR-0069 D3), then the + * session is tombstoned in place exactly as the platform's other + * server-side revocations write it (`membership-ended-session.ts`, + * `forced-reset-revocation.ts`): `expires_at` a second in the past plus + * `revoked_at` / `revoke_reason`. The reason is `admin`, the value the + * interactive-revoke ledger already records for an administrator's revoke + * ({@link ADMIN_SESSION_REVOKE_REASON}). better-auth's session read then + * answers `null` for that token, so its next request is unauthenticated. + * The vendor's own `/admin/revoke-user-session` is not the delegate: it + * authorizes on the legacy `role` scalar, which ADR-0068 D2 platform + * administrators do not carry (`admin-ban-endpoints.ts` records the + * measurement). + * + * Everyone else — a signed-in caller who neither owns the session nor holds + * platform-admin standing — reads the SAME `404 RESOURCE_NOT_FOUND` as an id + * that names no live session, byte for byte. The decision is made before + * anything about the target is revealed, so the answer carries no more than + * "no session you may revoke has this id". + * + * ## Why not the generic data path + * + * `sys_session` is `managedBy: 'better-auth'` and its writes are refused on the + * data API (ADR-0092 D2, `apiMethods: ['get', 'list']`). This is an ObjectStack + * mount on the raw app ahead of the auth catch-all, ledgered in + * `auth-route-ledger.ts`, in the `sys-oauth-application/register` family: an + * object-scoped door outside the `/admin/` namespace, because its first lane + * admits the session's owner. + * + * ## Refusals (ADR-0112: code AND status) + * + * - anonymous: `401 UNAUTHENTICATED`, before the body is read; + * - a body declared JSON that does not parse, or no `sessionId`: + * `400 INVALID_REQUEST`; + * - no live session the caller may revoke: `404 RESOURCE_NOT_FOUND`; + * - a held platform administrator on another user's session: `403` with the + * posture's own code (`judgePlatformAdmin`); + * - the owner lane: whatever better-auth's `/revoke-session` answers, status + * and body as it sent them. + */ + +import { SystemObjectName } from '@objectstack/spec/system'; +import type { AuthSessionApi } from '@objectstack/spec/contracts'; +import { inProcessSessionReadInput } from '@objectstack/types'; +import { inProcessRedispatchUrl } from './in-process-redispatch.js'; +import { + PLATFORM_ADMIN_REFUSAL_MESSAGES, + isPlatformAdminUser, + judgePlatformAdmin, +} from './platform-admin-gate.js'; +import { readJsonRequestBody } from './request-json-body.js'; +import { INTERACTIVE_REVOKE_REASON } from './session-tombstone.js'; + +/** The route, relative to the auth base path. */ +export const SYS_SESSION_REVOKE_PATH = '/sys-session/revoke'; + +/** The body key that names the session — the console's `recordIdParam`. */ +export const SYS_SESSION_REVOKE_ID_PARAM = 'sessionId'; + +/** The refusal's wire code — standard catalog (ADR-0112). */ +export const SYS_SESSION_REVOKE_NOT_FOUND_CODE = 'RESOURCE_NOT_FOUND'; + +/** + * The refusal's message. ONE string for every caller and every cause (no such + * id, an already-ended session, a session the caller may not revoke) — the + * byte-equality is load-bearing, see the header. + */ +export const SYS_SESSION_REVOKE_NOT_FOUND_MESSAGE = 'No session you may revoke matches the supplied id.'; + +/** + * The `revoke_reason` the administrator lane records: the interactive-revoke + * ledger's own value for an administrator's revoke, read rather than retyped + * so the two cannot drift. + */ +export const ADMIN_SESSION_REVOKE_REASON: string = INTERACTIVE_REVOKE_REASON['/admin/revoke-user-session']; + +/** What the in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; + +/** A live session row, as the server resolves it from an id. Never serialized. */ +export interface LiveSessionRow { + id: string; + userId: string; + token: string; +} + +/** The route's answer: a status and an ADR-0112 envelope (or the vendor's body, owner lane). */ +export interface SysSessionRevokeResult { + status: number; + body: unknown; +} + +/** The seams this route reads. {@link sysSessionRevokeDepsFor} wires the real ones. */ +export interface SysSessionRevokeDeps { + /** The caller's session, `customSession`-decorated (`isPlatformAdmin`, `authGate`). */ + readCallerSession(headers: Headers): Promise; + /** The live (unrevoked) session `sessionId` names, read server-side, or `null`. */ + findLiveSession(sessionId: string): Promise; + /** Tombstone the session in place with `reason`; `false` when no row was written. */ + tombstoneSession(sessionId: string, reason: string): Promise; + /** better-auth's universal handler — `AuthManager.handleRequest`. */ + handle(request: Request): Promise; + /** The auth route base, e.g. `/api/v1/auth`. */ + basePath: string; +} + +/** The slice of `AuthManager` the real seams are built from. */ +export interface SysSessionRevokeManager { + getApi(): Promise; + getAuthContext(): Promise; + getDataEngine(): unknown; + handleRequest(request: Request): Promise; +} + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +const notFound = (): SysSessionRevokeResult => ({ + status: 404, + body: { + success: false, + error: { code: SYS_SESSION_REVOKE_NOT_FOUND_CODE, message: SYS_SESSION_REVOKE_NOT_FOUND_MESSAGE }, + }, +}); + +const invalid = (message: string): SysSessionRevokeResult => ({ + status: 400, + body: { success: false, error: { code: 'INVALID_REQUEST', message } }, +}); + +const revoked = (sessionId: string): SysSessionRevokeResult => ({ + status: 200, + body: { success: true, data: { sessionId } }, +}); + +/** + * The real seams: the session read every mount here makes, better-auth's own + * storage adapter for the lookup (which hides tombstoned rows and re-attaches + * the `internal` token server-side), and the raw engine for the tombstone + * write, under a system context as the platform's other revocations write it. + */ +export function sysSessionRevokeDepsFor(manager: SysSessionRevokeManager, basePath: string): SysSessionRevokeDeps { + return { + async readCallerSession(headers) { + const api = (await manager.getApi()) as { getSession(input: unknown): Promise }; + return api.getSession(inProcessSessionReadInput(headers)); + }, + async findLiveSession(sessionId) { + const ctx = (await manager.getAuthContext()) as { + adapter: { findOne(q: { model: string; where: Array<{ field: string; value: unknown }> }): Promise }; + }; + const row = (await ctx.adapter.findOne({ + model: 'session', + where: [{ field: 'id', value: sessionId }], + })) as { id?: unknown; userId?: unknown; token?: unknown } | null; + if (!row?.id || row.userId == null || typeof row.token !== 'string' || row.token.length === 0) return null; + return { id: String(row.id), userId: String(row.userId), token: row.token }; + }, + async tombstoneSession(sessionId, reason) { + const engine = manager.getDataEngine() as { + update(object: string, doc: Record, opts?: unknown): Promise; + } | undefined; + if (!engine) throw new Error('The data engine is not available, so the session could not be revoked.'); + const now = Date.now(); + const written = await engine.update( + SystemObjectName.SESSION, + { + id: sessionId, + // A second in the past, matching `enforceSessionControls`, so every + // `expiresAt < now` liveness check is strictly true. + expires_at: new Date(now - 1000), + revoked_at: new Date(now), + revoke_reason: reason, + }, + { context: SYSTEM_CTX }, + ); + return written != null && written !== 0; + }, + handle: (request) => manager.handleRequest(request), + basePath, + }; +} + +/** + * Owner lane: better-auth's `/revoke-session`, with the resolved token and the + * caller's own credentials. Only status and body come back from here, so the + * re-dispatch carries `disableRefresh` for a cookie request + * (`inProcessRedispatchUrl`). + */ +async function revokeAsOwner( + deps: SysSessionRevokeDeps, + request: Request, + target: LiveSessionRow, +): Promise { + const incoming = new URL(request.url); + const headers = new Headers({ 'content-type': 'application/json' }); + const cookie = request.headers.get('cookie'); + if (cookie) headers.set('cookie', cookie); + const authorization = request.headers.get('authorization'); + if (authorization) headers.set('authorization', authorization); + headers.set('origin', request.headers.get('origin') || incoming.origin); + + const url = new URL(`${deps.basePath}/revoke-session`, incoming.origin).href; + const response = await deps.handle( + new Request(inProcessRedispatchUrl(url, headers), { + method: 'POST', + headers, + body: JSON.stringify({ token: target.token }), + }), + ); + if (response.ok) return revoked(target.id); + + let body: unknown; + try { + const text = await response.text(); + body = text ? JSON.parse(text) : { success: false }; + } catch { + body = { success: false }; + } + return { status: response.status, body }; +} + +/** `POST /api/v1/auth/sys-session/revoke` — see the module header. */ +export async function runSysSessionRevoke( + deps: SysSessionRevokeDeps, + request: Request, +): Promise { + // Identity first: an anonymous caller learns nothing about any id. + const session = await deps.readCallerSession(request.headers); + const user = session?.user; + const callerId = user?.id == null ? '' : String(user.id); + if (!callerId) { + return { + status: 401, + body: { success: false, error: { code: 'UNAUTHENTICATED', message: PLATFORM_ADMIN_REFUSAL_MESSAGES[401] } }, + }; + } + + const reading = await readJsonRequestBody(request); + if (!reading.ok) return invalid(reading.message); + const raw = (reading.body as Record | null | undefined)?.[SYS_SESSION_REVOKE_ID_PARAM]; + if (typeof raw !== 'string' || raw.length === 0) return invalid(`${SYS_SESSION_REVOKE_ID_PARAM} is required`); + + const target = await deps.findLiveSession(raw); + if (!target) return notFound(); + + if (target.userId === callerId) return revokeAsOwner(deps, request, target); + + // Another user's session: platform administrators only. Every other caller + // reads the not-found answer above, whatever the id names. + if (!isPlatformAdminUser(user)) return notFound(); + const verdict = judgePlatformAdmin(session); + if (!verdict.ok) return { status: verdict.refusal.status, body: verdict.refusal.body }; + + if (!(await deps.tombstoneSession(target.id, ADMIN_SESSION_REVOKE_REASON))) return notFound(); + return revoked(target.id); +}