Server-side login-as for support impersonation

Adds production-capable support impersonation on the server, gated by config, HMAC cookies, per-request revalidation, and platform-event audit trails.

Author: @dezbah-duchicela PR: dealops#6506 Stack: server 1/2, UI in #6507 Base: maindezbah/login-as-server Files: 27 Diff: +1688 / -28 Area: apps/server, legacy REST + tRPC State: open

What it adds

Three server endpoints for support impersonation: candidate listing, session start, and session end.

GET /api/v1/login-as/users · POST /api/v1/login-as · POST /api/v1/end-login-as

Security model

Only active DEALOPS_INTERNAL_STAFF actors can start sessions, and both actor and target are revalidated on every request.

Audit model

Start/end events land in the target customer org, and every event emitted during impersonation gets impersonatedByUserId.

Security fixes included

Fixes a broken legacy assertRole guard and removes ungated approval re-attribution via impersonateUserId.

Actor: DealOps internal staff
Target: customer user
Login-as cookie
Stytch session
Platform events

1. Why this exists

Problem today

Support needs to reproduce what a customer user sees without asking for credentials or relying on local-only dev impersonation.

Previous blocker

The old capability was hard-blocked outside localhost. It was not designed for production routing, multi-replica cookies, or audit requirements.

Design goal

Make impersonation usable in production only when explicitly enabled, while preserving who actually operated the session.

Stack sequencing: this is the server half. The UI that calls these endpoints is PR #6507 and is based on this branch, so this should merge first.

2. What changes

New REST surface

GET
/api/v1/login-as/users

Lists active candidate users in the actor’s own organization, excluding the actor. Mounted behind authenticateStytchSession so the actor is always the real staff member.

POST
/api/v1/login-as

Starts a session by exact target userId, scoped to the actor’s organization, emits user.login_as.started, then sets the signed cookie.

POST
/api/v1/end-login-as

Best-effort emits user.login_as.ended, always clears the cookie, and does not depend on Stytch latency to end impersonation.

Before / after auth behavior

Before
  • Impersonation was dev-only / localhost-bound.
  • Legacy assertRole admitted every authenticated user.
  • setApproval.impersonateUserId could re-attribute approvals with no role check.
  • Dead Stytch sessions could become 500s and page on-call.
After
  • Production can enable login-as with explicit env vars.
  • Legacy REST and tRPC auth honor a valid login-as cookie before Stytch.
  • Actor must still be active internal staff on every request.
  • Dead Stytch sessions return 401 so the client re-authenticates.

Key file map

Area Files Meaningful change
Login-as core apps/server/src/lib/auth/loginAs.ts Cookie signing, enablement checks, target lookup, actor revalidation, candidate listing, start/end helpers.
REST routes login-as.ts, login-as-users.ts, end-login-as.ts, index.ts Mounts the API surface and keeps start/list behind real Stytch auth instead of impersonated auth.
Legacy auth lib/auth/legacy/middleware.ts Checks login-as first in authenticateSession, adds authenticateStytchSession, fixes assertRole.
tRPC auth trpc/utils/auth.ts, trpc/createContext.ts, trpc/middleware/impersonationContext.ts, trpc/procedures/authed.ts Allows tRPC to resolve as the target user and carry the actor id into downstream event emission.
Audit events PlatformEventEmitter.ts, eventSchemas.ts Adds login-as event types and stamps impersonatedByUserId onto events emitted during a session.
Cookie utilities utils/stytch.ts Adds clear-cookie options and classifies Stytch session_not_found as an ordinary 401 condition.
Tests / env loginAs.test.ts, middleware.test.ts, turbo.json Adds coverage for gating, cookie shape, host-only domain behavior, role enforcement, Stytch 401 handling, and env propagation.

Security bug fixes bundled in

Legacy role guard

roles.findIndex(...) !== undefined was always true because misses return -1.

Now assertRole uses roles.includes(req.body.user.role), so protected legacy REST routes actually reject unauthorized users.

Approval impersonation input

setApproval.impersonateUserId let callers re-attribute an approval to any user id, with no role check and no record of the real caller.

The input is removed rather than gated because there are no callers.

Dead Stytch sessions

session_not_found now maps to 401 instead of 500 in auth paths.

This avoids paging on ordinary expiry/revocation and lets the client return to login.

3. How it works

1
Real actor
Staff member authenticates with Stytch. Start/list routes use authenticateStytchSession, not impersonated session auth.
2
Target lookup
Server accepts only an exact active user id inside the actor’s organization. Cross-org ids return 404.
3
Strict start audit
user.login_as.started must be written before the cookie is issued.
4
Signed cookie
Host-only, httpOnly HMAC cookie carries actor, target, authorized org, and 2-hour expiry.
5
Every request
Auth resolves as the target only after revalidating the actor as active internal staff in the signed org.
Cookie payload

The cookie is base64url(JSON).hmac.

  • actorUserId
  • actorStytchMemberId
  • targetUserId
  • organizationId
  • expiresAt
Why org binding matters

User organization membership can change inside the 2-hour window.

The signed organizationId is re-checked for both actor and target, so a moved/deactivated/demoted account stops resolving immediately.

Why host-only matters

The impersonation cookie deliberately drops the Stytch domain option.

That avoids invalid loopback domains and prevents overly broad cookies under shared hosts like *.onrender.com.

Enablement rules

Environment shape Default Required to enable Route behavior when disabled
Loopback client URL: localhost, 127.0.0.1, ::1, *.localhost Enabled unless explicitly false No env required; fallback per-process secret is allowed locally. Start/list return 404. Existing login-as cookies are ignored.
Remote/staging/prod URL Disabled DEALOPS_LOGIN_AS_ENABLED=true and DEALOPS_LOGIN_AS_SECRET.

Request attribution

Session identity

During impersonation, req.body.user and tRPC ctx.user are the target customer user.

This preserves app behavior: permissions, feature flags, org context, and downstream code see what the customer would see.

Operator identity

AsyncLocalStorage carries the actor id after the cookie has been accepted.

PlatformEventEmitter injects impersonatedByUserId into stored payloads for events emitted during the request.

Audit event semantics

4. What it doesn’t change

5. Risks / rollback / open questions

Test signal
6354

Full server suite passing, with typecheck and lint clean per PR description.

New coverage

Production gating, missing secret, secure cookie, host-only domain behavior, exact-id target resolution, org scoping, assertRole, dead Stytch sessions, and event stamping.

Not verified

No manual run against a real production deployment. The production path is covered by unit tests over env gating.

Behavioral risk: fixing assertRole can surface existing unauthorized callers as 401s. That is the correct security behavior, but reviewers should scan legacy REST consumers that relied on the old gap.
Operational rollback: outside loopback, unset DEALOPS_LOGIN_AS_ENABLED or set it to false. The routes return 404 and login-as cookies stop resolving. A full code rollback is only needed if the bundled assertRole enforcement causes unexpected production impact.
Follow-up worth considering: impersonatedByUserId is injected into event payload JSON rather than stored in a dedicated column. The PR notes a column would be the better long-term home and would require a migration.