diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index eb5091693..9d62578df 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -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) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | +| **`username`** (LFID username) | `lguerra` | 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`. One call site still passes the prefixed `sub` — `badges.controller.ts` resolves verified emails via the auth-service using `getEffectiveSub(req)` — but that auth-service lookup also accepts a username or email, so no upstream actually requires the prefixed `sub` today. +- **`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) + +Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper.ts`, never directly off `req.oidc.user`. They transparently return the **target** user's identity during impersonation and the session user's otherwise. + +| 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`** — its lone remaining caller (badges email lookup) passes `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`; its only remaining caller is the badges email lookup, which passes `sub` to the auth-service incidentally (that lookup also accepts a username or email) — a cleanup candidate, not an upstream requirement. + +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 diff --git a/docs/architecture/backend/impersonation.md b/docs/architecture/backend/impersonation.md index 8f600f6ec..045ef94af 100644 --- a/docs/architecture/backend/impersonation.md +++ b/docs/architecture/backend/impersonation.md @@ -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-`) 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 @@ -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. `lguerra`) | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); one incidental caller (badges email lookup) | + +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 @@ -246,7 +252,7 @@ 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). @@ -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 diff --git a/docs/architecture/frontend/feature-flags.md b/docs/architecture/frontend/feature-flags.md index 6e9f2f7bf..264cf2f3e 100644 --- a/docs/architecture/frontend/feature-flags.md +++ b/docs/architecture/frontend/feature-flags.md @@ -154,7 +154,7 @@ export class FeatureFlagService { kind: 'user', name: user.name || '', email: user.email || '', - targetingKey: user.preferred_username || user.username || user.sub || '', + targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'], }; await OpenFeature.setContext(userContext); @@ -752,19 +752,21 @@ const userContext: EvaluationContext = { kind: 'user', name: user.name || '', email: user.email || '', - targetingKey: user.preferred_username || user.username || user.sub || '', + targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'], }; ``` +> **Identity.** `targetingKey` uses the LFID username claim chain above — not Auth0 `sub`. LaunchDarkly rules keyed on legacy `sub` values stop matching after deploy; update them to LFID usernames. See [Authentication — Identity Claims](../backend/authentication.md#-identity-claims-username-vs-sub). + **Context Structure:** -| Field | Type | Required | Description | -| ----------------- | ------ | -------- | ----------------------------------- | -| `kind` | string | Yes | Always 'user' for user contexts | -| `targetingKey` | string | Yes | Unique identifier for the user | -| `name` | string | No | Display name for LaunchDarkly UI | -| `email` | string | No | Email for targeting rules | -| Custom attributes | any | No | Additional attributes for targeting | +| Field | Type | Required | Description | +| ----------------- | ------ | -------- | ---------------------------------------------------------------------- | +| `kind` | string | Yes | Always 'user' for user contexts | +| `targetingKey` | string | Yes | LFID username (`preferred_username` → `username` → SSO username claim) | +| `name` | string | No | Display name for LaunchDarkly UI | +| `email` | string | No | Email for targeting rules | +| Custom attributes | any | No | Additional attributes for targeting | **Targeting Use Cases:** @@ -778,7 +780,7 @@ const userContext: EvaluationContext = { ```typescript const userContext: EvaluationContext = { kind: 'user', - targetingKey: user.sub, + targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'], name: user.name, email: user.email, // Custom attributes for targeting @@ -1448,8 +1450,8 @@ See [Runtime Configuration Troubleshooting](../../runtime-configuration.md#troub 1. **Missing Targeting Key:** ```typescript - // targetingKey is required for user identification - targetingKey: user.preferred_username || user.username || user.sub || ''; + // targetingKey is required — LFID username, not Auth0 sub + targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username']; ``` 2. **Incorrect Attribute Names:**