Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c61948f
docs(architecture): document username vs sub identity claims (LFXV2-2…
audigregorie Jun 10, 2026
ea7e4b3
docs(architecture): fix prettier table formatting (LFXV2-2192)
audigregorie Jun 10, 2026
01dae13
docs(review): align identity claims doc with current code (LFXV2-2192)
audigregorie Jun 10, 2026
0e979d3
docs(review): clarify b2b_org_settings keys on prefixed sub (LFXV2-2192)
audigregorie Jun 10, 2026
1cc47fa
docs(architecture): explain id token vs access token in sub migration…
audigregorie Jun 12, 2026
9f6008c
docs(architecture): soften absolute access-token claim per review (LF…
audigregorie Jun 12, 2026
769b3d8
docs(architecture): align identity docs with LFXV2-2122 merge (LFXV2-…
audigregorie Jun 12, 2026
948ffcd
style(docs): fix prettier formatting on identity docs (LFXV2-2192)
audigregorie Jun 12, 2026
4fb3b80
Merge branch 'main' into feat/lfxv2-2192-username-sub-docs
audigregorie Jun 12, 2026
126d354
docs(architecture): add preferred_username to username source claims …
audigregorie Jun 12, 2026
64502e2
docs(architecture): remove ticket refs from identity docs (LFXV2-2192)
audigregorie Jun 15, 2026
e36959f
docs(architecture): drop PR link from identity migration (LFXV2-2192)
audigregorie Jun 15, 2026
dbd425b
docs(architecture): address review feedback on identity claims (LFXV2…
audigregorie Jun 15, 2026
8019e78
docs(architecture): align identity docs with code (LFXV2-2192)
audigregorie Jun 16, 2026
ffcdc85
docs(architecture): anonymize identity doc examples (LFXV2-2192)
audigregorie Jun 30, 2026
9b6f30d
style(docs): fix Prettier formatting in auth docs (LFXV2-2192)
audigregorie Jun 30, 2026
56cf82e
Merge branch 'main' into feat/lfxv2-2192-username-sub-docs
audigregorie Jul 1, 2026
bc5f072
docs(architecture): note mktg-agents getEffectiveSub caller (LFXV2-2192)
audigregorie Jul 2, 2026
20a64bf
style(docs): fix Prettier formatting in auth docs (LFXV2-2192)
audigregorie Jul 2, 2026
a7f596b
fix(docs): address PR review on identity guidance (LFXV2-2192)
audigregorie Jul 2, 2026
3a77da2
Merge branch 'main' into feat/lfxv2-2192-username-sub-docs
audigregorie Jul 2, 2026
619b35d
Merge branch 'main' into feat/lfxv2-2192-username-sub-docs
audigregorie Jul 2, 2026
c6d9de0
style(docs): fix Prettier table formatting for sub examples (LFXV2-2192)
audigregorie Jul 2, 2026
5a0fb49
Merge branch 'main' into feat/lfxv2-2192-username-sub-docs
audigregorie Jul 2, 2026
645e52a
Merge branch 'main' into feat/lfxv2-2192-username-sub-docs
audigregorie Jul 2, 2026
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
73 changes: 73 additions & 0 deletions docs/architecture/backend/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,79 @@ export interface M2MTokenResponse {
}
```

## 🆔 Identity Claims: `username` vs `sub`

Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing the wrong one breaks upstream lookups. They are **not** interchangeable.

### What each one is

| Claim | Example | Shape | Source claim(s) |
| ------------------------------ | ---------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **`sub`** (Auth0 subject) | <code>auth0&#124;jdoe</code> | Provider-prefixed, opaque, globally unique per identity | `user.sub` |
| **`username`** (LFID username) | `jdoe` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username`, `user.preferred_username` |

- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse it, and never display it as if it were a username. Stripping the connection prefix is misleading: the bare value only coincidentally matches the LFID handle today and is not guaranteed to, so a stripped `sub` is not a substitute for `username`. Two call sites still use `getEffectiveSub(req)`: `badges.controller.ts` resolves verified emails via the auth-service (which also accepts a username or email), and `mktg-agents.controller.ts` binds chat sessions to their creator via internal owner tokens (`createSessionOwnerToken`/`verifySessionOwnerToken`). Neither path requires the prefixed `sub` upstream — badges passes it incidentally, and marketing agents uses it only for internal session binding.
- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. Org role grants (`org-identity.controller.ts`, `org-navigation.service.ts`, `org-role-grants.service.ts`) query `b2b_org_settings` with `tags: ['member:${username}']` where `username` comes from `getEffectiveUsername(req)`. On surveys, `creator_username` holds the bare nickname and `creator_id` is set from the `https://sso.linuxfoundation.org/claims/username` claim.

### ID token vs access token — where the claims actually live

Auth0 issues **two** JWTs per session, and they carry identity differently. This split is the central complication of the `sub` → `username` migration.

| | **ID token** | **Access token** |
| -------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Lives on | `req.oidc.user` (typed as `User`) | `req.oidc.accessToken.access_token`, decoded via `decodeJwtPayload()` into `LfxAccessTokenClaims` |
| `sub` | `user.sub` | `claims.sub` |
| username claim | `https://sso.linuxfoundation.org/claims/username` (plus `nickname` / `username` / `preferred_username`) | `http://lfx.dev/claims/username` — **different namespace** |
| Consumed by | The BFF only (SSR, analytics, persistence, the `getEffective*` helpers) | Forwarded upstream as `Authorization: Bearer` to the Go microservices |

Note: `sub` is the only identifier present in both tokens under the same key. The username claim is namespaced differently in each (`https://sso.linuxfoundation.org/...` in the ID token vs `http://lfx.dev/...` in the access token), so any code bridging the two must map between namespaces — they are not the same key.

**Worked example — impersonation bridges the namespaces by hand.** Impersonation discards the target's ID token and rebuilds identity entirely from the exchanged **access token**, copying its `http://lfx.dev/claims/username` into every ID-token username slot so both namespaces resolve to the same handle (`server.ts`):

```ts
Object.assign(auth.user, {
sub: targetClaims.sub,
username: targetClaims['http://lfx.dev/claims/username'] || '',
'https://sso.linuxfoundation.org/claims/username': targetClaims['http://lfx.dev/claims/username'] || '',
nickname: targetClaims['http://lfx.dev/claims/username'] || '',
// ...
});
```

> **`getUsernameFromAuth()` naming.** For Authelia tokens it returns `preferred_username`; for Auth0 tokens it falls back to `getEffectiveUsername(req)`. The name is still easy to misread — prefer `getEffectiveUsername` directly when you need the LFID handle.

### When to use which

| Use case | Use |
| ------------------------------------------------------------------------------------- | ------------ |
| Calling an upstream microservice / query-service API that keys on the LF login handle | **username** |
| Persisting an author/owner/creator (`creator_id`, role grants, changelog viewer) | **username** |
| Analytics / observability user identity (DataDog RUM, OpenFeature targeting key) | **username** |
| Per-caller cache keys for user-scoped data | **username** |

> **Default to `username`.** `sub` has been phased out of backend identity references and no upstream currently requires it — see the migration note below.

### Server-side helpers (impersonation-aware)

Prefer the impersonation-aware helpers in `apps/lfx-one/src/server/utils/auth-helper.ts` when resolving effective caller identity (author/owner, cache keys, upstream params) instead of reading directly off `req.oidc.user`. They transparently return the **target** user's identity during impersonation and the session user's otherwise. Session-scoped enrichment that does not need impersonation swap (e.g. survey `creator_id` from the OIDC session) may still read `req.oidc.user` directly.

| Helper | Returns | Status |
| --------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for all new identity references |
| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — two remaining callers (badges email lookup, mktg-agents session owner binding) pass `sub` incidentally; no upstream requires it |
| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups |

### Migration: `sub` → `username`

Backend identity references have migrated from the Auth0 `sub` to the LFID `username`. In this repo:

- **Front-end (ID token):** DataDog RUM `id`, OpenFeature `targetingKey`, and survey `creator_id` now read `https://sso.linuxfoundation.org/claims/username` (OpenFeature no longer falls back to `sub` — existing LaunchDarkly rules keyed on sub values need updating before deploy).
- **BFF call sites:** `getEffectiveSub` → `getEffectiveUsername` in changelog, copilot, org-identity, org-navigation, org-lens-access, and org-membership cache keys; `project.service.ts` uses `resolveEmailToUsername` (not `resolveEmailToSub`) for permission and user-info lookups against the plain-LFID `b2b_org_settings` index.
- **Upstream matching:** the platform authorization proxy (Heimdall) derives the FGA principal from the access token's username attribute (falling back to `client_id@clients` for M2M), not `sub` — so the Go microservices authorize on the LFID handle.
- **`getEffectiveSub`** is annotated `@deprecated` in `auth-helper.ts`; two callers remain — badges email lookup (auth-service also accepts username or email) and mktg-agents chat session owner binding (internal token only) — both are cleanup candidates, not upstream requirements.

When adding new code, use `username`. No upstream currently requires the prefixed `sub`; if you ever hit one that does, use `getEffectiveSub` and note why inline.

## 🏗 Server-Side Implementation

### Auth Context Injection
Expand Down
40 changes: 23 additions & 17 deletions docs/architecture/backend/impersonation.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ Impersonation uses Auth0's Custom Token Exchange (CTE) feature (RFC 8693) to obt
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 calls `api.authentication.setUserById()` to issue a new token
- **`can_impersonate` claim** — added to LFX v2 access tokens via `custom_claims.js` for users matching an email allow-list
- **`can_impersonate` claim** — added to LFX v2 access tokens via `src/actions/custom_claims.js` for authorized impersonators (see Authorization below)
- **Token Exchange Profile** — maps the LFX v2 API `subject_token_type` to the impersonation CTE action
- **Auth Service Client** — the "LFX V2 Auth Service" client has `token_exchange` enabled with `allow_any_profile_of_type: ["custom_authentication"]`

### Authorization

Only users whose email matches the allow-list regex in the Auth0 action receive the `can_impersonate` claim. The allow-list is maintained in `src/includes/impersonation.js` in the `auth0-terraform` repo.
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 include `lfx-self-serve-allowed-impersonators`.
- **Dev tenant** (`linuxfoundation-dev`) — any verified `@linuxfoundation.org` or `@contractor.linuxfoundation.org` email qualifies, since Auth0 groups are non-trivial to set up there.

### Token Exchange Flow

Expand Down Expand Up @@ -158,27 +161,30 @@ This is the single choke point — every controller and service uses `req.bearer

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 |
| --------------------------- | --------------------------------------------- |
| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) |
| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname |
| `getEffectiveSub(req)` | Impersonated sub or OIDC sub |
| 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`** — Auth0 sub (prefixed, e.g. <code>auth0&#124;jdoe</code>); two incidental callers (badges email lookup, mktg-agents session owner binding) |

For the full `username` vs `sub` distinction and the `sub` → `username` migration, see [`authentication.md`](./authentication.md#-identity-claims-username-vs-sub).

These check `req.appSession['impersonationUser']` first, falling back to `req.oidc.user`. All controllers/services that filter by user identity use these helpers (meetings, events, committees, votes, surveys, mailing lists, documents, analytics, badges, persona detection).

**Exception:** The profile controller always uses `req.oidc.user` directly because profile operations (change password, update metadata, link identities) must act on the real user's account.
**Profile controller — partially impersonation-aware.** The profile controller resolves identity through `getUsernameFromAuth()`, which falls back to the impersonation-aware `getEffectiveUsername`. So most profile operations (get/update metadata, identities, work experiences, project affiliations, email verify/link) follow the **effective** identity and act on the **target** user during impersonation. The exception is password change and reset: they go through the separate `/api/profile/auth/start` management-token flow, which identifies the user by the management token itself (tied to the real user's re-authenticated session) and is not impersonation-aware.

### 6. SSR Handler

**`apps/lfx-one/src/server/server.ts`**

During SSR, the handler:
During SSR, the handler runs in this order:

1. Populates `auth.canImpersonate` by decoding the `can_impersonate` claim from the access token
2. When impersonation is active, overrides `auth.user` with the target user's claims (sub, email, username, name, picture)
3. Sets `auth.impersonating = true` and `auth.impersonator` with the real user's identity
1. Builds `auth.user` from the OIDC session (initially the real user)
2. Runs persona detection (`resolvePersonaForSsr`)
3. Populates `auth.canImpersonate` by decoding the `can_impersonate` claim from the access token
4. When an active impersonation session exists, overrides `auth.user` with the target user's claims (sub, email, username, name, picture) and sets `auth.impersonating = true` + `auth.impersonator`

Persona detection (`resolvePersonaForSsr`) runs after the override, so it resolves the target user's persona.
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`.

### 7. Frontend

Expand Down Expand Up @@ -229,7 +235,7 @@ Every request made under impersonation is logged at DEBUG level with opaque iden

```text
impersonation_request: Request under impersonation
impersonator_sub: auth0|asitha
impersonator_sub: auth0|jsmith
target_sub: auth0|jdoe
path: /api/user/meetings
```
Expand All @@ -238,15 +244,15 @@ Impersonation start/stop events are logged at INFO level:

```text
impersonation_granted: Impersonation session started
impersonator_sub: auth0|asitha
impersonator_sub: auth0|jsmith
target_sub: auth0|jdoe

impersonation_stopped: Impersonation session ended
```

## Limitations

1. **Profile operations are not impersonated** — the profile controller always operates on the real user's account. Viewing another user's profile page during impersonation shows the real user's profile data, not the target's.
1. **Most profile operations follow the impersonated identity** — the profile controller resolves the user via `getUsernameFromAuth()` → `getEffectiveUsername` (impersonation-aware), so viewing and updating profile metadata, identities, work experiences, and project affiliations during impersonation acts on the **target** user, not the real user. Password change and reset are the exception: they use the management-token profile-auth flow tied to the real user's re-authenticated session.

2. **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_name` field on committees is an exception (uses the real user's name).

Expand All @@ -260,7 +266,7 @@ impersonation_stopped: Impersonation session ended

## Future Work

- **Read-only profile viewing** — allow impersonators to view (but not modify) the target user's profile page
- **Read-only profile during impersonation** — profile reads already follow the target user, but writes (metadata, identity linking) do too; gate profile writes so impersonators can view the target's profile without modifying the account
- **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
Expand Down
Loading
Loading