Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): /admin/revoke-user-session refuses (404 RESOURCE_NOT_FOUND) when the token identifies no session - #10109
Conversation
…entifies no record
better-auth 1.7.1's handler deletes blindly and answers 200 { success: true }
unconditionally; a hooks.before admission gate now answers 404
RESOURCE_NOT_FOUND (ADR-0112) when no session carries the supplied token,
after grading the vendor's own permission question first so non-admin callers
keep the vendor's 401/403 unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx…hangeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
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 a250f283b51ba24467526aae407550c7e658d276 && git checkout a250f283b51ba24467526aae407550c7e658d276
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e61ee68320da9136fedbfe2609b0d21f97b587b4 9884c8ba64fb89b44f66de820e142ebfe4403e24 && git checkout -B drift-repro e61ee68320da9136fedbfe2609b0d21f97b587b4 && git merge --no-ff 9884c8ba64fb89b44f66de820e142ebfe4403e24
node scripts/docs-audit/affected-docs.mjs --json e61ee68320da9136fedbfe2609b0d21f97b587b4
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10069
The measurement first (the card's premise, re-established behaviourally)
The card's reading was from the installed dist only; this PR re-measured it end to end before changing anything. On the installed vendor better-auth 1.7.1 (read from this branch's
node_modules, not from the card), through the real pipeline (AuthManager.handleRequest→ better-auth → ObjectQL adapter, network-free),POST /admin/revoke-user-session:200 {"success":true}— the delete dispatched nothing and the endpoint reported success;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's mechanism) token also answered200 {"success":true};200 {"success":true}and tombstoned the session with reasonadmin(the session-list-revoke: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 interaction works as pinned atsession-tombstone.test.ts);403 YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS(harness sanity: the permission surface is real).Premise valid; the fix proceeded.
The fix — a
hooks.beforeadmission gate, NOT the sibling's predicateadmin-revoke-user-session-match-guard.ts+ wiring inauth-manager.ts, same seam as the/revoke-sessionguard from PR #10070, because an after-hook can replace a body but not a status.The sibling route skips on an ownership mismatch, so its guard reproduces an ownership predicate. This route deletes blindly and the caller is an admin acting on arbitrary users — there is no ownership dimension. The admission predicate here is only "does any session carry this token", asked through the vendor's own
internalAdapter.findSession. When no session does, the guard refuses 404RESOURCE_NOT_FOUND(ADR-0112: code AND status), for the three reasons recorded in the sibling's header (res.okcallers, DELETE-like semantics, standard-catalog member over a synonym extension).Permission is graded before existence
The guard runs ahead of the vendor's
adminMiddleware+hasPermission. Refusing 404 on zero-match without grading the caller first would hand every authenticated non-admin an existence oracle the vendor never gave them (guard-404 for a missing token vs vendor-403 for a live one). So the guard first asks the vendor's own permission question — the caller's session viagetAuthoritativeSessionFromCtx(the exact calladminMiddlewaremakes), then the vendor'shasPermissionlogic with the vendor's own inputs: the live options object retained on the mounted admin plugin and the vendor's exporteddefaultRolesas fallback. Any caller the vendor would refuse falls through to the vendor's own 401/403 untouched — pinned byte-identically for missing and live tokens alike, so nothing below the permission line can distinguish them. This also leaves the recorded #9482 surface unchanged (a platform admin without the legacyrolescalar still gets the vendor 403).hasPermissionitself is not exported by the vendor, so its ten lines are reproduced with the live options and the exported role map as inputs; both drift directions are pinned (a mirror gone loose answers this guard's 404 where the vendor's 403 belongs — integration test; a mirror gone strict is refused at unit level by the custom-roles pin).The existence-oracle question, re-decided rather than inherited
The sibling (#9714) made zero-match and foreign-token answers byte-identical because its caller is an arbitrary user. Here the refusal deliberately reveals "no session carries this token" — but only to callers who pass the vendor's own
session: ["revoke"]check. That caller class is already entitled to session-existence knowledge: the same default admin role grantssession: ["list"](/admin/list-user-sessionsover arbitrary users), so the 404 tells an entitled admin nothing they cannot already query. There is no foreign-vs-missing pair to collapse on this route — any live session is legitimately deletable by an entitled admin; only "no session at all" refuses.Tombstone interaction (issue #7732), re-verified behaviourally
This route carries reason
admininINTERACTIVE_REVOKE_REASON.hideRevokedSessionRowmakes a tombstoned row invisible tofindSession, so revoking an already-revoked token now answers 404 — consistent with the tombstone module's "a revoked session is not a session" doctrine and with the sibling guard; previously it was a silent false success (measured above). The admitted path still tombstones with reasonadmin, and the refused second revoke leaves the tombstone untouched (pinned).Scope
/admin/revoke-user-sessions(plural) matches by user id and cannot mis-identify a single record — left untouched, same reasoning as the sibling's plural routes.better-auth-schema-parity.test.ts,managed-extension-fields.test.ts) gained adefaultRolesskip entry — it is a role map, not a plugin factory; it declares no schema. Same shape as their existinghasPermissionentry.{ success: true }shape on this route: zero found; counter-check — the same search located the route's ledger row, tombstone map entry, dogfood refusal probe, and checklist prose, so the net demonstrably works. The dogfood non-admin refusal suite (admin-route-nonadmin-refusal.dogfood.test.ts) probes this route only below the permission line, which this guard leaves byte-identical.Verification (all at
b293081c2)pnpm --filter @objectstack/plugin-auth test—Test Files 60 passed (60) · Tests 1335 passed (1335).3 failed | 12 passed, eachAssertionError: expected 200 to be 404. Restored from the commit and proven byte-identical (git hash-object=8cffac5b…both sides). No rebuild was needed for the mutation to be observable — the suite imports./auth-managerin-package fromsrc/under vitest's transform; no dist is on the resolution path.pnpm check:type-check-debt— "33 ledger entr(ies) re-measured … none above its recorded number"; plugin-auth stays at its frozen count (109), not raised.node scripts/pm/dispatch-gates.mjs(re-derived after the ledger commit, which addedcheck:cross-package-test-inputs):check:changeset-gate-self-tests,check:objectui-changeset,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:cross-package-test-inputs,check:query-options-erasure,check:type-check-coverage,check:engine-double-contract("316 (file, verb) row(s) held by the RETAINED ledger"),check:where-matcher,check:nul-bytes,check:error-code-casing,check-adr-0087-registration("this PR adds no declared-breaking changeset"),check-changeset-no-major,check-empty-changeset,check-affected-docs— all exit 0 atb293081c2.Changeset: patch for
@objectstack/plugin-auth, stating the new answer plainly for a release-notes reader.Generated by Claude Code