# Edge OIDC through the gateway

> DFE has two ways to authenticate a person. Local auth is the default and needs
> nothing: dfe-engine holds the accounts and signs its own tokens. The other is
> edge OIDC, where Envoy Gateway takes the login, verifies the token, and hands
> dfe-engine an already-authenticated identity in `X-Oidc-*` headers.

This page is the second one, and what Envoy Gateway cannot do today; read it
before turning `jwtAuthn.enabled` on. [edge.md](/v/2.2.1/dfe-infra/deployment/edge.md) says which routes take
an edge policy at all.

## The values that turn OIDC on

There is no single on/off switch and deliberately no `auth.oidcEnabled`: the
engine reads whatever provider YAML is under
`<config.mountPath>/auth/oidc-providers`. Five values say five separate things.

| chart | value | what it decides |
|---|---|---|
| dfe-engine | `authConfig.providersConfigMap` | which ConfigMap of provider definitions is seeded into the engine's auth directory |
| dfe-engine | `oidc.enabled` + `oidc.providers` | which client credentials are mounted as env from which Secrets |
| dfe-engine | `auth.trustProxyHeaders` | whether the engine believes `X-Oidc-*` on an inbound request |
| dfe-engine | `auth.proxyProvider` | the provider a gateway login is stamped with; its `X-Oidc-Groups` link only to groups linked to that provider, so group files naming `dex` need `dex` here |
| envoy-gateway-config | `oidc.enabled` + `oidc.providers` + `oidc.targetRoutes` | which routes get an OIDC SecurityPolicy, and against which IdP |

`oidc.targetRoutes` names `dfe-engine`, and the engine's PUBLIC route follows it
-- so an edge policy on a cloud deploy also fronts
`GET /api/v1/auth/oidc/{provider}/callback`, which is where it collides with the
engine's own external-IdP login ([edge.md](/v/2.2.1/dfe-infra/deployment/edge.md#what-the-engine-apis-split-gets-wrong)).

## A private-CA IdP is not discoverable

An IdP the deployment hosts for itself is the common shape, because the gateway
chart issues the whole wildcard from `dfe-internal-ca`. Point a SecurityPolicy
at one with `provider.issuer` alone and it is rejected at admission:

```text
OIDC: Get "https://auth.example.com/.well-known/openid-configuration":
tls: failed to verify certificate: x509: certificate signed by unknown authority
```

That fetch is done by Envoy Gateway's CONTROL PLANE, against the system trust
store. The SecurityPolicy CRD has no field anywhere that takes a CA bundle:
`provider.backendSettings` is cluster settings only -- timeouts, retries, health
checks, load balancing -- with no TLS block in it.

So skip discovery. Name both endpoints, and send the token exchange to the IdP's
Service over the pod network:

```yaml
oidc:
  enabled: true
  providers:
    - name: dex
      issuerUrl: https://auth.example.com      # must equal the token `iss`
      clientId: dfe
      authorizationEndpoint: https://auth.example.com/auth
      tokenEndpoint: https://auth.example.com/token
      backendRefs:
        - name: dex
          namespace: dfe-local
          port: 5556
```

The authorization endpoint stays the public URL -- the browser goes there, not
Envoy. The token endpoint is called by Envoy, which is why it needs the
`backendRefs` alongside it. A `backendRefs` entry outside the target route's
namespace needs a ReferenceGrant in the IdP's namespace.

If the IdP Service itself speaks HTTPS with a private CA, the CA goes on a
BackendTLSPolicy targeting that Service, not on the SecurityPolicy.

## `X-Oidc-Groups` cannot be minted

`SecurityPolicy.spec.jwt.providers[].claimToHeaders` states its own limit:

> The claim must be of type; string, int, double, bool. Array type claims are
> not supported

`groups` is an array in every OIDC provider worth testing against. There is no
Envoy Gateway feature that flattens one, and inventing a workaround out of the
patch policy does not change what the filter accepts.

What that means in practice: the engine receives `X-Oidc-Subject` with no `X-Oidc-Groups`, so no linked group grants a role, and it refuses the request with 401 and creates no account:

```json
{"code": "no_role_granted", "message": "OIDC login refused: no DFE group with a role is linked to your identity provider groups. Ask a DFE administrator to link one."}
```

`_resolve_group_grants` maps the groups header to roles, and the header never arrives.

The way out is the engine reading the ID token itself rather than trusting a header the gateway cannot produce. Until that exists, edge OIDC admits only an account an administrator has added by hand to a group that carries a role, and that account then holds that group's roles, the same as a token sign-in would.

Two more things in the same path that do not meet at the Envoy Gateway this
repo pins (`versions.yaml`, `envoy-gateway`):

- The `EnvoyPatchPolicy`'s `jwt_authn` filter takes the token from an
  `Authorization: Bearer` header. An interactive OIDC login leaves the browser
  holding a cookie, not a bearer header.
- `auth.trustProxyHeaders` is a per-ENGINE flag, while the header stripping that
  makes `X-Oidc-*` trustworthy is per-gateway-LISTENER. One engine deployment
  serving both a policy-fronted route and a plain one cannot trust the headers on
  one and not the other, so setting the flag makes them spoofable everywhere that
  engine is reachable.

## Argo CD's own login

The Argo CD bootstrap installs signs in through the provider the gateway already fronts it with. bootstrap reads the gateway's `dfe-oidc-argocd-admin` SecurityPolicy, and `bootstrap/argocd_login.py` turns it into Argo's `oidc.config`: the same issuer and client id, and `clientSecret: $<secret>:client-secret`, a reference to the Secret that policy reads. The value never leaves that Secret. Dex is turned off.

`argocd-rbac-cm` maps `dfe-admins` and `dfe-infra` to `role:admin` and `dfe-infra-viewers` to `role:readonly`, the engine's own names (dfe-engine `docs/control-plane/rbac-vocabulary.md`). `dfe-infra` is Argo's admin because its engine role, `infra_admin`, already carries `argo:*`. Any other group signs in and sees nothing. The admin pair is `adminGroups` in `argocd/values/common.yaml`, and `scripts/tests/test_argocd_login.py` fails when the two differ. A deployment that changes `adminGroups` in its own overlay does not change Argo's admins.

- **No policy, no SSO.** With no provider, or `exposure.groups.admin.enabled: false`, the gateway renders no policy for the route, and Argo keeps its local `admin`. The access summary says which one a deployment got.
- **Read at bootstrap time.** On a first bootstrap the gateway syncs after Argo is installed, so the login is read again once the readiness gate passes. A provider added later reaches Argo on the next bootstrap run.
- **The IdP client allows `https://argocd.<domain>/auth/callback`**, beside the gateway's own `/oauth2/callback`.
- **The edge still decides who reaches Argo.** A `dfe-infra-viewers` user needs that group in `adminGroups` as well, or the gateway refuses them first. That list is also Kafbat's admin role, so a deployment admitting viewers at the edge names Kafbat's admins itself, in `rbac.roles.admin.subjects` of its deploy repo's `infra/kafbat.yaml`.
- **A private-CA IdP on the deployment's own domain** is verified against the CA that signed the gateway's certificate, set as Argo's `rootCA`. A public IdP keeps the system roots. The issuer hostname must resolve inside the cluster, as below.
- **An adopted Argo is never reconfigured.**

## Reaching your own gateway from inside the cluster

A pod that has to use the deployment's public hostname rather than a Service
name -- the engine fetching an IdP's discovery document, for one -- needs two
things that are easy to miss.

**Egress on the listener port.** `network-policies` ships
`allow-gateway-egress` for this. The baseline policy permits 443 to anywhere,
which reads as "pods may reach the gateway", and they cannot: the Envoy Service
maps 443 to container port 10443, NetworkPolicy is evaluated after DNAT, so the
packet is judged against 10443 and dropped. Refused-vs-timeout is the tell --
443 straight at the proxy pod is refused by the kernel, 10443 never arrives.

**A name that resolves, to an address that hairpins.** This one the deployment
owns, not DFE. The deployment's own hostnames have to resolve in-cluster, and
they have to resolve to an address a pod can actually connect to:

- With a cloud DNS provider configured, external-dns publishes the records and
  in-cluster resolution follows the public ones.
- On a private domain with no cloud DNS, the cluster needs a split-horizon
  answer for the deployment's domain. That is a cluster-DNS change -- a CoreDNS
  zone or drop-in, whose mechanism differs per distribution -- so it belongs in
  the deployment's own infrastructure config, not in a DFE chart.
- Answer with the gateway Service's ClusterIP, NOT its LoadBalancer VIP. Many
  on-prem load balancers do not hairpin, so a pod that resolves the VIP connects
  and then times out. Set `envoyGateway.service` deliberately if the deployment
  needs a stable name for that record.

SNI and Host stay the public name either way, so Envoy routes on the HTTPRoute
hostname and serves the wildcard certificate as normal.

## Related

- [index.md](/v/2.2.1/dfe-infra/deployment.md) - the deploy layers and the values cascade
- `helm/edge/gateway/values.yaml` - the `oidc` and `jwtAuthn` keys
- `helm/charts/dfe-extras/values.yaml` - the engine's `auth` and `authConfig` defaults, under `extras.defaults.dfe-engine`
