# OIDC authentication

> **The DFE engine is the single JWT issuer. dfe-hyperdx verifies its token rather
> than trusting a header.**

Envoy terminates OIDC at the edge and verifies the engine's ES384 token.
dfe-hyperdx verifies it _again_ against the engine's JWKS, because dfe-hyperdx
is a policy enforcement point and an unauthenticated header is not an identity.

None of this touches upstream's auth. It is one middleware registered ahead of
Passport, and with `DFE_AUTH_MODE` unset it is never registered at all - the
fork behaves exactly as upstream ships.

---

## The flow

```mermaid
sequenceDiagram
    participant User as Browser
    participant Envoy as Envoy at the edge
    participant Engine as DFE engine
    participant HDX as dfe-hyperdx API

    User->>Envoy: request
    Envoy->>Envoy: OIDC flow, verify engine token
    Envoy->>HDX: forward with Bearer token or dfe_token cookie
    HDX->>Engine: fetch JWKS (cached)
    HDX->>HDX: jwtVerify ES384, check iss
    HDX->>Engine: GET /api/v1/auth/me with the token (cached)
    Engine-->>HDX: groups, ClickHouse identity and role
    HDX->>HDX: sub claim to email, ClickHouse identity to team
    HDX->>HDX: find-or-provision user and team, ensure its connection
    HDX->>HDX: req.login(session false)
    HDX-->>User: upstream isUserAuthenticated now passes
```

Three things about the flow are worth knowing:

- **`req.login(..., { session: false })`** populates `req.user` exactly as
  Passport would, so every upstream route guard works unmodified. That is why
  the fork needs no changes to upstream's auth code.
- **A missing or invalid token falls THROUGH, it does not 401.** The route-level
  `isUserAuthenticated` guard is what rejects the request. Failing open here
  would be a hole; failing closed here would break upstream's own session login,
  which still has to work.
- **A valid token the engine refuses is answered here.** The engine's own 401 or
  403 passes straight through, a session the engine grants no group or no
  ClickHouse identity gets 403, and an engine that is unreachable, times out or
  answers with anything unreadable gets 401. None of them falls back to the
  token's claims.

---

## Where the identity comes from

```mermaid
flowchart TB
    entry["dfeIdentityMiddleware<br/>jwt-verify.ts"] --> mode{"DFE_AUTH_MODE"}
    mode -->|oidc-proxy, the default| jwt["engineJwtMiddleware<br/>verify ES384 against JWKS"]
    mode -->|header-dev| hdr["oidcIdentityMiddleware<br/>trust headers UNVERIFIED"]
    mode -->|unset| off["never registered<br/>upstream behaviour"]

    jwt --> resolve["find-or-provision<br/>user and team"]
    hdr --> resolve

    classDef safe fill:#009E73,stroke:#005f45,color:#ffffff
    classDef danger fill:#D55E00,stroke:#7a3500,color:#ffffff
    classDef neutral fill:#0072B2,stroke:#00456b,color:#ffffff
    class jwt,off safe
    class hdr danger
    class entry,mode,resolve neutral
```

**`header-dev` is dev-only and trusts headers without any verification.** It
exists so local development works without a running engine. Anything that can
reach the API can assert any identity under it. Never set it in production.

### Token source

`Authorization: Bearer <token>` if present, otherwise a `dfe_token` cookie. The
cookie header is parsed directly rather than adding `cookie-parser`, to avoid a
dependency for one lookup.

In `oidc-proxy` mode a request that does not authenticate through an engine
token is anonymous, even when it carries an old dfe-hyperdx session cookie. The
middleware clears the user passport restored before it runs.

### Claims

| Claim  | Used for                                                       |
| ------ | -------------------------------------------------------------- |
| `sub`  | the user's email; without it the request falls through         |
| `iss`  | enforced when `DFE_ENGINE_ISSUER` is set                       |
| `exp`  | caps how long the engine's answer for the token is kept        |
| `role` | the dashboard role, only when the engine's answer carries none |

### Team

The `groups` claim is never read. Since dfe-engine#591 a token's own groups
grant nothing: the engine resolves a session's groups from the account the token
binds. So the middleware asks the engine, `GET /api/v1/auth/me` with the
caller's token.

The team is named after the ClickHouse user the engine hands the caller
(`hyperdx_identity`): one team per org, plus one platform team. An empty answer
is a refusal, never `DFE_AUTH_DEFAULT_TEAM`. A user already on another team is
moved onto this one, because upstream never moves a user off the team it was
created on. The rule, the connection seeding and the startup repair are in
[team-identity.md](/v/2.2.2/dfe-ui/dfe-hyperdx/architecture/team-identity.md).

The dashboard role is the engine's `hyperdx_role` from the same answer, and the
token's `role` claim only when the engine sends none.

The answer is cached per token for 30 seconds, never past the token's `exp`, at
most 1000 entries, and concurrent requests for one token share one lookup. That
30 seconds is how long a removed group, a changed role or a deleted account
takes to reach dfe-hyperdx. A lookup the engine could not answer is never
cached.

Both the user and the team are find-or-create, so there is no registration or
invite step in DFE mode.

---

## Configuration

| Variable                 | Default              | Purpose                                                                                       |
| ------------------------ | -------------------- | --------------------------------------------------------------------------------------------- |
| `DFE_AUTH_MODE`          | unset                | `oidc-proxy`, `header-dev`, or unset for stock upstream. Any other value stops the API        |
| `DFE_ENGINE_JWKS_URL`    | -                    | engine JWKS URL, and `oidc-proxy` mode will not start without it                              |
| `DFE_ENGINE_ISSUER`      | unset                | enforced as the `iss` claim when set                                                          |
| `DFE_ENGINE_TIMEOUT_MS`  | `5000`               | how long any request to the engine may take before it is abandoned                            |
| `DFE_AUTH_HEADER_EMAIL`  | `x-forwarded-email`  | `header-dev` only, which will not start with `NODE_ENV=production`                            |
| `DFE_AUTH_HEADER_GROUPS` | `x-forwarded-groups` | `header-dev` only                                                                             |
| `DFE_AUTH_DEFAULT_TEAM`  | unset                | the engine service identity's team, and the `header-dev` team when no group header is present |

Read in `packages/api/src/dfe/config.ts`; `isDfeEnabled` is what `api-app.ts`
gates the middleware registration on.

---

## The files

| File                                   | Role                                                         |
| -------------------------------------- | ------------------------------------------------------------ |
| `dfe/middleware/jwt-verify.ts`         | entry point, mode switch, ES384 verification                 |
| `dfe/middleware/oidc-identity.ts`      | the `header-dev` path                                        |
| `dfe/controllers/engine-session.ts`    | the engine's answer for a token, cached; the team and role   |
| `dfe/controllers/user-provisioning.ts` | find-or-create the user, and move it to its team             |
| `dfe/controllers/team-provisioning.ts` | find-or-create the team                                      |
| `dfe/controllers/org-connection.ts`    | the team's one connection, as the team's own ClickHouse user |
| `dfe/tasks/team-connection-repair.ts`  | the startup delete of connections that are not their team's  |
| `dfe/config.ts`                        | the variables above                                          |
| `api-app.ts`                           | the one upstream file touched - a guarded `app.use`          |

The JWKS resolver is built once and reused. `createRemoteJWKSet` does its own
fetch caching, coalescing and cooldown, so there is no key cache of ours to get
wrong.

---

## AI steering

| Don't                                              | Do                                                         | Why                                                                            |
| -------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Trust `x-forwarded-email` in new code              | Read `req.user`, set by the middleware                     | The header is only authoritative in `header-dev`, which is not production      |
| Return 401 for a missing or invalid token          | Fall through and let `isUserAuthenticated` decide          | Upstream session login must keep working alongside DFE mode                    |
| Read the token's `groups` claim for a team         | Take `resolveEngineSession`'s team                         | The claim grants nothing; the account the token binds decides                  |
| Name a team after a group, or reuse its connection | Name it after the ClickHouse identity; seed only that user | A team's connection is what every member queries as                            |
| Fall back to a claim when the engine is down       | Refuse                                                     | A team is ClickHouse access, so an unanswered lookup must not grant one        |
| Add auth logic to upstream's `middleware/auth.ts`  | Add it under `dfe/` and register it in `api-app.ts`        | Editing upstream's auth is permanent conflict surface, and the guard blocks it |
| Widen `algorithms` beyond `['ES384']`              | Leave it pinned                                            | Algorithm confusion is the classic JWT verification bug                        |
