Skip to content

About

A operator that provides CRD for Dex entities and dynamicly provision dex configuration with the original dex helm chart.

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

dex-operator

Build Status Coverage Go Report Card License

A Kubernetes operator (Go, controller-runtime) that assembles the configuration of Dex dynamically from Custom Resources.

Dex itself keeps being installed via the official Dex Helm chart. The operator watches Custom Resources across the cluster and renders two Secrets into the Dex namespace:

  • Config Secret — the complete Dex config.yaml (issuer, storage, web, gRPC, logger, expiry, connectors, static clients)
  • Env Secret — all credentials as environment variables (e.g. GRAFANA_CLIENT_SECRET), attached to the Dex container via envFrom and referenced from the config as $VAR / secretEnv
flowchart LR
  subgraph "app namespaces"
    SC["DexStaticClient"]
    CONN["Dex*Connector"]
    SEC[("credential Secrets")]
  end
  OP["dex-operator"]
  subgraph "dex namespace"
    INST["DexInstallation"]
    CFG[("config Secret")]
    ENV[("env Secret")]
    DEX["Dex Deployment"]
  end
  SC --> OP
  CONN --> OP
  SEC --> OP
  INST --> OP
  OP --> CFG
  OP --> ENV
  CFG --> DEX
  ENV --> DEX
Loading

✨ Key Features

  • 🧩 Declarative Dex configuration — 18 namespace-scoped CRDs under dex.gtrfc.com/v1; no hand-written config.yaml
  • 🔌 16 connector types — LDAP, OIDC, SAML, GitHub, GitLab, Google, Microsoft, and more, each with its own strongly-typed CRD
  • 🔐 No secrets in Custom Resources — credentials stay in Kubernetes Secrets and are referenced by name/key; client secrets are injected into Dex as environment variables, never written in plaintext into the rendered config
  • 🏢 Multi-tenant by design — apps register their own OAuth2 clients from their own namespaces; two per-installation allowlists decide who may contribute clients and who may contribute connectors (connectors default to the installation's own namespace)
  • 🪪 One owner per ID — a client or connector ID renders for exactly one resource, and a client trusts only peers in its own namespace; contested IDs, failing resources and dropped peers are left out and reported instead of breaking the render for everybody
  • 🚦 Public (PKCE) and confidential clients — secretless static clients for CLIs/SPAs, validated at admission time via CEL rules (no webhook needed)
  • 🔁 Live reconfiguration — changes to any CR or referenced Secret re-render the config; optional automatic rollout restart of the Dex Deployment
  • 👀 Secret rotation aware — the operator watches referenced Secrets and reacts to credential rotations automatically
  • 🧮 Deterministic output — stable ordering and semantic YAML comparison prevent restart loops and config churn
  • 📊 Operational visibility — Ready conditions, connector/client counts, the list of rejected resources on the installation, and kubectl get print columns on every resource
  • 📦 Helm installation — including a pre-upgrade hook job that keeps CRDs up to date

📛 Naming Conventions

Everything the operator generates follows deterministic naming rules. Knowing them makes debugging and Secret handling predictable.

Resources

Convention Rule
API group / version dex.gtrfc.com/v1, all CRDs namespace-scoped
Connector CRDs Dex<Provider>Connector, e.g. DexOIDCConnector, DexLDAPConnector
Connector ID spec.id, falling back to metadata.name. The ID appears in Dex login URLs and in generated env var names
Installation reference Every connector and client points to its installation via spec.installationRef (name + namespace)

kubectl short names: dexinst, dexsc, dexldap, dexgithub, dexsaml, dexgitlab, dexoidc, dexoauth2, dexgoogle, dexlinkedin, dexmicrosoft, dexauthproxy, dexbitbucket, dexlocal, dexopenshift, dexcrowd, dexgitea, dexkeystone.

Generated environment variables

Credentials are materialized in the env Secret under deterministic names. Names are uppercased and every non-alphanumeric character becomes _:

Source Pattern Example
Connector client secret <TYPE>_<ID>_CLIENT_SECRET OIDC_KEYCLOAK_CLIENT_SECRET
LDAP bind password LDAP_<ID>_BIND_PW LDAP_CORPORATE_LDAP_BIND_PW
Keystone admin password KEYSTONE_<ID>_PASSWORD KEYSTONE_OPENSTACK_PASSWORD
Storage password STORAGE_<BACKEND>_PASSWORD STORAGE_POSTGRES_PASSWORD
Static client secret <RESOURCE_NAME>_CLIENT_SECRET GRAFANA_CLIENT_SECRET (from a DexStaticClient named grafana)
Fallback when the name above is taken <BASE>_<HASH>_<FIELD> — <HASH> is 8 hex characters of SHA-256 over <kind>/<namespace>/<name> GRAFANA_1A2B3C4D_CLIENT_SECRET (example — a second grafana in another namespace)

No two resources ever share an env var. Names are assigned in one order across storage, connectors and static clients — storage first, then resources in the installation's namespace, then the oldest — and a resource whose name is already taken (grafana in two namespaces, my-app and my.app, a client named oidc-okta next to the OIDC connector okta) gets the fallback name and works normally. When the holder of the plain name goes away, it moves back to the plain name (one config change). A name that collides with nothing is the plain name.

Secret keys and file paths

Convention Rule
Static client Secret keys client-id / client-secret (overridable via secretRef.clientIDKey / secretRef.clientSecretKey)
Config Secret content single key config.yaml
Managed Secret labels app.kubernetes.io/managed-by: dex-operator, dex.gtrfc.com/installation: <name>
Derived CORS origin scheme://host[:port] of every https redirectURIs entry of a DexStaticClient with cors: true — host lowercased, a redundant :443 dropped (e.g. https://dtrack.example.com/static/cb.html → https://dtrack.example.com)
Connector cert files /etc/dex/certs/<connector-id>-<field>.pem (e.g. -root-ca, -client-cert, -client-key, -ca)
Google service account /etc/dex/secrets/<connector-id>-service-account.json
Storage TLS files /etc/dex/certs/postgres-*.pem, etcd-*.pem, mysql-*.pem

Note: file-based references (SAML CA, client TLS certs, service-account JSON, storage TLS) are rendered into the config at the paths above, but mounting those files into the Dex Pod is currently not automated — add the volumes/volumeMounts in your Dex Helm values. Inline-capable material (LDAP rootCAData) is embedded base64-encoded and needs no mount.

📚 Documentation

Document Content
docs/operations/ Running the operator: when a change reaches Dex, why a rotated secret needs a Dex restart, mounting file material, reading the conditions, upgrading
docs/security/ The security architecture, one page per perspective: trust boundaries, tenancy, connectors, clients, secret flow, validation, privilege footprint, rotation — each with the gaps it leaves open
SECURITY.md How to report a vulnerability, supported versions
docs/developer/ Repository layout, packages and their responsibilities, architecture and reconcile flow, how to add a connector type, build, test and release
docs/adr/ The design decisions and what was rejected
Custom Resource Reference Every CRD with a fully populated example
Dex documentation Upstream semantics of all connector and OAuth2 options

🚀 TL;DR — Fast Start

1. Install the operator

helm repo add dex-operator https://guided-traffic.github.io/dex-operator
helm repo update

helm install dex-operator dex-operator/dex-operator \
  --namespace dex-operator-system \
  --create-namespace

2. Install Dex (official chart, wired to the operator)

Dex must read the operator-generated Secrets instead of an inline config. Minimal working dex-values.yaml:

# dex-values.yaml
configSecret:
  create: false
  name: dex-config        # must match DexInstallation .spec.configSecretName
envFrom:
  - secretRef:
      name: dex-env       # must match DexInstallation .spec.envSecretName
helm repo add dex https://charts.dexidp.io
helm repo update

helm install dex dex/dex \
  --namespace dex \
  --create-namespace \
  --values dex-values.yaml

The env Secret is created by the operator once a DexInstallation exists. On first install, create the DexInstallation (step 3) before or right after installing Dex so the referenced Secrets exist.

3. Minimal DexInstallation

apiVersion: dex.gtrfc.com/v1
kind: DexInstallation
metadata:
  name: main
  namespace: dex
spec:
  issuer: https://dex.example.com
  storage:
    type: kubernetes
  configSecretName: dex-config
  envSecretName: dex-env
  allowedNamespaces: ["*"]   # static clients from every namespace
  # allowedConnectorNamespaces omitted: connectors only from "dex"
  rolloutRestart:
    enabled: true
    deploymentName: dex

4. Minimal static clients

Confidential client (with client secret) — for server-side apps like Grafana:

apiVersion: v1
kind: Secret
metadata:
  name: grafana-oidc
  namespace: monitoring
type: Opaque
stringData:
  client-id: grafana
  client-secret: change-me
---
apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
  name: grafana
  namespace: monitoring
spec:
  installationRef:
    name: main
    namespace: dex
  displayName: Grafana
  secretRef:
    name: grafana-oidc
  redirectURIs:
    - https://grafana.example.com/login/generic_oauth

Public client (no secret, PKCE) — for CLIs, native apps, SPAs:

apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
  name: my-cli
  namespace: monitoring
spec:
  installationRef:
    name: main
    namespace: dex
  displayName: My CLI
  public: true
  clientID: my-cli
  # redirectURIs omitted: Dex accepts loopback, OOB and device-flow defaults

5. Minimal upstream IdP: Keycloak

Keycloak is a standard OIDC provider, so it plugs in via DexOIDCConnector. In Keycloak, create a confidential client (e.g. dex) with redirect URI https://dex.example.com/callback, then:

apiVersion: v1
kind: Secret
metadata:
  name: keycloak-oidc
  namespace: dex
type: Opaque
stringData:
  client-id: dex
  client-secret: keycloak-client-secret
---
apiVersion: dex.gtrfc.com/v1
kind: DexOIDCConnector
metadata:
  name: keycloak
  namespace: dex
spec:
  installationRef:
    name: main
    namespace: dex
  displayName: Keycloak
  issuer: https://keycloak.example.com/realms/my-realm
  clientIDRef:
    name: keycloak-oidc
    key: client-id
  clientSecretRef:
    name: keycloak-oidc
    key: client-secret
  redirectURI: https://dex.example.com/callback

Verify

kubectl get dexinstallation -n dex
# NAME   ISSUER                    STORAGE      CONNECTORS   CLIENTS   READY   AGE
# main   https://dex.example.com   kubernetes   1            2         True    1m

kubectl get secret dex-config dex-env -n dex
Upgrade / Uninstall
# Upgrade (CRDs are updated automatically by a pre-upgrade hook job)
helm repo update
helm upgrade dex-operator dex-operator/dex-operator \
  --namespace dex-operator-system

# Uninstall
helm uninstall dex-operator --namespace dex-operator-system

Upgrading to the release that introduces allowedConnectorNamespaces: allowedNamespaces no longer admits connectors; it governs static clients only. Connectors are admitted by allowedConnectorNamespaces, which defaults to the DexInstallation's own namespace. Check before upgrading:

  • Connectors in a namespace other than the installation's own are dropped from the config on upgrade and report Ready=False with ... allowedConnectorNamespaces (omitted: only "<ns>" is allowed). List their namespaces in allowedConnectorNamespaces (and the installation's own namespace too, if connectors live there).
  • Connectors in the installation's own namespace that the old allowedNamespaces excluded become active on upgrade, because the new default admits that namespace. Delete such connectors before upgrading if they are not meant to be used.
  • Installations whose connectors all live in the installation's own namespace, and whose allowedNamespaces admitted it, render byte-identical config: no config diff, no Dex rollout.

Upgrading to the release that makes client and connector IDs unique per installation: check before upgrading:

  • Cross-namespace trusted peers stop working. A trustedPeers entry naming a client in another namespace — or a client that does not exist — is dropped: the config changes, Dex rolls out, and cross-client token requests from that peer fail until both clients live in one namespace (move the client's secretRef Secret with it). Find them after the upgrade in status.droppedTrustedPeers of the installation.
  • Existing duplicate IDs change the config. After the upgrade a duplicate between the installation's namespace and another one renders only the installation's entry; a duplicate between two other namespaces, or within one namespace, renders neither, and both relying parties fail until one resource is removed or renamed. The same holds for connector IDs, across connector kinds.
  • An installation frozen by a failing resource renders again, carrying every change that was held back; the failing resource is left out and Dex rolls out without it.
  • The first render patches status.clientID on every static client, one status write each.
  • No duplicate IDs, no failing resource and every trustedPeers entry held in its own namespace: byte-identical config, no Dex rollout.

📖 Custom Resource Reference

Shared concepts for all resources:

  • installationRef — every connector and static client references exactly one DexInstallation by name + namespace. Static clients must come from a namespace in that installation's allowedNamespaces, connectors from a namespace in its allowedConnectorNamespaces (default: the installation's own namespace). Resources from other namespaces are ignored and marked with Ready=False; the condition follows allowlist changes on the installation.
  • IDs are unique per installation — client IDs and connector IDs (separate spaces, connectors across all 16 kinds) render for one resource only. An ID renders when exactly one admitted resource claims it, or when exactly one of its claimants is in the installation's namespace; otherwise it renders for nobody. Every claimant that does not render is Ready=False with reason DuplicateID, and the installation lists it in status.rejectedChildren with its claimed id — find all claimants of an ID there. Nothing outside the installation's namespace wins a contest, so keep security-relevant clients there. While a DexLocalConnector exists, the connector ID local belongs to Dex's password database. A confidential client whose Secret is missing keeps claiming the ID in its status.clientID. Moving a client to another namespace needs no particular order: either the old and the new object contest each other until the old one is deleted, or the ID is free until the new one exists — one render goes without the client either way. Details: docs/security/tenancy.md.
  • A failing resource is left out, not fatal — a connector or client whose Secret or key is missing is skipped with Ready=False, reason BuildFailed, and listed in status.rejectedChildren; everything else renders. Only an unresolvable storage credential stops the render, and so does the connector guard: when connectors exist but none of them renders and no DexLocalConnector does, the last written config stays (Dex does not start without a connector) and the installation is Ready=False.
  • id (connectors) — the Dex connector ID; defaults to metadata.name.
  • displayName — the human-readable name shown on the Dex login/approval screen.
  • *Ref fields — {name, key} references to Kubernetes Secrets in the same namespace as the referencing resource.
  • Status — all resources report a Ready condition and observedGeneration; the installation's and a static client's additional status is in their sections below.

The examples below are maximally populated. Fields are set to their default where one exists; otherwise a realistic example value is shown. Security-relevant fields carry a short note — absence of a note means the field has no direct security impact.

DexInstallation

The global Dex configuration. One DexInstallation corresponds to one Dex deployment.

apiVersion: dex.gtrfc.com/v1
kind: DexInstallation
metadata:
  name: main
  namespace: dex
spec:
  # ── Core ──────────────────────────────────────────────────────────────
  # Trust anchor of your SSO: the value baked into every issued token.
  # Must be the exact HTTPS URL clients use to reach Dex; changing it
  # invalidates all existing tokens. (required)
  issuer: https://dex.example.com

  # ── Storage (required) ────────────────────────────────────────────────
  # type: kubernetes | memory | postgres | sqlite3 | etcd | mysql
  # "kubernetes" (CRD-based) needs no credentials. "memory" loses all
  # sessions/refresh tokens on restart — dev only.
  storage:
    type: postgres                    # example (simplest choice: kubernetes)
    postgres:
      host: postgres.db.svc:5432      # example
      database: dex                   # example
      user: dex                       # example
      passwordRef:                    # password never appears in this CR or in
        name: dex-postgres            # the rendered config; it is injected as
        key: password                 # env var STORAGE_POSTGRES_PASSWORD
      ssl:
        # Security: "disable" sends credentials and tokens in cleartext to the
        # DB; use verify-ca/verify-full in production.
        mode: verify-full             # example (disable|require|verify-ca|verify-full)
        caRef:   { name: dex-postgres-tls, key: ca.crt }      # → /etc/dex/certs/postgres-ca.pem
        certRef: { name: dex-postgres-tls, key: tls.crt }     # → /etc/dex/certs/postgres-client-cert.pem
        keyRef:  { name: dex-postgres-tls, key: tls.key }     # → /etc/dex/certs/postgres-client-key.pem
      connectionTimeout: 5            # seconds, example
      maxOpenConns: 5                 # example
      maxIdleConns: 5                 # example
      connMaxLifetime: 300            # seconds, example
    # Other backends (mutually exclusive with postgres):
    # sqlite3: { file: /var/dex/dex.db }
    # etcd:
    #   endpoints: ["https://etcd-0:2379"]
    #   namespace: dex/
    #   username: dex
    #   passwordRef: { name: dex-etcd, key: password }
    #   ssl: { serverName: etcd, caRef: {...}, certRef: {...}, keyRef: {...} }
    # mysql: same shape as postgres (password env: STORAGE_MYSQL_PASSWORD)

  # ── HTTP endpoints ────────────────────────────────────────────────────
  web:
    http: 0.0.0.0:5556                # default in the Dex chart
    https: 0.0.0.0:5554               # example; requires tlsCert/tlsKey
    tlsCert: /etc/dex/tls/tls.crt     # example (file must be mounted into Dex)
    tlsKey: /etc/dex/tls/tls.key      # example
    # Security (CORS): only list origins that must call Dex from the browser
    # (e.g. SPAs using PKCE). "*" would allow any website to probe Dex.
    # Browser clients can instead self-register via DexStaticClient.spec.cors;
    # this list stays as the base and escape hatch. The rendered list is this
    # list, in order, plus the derived origins appended and sorted.
    allowedOrigins:
      - https://app.example.com       # example
    allowedHeaders:
      - Authorization                 # example

  # ── gRPC API (optional; used for dynamic client management) ───────────
  grpc:
    addr: 0.0.0.0:5557                # example
    # Security: the gRPC API can create/modify OAuth2 clients. Always front
    # it with mTLS (tlsClientCA) and never expose it outside the cluster.
    tlsCert: /etc/dex/grpc/tls.crt    # example
    tlsKey: /etc/dex/grpc/tls.key     # example
    tlsClientCA: /etc/dex/grpc/ca.crt # example — enables client-cert auth
    reflection: false                 # default

  # ── Logging ───────────────────────────────────────────────────────────
  logger:
    level: info                       # default (debug|info|warning|error)
    format: text                      # default (text|json)

  # ── Token lifetimes ───────────────────────────────────────────────────
  expiry:
    signingKeys: 6h                   # Dex default — rotation of JWT signing keys
    idTokens: 24h                     # Dex default — shorter = faster credential
                                      # revocation, more re-authentication
    authRequests: 24h                 # Dex default
    deviceRequests: 5m                # Dex default
    refreshTokens:
      # Security: refresh tokens are long-lived credentials. Rotation +
      # inactivity windows bound the damage of a stolen token.
      disableRotation: false          # default — keep rotation on
      reuseInterval: 3s               # example — tolerates concurrent refreshes
      validIfNotUsedFor: 2160h        # example (90 days of inactivity)
      absoluteLifetime: 3960h         # example (hard cap ~165 days)

  # ── OAuth2 behaviour ──────────────────────────────────────────────────
  oauth2:
    responseTypes: ["code"]           # default — the code flow; avoid enabling
                                      # implicit ("token") flows unless required
    # Security: the approval screen is the user's chance to see which client
    # requests access. Skipping it is fine when all clients are first-party.
    skipApprovalScreen: false         # default
    alwaysShowLoginScreen: false      # default
    grantTypes: ["authorization_code", "refresh_token"]  # example (default: all supported)
    # Security: enables the password grant against this connector — user
    # credentials pass through the client. Leave unset unless a legacy
    # client truly needs it.
    passwordConnector: local          # example

  # ── Login UI ──────────────────────────────────────────────────────────
  frontend:
    dir: /srv/dex/web                 # example — custom web assets
    theme: dark                       # example
    issuer: Example Corp SSO          # example — display name on the login page
    logoURL: https://example.com/logo.svg  # example

  # ── Operator wiring ───────────────────────────────────────────────────
  configSecretName: dex-config        # required — Secret receiving config.yaml
  envSecretName: dex-env              # required — Secret receiving all env vars

  # Security: tenancy control for STATIC CLIENTS only. Only DexStaticClients
  # from these namespaces are included. Empty/omitted = deny all (the
  # installation's own namespace is not implied). "*" = allow every
  # namespace — combine with RBAC on the CRDs (see docs/security/tenancy.md).
  allowedNamespaces:
    - monitoring                      # example
    - team-a                          # example

  # Security: tenancy control for CONNECTORS (all 16 kinds). A connector adds
  # an identity source whose users every client of this installation accepts,
  # so admitting a namespace here trusts it with identity issuance for the
  # whole installation. Omitted = only this installation's namespace (default
  # behaviour, no entry needed). Once set, the list is exhaustive: list the
  # own namespace too if connectors live there. "*" must be the only entry;
  # [] is rejected. Exact names only; a listed namespace that does not exist
  # yet admits whoever creates it first.
  allowedConnectorNamespaces:
    - dex                             # example
    - platform-idp                    # example

  rolloutRestart:
    enabled: true                     # example (default: false) — restart Dex
    deploymentName: dex               # when config.yaml changes so file-based
                                      # config is re-read immediately

Status, written by the operator (example):

status:
  observedGeneration: 3
  connectorCount: 2                   # rendered connectors, a DexLocalConnector included
  staticClientCount: 5                # rendered static clients
  # Resources that reference this installation and pass its allowlists, but
  # are not in the rendered config. reason: DuplicateID | BuildFailed. The
  # message is the one the resource shows in its own Ready condition.
  # Security: lists namespaces, names and IDs of every tenant concerned —
  # readable by whoever can read this installation.
  rejectedChildren:
    - kind: DexStaticClient
      namespace: team-a
      name: app
      id: shared-app
      reason: DuplicateID
      message: client ID "shared-app" is claimed by more than one DexStaticClient of DexInstallation dex/main
    - kind: DexStaticClient
      namespace: team-b
      name: app
      id: shared-app
      reason: DuplicateID
      message: client ID "shared-app" is claimed by more than one DexStaticClient of DexInstallation dex/main
  # trustedPeers entries left out because no static client in the trusting
  # client's namespace holds the peer ID.
  droppedTrustedPeers:
    - namespace: monitoring
      name: grafana
      peerID: argocd
  conditions:
    - type: Ready                     # True: rendered and both Secrets written
      status: "True"
      reason: Reconciled
    - type: ChildrenRejected          # True while rejectedChildren is non-empty
      status: "True"
      reason: ChildrenRejected
      message: "rejected children: 2, see status.rejectedChildren"
    - type: TrustedPeersDropped       # True while droppedTrustedPeers is non-empty
      status: "True"
      reason: TrustedPeersDropped
      message: "dropped trustedPeers entries: 1, see status.droppedTrustedPeers"

DexStaticClient

An OAuth2 client registered with Dex. Two modes exist, and the difference matters for security:

Confidential (with secret) Public / secretless
Spec secretRef → Secret with client-id/client-secret public: true + inline clientID
Client authentication client secret at the token endpoint none — PKCE (RFC 7636) binds the code to the initiator
redirectURIs required optional — Dex falls back to loopback, OOB and device-flow URIs
Env Secret entry <NAME>_CLIENT_SECRET (or its fallback name) none
Typical use server-side web apps (Grafana, ArgoCD, …) CLIs, native apps, SPAs

Why it matters: a confidential client proves its identity with a secret, so only the real backend can redeem authorization codes — the redirect URI allowlist is a second line of defense. A public client cannot hold a secret (anything shipped to a browser or laptop is extractable); its protection rests entirely on PKCE plus redirect-URI control. That trade-off is acceptable for interactive tools, but never model a server-side app as public: you would silently drop the client-authentication layer. Dex supports secretless clients since v2.24.0.

Confidential client, fully populated:

apiVersion: v1
kind: Secret
metadata:
  name: grafana-oidc
  namespace: monitoring
type: Opaque
stringData:
  client-id: grafana
  client-secret: change-me            # generate: openssl rand -base64 32
---
apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
  name: grafana                       # → env var GRAFANA_CLIENT_SECRET
  namespace: monitoring
spec:
  installationRef:
    name: main
    namespace: dex
  displayName: Grafana                # required — shown on the approval screen
  secretRef:
    name: grafana-oidc                # Secret in the same namespace
    clientIDKey: client-id            # default
    clientSecretKey: client-secret    # default
  # Security: exact-match allowlist. Broad or attacker-influenced entries
  # enable authorization-code interception.
  redirectURIs:
    - https://grafana.example.com/login/generic_oauth
  # Security: the client IDs listed here may obtain ID tokens with THIS
  # client's ID as audience (cross-client trust) — every resource server
  # that accepts this audience accepts those tokens. Only IDs held by a
  # DexStaticClient in this same namespace are rendered; any other entry is
  # left out and reported (condition TrustedPeersDropped).
  trustedPeers:
    - grafana-cli                     # example — a client in namespace monitoring
  public: false                       # default
  cors: false                         # default — see "Browser clients" below
status:                               # written by the operator
  clientID: grafana                   # the ID last resolved for this client
                                      # (print column "Client ID"); while the
                                      # Secret is missing, the client keeps
                                      # claiming it
  conditions:
    - type: Ready                     # False with reason DuplicateID or
      status: "True"                  # BuildFailed when the installation
      reason: Reconciled              # left this client out
    - type: TrustedPeersDropped       # True with the dropped peer IDs
      status: "False"
      reason: NoneDropped

Public client, fully populated:

apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
  name: my-cli
  namespace: monitoring
spec:
  installationRef:
    name: main
    namespace: dex
  displayName: My CLI
  public: true
  clientID: my-cli                    # inline — a client ID is not confidential
  cors: false                         # default — see "Browser clients" below
  # Optional for public clients. Omit to accept Dex's defaults:
  # http://localhost:<any-port>, urn:ietf:wg:oauth:2.0:oob, /device/callback
  redirectURIs:
    - http://127.0.0.1:8085/callback  # example

Rendered Dex config (public client) — note the absent secretEnv:

staticClients:
  - id: my-cli
    name: My CLI
    redirectURIs:
      - http://127.0.0.1:8085/callback
    public: true

Admission-time validation (CEL, enforced by the API server on kubectl apply):

public clientID secretRef valid behavior
false — set ✅ confidential client: id and secret from the Secret
false set — ❌ rejected: confidential clients require secretRef
true set — ✅ secretless public client, no env variable emitted
true — set ✅ public client with id + secret from the Secret (PKCE and a secret)
any set set ❌ rejected: ambiguous

Additionally: confidential clients must set at least one entry in redirectURIs.

Trusted peers stay in the namespace. A trustedPeers entry renders only when a DexStaticClient in the trusting client's own namespace holds that client ID — the one that renders, or the sole claimant whose Secret is missing. An entry naming a client in another namespace, a contested ID, or an ID nobody holds yet is left out of the config, listed in the installation's status.droppedTrustedPeers and shown on the trusting client as TrustedPeersDropped=True; it comes back as soon as a client of that namespace holds the ID. This holds in the installation's namespace too. To let two applications trust each other, put both clients into one namespace.

Browser clients (cors: true): Dex gates its browser-facing endpoints (discovery, token, keys) behind a CORS allowlist. Server-side clients never hit it; an SPA that runs code+PKCE via XHR does. Setting cors: true registers the origins of this client's own https redirectURIs in the installation's web.allowedOrigins, so a browser client can self-register from its own namespace instead of requiring an edit on the DexInstallation:

apiVersion: dex.gtrfc.com/v1
kind: DexStaticClient
metadata:
  name: dependency-track
  namespace: dependency-track
spec:
  installationRef:
    name: main
    namespace: dex
  displayName: Dependency-Track
  public: true
  clientID: dependency-track
  cors: true                          # opt-in; default false
  redirectURIs:
    - https://dtrack.example.com/static/oidc-callback.html

renders into the installation's config as:

web:
  allowedOrigins:
    - https://dtrack.example.com      # derived — path stripped

Rules:

  • Only https URIs contribute. http://localhost/loopback, custom schemes and the OOB URN are redirect conveniences for native clients, not browser origins, and are skipped silently. Use DexInstallation.spec.web.allowedOrigins for those.
  • Origins are derived only from the client's own redirectURIs — the flag grants no authority beyond that already RBAC-gated field. There is no free-form origin field.
  • Derived origins are appended to DexInstallation.spec.web.allowedOrigins (which stays as base and escape hatch) in sorted order; the authored list keeps its order and duplicates collapse.
  • Derivation is stateless: deleting the client or removing the flag drops the origin on the next render.
  • Dex matches origins literally (no subdomain wildcards — https://*.example.com would never match). The only wildcard is "*", which can never be produced by derivation.
  • If the installation has no spec.web at all, a derived origin creates the web: block containing only allowedOrigins; the listener addresses come from the Dex chart's CLI flags and stay untouched.

Connectors

Sixteen connector CRDs cover Dex's upstream identity providers. All share installationRef, id (default: metadata.name, unique across all connector kinds of an installation — see installationRef above) and displayName; client secrets always land in the env Secret as <TYPE>_<ID>_CLIENT_SECRET (or its fallback name, see naming conventions), while client IDs are embedded inline (they are not confidential).

CRD Dex type Credential env vars File mounts needed
DexLDAPConnector ldap LDAP_<ID>_BIND_PW client cert/key (CA is inlined)
DexOIDCConnector oidc OIDC_<ID>_CLIENT_SECRET optional root CA
DexSAMLConnector saml — IdP signing CA
DexGitHubConnector github GITHUB_<ID>_CLIENT_SECRET optional root CA (GH Enterprise)
DexGitLabConnector gitlab GITLAB_<ID>_CLIENT_SECRET —
DexGoogleConnector google GOOGLE_<ID>_CLIENT_SECRET optional service-account JSON
DexMicrosoftConnector microsoft MICROSOFT_<ID>_CLIENT_SECRET —
DexOAuth2Connector oauth OAUTH2_<ID>_CLIENT_SECRET optional root CA
DexLinkedInConnector linkedin LINKEDIN_<ID>_CLIENT_SECRET —
DexBitbucketConnector bitbucket-cloud BITBUCKET_<ID>_CLIENT_SECRET —
DexGiteaConnector gitea GITEA_<ID>_CLIENT_SECRET —
DexOpenShiftConnector openshift OPENSHIFT_<ID>_CLIENT_SECRET optional root CA
DexAtlassianCrowdConnector atlassian-crowd CROWD_<ID>_CLIENT_SECRET —
DexKeystoneConnector keystone KEYSTONE_<ID>_PASSWORD —
DexAuthProxyConnector authproxy — —
DexLocalConnector built-in password DB — —

DexLDAPConnector

Authenticates users against an LDAP directory (Active Directory, OpenLDAP, …).

Fully populated example
apiVersion: v1
kind: Secret
metadata:
  name: ldap-credentials
  namespace: dex
type: Opaque
stringData:
  bind-password: my-ldap-password
  root-ca.pem: |
    -----BEGIN CERTIFICATE-----
    ...
    -----END CERTIFICATE-----
---
apiVersion: dex.gtrfc.com/v1
kind: DexLDAPConnector
metadata:
  name: corporate-ldap
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: corporate-ldap                  # default: metadata.name
  displayName: Corporate LDAP        # required
  host: ldap.example.com:636          # required (host:port)

  # Security: the three flags below each weaken transport security.
  insecureNoSSL: false                # default — true = plaintext LDAP (389)
  insecureSkipVerify: false           # default — true = accept any server cert
  startTLS: false                     # default — upgrade 389 via STARTTLS

  # CA to verify the LDAP server. Inlined base64 as rootCAData — no mount.
  rootCARef: { name: ldap-credentials, key: root-ca.pem }
  # Mutual TLS client credentials (mounted as files, see Naming Conventions):
  clientCertRef: { name: ldap-credentials, key: tls.crt }   # example
  clientKeyRef:  { name: ldap-credentials, key: tls.key }   # example

  # Security: use a minimal read-only service account for the bind DN, never
  # an admin account. The password is injected as LDAP_CORPORATE_LDAP_BIND_PW.
  bindDN: cn=serviceaccount,dc=example,dc=com
  bindPWRef: { name: ldap-credentials, key: bind-password }

  usernamePrompt: SSO Username        # example — login form label

  userSearch:                         # required
    baseDN: ou=Users,dc=example,dc=com          # required
    # Security: the filter scopes who can log in at all — keep it as narrow
    # as possible (e.g. a group membership requirement).
    filter: "(objectClass=person)"    # example
    username: sAMAccountName          # required — attribute matched to login input
    scope: sub                        # default (sub|one)
    idAttr: DN                        # default — stable user ID claim
    emailAttr: mail                   # default
    nameAttr: displayName             # example
    preferredUsernameAttr: sAMAccountName        # example
    emailSuffix: example.com          # example — for entries without mail attr

  groupSearch:
    baseDN: ou=Groups,dc=example,dc=com          # required
    filter: "(objectClass=group)"     # example
    scope: sub                        # default (sub|one)
    userMatchers:                     # required, ≥1 — how users map to groups
      - userAttr: DN                  # required
        groupAttr: member             # required
        recursionGroupAttr: memberOf  # example — resolves nested groups
    nameAttr: cn                      # default — value of the groups claim

DexOIDCConnector

Generic OIDC upstream — Keycloak, Okta, Auth0, Azure AD (OIDC mode), …

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexOIDCConnector
metadata:
  name: keycloak
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: keycloak                        # default: metadata.name
  displayName: Keycloak               # required
  issuer: https://keycloak.example.com/realms/my-realm   # required
  clientIDRef:     { name: keycloak-oidc, key: client-id }      # required
  clientSecretRef: { name: keycloak-oidc, key: client-secret }  # required
                                      # → env OIDC_KEYCLOAK_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  scopes: [openid, profile, email, groups]        # example; openid always added
  getUserInfo: false                  # default — query the userinfo endpoint
  userNameKey: preferred_username     # example (default claim: name)
  userIDKey: sub                      # example (default claim: sub)
  promptType: consent                 # example — value of the prompt parameter
  overrideClaimMapping: false         # default
  claimMapping:
    preferredUsername: preferred_username   # example
    email: email                      # example
    groups: groups                    # example

  # Security: skips the email_verified check — an upstream account with an
  # unverified (possibly attacker-chosen) email is accepted.
  insecureSkipEmailVerified: false    # default
  # Security: trusts a groups claim outside the discovery contract.
  insecureEnableGroups: false         # default

  basicAuthUnsupported: false         # default — force client_secret_post
  hostedDomains: [example.com]        # example — Google IdPs only
  acrValues: ["urn:mace:incommon:iap:silver"]     # example
  # CA for the IdP's HTTPS endpoint (mounted as file → rootCAs):
  rootCARef: { name: keycloak-oidc, key: ca.crt } # example

DexSAMLConnector

SAML 2.0 upstream IdP (POST binding).

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexSAMLConnector
metadata:
  name: adfs
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: adfs                            # default: metadata.name
  displayName: Corporate SAML         # required
  ssoURL: https://adfs.example.com/adfs/ls        # required
  # Security: the CA that verifies the IdP's assertion signature — this is
  # what makes SAML responses trustworthy. Mounted as file → `ca`.
  caRef: { name: saml-idp, key: ca.crt }
  ssoIssuer: http://adfs.example.com/adfs/services/trust   # example
  entityIssuer: https://dex.example.com/callback  # example — SP entity ID
  redirectURI: https://dex.example.com/callback   # example — ACS URL
  nameIDPolicyFormat: persistent      # example
  usernameAttr: name                  # example
  emailAttr: email                    # example
  groupsAttr: groups                  # example
  # Security: allowlist on the groups claim — only members may log in.
  allowedGroups: [sso-users]          # example
  # Security: NEVER enable outside local development — accepts forged
  # assertions from anyone.
  insecureSkipSignatureValidation: false          # default

DexGitHubConnector

GitHub / GitHub Enterprise OAuth login.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexGitHubConnector
metadata:
  name: github
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: github                          # default: metadata.name
  displayName: GitHub                 # required
  clientIDRef:     { name: github-oauth, key: client-id }      # required
  clientSecretRef: { name: github-oauth, key: client-secret }  # required
                                      # → env GITHUB_GITHUB_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  hostName: github.example.com        # example — GitHub Enterprise (default: github.com)
  rootCARef: { name: github-oauth, key: ca.crt }  # example — GHE TLS CA (mounted)
  # Security: without orgs, ANY GitHub account may log in. Restrict to your
  # organizations (and optionally teams).
  orgs:
    - name: my-org                    # required per entry
      teams: [developers, platform]   # example
  loadAllGroups: false                # default — load all orgs/teams as groups
  teamNameField: slug                 # example (name|slug|both)
  useLoginAsID: false                 # default — true replaces the immutable
                                      # numeric ID with the reusable login name

DexGitLabConnector

GitLab.com or self-managed GitLab.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexGitLabConnector
metadata:
  name: gitlab
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: gitlab                          # default: metadata.name
  displayName: GitLab                 # required
  baseURL: https://gitlab.example.com # default: https://gitlab.com
  clientIDRef:     { name: gitlab-oauth, key: client-id }      # required
  clientSecretRef: { name: gitlab-oauth, key: client-secret }  # required
                                      # → env GITLAB_GITLAB_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  # Security: without groups, any account on the GitLab instance may log in.
  groups: [my-group]                  # example
  useLoginAsID: false                 # default

DexGoogleConnector

Google Workspace / Gmail login, optionally with group claims via the Admin SDK.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexGoogleConnector
metadata:
  name: google
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: google                          # default: metadata.name
  displayName: Google                 # required
  clientIDRef:     { name: google-oauth, key: client-id }      # required
  clientSecretRef: { name: google-oauth, key: client-secret }  # required
                                      # → env GOOGLE_GOOGLE_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  # Security: without hostedDomains, ANY Google account (including private
  # Gmail) may log in.
  hostedDomains: [example.com]        # example
  # Security: additional allowlist on Google Groups membership.
  groups: [sso-users@example.com]     # example
  # Service account for the Admin SDK (group lookups). Mounted as file at
  # /etc/dex/secrets/<id>-service-account.json. Grant it ONLY the read-only
  # Admin SDK scopes it needs.
  serviceAccountFileRef: { name: google-sa, key: sa.json }     # example
  adminEmail: admin@example.com       # example — impersonated admin account
  fetchTransitiveGroupMembership: false           # default

DexMicrosoftConnector

Microsoft Entra ID (Azure AD).

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexMicrosoftConnector
metadata:
  name: entra
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: entra                           # default: metadata.name
  displayName: Microsoft              # required
  clientIDRef:     { name: entra-oauth, key: client-id }       # required
  clientSecretRef: { name: entra-oauth, key: client-secret }   # required
                                      # → env MICROSOFT_ENTRA_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  # Security: "common" accepts accounts from ANY tenant, "consumers" personal
  # Microsoft accounts. Pin your tenant ID unless you really are multi-tenant.
  tenant: 4f1e-your-tenant-id         # example
  onlySecurityGroups: false           # default
  # Security: allowlist on group membership.
  groups: [platform-team]             # example
  groupNameFormat: name               # example (id|name); "id" is immutable,
                                      # "name" is human-readable but renameable
  domainHint: example.com             # example — pre-selects the tenant

DexOAuth2Connector

Generic OAuth2 provider without OIDC discovery — endpoints are configured manually.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexOAuth2Connector
metadata:
  name: legacy-idp
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: legacy-idp                      # default: metadata.name
  displayName: Legacy IdP             # required
  clientIDRef:     { name: legacy-oauth, key: client-id }      # required
  clientSecretRef: { name: legacy-oauth, key: client-secret }  # required
                                      # → env OAUTH2_LEGACY_IDP_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  # Security: no discovery document means YOU vouch for these endpoints.
  # A wrong/spoofed tokenURL would receive your client secret.
  authorizationURL: https://idp.example.com/oauth/authorize    # required
  tokenURL: https://idp.example.com/oauth/token   # required
  userInfoURL: https://idp.example.com/oauth/userinfo          # example
  scopes: [openid, profile, email]    # example
  rootCARef: { name: legacy-oauth, key: ca.crt }  # example — mounted → rootCAs
  # Security: disables TLS verification towards the provider — dev only.
  insecureSkipVerify: false           # default
  userIDKey: id                       # default
  claimMapping:
    userName: user_name               # default
    preferredUsername: preferred_username         # example
    email: email                      # example
    emailVerified: email_verified     # example
    groups: groups                    # example

DexLinkedInConnector

LinkedIn login.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexLinkedInConnector
metadata:
  name: linkedin
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: linkedin                        # default: metadata.name
  displayName: LinkedIn               # required
  clientIDRef:     { name: linkedin-oauth, key: client-id }     # required
  clientSecretRef: { name: linkedin-oauth, key: client-secret } # required
                                      # → env LINKEDIN_LINKEDIN_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  # Note: LinkedIn offers no org/group restriction — any LinkedIn account can
  # authenticate. Gate authorization in your applications.

DexBitbucketConnector

Bitbucket Cloud login.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexBitbucketConnector
metadata:
  name: bitbucket
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: bitbucket                       # default: metadata.name
  displayName: Bitbucket              # required
  clientIDRef:     { name: bitbucket-oauth, key: client-id }     # required
  clientSecretRef: { name: bitbucket-oauth, key: client-secret } # required
                                      # → env BITBUCKET_BITBUCKET_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  # Security: without teams, any Bitbucket account may log in.
  teams: [my-workspace]               # example

DexGiteaConnector

Gitea login.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexGiteaConnector
metadata:
  name: gitea
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: gitea                           # default: metadata.name
  displayName: Gitea                  # required
  baseURL: https://gitea.example.com  # required
  clientIDRef:     { name: gitea-oauth, key: client-id }       # required
  clientSecretRef: { name: gitea-oauth, key: client-secret }   # required
                                      # → env GITEA_GITEA_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  # Security: without orgs, any account on the Gitea instance may log in.
  orgs:
    - name: My Org                    # required per entry (full name)
      teams: [developers]             # example
  loadAllGroups: false                # default
  useLoginAsID: false                 # default

DexOpenShiftConnector

OpenShift OAuth server as upstream IdP.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexOpenShiftConnector
metadata:
  name: openshift
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: openshift                       # default: metadata.name
  displayName: OpenShift              # required
  issuer: https://api.openshift.example.com:6443  # required — API server URL
  clientIDRef:     { name: openshift-oauth, key: client-id }     # required
  clientSecretRef: { name: openshift-oauth, key: client-secret } # required
                                      # → env OPENSHIFT_OPENSHIFT_CLIENT_SECRET
  redirectURI: https://dex.example.com/callback   # example
  # Security: allowlist on OpenShift group membership.
  groups: [cluster-users]             # example
  # Security: skips API server certificate verification — dev only.
  insecureCA: false                   # default
  rootCARef: { name: openshift-oauth, key: ca.crt }   # example — mounted

DexAtlassianCrowdConnector

Atlassian Crowd directory.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexAtlassianCrowdConnector
metadata:
  name: crowd
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: crowd                           # default: metadata.name
  displayName: Atlassian Crowd        # required
  baseURL: https://crowd.example.com/crowd        # required
  # clientIDRef holds the Crowd APPLICATION NAME, clientSecretRef the
  # application password (→ env CROWD_CROWD_CLIENT_SECRET).
  clientIDRef:     { name: crowd-app, key: client-id }         # required
  clientSecretRef: { name: crowd-app, key: client-secret }     # required
  # Security: allowlist on Crowd group membership.
  groups: [jira-users]                # example
  preferredUsernameField: name        # example (key|name|email)
  usernamePrompt: Crowd Login         # example

DexKeystoneConnector

OpenStack Keystone identity service.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexKeystoneConnector
metadata:
  name: openstack
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: openstack                       # default: metadata.name
  displayName: OpenStack              # required
  keystoneHost: https://keystone.example.com:5000 # required
  domain: default                     # example — restricts the Keystone domain
  # Security: admin credentials used for user/group lookups — scope this
  # account minimally. Password → env KEYSTONE_OPENSTACK_PASSWORD.
  keystoneUsername: demo              # example
  keystonePasswordRef: { name: keystone-admin, key: password }  # example

DexAuthProxyConnector

Delegates authentication to a trusted reverse proxy that sets identity headers.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexAuthProxyConnector
metadata:
  name: authproxy
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: authproxy                       # default: metadata.name
  displayName: SSO Proxy              # required
  # Security: Dex trusts these headers UNCONDITIONALLY. The proxy MUST be the
  # only network path to Dex's /callback for this connector, and it must strip
  # client-supplied copies of these headers — otherwise anyone can forge any
  # identity.
  userIDHeader: X-Remote-User-ID      # default
  userHeader: X-Remote-User           # default
  userNameHeader: X-Remote-User-Display-Name      # default
  emailHeader: X-Remote-User-Email    # default
  groupHeader: X-Remote-Group         # default
  groupHeaderSeparator: ","           # example
  staticGroups: [proxy-users]         # example — appended to every login

DexLocalConnector

Enables Dex's built-in password database (enablePasswordDB: true). Intended for bootstrap and testing; prefer a real IdP in production.

Fully populated example
apiVersion: dex.gtrfc.com/v1
kind: DexLocalConnector
metadata:
  name: local
  namespace: dex
spec:
  installationRef: { name: main, namespace: dex }
  id: local                           # default: metadata.name
  displayName: Email                  # required — label on the login form

🛠 Development

make build          # build the manager binary
make run            # run locally against the current kubeconfig
make generate-all   # regenerate CRDs, DeepCopy code and sync the Helm chart
make lint           # go vet + gofmt + golangci-lint
make test           # unit + envtest suites

See docs/developer/ for the full repository walkthrough, test matrix (unit / integration / e2e) and release process.

License

Apache License 2.0 — see LICENSE.

About

A operator that provides CRD for Dex entities and dynamicly provision dex configuration with the original dex helm chart.

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages