Skip to content

fix(auth): the data lane honors set-auth-token — impersonation takes effect and is visible (#4467) - #4511

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4467-impersonation-visible
Aug 13, 2026
Merged

fix(auth): the data lane honors set-auth-token — impersonation takes effect and is visible (#4467)#4511
yinlianghui merged 2 commits into
mainfrom
claude/issue-4467-impersonation-visible

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4467

The mechanism: two lanes inject one credential, only one honored the rotation

The console injects the same localStorage bearer from two places:

lane implementation used by
auth createBearerFetch inside createAuthClient.ts sign-in, get-session, the auth endpoints
data createAuthenticatedFetch the adapter, provider: 'api' data sources, every metadata type: 'api' action

better-auth's server-side bearer plugin hands a rotated session token back in the
set-auth-token response header on whichever lane the call arrived over. Only the auth
lane read it — it was the repo's single reader. A rotation issued to a data-lane call was
discarded, and the browser kept sending the OLD token.

POST /auth/admin/impersonate-user is exactly such a call: an ordinary metadata action,
so it runs over the data lane. The impersonated session token arrived in set-auth-token
and was dropped on the floor, while the server's bearer plugin kept overwriting the
impersonation cookie with the admin bearer the console kept sending.

Impersonation was therefore a complete no-op in the console, not merely an invisible
one
— and the audit risk is the inverse of the card's original framing: support staff
believed they were seeing a user's view while acting entirely as themselves. This is the
measurement that falsified the card's stated premise (client identity going stale);
identity was never stale, it re-resolved correctly every SPA boot, to the admin, because
the admin is who the session still was.

The fix

  1. createAuthenticatedFetch mirrors the auth lane's three-line capture. One
    contract, one answer, on both lanes — no impersonation-specific logic in a generic
    lane, and no knowledge of which endpoint rotated. Gated on isApiCall, the same
    condition that decided we authenticated the request at all; untrusted targets remain
    the sameOriginOnly option's job, which short-circuits before any header work.
  2. AuthProvider grows refreshSession() — re-resolves user/session in place,
    deliberately without raising isLoading (a refresh must not blank the console it runs
    under).
  3. TokenStorage.subscribeRotation() notifies when a token already in hand is
    replaced by a different one. First store, clear(), and re-storing the same value stay
    silent: those transitions have an owner that updates identity itself. An ownerless
    rotation is precisely what starting impersonation produces — the action runtime
    executes type: 'api' actions generically and cannot know one endpoint changed who the
    user is without hard-coding that endpoint into a generic runtime. The rotation is the
    signal, and it is the server's own declaration rather than our guess about a URL.
  4. AuthClientSession.impersonatedBy? — interface widening, per the fix(list): OBJECT_API_DISABLED renders an honest cannot-work state instead of the empty state (#4408) #4495 precedent.
  5. The banner mounts in ConsoleShell, the one provider stack every console route
    passes through, naming BOTH parties plus a stop affordance. It derives from the
    session, not from client memory of the click, so it survives a full SPA reboot, a new
    tab and a browser restart, and it cannot disagree with who the server thinks is acting.
  6. The exit fails loudly. stop-impersonating restores the administrator from the
    admin_session cookie, so a cookie-blocked deployment cannot exit this way. A stop
    that resolves but leaves the session impersonated keeps the banner up and says so —
    silently appearing to succeed would be strictly worse than the original defect.

Admin-only navigation needed no separate change: it is gated on user.isPlatformAdmin,
which AppContent reads from useAuth(), so once identity re-resolves to the
impersonated user the admin surfaces follow structurally.

Red-first

The card's original harness — a mocked session carrying impersonatedBy — would have been
a phantom pin: production could not reach that state at all, because the token that
produces it was being thrown away. The primary pin is therefore lane-level. The lane fix
was taken out with git checkout of the pre-fix file (never git stash), the suites run,
and the file restored and verified identical by sha256.

Verbatim, with the fix removed:

 FAIL  |dom| packages/auth/src/__tests__/impersonation-lane-4467.test.tsx > set-auth-token rotation — lane symmetry (#4467) > DATA lane adopts a rotated set-auth-token (the #4467 defect)
AssertionError: expected 'admin-token' to be 'impersonated-token' // Object.is equality
Expected: "impersonated-token"
Received: "admin-token"

 FAIL  |dom| packages/auth/src/__tests__/impersonation-lane-4467.test.tsx > identity follows an ownerless rotation (#4467) > a DATA-lane rotation re-resolves the console identity (impersonation takes effect)
AssertionError: expected 'Dev Admin' to be 'RT8 Dave' // Object.is equality

 FAIL  |dom| packages/app-shell/src/layout/__tests__/ImpersonationBanner.test.tsx > ImpersonationBanner (#4467) > stop: calls the endpoint, adopts the restored token, refreshes identity, banner goes

 Test Files  2 failed (2)
      Tests  3 failed | 13 passed (16)

expected 'Dev Admin' to be 'RT8 Dave' is the card's own symptom reproduced in a unit
test — the console greeting the admin over a session that is someone else.

The 13 green beside those 3 reds are the point: the auth lane's identical capture
stays green throughout, which is the asymmetry stated as a test rather than as prose.
Restored, all 16 pass.

The banner cases ride the real identity flow — AuthProvider resolving from a client
whose getSession answers as a function of the token in TokenStorage, exactly as the
server does. Nothing injects context state and nothing mocks impersonatedBy into a
session the console could not otherwise reach, which is what makes the exit case a live
check on the lane fix too.

Must-not-change, green on both sides: an ordinary session renders zero banner and
identical chrome; a data-lane response without the header leaves the stored token alone; a
sameOriginOnly cross-origin response cannot rotate anything; re-storing the same token
neither notifies nor re-resolves identity.

What the harness cannot drive, stated rather than faked (recorded in-file): jsdom has
no server, so the cookie half of the mechanism is out of reach — the admin_session
cookie, the bearer plugin overwriting the request's session cookie, and the signed-cookie
round trip. Those were measured live against a running stack on the card.

Honest costs, recorded

  • While impersonating, the administrator's own token is replaced in localStorage for
    the duration.
  • A client that misses the stop rotation is stranded until re-login — which is why the
    exit states its own failure instead of appearing to succeed.

Accepted on the card as the price of the feature actually functioning. Defence-in-depth on
the server side — refusing bearer-shadowed impersonation — is filed upstream as
objectstack#8243.

Consumption-radius note

external/api.test.ts builds a hand-rolled Response fake behind a cast, and omitted
headers, which this lane now reads on every API response. The fake was made honest
rather than the lane made lenient
— the auth lane has always read headers unguarded,
and a tolerant ?. here would have re-introduced the asymmetry this PR removes. The
repo-root run over 377 files is the sweep that found it; the other eight Response fakes
in the tree either use a real Response or never route through this lane.

Verification

  • Dependency-closure build first, then the three packages — exit 0.
  • type-check (both tsc --noEmit and tsconfig.test.json) for auth, app-shell, i18n — green.
  • Repo-root vitest, auth + app-shell: 377 files, 3655 passed, 1 skipped.
  • i18n suite 801 passed (all-locales key parity included); check:i18n-keys,
    check:i18n-drift, changeset:check, check:control-bytes all green.
  • eslint: 0 errors. AuthProvider.tsx carries the same 3 pre-existing
    set-state-in-effect warnings as origin/main (lines 99/506/513 there, 177/565/572
    here) — no new findings; the banner and both new test files produce zero.

Changesets

@object-ui/auth minor — published transport behavior moves (a data-lane response now
replaces the stored session token) plus three additive surfaces.
@object-ui/app-shell patch, @object-ui/i18n patch.


Generated by Claude Code

claude added 2 commits August 13, 2026 02:07
…s effect (#4467)

The console injects the same localStorage bearer from two lanes: the AUTH lane
(`createBearerFetch` inside `createAuthClient`) and the DATA lane
(`createAuthenticatedFetch` — the adapter, `provider: 'api'` data sources, and
every metadata `type: 'api'` action). better-auth's server-side bearer plugin
hands a ROTATED session token back in the `set-auth-token` response header on
whichever lane the call arrived over, and only the auth lane read it.

`POST /auth/admin/impersonate-user` is exactly such a call — an ordinary
metadata action — so the impersonated session token was dropped on the floor
while the server's bearer plugin kept overwriting the impersonation cookie with
the admin bearer the console kept sending. Impersonation was a complete no-op in
the console, not merely an invisible one: support staff believed they were
seeing a user's view while acting entirely as themselves.

This mirrors the auth lane's three-line capture into the data lane — one
contract, one answer, with no impersonation-specific logic in a generic lane.
Gated on `isApiCall`, the same condition that decided we authenticated the
request at all; untrusted targets remain the `sameOriginOnly` option's job.

Riding along, because identity has to follow the rotation:

- `AuthContextValue.refreshSession()` re-resolves user/session in place, without
  raising `isLoading`.
- `TokenStorage.subscribeRotation()` notifies when a token already in hand is
  replaced by a different one. First store, `clear()` and re-storing the same
  value stay silent — those transitions have an owner that updates identity
  itself. An ownerless rotation is what starting impersonation produces.
- `AuthClientSession.impersonatedBy?` — interface widening (#4495 precedent).

`external/api.test.ts` builds a hand-rolled `Response` fake behind a cast; it
omitted `headers`, which this lane now reads on every API response. The fake is
made honest rather than the lane made lenient — the auth lane has always read
`headers` unguarded.
…ails loudly (#4467)

While `session.impersonatedBy` is present, `ConsoleShell` renders a banner
naming BOTH parties — the impersonated user, whose name every write is recorded
under, and the administrator who started it — plus a stop affordance.

It derives from the session rather than from client memory of the click, so it
survives a full SPA reboot, a new tab and a browser restart, and it cannot
disagree with who the server thinks is acting. An ordinary session renders
`null` and its chrome is unchanged.

It mounts in `ConsoleShell` — the one provider stack every console route passes
through — beside the other global surfaces with a single home (`RemediationOverlay`,
`NotificationSnackbar`). The page-level bars it resembles visually
(`DraftPreviewBar`, `UnpublishedAppBar`) mount inside `ConsoleLayout`, which
only wraps `/apps/*`; the card was filed on a console whose `/home` showed no
sign of impersonation at all, so a home that could not carry the indicator would
have reproduced the bug.

The exit calls `POST /auth/admin/stop-impersonating` over the same data lane
(so the restored administrator token is adopted) and then awaits a refresh. The
server resolves the administrator from the `admin_session` COOKIE, so a
deployment that blocks cookies cannot exit this way — the banner says so and
stays up instead of appearing to succeed, which would leave the operator doing
ordinary work under someone else's identity.

Admin-only navigation needs no separate change: it is gated on
`user.isPlatformAdmin`, which `AppContent` reads from `useAuth()` — so once
identity re-resolves to the impersonated user, the admin surfaces follow.

Ten locale packs carry the banner's copy.
@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)
objectui Ignored Ignored Aug 13, 2026 2:10am

Request Review

@github-actions github-actions Bot added the tests label Aug 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-C_w2Rel7.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.79KB 41.35KB
fields (index.js) 230.07KB 57.07KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.07KB 17.65KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.40KB 50.10KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 02:22
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT — step-7 复核 by PM session session_017Qqyix2QcnpUC9XeYVDzx3.

  • The A-scope survived audit intact, and the one gap the widened lane exposed (api.test.ts's hand-rolled Response fake behind a cast) was fixed at the PRODUCER — the alternative (headers?.get in the lane) would have re-introduced exactly the asymmetry this PR removes. Contract-first applied where it bites.
  • Red-first is the card's symptom as a unit test: expected 'Dev Admin' to be 'RT8 Dave', with the auth lane's identical capture green beside the data lane's red — the asymmetry stated as a TEST, not prose. sha256-verified restores.
  • Banner placement measured, not assumed: ConsoleShell above the route Suspense (a ConsoleLayout mount would have reproduced the bug — /home carries no ConsoleLayout). Session-derived, both parties named, fail-loudly in BOTH failure shapes including cookie-blocked accepted-but-not-restored. Admin-nav needs no code — follows the refreshed identity structurally, measured.
  • Grading consistent with precedent: auth MINOR (three additive published surfaces + the described transport-behavior move, costs recorded); app-shell patch per the fix(app-shell): organization & invitation UI translates its six English holdouts (#4474) #4496 precedent (module-level .d.ts additions, main entry unchanged); i18n patch. The premise-inversion (the card said stale-identity; the truth was discarded-token) is recorded in the body so the merged card cannot mislead.
  • CI 20/20 green at job level.

Flipping ready + arming auto-merge. objectstack#8243 (server-side defence-in-depth) remains the upstream companion.


Generated by Claude Code


Generated by Claude Code

Merged via the queue into main with commit 5cc847c Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4467-impersonation-visible branch August 13, 2026 02:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants