fix(plugin-auth): rotate the caller's bearer on impersonation, and recover the admin via bearer on exit (#8243) - #8432
Conversation
…cover the admin via bearer on exit (#8243) better-auth's bearer() plugin authenticates by overwriting the request's session cookie with the bearer token; /admin/impersonate-user hands the impersonation session over AS a cookie. For any bearer client those two collide: the caller's unchanged Authorization header keeps being converted back into the ADMIN's session, so impersonation returned 200 and did nothing, with every subsequent write attributed to the admin. Rotation, not refusal (maintainer ruling, 2026-08-13): a bearer- authenticated impersonation now invalidates the token the caller holds and hands back a recovery credential, so the impersonated session is the only one that resolves afterwards. stop-impersonating honours that credential on x-admin-session-token, because resolving the admin through the admin_session cookie alone leaves the exit path dead in cookie-blocked deployments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…h, and narrow the carried-forward session state (#8243) check:engine-double-contract flagged the new fake's update() as looser than the producer. Route it through assertEngineUpdateDispatch, the same way its delete() already routes through assertEngineDeleteDispatch. Auditing the double for looseness BEYOND the dispatch signature turned up a real one on the production side: rotation spread the whole old session row into createSession as an override — a set of keys nobody had reasoned about, which an in-memory double accepts and a real ObjectQL insert can refuse. Carry exactly one field across (the admin's selected organization, the only piece they would notice losing) and let everything else be re-derived for a session minted now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Fixes #8243
Implements the maintainer's ruling of 2026-08-13: rotation, not refusal, with the
stop-impersonatingbearer-recovery half shipping alongside it.The defect
POST /api/v1/auth/admin/impersonate-useranswered 200 and did nothing for every bearer-authenticated client.better-auth's
bearer()plugin authenticates a request by overwriting the request's session cookie with the bearer token. The admin plugin's impersonation route does the opposite — it hands the impersonation session over as a cookie. A browser composes those two; a bearer client cannot. The caller kept replaying its unchangedAuthorizationheader, that header kept being converted back into the admin's session, and the impersonation cookie was never read. Since the framework's data routes resolve identity through the same seam (runtime/src/security/resolve-session-principal.ts), every write made "while impersonating" was attributed to the admin — with nothing anywhere reporting it.What changed
Two wrappers around the vendor routes. No fork, no vendoring, no patched dependency.
Entry — rotation. On a bearer-authenticated impersonation, the token the caller holds is invalidated as part of impersonating: mint a rotated admin session, hand the caller its recovery credential, then delete the original session — in that order, so any failure leaves the admin signed in with impersonation refused rather than locked out. Afterwards the only token that resolves is the impersonated one better-auth already emits on
set-auth-token. A client that ignores the rotation gets a loud 401 instead of a silently wrong identity.Exit — bearer-carried recovery.
stop-impersonatingresolved the admin through theadmin_sessioncookie alone, so it was dead in exactly the deployments this card is about. The recovery credential now also travels on aset-admin-session-tokenresponse header (exposed viaAccess-Control-Expose-Headers, alongsideset-auth-token) and is accepted back onx-admin-session-token. A real cookie still wins, and the vendor route's own checks all still run — this adds a lane, it does not open one.Cookie-authenticated impersonation is unchanged byte for byte: a browser caller has no stale credential in hand to invalidate.
Two seam choices worth flagging to a reviewer
hooks.after, the one place where the admin's session and the adapter are both in hand after the route succeeded.AuthManager.handleRequest), not a before-hook. That is load-bearing:bearer()'s own before-hook rebuilds the header set fromc.request.headers, so aCookieinjected by any hook is clobbered by whichever hook sorts after it. Written into the request itself,bearer()'s parse-mutate-serialize preserves it. Same reasoning that putrunSubjectErasureAtomicallyat this seam.Anti-vacuity: predict-then-mutate ablation
The failure mode is a silent 200 no-op, so a test asserting the endpoint returns 200 would have been green against the bug. Predictions were written before mutating; the ablation removed both halves, leaving vendor behaviour.
set-auth-tokenpresent and differs from the admin bearerexpected 'n65zXWqt...' not to be 'n65zXWqt...'set-auth-tokenbearer resolves to the targetset-admin-session-tokenemittedexpected null to be truthyexpected true to be falsestop-impersonatingvia bearerexpected 500 to be 200stop-impersonatingwith no recovery (negative control)Ablated: 5 failed, 4 passed — and the 4 that passed are precisely the assertions that cannot distinguish the fix from the bug, which is why they are labelled as controls rather than pins. Restored: 10 passed.
Every pin ends at which principal the next request resolves to, asked through
auth.api.getSession({ headers })— literally whatresolve-session-principal.tscalls — never at a status code.Verification
pnpm --filter @objectstack/plugin-auth test— 50 files, 1138 tests, all passpnpm --filter @objectstack/plugin-auth typecheck— cleancheck:engine-double-contract—OK, 195 pinned(the new fake'supdate()routes throughassertEngineUpdateDispatch, itsdelete()throughassertEngineDeleteDispatch; nothing added to the shrink-only baseline)check:nul-bytes,check:error-code-casing,check:route-envelope,check:empty-changeset,check:authz-resolver— all passauth-manager.ts:180, present onmainat line 174 and merely shifted by the new import block)Reviewer notes
better-auth/better-authis unreachable from this session (the GitHub proxy is scoped toobjectstack-ai/*, andadd_reporefuses cross-tier adds). Needs a hand with upstream access; the link then goes on the card.set-auth-tokenrotation) is the client half and stands regardless.Surface is disjoint from #8317 / #8417, which is in flight in the same package: that card touches
auth-plugin.ts,index.ts,member-role-canonical.ts; this one touchesauth-manager.tsplus two new files. The new module is deliberately not exported from the packageindex.tsto keep it that way.Generated by Claude Code