Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/impersonation-bearer-rotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-auth": patch
---

fix(plugin-auth): impersonation actually takes effect for bearer clients — rotate the caller's token, and let `stop-impersonating` recover the admin via bearer (#8243)

`POST /api/v1/auth/admin/impersonate-user` answered **HTTP 200 and did nothing**
for every bearer-authenticated client — the console after every normal sign-in,
and every deployment where cookies are blocked, which is the exact context
better-auth's `bearer()` plugin exists for.

Two correct pieces of better-auth collided. `bearer()` authenticates a request by
**overwriting the request's session cookie** with the bearer token. The admin
plugin's impersonation route does the opposite: it mints the impersonation
session and hands it over **as a cookie**, parking the admin's own session token
in a signed `admin_session` cookie for the way back. A browser composes those
two; a bearer client cannot. The client kept replaying its unchanged
`Authorization: Bearer` header, that header kept being converted back into the
**admin's** session, and the impersonation cookie was never read.

Nothing reported this. The endpoint returned success, an impersonation session
row existed, and every subsequent request — including every write, since the
framework's data routes resolve identity through the same seam — was attributed
to the **admin** rather than the impersonated user.

**Impersonation now rotates the caller's credential.** When the caller
authenticated with a bearer, the token it holds is invalidated as part of
impersonating: a rotated admin session is minted, the caller is handed it as a
recovery credential, and the original admin session is deleted. Afterwards the
only token that resolves is the impersonated one better-auth already emits on
`set-auth-token`. A client that adopts the rotation is the impersonated
principal; a client that ignores it gets a loud 401 on its next request.
"Impersonation succeeded but did not take effect" is no longer expressible.

Refusing bearer-authenticated impersonation was considered and rejected: it
would leave cookie-blocked deployments unable to impersonate at all.

**The exit path ships with it.** `POST /admin/stop-impersonating` resolved the
admin through the `admin_session` **cookie alone**, so it was dead in precisely
the deployments this fix is about. The recovery credential is now emitted on a
`set-admin-session-token` response header (exposed via
`Access-Control-Expose-Headers`, alongside `set-auth-token`) and accepted back on
an `x-admin-session-token` request header. Clients that already work through
cookies need no change: a real `admin_session` cookie still wins, and the vendor
route's own checks all still run — this adds a lane, it does not open one.

For API clients, the flow is the same one `set-auth-token` already asks for:
read both headers off the impersonation response, send `Authorization: Bearer`
with the new token, and send the recovery credential back on
`x-admin-session-token` when leaving impersonation.

Unaffected: cookie-authenticated impersonation, which is unchanged byte for
byte — a browser caller has no stale credential in hand to invalidate.
47 changes: 47 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import {
type AuthEventAuditSurface,
} from './auth-session-audit.js';
import { SESSION_ERASURE_PATHS } from './session-tombstone.js';
import {
ADMIN_SESSION_COOKIE_KEY,
STOP_IMPERSONATING_PATH,
rotateCallerBearerOnImpersonation,
withBearerAdminSessionRecovery,
} from './impersonation-bearer-rotation.js';
import {
invitationRoleCapFailure,
isPlainMemberInvitation,
Expand Down Expand Up @@ -1557,6 +1563,18 @@ export class AuthManager {
}
}),
after: createAuthMiddleware(async (ctx: any) => {
// ── #8243: impersonation must actually take effect for a bearer ──
// FIRST among the after-hooks that touch the response, because the
// bearer plugin's own after-hook runs behind this one and must see
// what we stage: it re-reads `Access-Control-Expose-Headers` to add
// `set-auth-token`, so the recovery header we expose here survives
// only by being merged before it. See
// `impersonation-bearer-rotation.ts` for the mechanism — the short
// version is that `bearer()` converts the caller's token back into
// the ADMIN's session cookie on every later request, so without
// rotating that token, `/admin/impersonate-user` is a 200 no-op.
await rotateCallerBearerOnImpersonation(ctx);

// ── ADR-0069 D2: account lockout (counter) ──────────────────
// better-auth catches an INVALID_EMAIL_OR_PASSWORD APIError and runs
// the after-hook with it on `ctx.context.returned`; a success leaves
Expand Down Expand Up @@ -3335,6 +3353,35 @@ export class AuthManager {
}

const auth = await this.getOrCreateAuth();

// [#8243] Let a bearer client carry the `admin_session` recovery credential
// back out of impersonation. better-auth's `/admin/stop-impersonating`
// resolves the admin through the `admin_session` COOKIE alone, so in a
// cookie-blocked deployment — the exact context `bearer()` exists for — the
// exit path is dead. We accept the credential on a header and write it into
// the request's own `Cookie` before better-auth sees it; the vendor route
// then runs completely unmodified, checking everything it always checked.
//
// The REQUEST seam, not a before-hook: `bearer()`'s 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 keeps it.
if (this.betterAuthEndpointPath(request) === STOP_IMPERSONATING_PATH) {
try {
const authContext: any = await (auth as any).$context;
const adminCookieName: string | undefined =
authContext?.createAuthCookie?.(ADMIN_SESSION_COOKIE_KEY)?.name;
if (adminCookieName) {
request = await withBearerAdminSessionRecovery(request, adminCookieName);
}
} catch {
// Cookie name unresolvable (e.g. a dynamic-baseURL context we cannot
// reach here) → leave the request alone. The vendor route then answers
// exactly as it does today for a missing cookie: a loud failure, never
// a silent wrong identity.
}
}

// better-auth's HTTP entrypoint (`createBetterAuth.handler`) wraps execution
// in `runWithAdapter` but NOT `runWithRequestState`. Endpoints that read
// request-state via `defineRequestState()` (e.g. `should-session-refresh`,
Expand Down
Loading
Loading