Skip to main content

Dex OIDC (GitHub login)

Dex is an OpenID Connect (OIDC) identity broker. It federates a GitHub login into standard OIDC ID tokens that the Kubernetes API server trusts. Combined with the existing Cloudflare Tunnel and the kubelogin (kubectl oidc-login) plugin, a single kubectl command triggers a GitHub browser login and only verified identities ever reach the home network.

The API server itself stays private: only the Dex OIDC endpoint is published through the tunnel. No router ports are opened.

How the pieces fit
#

flowchart LR
  subgraph local["Workstation"]
    K[kubectl]
    KL[kubelogin]
    B[Browser]
  end
  subgraph cf["Cloudflare edge"]
    T[(Tunnel: dex.mi-homes.org)]
  end
  subgraph cluster["Home cluster (private)"]
    CFD[cloudflared]
    D[Dex]
    API[k3s API server]
  end
  GH[(GitHub OAuth)]

  K -->|exec credential plugin| KL
  KL -->|opens| B
  B -->|OIDC authorize| T --> CFD --> D
  D -->|federate| GH
  GH -->|code| D
  D -->|ID token| KL
  KL -->|bearer token| API
  API -->|verify iss/JWKS| T
  1. kubectl invokes kubelogin as a client-go credential plugin.
  2. kubelogin opens the browser to Dex at https://dex.mi-homes.org (reached only through the tunnel).
  3. Dex federates the login to GitHub; GitHub returns the user’s org/team membership.
  4. Dex mints an OIDC ID token (with email and groups claims) and hands it back to kubelogin.
  5. kubectl sends the ID token to the API server. The API server validates iss and the signature against Dex’s discovery/JWKS endpoints (also fetched through the tunnel), then applies RBAC.

Cloudflare Tunnel as the stealth bridge
#

The cluster already runs cloudflared (namespace cloudflare-tunnel) as a token-managed tunnel, so routing is configured in the Cloudflare Zero Trust dashboard, not in Git. Add a Public Hostname for Dex:

  • Subdomain: dex
  • Domain: your domain (mi-homes.org)
  • Type: HTTP
  • URL: dex.dex.svc.cluster.local:5556

Cloudflare terminates TLS at the edge with a valid public certificate, so the API server needs no oidc-ca-file. Do not put Cloudflare Access in front of dex.mi-homes.org; the OIDC endpoints must be reachable directly for the discovery, token, and JWKS calls.

GitHub OAuth App
#

Create a GitHub OAuth App (org mi-homes → Settings → Developer settings → OAuth Apps):

  • Homepage URL: https://dex.mi-homes.org
  • Authorization callback URL: https://dex.mi-homes.org/callback

Store the credentials in Vault so External Secrets Operator can sync them into the dex namespace:

vault kv put secret/homelab/dex \
  GITHUB_CLIENT_ID=<oauth-client-id> \
  GITHUB_CLIENT_SECRET=<oauth-client-secret>

The ExternalSecret in homelabs/dex/manifests/externalsecret.yaml materializes these into a Kubernetes Secret named dex-github, which the Dex Helm release reads via envVars. Dex expands $GITHUB_CLIENT_ID / $GITHUB_CLIENT_SECRET in its connector config at startup.

Dex deployment (GitOps)
#

Dex is an Argo CD application (clusters/home-prod/argocd/applications/dex.yaml) using the upstream chart plus a public, shareable template base in homelabs/dex/ and a private per-environment overlay in homelabs-private at clusters/home-prod/overlays/dex/:

  • Public base (homelabs/dex/): generic values.yaml (placeholder issuer dex.example.com, org your-github-org), manifests/ for the Namespace and ExternalSecret, and rbac.yaml.template as a starting point.
  • Private overlay (homelabs-private/clusters/home-prod/overlays/dex/): the real values.yaml (issuer https://dex.mi-homes.org, GitHub org mi-homes) and the real rbac.yaml group→role bindings.

The merged configuration:

  • Issuer: https://dex.mi-homes.org (must match the API server oidc-issuer-url exactly).
  • Storage: kubernetes (CRD-backed) so refresh tokens survive restarts.
  • Connector: GitHub, scoped to the mi-homes org, teamNameField: slug so groups are emitted as org:team (e.g. mi-homes:k8s-admins).
  • Static client: kubernetes, declared public so kubelogin uses PKCE (no client secret on the workstation).

Create the GitHub teams that map to access levels:

  • k8s-admins → cluster-admin
  • k8s-readonly → read-only

Kubernetes API server (k3s)
#

OIDC flags are set in the k3s server args (kubernetes/k3s-ansible/inventory/my-cluster/group_vars/all.yml):

--kube-apiserver-arg=oidc-issuer-url=https://dex.mi-homes.org
--kube-apiserver-arg=oidc-client-id=kubernetes
--kube-apiserver-arg=oidc-username-claim=email
--kube-apiserver-arg=oidc-username-prefix=oidc:
--kube-apiserver-arg=oidc-groups-claim=groups
--kube-apiserver-arg=oidc-groups-prefix=oidc:

Re-run the playbook (or edit /etc/rancher/k3s/config.yaml and sudo systemctl restart k3s on each control-plane node). The oidc: prefixes namespace OIDC identities so they cannot collide with built-in users or groups; RBAC subjects therefore use names like oidc:mi-homes:k8s-admins.

Keep the legacy client-cert kubeconfig as a break-glass path until the OIDC flow is confirmed.

RBAC mapping
#

homelabs-private/clusters/home-prod/overlays/dex/rbac.yaml binds the prefixed GitHub-team groups to cluster roles (the public base ships rbac.yaml.template as an example only):

GitHub team (group claim)RBAC subjectClusterRole
mi-homes:k8s-adminsoidc:mi-homes:k8s-adminscluster-admin
mi-homes:k8s-readonlyoidc:mi-homes:k8s-readonlyview

Workstation: kubelogin
#

Install the plugin so kubectl can find it:

kubectl krew install oidc-login   # or download kubelogin and rename to kubectl-oidc_login on PATH

Add an OIDC user and context to your kubeconfig (keep the existing cert context as break-glass):

users:
  - name: oidc-dex
    user:
      exec:
        apiVersion: client.authentication.k8s.io/v1
        command: kubectl
        args:
          - oidc-login
          - get-token
          - --oidc-issuer-url=https://dex.mi-homes.org
          - --oidc-client-id=kubernetes
          - --oidc-pkce-method=S256
          - --oidc-extra-scope=email
          - --oidc-extra-scope=groups
contexts:
  - name: home-oidc
    context:
      cluster: default
      user: oidc-dex

kubelogin listens on http://localhost:8000 for the OAuth callback, which is why that URL is registered as a redirect URI on the kubernetes static client.

The login flow
#

kubectl config use-context home-oidc
kubectl oidc-login clean   # clear any cached token
kubectl get nodes

Running kubectl get nodes opens the browser to Dex, which redirects to GitHub. After you approve, Dex returns an ID token, kubelogin caches it, and kubectl reaches the private API server through the tunnel. Subsequent commands reuse the cached token until it expires, then silently refresh.

Confirm the claims and your effective access:

kubectl oidc-login setup --oidc-issuer-url=https://dex.mi-homes.org --oidc-client-id=kubernetes --oidc-pkce-method=S256
kubectl auth whoami
kubectl auth can-i '*' '*' --all-namespaces

Verify and troubleshoot
#

# Discovery reachable through the tunnel
curl -s https://dex.mi-homes.org/.well-known/openid-configuration | jq .issuer

# Dex pod and synced secret
kubectl -n dex get pods
kubectl -n dex get externalsecret dex-github
kubectl -n dex get secret dex-github
  • Unauthorized: the API server oidc-issuer-url must match Dex’s issuer byte-for-byte; the cluster must have egress to fetch discovery/JWKS.
  • no groups: the user must be a member of the configured GitHub org/team and approve org access during the GitHub consent screen.
  • Forbidden after login: authentication worked but no RBAC binding matches the prefixed group; check the names in rbac.yaml.