Skip to content

fix(plugin-auth): rotate the caller's bearer on impersonation, and recover the admin via bearer on exit (#8243) - #8432

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8243-impersonation-bearer-rotation
Aug 13, 2026
Merged

fix(plugin-auth): rotate the caller's bearer on impersonation, and recover the admin via bearer on exit (#8243)#8432
os-zhuang merged 2 commits into
mainfrom
claude/issue-8243-impersonation-bearer-rotation

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #8243

Implements the maintainer's ruling of 2026-08-13: rotation, not refusal, with the stop-impersonating bearer-recovery half shipping alongside it.

The defect

POST /api/v1/auth/admin/impersonate-user answered 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 unchanged Authorization header, 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-impersonating resolved the admin through the admin_session cookie alone, so it was dead in exactly the deployments this card is about. The recovery credential now also travels on a set-admin-session-token response header (exposed via Access-Control-Expose-Headers, alongside set-auth-token) and is accepted back on x-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

  • Rotation rides better-auth's global hooks.after, the one place where the admin's session and the adapter are both in hand after the route succeeded.
  • Recovery ingestion rides the request seam (AuthManager.handleRequest), not a before-hook. That is load-bearing: bearer()'s own before-hook rebuilds the header set from c.request.headers, so a Cookie injected 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 put runSubjectErasureAtomically at 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.

Assertion Predicted (ablated) Observed (ablated)
impersonate returns 200 pass — the vacuity trap pass
set-auth-token present and differs from the admin bearer pass pass
old admin bearer resolves to the ADMIN — the defect expected 'n65zXWqt...' not to be 'n65zXWqt...'
rotated set-auth-token bearer resolves to the target pass pass
set-admin-session-token emitted absent expected null to be truthy
admin session row deleted survives expected true to be false
stop-impersonating via bearer 500 expected 500 to be 200
stop-impersonating with no recovery (negative control) 500 both ways 500 both ways
cookie lane untouched identical both ways identical both ways

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 what resolve-session-principal.ts calls — never at a status code.

Verification

  • pnpm --filter @objectstack/plugin-auth test50 files, 1138 tests, all pass
  • pnpm --filter @objectstack/plugin-auth typecheck — clean
  • check:engine-double-contractOK, 195 pinned (the new fake's update() routes through assertEngineUpdateDispatch, its delete() through assertEngineDeleteDispatch; nothing added to the shrink-only baseline)
  • check:nul-bytes, check:error-code-casing, check:route-envelope, check:empty-changeset, check:authz-resolver — all pass
  • eslint on the touched files — 0 errors (1 pre-existing unused-disable warning at auth-manager.ts:180, present on main at line 174 and merely shifted by the new import block)

Reviewer notes

  • Upstream issue not yet filed. The ruling requires it and the full text is drafted and posted on better-auth bearer plugin lets a bearer session silently shadow an impersonation the server just created — /admin/impersonate-user returns 200 and is a no-op for any bearer client #8243 ready to file verbatim — but better-auth/better-auth is unreachable from this session (the GitHub proxy is scoped to objectstack-ai/*, and add_repo refuses cross-tier adds). Needs a hand with upstream access; the link then goes on the card.
  • Rotation carries exactly one field across to the replacement session — the admin's selected organization. Spreading the whole old session row was the first cut and was narrowed deliberately: it hands the producer a set of keys nobody reasoned about, which an in-memory double accepts and a real ObjectQL insert can refuse.
  • Companion, not in this PR: objectui#4467 option A (console adopts set-auth-token rotation) 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 touches auth-manager.ts plus two new files. The new module is deliberately not exported from the package index.ts to keep it that way.


Generated by Claude Code

…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
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 13, 2026 1:16pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-auth)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants