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
Three things about the flow are worth knowing:
req.login(..., { session: false })populatesreq.userexactly 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
isUserAuthenticatedguard 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
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.
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 |