From e381fd5abd1336a05cb87f4c514ce2356feb834b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:10:53 +0000 Subject: [PATCH 1/3] fix(plugin-auth): refuse a /revoke-session that identifies no record instead of answering success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit better-auth 1.7.1's revoke-session handler skips the delete when the supplied token matches zero rows (or another user's row) and still answers 200 { status: true } — its success line is unconditional. A before-hook admission gate now refuses those requests with 404 RESOURCE_NOT_FOUND (ADR-0112: code and status together), asking the vendor's own predicate through the vendor's own adapter call. Zero-match and foreign-token refusals are byte-identical (no existence oracle); unauthenticated callers, non-string tokens and adapter read failures fall through to the vendor's own answers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../plugins/plugin-auth/src/auth-manager.ts | 65 ++++ .../src/revoke-session-match-guard.test.ts | 311 ++++++++++++++++++ .../src/revoke-session-match-guard.ts | 140 ++++++++ 3 files changed, 516 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts create mode 100644 packages/plugins/plugin-auth/src/revoke-session-match-guard.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 7646c234a2..25737f9d09 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -49,6 +49,11 @@ import { removalBlockedByOwnerTarget, } from './remove-member-permission-guard.js'; import { isPlaceholderEmail } from './placeholder-email.js'; +import { + REVOKE_SESSION_NOT_FOUND_CODE, + REVOKE_SESSION_NOT_FOUND_MESSAGE, + revokeTargetsCallerSession, +} from './revoke-session-match-guard.js'; import { reconcileMembership, type MembershipPolicy, @@ -1466,6 +1471,22 @@ export class AuthManager { // fall through — the vendor still re-decides everything it owns } + // ── #9714: a revoke that identifies NO record must not report ── + // success. better-auth 1.7.1's `revoke-session` handler skips the + // delete when the token matches zero rows (or another user's row) + // and still answers `200 { status: true }` — its success line is + // unconditional. Refuse those requests HERE, with the vendor's own + // admission predicate asked through the vendor's own adapter call, + // so the handler only ever runs on requests whose success answer is + // true. Before-hook on purpose: an after-hook cannot change the + // status. `revoke-session-match-guard.ts` carries the full reading, + // including the 404-over-`{status:false}` decision and the + // no-existence-oracle property. + if (ctx?.path === '/revoke-session') { + await this.assertRevokeSessionIdentifiesRecord(ctx); + // fall through — the vendor still performs the revoke itself + } + // ── ADR-0024: admin-gate self-service SSO provider registration ── // `@better-auth/sso`'s POST /sso/register only checks org-admin when // `body.organizationId` is present (index.mjs: `if (ctx.body @@ -4441,6 +4462,50 @@ export class AuthManager { } } + /** + * [#9714] `/revoke-session` admission gate — refuse (404, standard + * `RESOURCE_NOT_FOUND`) when the supplied token identifies no session + * belonging to the caller, instead of letting the vendor's unconditional + * success line answer `{ status: true }` over a skipped delete. + * + * The predicate is the vendor's own (`findSession(token)?.session.userId === + * `), asked through the same `internalAdapter.findSession` the + * handler calls — never a second spelling of it. Everything the guard cannot + * decide falls through to the vendor: unauthenticated callers (its session + * middleware answers 401), a non-string token (its zod body schema answers + * 400), an adapter read failure (never convert "could not look" into "does + * not exist"). Zero-match and foreign-token answers are byte-identical — no + * existence oracle. Full reading: `revoke-session-match-guard.ts`. + */ + private async assertRevokeSessionIdentifiesRecord(ctx: any): Promise { + const token = ctx?.body?.token; + if (typeof token !== 'string') return; // vendor's body schema answers 400 + + let admitted: boolean; + try { + const actor = await this.resolveActor(ctx); + // No resolvable caller → the vendor's sensitiveSessionMiddleware issues + // the 401. Not ours to pre-empt (and answering 404 here would grade an + // anonymous probe's question before its authentication). + if (!actor?.userId) return; + + const found = await ctx.context.internalAdapter.findSession(token); + admitted = revokeTargetsCallerSession(found, actor.userId); + } catch { + // A lookup that did not complete hands the request back to the vendor + // unchanged — an engine hiccup must not invent a "not found". + return; + } + + if (!admitted) { + const { APIError } = await import('better-auth/api'); + throw new APIError('NOT_FOUND', { + message: REVOKE_SESSION_NOT_FOUND_MESSAGE, + code: REVOKE_SESSION_NOT_FOUND_CODE, + }); + } + } + /** * [#3697] The issuer's own better-auth membership role in `orgId` — the * input to the invitation role cap. diff --git a/packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts b/packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts new file mode 100644 index 0000000000..e86f06ec92 --- /dev/null +++ b/packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts @@ -0,0 +1,311 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #9714 — `POST /revoke-session` must not answer success over a skipped +// delete. better-auth 1.7.1's handler skips the delete when the supplied +// token matches zero rows (or another user's row) and still answers +// `200 { status: true }` — measured on this exact pipeline before the guard +// existed (both branches answered `200 {"status":true}` with the target row +// untouched). The guard in `revoke-session-match-guard.ts` refuses those +// requests with 404 `RESOURCE_NOT_FOUND` (ADR-0112: code AND status, never +// one alone). +// +// Real better-auth pipeline throughout, following `session-tombstone.test.ts`: +// requests go in as `Request` objects through `AuthManager.handleRequest`, the +// cookie is the one better-auth minted, and the revoke path is better-auth's +// own. A stub of our adapter would prove nothing — the whole question is what +// answer leaves the library. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { AuthManager } from './auth-manager'; +import { + REVOKE_SESSION_NOT_FOUND_CODE, + REVOKE_SESSION_NOT_FOUND_MESSAGE, + revokeTargetsCallerSession, +} from './revoke-session-match-guard'; + +/** + * In-memory IDataEngine — the `session-tombstone.test.ts` harness, unchanged, + * because these tests assert against the same table through the same library + * and a second, more forgiving fake would be able to disagree with it. Both + * write paths stay pinned to ObjectQL's own dispatch predicates + * ({@link assertEngineDeleteDispatch} / {@link assertEngineUpdateDispatch}). + */ +const createMemoryEngine = () => { + const tables = new Map(); + const rows = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: any, b: any) => + a instanceof Date || b instanceof Date + ? new Date(a as any).getTime() === new Date(b as any).getTime() + : a === b; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + const actual = row[k]; + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) { + if ('$ne' in v) return !eq(actual, v.$ne); + if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x)); + if ('$gt' in v) return actual > v.$gt; + if ('$gte' in v) return actual >= v.$gte; + if ('$lt' in v) return actual < v.$lt; + if ('$lte' in v) return actual <= v.$lte; + if ('$regex' in v) return new RegExp(String(v.$regex)).test(String(actual ?? '')); + } + return eq(actual, v); + }); + /** `fields` projection — `id` always survives, as it does in ObjectQL. */ + const project = (row: any, fields?: string[]) => { + if (!Array.isArray(fields) || fields.length === 0) return { ...row }; + const out: any = {}; + for (const f of ['id', ...fields]) if (f in row) out[f] = row[f]; + return out; + }; + let seq = 0; + return { + tables, + async insert(name: string, data: any) { + const row = { id: data.id ?? `row_${++seq}`, ...data }; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, q: any = {}) { + const row = rows(name).find((r) => matches(r, q.where)); + return row ? project(row, q.fields) : null; + }, + async find(name: string, q: any = {}) { + let out = rows(name).filter((r) => matches(r, q.where)); + const order = q.orderBy?.[0]; + if (order) { + out = [...out].sort( + (a, b) => (a[order.field] > b[order.field] ? 1 : -1) * (order.order === 'desc' ? -1 : 1), + ); + } + if (q.offset) out = out.slice(q.offset); + if (q.limit) out = out.slice(0, q.limit); + return out.map((r) => project(r, q.fields)); + }, + async count(name: string, q: any = {}) { + return rows(name).filter((r) => matches(r, q.where)).length; + }, + async update(name: string, patch: any, options?: any) { + const dispatch = assertEngineUpdateDispatch(patch, options); + if (dispatch.kind === 'multi') { + const hit = rows(name).filter((r) => matches(r, options?.where)); + for (const row of hit) Object.assign(row, patch); + return hit.length; + } + const row = rows(name).find((r) => r.id === dispatch.id); + if (!row) return null; + Object.assign(row, patch); + return { ...row }; + }, + async delete(name: string, q: any = {}) { + assertEngineDeleteDispatch(q); + const table = rows(name); + const keep = table.filter((r) => !matches(r, q.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +}; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-9714'; + +const makeManager = (engine: any) => + new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + } as any); + +const post = (manager: AuthManager, path: string, cookie?: string, body?: unknown) => + manager.handleRequest( + new Request(`http://localhost:3000/api/v1/auth/${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify(body ?? {}), + }), + ); + +const signUp = (manager: AuthManager, email: string) => + post(manager, 'sign-up/email', undefined, { email, password: PASSWORD, name: 'RevokeGuard' }); + +const getSession = (manager: AuthManager, cookie: string) => + manager.handleRequest( + new Request('http://localhost:3000/api/v1/auth/get-session', { headers: { cookie } }), + ); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +/** + * Is this cookie still authenticated? better-auth answers `/get-session` with + * HTTP 200 and a JSON `null` body when the session is gone — NOT a 401 — so a + * status-only assertion would pass against a fully revoked session. + */ +const isAuthenticated = async (manager: AuthManager, cookie: string): Promise => { + const res = await getSession(manager, cookie); + if (res.status !== 200) return false; + const body = await res.json().catch(() => null); + return Boolean((body as any)?.user?.id); +}; + +const sessionRows = (engine: any) => (engine.tables.get('sys_session') ?? []) as any[]; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#9714 — /revoke-session refuses when the token identifies no record', () => { + it('a token matching ZERO rows answers 404 with code AND status — never { status: true }', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const cookie = cookieFrom(await signUp(manager, 'zero-match@example.com')); + + const res = await post(manager, 'revoke-session', cookie, { + token: 'no-such-token-anywhere', + }); + const body: any = await res.json(); + + // ADR-0112: code AND status, never one alone. + expect(res.status).toBe(404); + expect(body?.code).toBe(REVOKE_SESSION_NOT_FOUND_CODE); + // The defect's exact shape must be gone, not merely accompanied. + expect(body?.status).not.toBe(true); + // The caller's own session is untouched by its failed revoke. + expect(await isAuthenticated(manager, cookie)).toBe(true); + }); + + it("another user's live token answers BYTE-IDENTICALLY to a nonexistent one, and the row survives", async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + cookieFrom(await signUp(manager, 'alice-target@example.com')); + const aliceRow = sessionRows(engine)[0]!; + const mallory = cookieFrom(await signUp(manager, 'mallory-caller@example.com')); + + const foreign = await post(manager, 'revoke-session', mallory, { token: aliceRow.token }); + const foreignBody = await foreign.text(); + const missing = await post(manager, 'revoke-session', mallory, { token: 'no-such-token' }); + const missingBody = await missing.text(); + + // No existence oracle: same status, same bytes. + expect(foreign.status).toBe(404); + expect(missing.status).toBe(404); + expect(foreignBody).toBe(missingBody); + expect(JSON.parse(foreignBody)?.code).toBe(REVOKE_SESSION_NOT_FOUND_CODE); + expect(JSON.parse(foreignBody)?.message).toBe(REVOKE_SESSION_NOT_FOUND_MESSAGE); + + // Alice's session row is untouched: not deleted, not tombstoned. + const survivor = sessionRows(engine).find((r) => r.token === aliceRow.token); + expect(survivor).toBeDefined(); + expect(survivor.revoked_at).toBeUndefined(); + }); + + it('a MATCHING revoke still succeeds end to end — the guard admits what the vendor deletes', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const first = cookieFrom(await signUp(manager, 'real-revoke@example.com')); + const firstRow = sessionRows(engine)[0]!; + // Second session for the same user, revoked from the first. + const second = cookieFrom( + await post(manager, 'sign-in/email', undefined, { + email: 'real-revoke@example.com', + password: PASSWORD, + }), + ); + const secondRow = sessionRows(engine).find((r) => r.id !== firstRow.id)!; + + const res = await post(manager, 'revoke-session', first, { token: secondRow.token }); + const body: any = await res.json(); + + // The vendor's success answer, now true when given. + expect(res.status).toBe(200); + expect(body).toHaveProperty('status', true); + // The revoke really happened (#7732 tombstone) and the cookie is dead. + const row = sessionRows(engine).find((r) => r.id === secondRow.id); + expect(row?.revoke_reason).toBe('user_revoked'); + expect(await isAuthenticated(manager, second)).toBe(false); + expect(await isAuthenticated(manager, first)).toBe(true); + }); + + it('revoking an ALREADY-REVOKED token answers 404 — a revoked session is not a session', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const first = cookieFrom(await signUp(manager, 'double-revoke@example.com')); + const firstRow = sessionRows(engine)[0]!; + const secondCookie = cookieFrom( + await post(manager, 'sign-in/email', undefined, { + email: 'double-revoke@example.com', + password: PASSWORD, + }), + ); + void secondCookie; + const secondRow = sessionRows(engine).find((r) => r.id !== firstRow.id)!; + + expect((await post(manager, 'revoke-session', first, { token: secondRow.token })).status).toBe(200); + + const again = await post(manager, 'revoke-session', first, { token: secondRow.token }); + const body: any = await again.json(); + expect(again.status).toBe(404); + expect(body?.code).toBe(REVOKE_SESSION_NOT_FOUND_CODE); + }); + + it('an UNAUTHENTICATED caller still gets the vendor 401, never this 404', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + cookieFrom(await signUp(manager, 'anon-probe@example.com')); + const row = sessionRows(engine)[0]!; + + // A real token, no credentials: the guard must stay silent and let the + // vendor's session middleware answer the authentication question — a 404 + // here would grade an anonymous probe's existence question. + const res = await post(manager, 'revoke-session', undefined, { token: row.token }); + expect(res.status).toBe(401); + const body: any = await res.json().catch(() => null); + expect(body?.code).not.toBe(REVOKE_SESSION_NOT_FOUND_CODE); + }); + + it('an EMPTY-string token answers 404 — previously the quietest false success of all', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const cookie = cookieFrom(await signUp(manager, 'empty-token@example.com')); + + const res = await post(manager, 'revoke-session', cookie, { token: '' }); + // better-auth's zod body schema accepts an empty string (`z.string()`), so + // this reaches the guard, matches zero rows, and must refuse. + const body: any = await res.json(); + expect(res.status).toBe(404); + expect(body?.code).toBe(REVOKE_SESSION_NOT_FOUND_CODE); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('revokeTargetsCallerSession — the vendor predicate, reproduced', () => { + const found = { session: { userId: 'u_1' }, user: { id: 'u_1' } }; + + it('admits the owner', () => { + expect(revokeTargetsCallerSession(found, 'u_1')).toBe(true); + }); + + it('refuses null / undefined / foreign / shapeless results and empty callers', () => { + expect(revokeTargetsCallerSession(null, 'u_1')).toBe(false); + expect(revokeTargetsCallerSession(undefined, 'u_1')).toBe(false); + expect(revokeTargetsCallerSession(found, 'u_2')).toBe(false); + expect(revokeTargetsCallerSession({}, 'u_1')).toBe(false); + expect(revokeTargetsCallerSession({ session: {} }, 'u_1')).toBe(false); + expect(revokeTargetsCallerSession(found, '')).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-auth/src/revoke-session-match-guard.ts b/packages/plugins/plugin-auth/src/revoke-session-match-guard.ts new file mode 100644 index 0000000000..a66a8f7198 --- /dev/null +++ b/packages/plugins/plugin-auth/src/revoke-session-match-guard.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#9714] `POST /revoke-session` — a revoke that identifies NO record must not + * report success. + * + * ## The defect, and where it is minted + * + * Not here: the wrong answer comes out of the pinned vendor. better-auth + * `1.7.1` (the installed line — re-measured for #9714 after the #3002 family + * move off `1.7.0-rc.2`), `dist/api/routes/session.mjs`, `revokeSession` runs: + * + * ```js + * const token = ctx.body.token; + * if ((await ctx.context.internalAdapter.findSession(token))?.session.userId + * === ctx.context.session.user.id) try { + * await ctx.context.internalAdapter.deleteSession(token); + * } catch (error) { … throw INTERNAL_SERVER_ERROR … } + * return ctx.json({ status: true }); + * ``` + * + * The final line is unconditional. When the supplied token matches zero rows — + * or a row belonging to someone else (1.7.1's new ownership guard, absent on + * the rc line #8018 originally measured) — the delete is skipped and the + * endpoint still answers `200 { status: true }`. A security control that + * no-ops while telling the operator it worked. Measured behaviourally on this + * repo's real pipeline (AuthManager.handleRequest → better-auth 1.7.1 → + * ObjectQL adapter): zero-match and foreign-token both answered + * `200 {"status":true}` with the target row untouched. + * + * ## The fix: a before-hook admission gate, vendor predicate reproduced + * + * The vendor is not ours to edit, so the correction happens at our bridge, in + * the global `hooks.before` — the same seam and for the same reason as the + * `/organization/remove-member` guard (`remove-member-permission-guard.ts`): + * an after-hook can replace a body but not a status, and this fix's whole + * point is that the transport-visible answer stops being a success. + * + * The admission predicate is the vendor's own, reproduced rather than + * redesigned: `findSession(token)?.session.userId === `, + * asked through the same `ctx.context.internalAdapter.findSession` call the + * handler itself makes one moment later. Whenever the guard admits, the + * vendor's own condition holds on the same data, so the delete it performs is + * the one the answer claims. Whenever the guard refuses, the vendor would have + * no-oped. (`session-tombstone.ts` refused the route boundary because + * re-implementing revoke means duplicating ownership checks; this guard + * duplicates nothing — it asks the vendor's exact question through the + * vendor's exact adapter call, and the handler still re-decides everything it + * owns.) + * + * ## The refusal shape, decided deliberately + * + * A zero-match revoke is arguably "not found" and arguably "nothing to do". + * This guard answers **404 `RESOURCE_NOT_FOUND`** (ADR-0112: code AND status), + * for three reasons: + * + * 1. A `200 { status: false }` would still read as success to every caller + * that checks `res.ok` — which is exactly the class of caller (scripts, AI + * clients) this route now has, since the console refuses before dispatch + * (objectui#4670). The false-success defect would survive its own fix. + * 2. DELETE-like semantics: a mutation naming a specific resource that cannot + * be identified is a failed request, and 404 is its established spelling. + * Idempotent-retry callers treat a 404 on the second attempt as settled. + * 3. `RESOURCE_NOT_FOUND` is the standard-catalog member for this condition. + * Registering a `SESSION_NOT_FOUND` extension would be a semantic synonym + * of a standard member, which the ledger's #8211 admission rule refuses. + * + * ## No existence oracle + * + * A token that matches nothing and a token that matches ANOTHER USER's live + * session answer **byte-identically** (same status, same code, same message). + * The vendor's own skip path already treats them identically; the guard + * preserves that. The refusal reveals only "no session of YOURS carries this + * token" — a fact the caller is entitled to, since `list-sessions` hands the + * owner their own token list. `revoke-session-match-guard.test.ts` pins the + * two answers equal. + * + * ## What the guard deliberately does NOT decide + * + * - **Unauthenticated / unresolvable callers fall through** — better-auth's + * `sensitiveSessionMiddleware` owns that refusal (401), and pre-empting it + * would turn "who are you" into "does X exist". Same posture as the + * `/sso/register` and `/organization/remove-member` gates. + * - **A non-string `token` falls through** — the endpoint's own zod body + * schema answers the 400. + * - **Session freshness stays the vendor's.** `sensitiveSessionMiddleware` + * also enforces freshness, after this hook; a stale-but-valid caller with a + * zero-match token gets this guard's 404 rather than the vendor's staleness + * refusal. Both are refusals; duplicating the freshness predicate here to + * restore the vendor's precedence would be a second spelling of a security + * check, which is where bypasses live. + * - **An adapter read failure falls through** — the guard never converts "I + * could not look" into "it does not exist"; the vendor's handler owns its + * own error path. + * + * ## Interaction with session tombstones (#7732) + * + * `hideRevokedSessionRow` makes a tombstoned row invisible to + * `internalAdapter.findSession`, so revoking an ALREADY-REVOKED token now + * answers 404 — consistent with that module's own doctrine ("a revoked session + * is not a session"), and previously a silent `{ status: true }` no-op through + * the same unconditional-success line. + * + * ## Scope + * + * `/revoke-session` only — the route #8018/#9714 measured, the one that takes + * a caller-supplied match key. `/revoke-sessions` and `/revoke-other-sessions` + * match by the CALLER's user id, so they cannot mis-identify; zero sessions to + * sweep is genuinely "nothing to do", not a mis-identified record. The admin + * plugin's `/admin/revoke-user-session` has the same defect class upstream + * (unconditional `{ success: true }`) and is tracked separately — it is a + * different permission surface with a different answer key, not a rider here. + */ + +/** The refusal's wire code — standard catalog (ADR-0112), see header. */ +export const REVOKE_SESSION_NOT_FOUND_CODE = 'RESOURCE_NOT_FOUND'; + +/** + * The refusal's message. One string for zero-match and foreign-token alike — + * the byte-equality is load-bearing (no existence oracle, see header). + */ +export const REVOKE_SESSION_NOT_FOUND_MESSAGE = + 'No session of yours matches the supplied token.'; + +/** + * The vendor's own admission predicate, reproduced: does `found` (the + * `internalAdapter.findSession` result) name a session belonging to + * `callerUserId`? + * + * Shape-tolerant on purpose: `findSession` answers `{ session, user }` (or + * `null`), and the comparison is the handler's own + * `found?.session.userId === user.id`. Anything unreadable is "no match" — + * which is also what the vendor's optional-chain makes of it. + */ +export function revokeTargetsCallerSession(found: unknown, callerUserId: string): boolean { + if (!callerUserId) return false; + const session = (found as { session?: { userId?: unknown } } | null | undefined)?.session; + const owner = session?.userId; + return owner != null && String(owner) === callerUserId; +} From 4d87d36220b81a41f58dfd2ac266557a82932e95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:27:33 +0000 Subject: [PATCH 2/3] chore: changeset for the revoke-session zero-match refusal Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .changeset/revoke-session-zero-match-refusal.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/revoke-session-zero-match-refusal.md diff --git a/.changeset/revoke-session-zero-match-refusal.md b/.changeset/revoke-session-zero-match-refusal.md new file mode 100644 index 0000000000..2432d24139 --- /dev/null +++ b/.changeset/revoke-session-zero-match-refusal.md @@ -0,0 +1,5 @@ +--- +'@objectstack/plugin-auth': patch +--- + +`POST /api/v1/auth/revoke-session` no longer reports success when it revokes nothing. A request whose `token` does not identify a session belonging to the caller now answers `404` with error code `RESOURCE_NOT_FOUND`, instead of `200 { status: true }` over a skipped delete. A token that matches nothing and a token that belongs to another user answer identically, so the refusal discloses no session-existence information. Requests that do identify the caller's own session are unchanged and still answer `200 { status: true }`. From 940a9606b12eab9c13f5f9d9f42f9f8d3b3916f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:32:38 +0000 Subject: [PATCH 3/3] chore: record the new pinned engine double in the engine-double-contract ledger Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- scripts/engine-double-contract.pinned.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index b25b6f2d00..747cbf351e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1101,6 +1101,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/revoke-session-match-guard.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/session-of-record.test.ts", "verb": "delete",