Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): refuse a /revoke-session that identifies no record instead of answering success - #10070
Conversation
…instead of answering success
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946BxCo-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…act ledger Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5609711d197da76d7e6d20e5572421a523014a31 && git checkout 5609711d197da76d7e6d20e5572421a523014a31
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 19031388db12560343600e7347ddda12f375d45e 940a9606b12eab9c13f5f9d9f42f9f8d3b3916f6 && git checkout -B drift-repro 19031388db12560343600e7347ddda12f375d45e && git merge --no-ff 940a9606b12eab9c13f5f9d9f42f9f8d3b3916f6
node scripts/docs-audit/affected-docs.mjs --json 19031388db12560343600e7347ddda12f375d45e
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9714
Premise re-verified first (binding condition from the triage grading)
The card's
{ status: true }reading was inherited from #8018's rc-era measurement; the unlock scan required re-measuring on the stable vendor line before any fix. Measured on this branch:1.7.1(read from the installed package,node_modules/.pnpm/better-auth@1.7.1_*/…/package.json— not from any card; the card said1.7.0-rc.2, a sibling comment said1.7.0, both stale).dist/api/routes/session.mjs): therevoke-sessionhandler's success line is unconditional — the delete runs only whenfindSession(token)?.session.userId === ctx.context.session.user.id, andreturn ctx.json({ status: true })runs regardless. 1.7.1 added an ownership guard the rc line did not have, which widened the silent no-op: a foreign token now also skips the delete and still answers success.AuthManager.handleRequest→ better-auth 1.7.1 → ObjectQL adapter), before the fix existed:200 {"status":true}200 {"status":true}, target row untouchedPremise holds on the stable line.
premise_still_valid: true.The fix
A
hooks.beforeadmission gate (the established seam — same as the/organization/remove-memberand/sso/registergates, and before-hook for the same reason: an after-hook can replace a body but not a status). On/revoke-session, the guard asks the vendor's own admission predicate through the vendor's owninternalAdapter.findSessioncall; when the token identifies no session belonging to the caller, it refuses with 404, codeRESOURCE_NOT_FOUND(ADR-0112: code AND status). When it admits, the vendor's own condition holds on the same data and the success answer is true. Full reading inpackages/plugins/plugin-auth/src/revoke-session-match-guard.ts, including:200 { status: false }: a 200 still reads as success to everyres.okcaller — the false-success defect would survive its own fix. A mutation naming a resource it cannot identify is a failed request; 404 is its established spelling.RESOURCE_NOT_FOUNDand not a newSESSION_NOT_FOUND: the ledger's [finding]@objectstack/restregisters four generic synonyms the standard catalog already covers (CONFLICT,NOT_FOUND,FORBIDDEN,INTERNAL) — contract call, not a cleanup #8211 admission rule refuses semantic synonyms of standard-catalog members;RESOURCE_NOT_FOUNDis the standard member for this condition, so no ledger registration and no new vocabulary.list-sessions.sensitiveSessionMiddleware), non-string tokens (400 from the body schema), adapter read failures (never convert "could not look" into "does not exist").sys_session.revoked_at/revoke_reasonare declared readonly "System-managed" but the interactive-revoke path deletes rather than tombstones — ADR-0069 D4 revoke-audit trail is effectively inert #7732): an already-revoked token now answers 404 — consistent with "a revoked session is not a session"; previously it was another silent{ status: true }no-op.Scope note:
/revoke-sessionsand/revoke-other-sessionsmatch by the caller's user id and cannot mis-identify a record, so they are deliberately untouched. The server-resolved revoke mechanism stays out per the card's own note. The admin plugin's/admin/revoke-user-sessionhas the same defect class upstream and is filed separately as #10069 — it is not addressed here and #9714's scope does not cover it.Verification (all at head
940a9606bunless noted)revoke-session-match-guard.test.ts, real better-auth pipeline, 8 tests): zero-match → 404 + code; foreign token → byte-identical answer + row survives untombstoned; matching revoke still succeeds end to end (tombstone written, cookie dead — preservation pin); double revoke → 404; unauthenticated → vendor 401, never this 404; empty-string token → 404; 2 unit tests on the reproduced predicate.4 failed | 4 passed, every failureAssertionError: expected 200 to be 404. Restored byte-identically, proven bygit hash-object(25737f9d09fc87155a94e47868303869d868148epre and post). No rebuild was required for the mutation to be observable: the suite imports./auth-manageras an in-package relative source import, resolved by vitest fromsrc/, never throughdist.pnpm --filter @objectstack/plugin-auth test→Test Files 59 passed (59) · Tests 1320 passed (1320)(includes the auth-route-ledger conformance suite — the route table is unchanged; a before-hook adds no route).940a9606b:check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured …, none above its recorded number.plugin-auth stays at exactly 109; this PR contributes 0 errors.node scripts/pm/dispatch-gates.mjs, no hand-fed paths) and run to completion at940a9606b— all green, verdict lines quoted:check-engine-double-contract: OK — 325 pinned, 133 in the DEBT ledger, 2 exempt.(the ledger learned the new pinned double in its own commit);where-matcher conformance holds: 263 matcher(s) … (158 refuse);no lowercase error codes in 4288 scanned file(s) (ADR-0112);Route-envelope conformance — 10 route module(s) audited: 7 conformant, 2 ratcheted, 1 exempt;check-nul-bytes: OK; changeset gates (no major,no empty, ADR-0087adds no declared-breaking changeset) all green;check:cross-package-test-inputs OK: 12 package(s) read outside themselves, all declared; plus slot-lookup, test-source-alias, type-source-resolution, query-options-erasure, docs-audit — all OK.{ status: true }pins exist outside plugin-auth (checkedpackages/,apps/,examples/, includingpackages/restandpackages/client— the SDK'ssessions.revokereturnsres.json()without asserting the shape; also swept the objectui checkout: nothing).Changeset
.changeset/revoke-session-zero-match-refusal.md— patch, states the new answer plainly for a release-notes reader, mechanism only.Generated by Claude Code