User impersonation allows authorized LF staff to view the application as another user. This is a debugging and support tool — the impersonator sees the target user's dashboard, meetings, committees, and other data as if they were logged in as that user.
Impersonation uses Auth0's Custom Token Exchange (CTE) feature (RFC 8693) to obtain an access token with the target user's identity while keeping the impersonator's OIDC session intact.
JIRA: LFXV2-1463
The Auth0 side is managed in the auth0-terraform repo:
- CTE Action (
lfx_impersonation_token_exchange.js) — validates the requestor, looks up the target user via Management API, and callsapi.authentication.setUserById()to issue a new token can_impersonateclaim — added to LFX v2 access tokens viasrc/actions/custom_claims.jsfor authorized impersonators (see Authorization below)- Token Exchange Profile — maps the LFX v2 API
subject_token_typeto the impersonation CTE action - Auth Service Client — the "LFX V2 Auth Service" client has
token_exchangeenabled withallow_any_profile_of_type: ["custom_authentication"]
The can_impersonate claim is granted in src/actions/custom_claims.js (auth0-terraform), and the authorization rule differs by tenant:
- Production — the user's per-client group list in
app_metadata(groups-<client_id>) must includelfx-self-serve-allowed-impersonators. - Dev tenant (
linuxfoundation-dev) — any verified@linuxfoundation.orgor@contractor.linuxfoundation.orgemail qualifies, since Auth0 groups are non-trivial to set up there.
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ ┌─────────┐
│ Frontend │ │ Express (us) │ │ Auth Service │ │ Auth0 │ │ Upstream│
│ (Angular) │ │ │ │ (NATS) │ │ CTE │ │ µsvc │
└────┬─────┘ └──────┬───────┘ └──────┬───────┘ └─────┬─────┘ └────┬────┘
│ │ │ │ │
│ POST /api/impersonate │ │ │
│ { targetUser: "jdoe" } │ │ │
│─────────────────>│ │ │ │
│ │ │ │ │
│ Server checks: │ │ │
│ - can_impersonate claim │ │ │
│ - service configured │ │ │
│ │ │ │ │
│ │ NATS request │ │ │
│ │ subject_token= │ │ │
│ │ <user's JWT> │ │ │
│ │ target_user=jdoe │ │ │
│ │───────────────────>│ │ │
│ │ │ │ │
│ │ │ POST /oauth/token │ │
│ │ │ grant_type= │ │
│ │ │ token-exchange │ │
│ │ │──────────────────>│ │
│ │ │ │ │
│ │ │ { access_token } │ │
│ │ │<──────────────────│ │
│ │ │ │ │
│ │ { access_token } │ │ │
│ │<───────────────────│ │ │
│ │ │ │
│ Store in appSession: │ │
│ impersonationToken │ │
│ impersonationUser │ │
│ impersonator │ │
│ │ │ │
│ 200 OK │ │ │
│<─────────────────│ │ │
│ │ │ │
│ Page reload │ │ │
│─────────────────>│ │ │
│ │ │ │
│ Auth middleware: │ │
│ req.bearerToken = │ │
│ impersonation token │ │
│ │ │ │
│ │ Bearer <target> │ │
│ │──────────────────────────────────>│
│ │ │ │
│ │ target user's data │
│ │<─────────────────────────────────│
│ Response │ │ │
│<─────────────────│ │ │
The CTE is performed by the lfx-v2-auth-service via NATS request-reply on subject lfx.auth-service.impersonation.token_exchange. The auth service handles all Auth0 client authentication (private key JWT, RFC 7523) internally — the UI server only sends the subject token and target user.
// NATS request payload
{ subject_token: "<user's LFX v2 JWT>", target_user: "jdoe@example.com" }
// NATS response (success)
{ success: true, data: { access_token: "<target user's JWT>" } }
// NATS response (failure — documented Auth0 deny)
{ success: false, error: "target_user_not_found: Target user 'jdoe' not found" }
// NATS response (failure — current auth-service wrap when Auth0 returns HTTP 400)
{ success: false, error: "token exchange request failed: upstream returned status 400" }exchangeToken() classifies those lookup-shaped strings as HTTP 404 TARGET_USER_NOT_FOUND with user-facing copy. Other success: false errors stay 400 CTE_EXCHANGE_FAILED with a generic message. Raw upstream text is kept on errorBody.upstreamError for logs (getLogContext). toResponse() does not forward that key, so it never becomes upstreamCode. The impersonation dialog maps TARGET_USER_NOT_FOUND to the locate copy and never renders the upstream string.
Profile enrichment (fetching the target user's name and picture) also uses NATS via the lfx.auth-service.user_metadata.read subject — no direct Auth0 Management API calls are made from the UI server.
packages/shared/src/interfaces/impersonation.interface.ts
ImpersonationUser— target user identity (sub,email,username,name?,picture?)Impersonator— real user identity (sub,email,name)ImpersonationStartRequest,ImpersonationStartResponse,ImpersonationStatusResponse
packages/shared/src/interfaces/auth.interface.ts — AuthContext has additional fields:
canImpersonate?: boolean— whether the user has thecan_impersonateclaimimpersonating?: boolean— whether an impersonation session is activeimpersonator?: Impersonator— the real user's identity during impersonation
apps/lfx-one/src/server/services/impersonation.service.ts
| Method | Purpose |
|---|---|
exchangeToken(req, targetUser) |
Performs CTE via NATS to lfx-v2-auth-service |
fetchTargetUserProfile(req, userId) |
Fetches target user's name/picture via NATS user_metadata.read |
startImpersonation(req, tokenResponse, claims, profile) |
Stores impersonation state in appSession |
stopImpersonation(req) |
Clears impersonation state from appSession |
getImpersonationToken(req) |
Returns active impersonation token or null (clears if expired) |
getImpersonationStatus(req) |
Returns current impersonation status |
apps/lfx-one/src/server/routes/impersonation.route.ts — mounted at /api/impersonate
| Endpoint | Method | Purpose |
|---|---|---|
/api/impersonate |
POST | Start impersonation (body: { targetUser: "email-or-username" }) |
/api/impersonate/stop |
POST | Stop impersonation, clear session |
/api/impersonate/status |
GET | Check current impersonation state |
apps/lfx-one/src/server/middleware/auth.middleware.ts
In extractBearerToken(), the impersonation token is checked before the normal OIDC token extraction:
if (impersonationToken && !expired) {
req.bearerToken = impersonationToken; // All upstream calls use target's identity
return { success: true, needsLogout: false };
}
// ... normal OIDC token extraction followsThis is the single choke point — every controller and service uses req.bearerToken for upstream API calls, so all microservices automatically see the target user's identity.
apps/lfx-one/src/server/utils/auth-helper.ts
Many controllers and services read the user's email/username from req.oidc.user for server-side filtering (e.g., "get my meetings"). During impersonation, req.oidc.user is still the real user. Three helpers resolve the correct identity:
| Helper | Returns | Notes |
|---|---|---|
getEffectiveEmail(req) |
Impersonated email or OIDC email (lowercased) | Email-keyed lookups |
getEffectiveUsername(req) |
Impersonated username or OIDC nickname/username/preferred_username | Preferred for identity references (LFID username, e.g. jdoe) |
getEffectiveSub(req) |
Impersonated sub or OIDC sub | @deprecated for identity references generally — Auth0 sub (prefixed, e.g. auth0|jdoe); a handful of illustrative (not exhaustive) callers remain, including incidental holdovers (badges' and CLA's auth-service lookups, mktg-agents' internal session-token binding) and a deliberate use for indefinitely-retained server log metadata (weekly-brief share/rating events) — see authentication.md for the full breakdown |
For the full username vs sub distinction and the sub → username migration, see authentication.md.
These check req.appSession['impersonationUser'] first, falling back to req.oidc.user. isImpersonating(req) (active-session predicate) rounds out the set. All controllers/services that filter by user identity use these helpers (meetings, events, committees, votes, surveys, mailing lists, documents, analytics, badges, persona detection).
Real (non-effective) identity — the deliberate exception (LFXV2-3093): two helpers do the opposite of the getEffective* family — they resolve the real impersonator's own identity, ignoring impersonation state entirely:
| Helper | Returns | Notes |
|---|---|---|
getRealEmail(req) |
The real user's OIDC email (lowercased), never the impersonation target's | Use only where the actual actor — not the target — must be attributed for an externally-visible, hard-to-retract action |
resolveRealAccessToken(req) |
Promise<string | null> — the real user's own bearer token, even while impersonating |
Reads req.oidc.accessToken directly (never touched by the impersonation-token swap), refreshing it if expired since extractBearerToken's own refresh step is skipped whenever impersonation is active; returns null (fail closed) if it can't be resolved — callers must never fall back to the impersonation token |
The only current caller is WeeklyBriefService.shareBrief()'s mailing-list send — see Limitation 2 below.
Profile & account settings — read-only during impersonation (LFXV2-2572): The profile controller's read endpoints resolve identity through the effective helpers, so Profile pages and Account Settings show the target user's data:
GET /api/profile,GET /api/profile/emails,GET /api/profile/linux-emailusegetEffectiveSub/getEffectiveEmail/getEffectiveUsername.GET /api/profile/identities,/work-experiences,/project-affiliationsresolve the target'slfid(viaresolveEffectiveLfid). CDP reads are preserved — work history and CDP-listed / non-verified identities still display.- Individual enrollment & Linux.com add-on (
EnrollmentService.getIndividualEnrollments/hasLinuxComAddon) call the member-service/me/membershipsthrough the API gateway.req.apiGatewayTokenis the impersonator's (no CTE for the API-gateway audience), so during impersonation these reads passbearerToken: req.bearerToken(the target's CTE token) togatewayFetch— the same overrideupdateAutoRenewuses — and/meresolves to the target. If that fetch fails,getIndividualEnrollmentsdegrades to the standard (unenrolled) product card. The auto-renew write stays blocked; the enroll/renew CTAs and toggle render disabled. GET /api/profile/developeris suppressed (403) while impersonating —req.bearerTokenis the target's live token and must never be surfaced to the impersonator.- The Linux.com forward target still can't be read during impersonation (needs the impersonator's Flow-C management token); the claimed alias itself is shown from the target's
user_emails.read.
Profile writes cannot act on the target (there is no CTE equivalent for the Auth0 Management API — they use the impersonator's Flow C management token), so they are blocked:
- Every mutating / Flow-C-initiating profile route is guarded by
blockDuringImpersonation(middleware/impersonation-readonly.middleware.ts), returning 403IMPERSONATION_READ_ONLY. - Flow C has a root-level twin (
/passwordless/callbackinserver.ts) that Auth0 actually redirects to; social-link's callback lives only at/social/callbackinserver.ts, with no twin in the/apirouter. Both are redirect-only handlers outside the/apierror-handler mount, so anext(err)there would render as raw JSON in a top-level navigation; they enforce the same guarantee in-handler viaProfileController.blockCallbackDuringImpersonation, redirecting to<returnTo>?error=impersonation_read_onlybefore any code exchange or token mint (#1936). For social-link,<returnTo>is the allowlisted page that started the connect rather than a fixed path, so the redirect lands wherever the Add-identity dialog was opened from. getIdentitieskeeps its CDP read but skips the reconciliation write (thecdpPostsQueuedcreate + auto-verify) via askipCdpMutationsflag (derived fromisImpersonating(req)insidereconcileIdentities), so viewing a target's identities never mutates their CDP records.- The frontend renders the corresponding edit affordances visible but disabled (gated on
userService.impersonating()) and shows a read-only banner on the profile shell and Account Settings.
apps/lfx-one/src/server/server.ts
During SSR, the handler runs in this order:
- Builds
auth.userfrom the OIDC session (initially the real user) - Runs persona detection (
resolvePersonaForSsr) - Populates
auth.canImpersonateby decoding thecan_impersonateclaim from the access token - When an active impersonation session exists, overrides
auth.userwith the target user's claims (sub, email, username, SSO username claim, preferred_username, name, nickname, picture) and setsauth.impersonating = true+auth.impersonator- Every claim
FeatureFlagService'stargetingKeychain reads must be overwritten here, or a stale value from the impersonator's own session wins the fallback (LFXV2 #2316) given_name/family_name(and thefirst_name/last_namealternates declared onUser) are blanked rather than overwritten, since the impersonation session only stores the target's combined display name, not a first/last split — leaving them stale would otherwise leak the impersonator's real name into any consumer that reads either pair
- Every claim
Note that persona detection (step 2) resolves the target user's persona even though it runs before the auth.user override (step 4). It does so not because of ordering but because resolvePersonaForSsr reads identity through the getEffective* helpers, which consult req.appSession['impersonationUser'] directly — independent of auth.user.
Components:
- Impersonation banner (
main-layout.component.html) — fixed yellow bar at the top showing who is being impersonated and a "Stop" button - Impersonation trigger (
lens-switcher.component.html) — user-secret icon in the sidebar footer (visible only whencanImpersonateis true), opens a dialog to enter a target email/username
Services:
ImpersonationService— frontend HTTP client for start/stop/status endpointsUserService— signals:canImpersonate,impersonating,impersonator
Hydration: AppComponent reads impersonation state from AuthContext via TransferState and populates UserService signals.
All impersonation state is stored on req.appSession, which express-openid-connect backs with one of two storage modes:
Default (SESSION_STORE_ENABLED unset/false):
┌─────────────────────────────────────────────────────┐
│ req.appSession (encrypted, chunked cookie) │
│ │
│ Primary OIDC Session (managed by library): │
│ access_token, refresh_token, id_token │
│ │
│ Impersonation (manually stored): │
│ impersonationToken — target user's JWT │
│ impersonationExpiresAt — expiry timestamp │
│ impersonationUser — { sub, email, ...} │
│ impersonator — { sub, email, name } │
└─────────────────────────────────────────────────────┘
SESSION_STORE_ENABLED=true:
┌─────────────────────────────────────────────────────┐
│ req.appSession (Valkey-backed, keyed by opaque id) │
│ Cookie carries only the session id — the same │
│ fields above (access_token .. impersonator) live in │
│ Valkey instead of the cookie. │
└─────────────────────────────────────────────────────┘
This works across replicas without sticky sessions regardless of session backend: by default the cookie holds the full encrypted session (cookie-based, no server-side store); with SESSION_STORE_ENABLED=true the cookie holds only an opaque session id and the data (including these impersonation fields) lives server-side in Valkey, keyed by that id (see Runtime Configuration).
No impersonation-specific environment variables are required. Both the token exchange and profile enrichment use NATS to communicate with the auth service. The only requirement is NATS_URL.
When the impersonation token expires, the auth middleware clears the session and falls through to the real user's token. The user is silently returned to their own identity. To re-impersonate, they must initiate a new session.
Every request made under impersonation is logged at DEBUG level with opaque identifiers:
impersonation_request: Request under impersonation
impersonator_sub: auth0|jsmith
target_sub: auth0|jdoe
path: /api/user/meetings
Impersonation start/stop events are logged at INFO level:
impersonation_granted: Impersonation session started
impersonator_sub: auth0|jsmith
target_sub: auth0|jdoe
impersonation_stopped: Impersonation session ended
-
Profile viewing is impersonated but read-only (LFXV2-2572) — Profile pages and Account Settings show the target user's data during impersonation (including CDP work history/identities and the target's individual-enrollment + Linux.com add-on status, fetched with the target's bearer token). All profile writes are blocked (
403 IMPERSONATION_READ_ONLY), the developer API token is suppressed, and CDP writes (including thegetIdentitiesreconciliation create) are suppressed. Edit affordances render visible-but-disabled; editing would act on the real user's account, so it is disabled rather than allowed. The Linux.com forward target is the one datum that can't be shown (needs the impersonator's Flow-C token). The Flow C and social-link root callbacks enforce the same block, but as a redirect (?error=impersonation_read_only) rather than a 403, since they sit outside the/apierror-handler mount (#1936). Which component reports that error depends on where the flow started, because social-link'sreturnTois caller-supplied (allowlisted inProfileController.allowedProfileReturnPaths):ProfileLayoutComponenttoasts it under/profile, andProfileCardComponenttoasts it on the mentorship registration form, which mounts under the main layout where that shell never renders. A page added to the allowlist has to read these codes itself. -
Write operations use the target's identity — creating meetings, committees, or votes while impersonating will attribute them to the target user (via the bearer token). The
created_by_namefield on committees is an exception (uses the real user's name), as is the weekly-brief mailing-list share (LFXV2-3093):WeeklyBriefService.shareBrief()authorizes and sends under the real impersonator's identity/token (resolveRealAccessToken/getRealEmail), not the target's, because it creates a real, persisted, hard-to-retract external send — see that method's doc comment for the full rationale. -
Local dev (Authelia) not supported — CTE is an Auth0-specific feature. Impersonation won't work with the Authelia dev auth provider.
-
Target user must exist in LFID connection — the Auth0 CTE action looks up users in the
Username-Password-Authenticationconnection only. Social-only or enterprise SSO users cannot be impersonated. -
Persona cookie stale during impersonation — if the real user has a cached persona cookie, the first page load after starting impersonation may briefly show the wrong persona until the cookie is refreshed via NATS.
-
No impersonation of impersonators — if user A impersonates user B, user B's
can_impersonateclaim is not evaluated. Nested impersonation is not supported.
- Impersonation audit dashboard — a dedicated UI for reviewing impersonation logs
- Session duration controls — configurable max impersonation duration, auto-expiry notifications
- Impersonation notifications — optionally notify the target user when they are being impersonated
- Allow-list management UI — manage authorized impersonators without Terraform changes
- Authelia dev support — mock impersonation flow for local development