Skip to main content

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 }) 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​

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​

ClaimUsed for
subthe user's email; without it the request falls through
issenforced when DFE_ENGINE_ISSUER is set
expcaps how long the engine's answer for the token is kept
rolethe 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​

VariableDefaultPurpose
DFE_AUTH_MODEunsetoidc-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_ISSUERunsetenforced as the iss claim when set
DFE_ENGINE_TIMEOUT_MS5000how long any request to the engine may take before it is abandoned
DFE_AUTH_HEADER_EMAILx-forwarded-emailheader-dev only, which will not start with NODE_ENV=production
DFE_AUTH_HEADER_GROUPSx-forwarded-groupsheader-dev only
DFE_AUTH_DEFAULT_TEAMunsetthe 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​

FileRole
dfe/middleware/jwt-verify.tsentry point, mode switch, ES384 verification
dfe/middleware/oidc-identity.tsthe header-dev path
dfe/controllers/engine-session.tsthe engine's answer for a token, cached; the team and role
dfe/controllers/user-provisioning.tsfind-or-create the user, and move it to its team
dfe/controllers/team-provisioning.tsfind-or-create the team
dfe/controllers/org-connection.tsthe team's one connection, as the team's own ClickHouse user
dfe/tasks/team-connection-repair.tsthe startup delete of connections that are not their team's
dfe/config.tsthe variables above
api-app.tsthe 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'tDoWhy
Trust x-forwarded-email in new codeRead req.user, set by the middlewareThe header is only authoritative in header-dev, which is not production
Return 401 for a missing or invalid tokenFall through and let isUserAuthenticated decideUpstream session login must keep working alongside DFE mode
Read the token's groups claim for a teamTake resolveEngineSession's teamThe claim grants nothing; the account the token binds decides
Name a team after a group, or reuse its connectionName it after the ClickHouse identity; seed only that userA team's connection is what every member queries as
Fall back to a claim when the engine is downRefuseA team is ClickHouse access, so an unanswered lookup must not grant one
Add auth logic to upstream's middleware/auth.tsAdd it under dfe/ and register it in api-app.tsEditing upstream's auth is permanent conflict surface, and the guard blocks it
Widen algorithms beyond ['ES384']Leave it pinnedAlgorithm confusion is the classic JWT verification bug