From 0cdc143227339d43c8fae1708f6f517c0c807e74 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 19 Feb 2026 20:24:24 -0800 Subject: [PATCH] Deploy Dex OIDC identity provider with Grafana SSO (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Deploys Dex OIDC identity provider on ringtail k3s cluster as central authentication service - Integrates Grafana as first SSO client via `auth.generic_oauth` - Uses Kubernetes CRD storage backend (no PVC needed) - All secrets (bcrypt hash, client secrets) injected via ExternalSecrets from 1Password item "Dex (blumeops)" - NixOS-built container image via `containers/dex/default.nix` ## Pre-requisites (manual, before deployment) 1. Create 1Password item "Dex (blumeops)" in `blumeops` vault with fields: - `password`: strong generated password for Dex login - `static-password-hash`: bcrypt hash of above (`htpasswd -BnC 10 eblume`, copy hash after `eblume:`) - `grafana-client-secret`: random 32-char hex (`openssl rand -hex 16`) 2. Build container: `mise run container-tag-and-release dex v1.0.0` ## Deployment sequence 1. Build container: `mise run container-tag-and-release dex v1.0.0` 2. Deploy Caddy: `mise run provision-indri -- --tags caddy` 3. Sync ArgoCD: `argocd app sync apps` → `argocd app sync dex` 4. Verify Dex: `curl https://dex.ops.eblu.me/.well-known/openid-configuration` 5. Sync Grafana: `argocd app sync grafana-config` → `argocd app sync grafana` 6. Test SSO: Visit `https://grafana.ops.eblu.me/login`, click "Sign in with Dex" ## Verification - [ ] Container image exists: `mise run container-list` shows `dex:v1.0.0-nix` - [ ] `curl https://dex.ops.eblu.me/.well-known/openid-configuration` returns valid OIDC discovery - [ ] `curl https://dex.ops.eblu.me/healthz` returns healthy - [ ] Grafana login shows "Sign in with Dex" button alongside local login - [ ] OIDC flow: click Dex → enter credentials → redirect back → logged in as Admin - [ ] Break-glass: local admin login still works - [ ] `mise run services-check` passes ## Files changed | File | Action | Purpose | |------|--------|---------| | `containers/dex/default.nix` | Create | NixOS container build | | `argocd/apps/dex.yaml` | Create | ArgoCD app targeting ringtail | | `argocd/manifests/dex/*` (8 files) | Create | K8s manifests (RBAC, ExternalSecret, Deployment, Service, Ingress) | | `argocd/manifests/grafana-config/external-secret-dex-oauth.yaml` | Create | Grafana OIDC client secret | | `argocd/manifests/grafana-config/kustomization.yaml` | Modify | Add new ExternalSecret resource | | `argocd/manifests/grafana/values.yaml` | Modify | Add `auth.generic_oauth` config + envFromSecrets | | `ansible/roles/caddy/defaults/main.yml` | Modify | Add `dex.ops.eblu.me` reverse proxy entry | | `docs/changelog.d/feature-dex-oidc.feature.md` | Create | Changelog fragment | Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/222 --- ansible/roles/caddy/defaults/main.yml | 3 + argocd/apps/dex.yaml | 18 ++++++ argocd/manifests/dex/deployment.yaml | 53 ++++++++++++++++++ argocd/manifests/dex/external-secret.yaml | 55 +++++++++++++++++++ argocd/manifests/dex/ingress-tailscale.yaml | 26 +++++++++ argocd/manifests/dex/kustomization.yaml | 9 +++ argocd/manifests/dex/service.yaml | 13 +++++ .../external-secret-dex-oauth.yaml | 22 ++++++++ .../grafana-config/kustomization.yaml | 1 + argocd/manifests/grafana/values.yaml | 16 +++++- containers/dex/default.nix | 28 ++++++++++ docs/changelog.d/feature-dex-oidc.feature.md | 1 + 12 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 argocd/apps/dex.yaml create mode 100644 argocd/manifests/dex/deployment.yaml create mode 100644 argocd/manifests/dex/external-secret.yaml create mode 100644 argocd/manifests/dex/ingress-tailscale.yaml create mode 100644 argocd/manifests/dex/kustomization.yaml create mode 100644 argocd/manifests/dex/service.yaml create mode 100644 argocd/manifests/grafana-config/external-secret-dex-oauth.yaml create mode 100644 containers/dex/default.nix create mode 100644 docs/changelog.d/feature-dex-oidc.feature.md diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index ce2235a..f7d44fe 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -79,6 +79,9 @@ caddy_services: - name: nvr host: "nvr.{{ caddy_domain }}" backend: "https://nvr.tail8d86e.ts.net" + - name: dex + host: "dex.{{ caddy_domain }}" + backend: "https://dex.tail8d86e.ts.net" - name: ntfy host: "ntfy.{{ caddy_domain }}" backend: "https://ntfy.tail8d86e.ts.net" diff --git a/argocd/apps/dex.yaml b/argocd/apps/dex.yaml new file mode 100644 index 0000000..2da0939 --- /dev/null +++ b/argocd/apps/dex.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: dex + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/dex + destination: + server: https://ringtail.tail8d86e.ts.net:6443 + namespace: dex + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/manifests/dex/deployment.yaml b/argocd/manifests/dex/deployment.yaml new file mode 100644 index 0000000..ba02856 --- /dev/null +++ b/argocd/manifests/dex/deployment.yaml @@ -0,0 +1,53 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dex + namespace: dex +spec: + replicas: 1 + selector: + matchLabels: + app: dex + template: + metadata: + labels: + app: dex + spec: + containers: + - name: dex + image: registry.ops.eblu.me/blumeops/dex:v1.0.0-nix + ports: + - name: http + containerPort: 5556 + volumeMounts: + - name: config + mountPath: /etc/dex/cfg + readOnly: true + - name: data + mountPath: /var/dex + livenessProbe: + httpGet: + path: /healthz + port: 5556 + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /healthz + port: 5556 + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" + volumes: + - name: config + secret: + secretName: dex-config + - name: data + emptyDir: {} diff --git a/argocd/manifests/dex/external-secret.yaml b/argocd/manifests/dex/external-secret.yaml new file mode 100644 index 0000000..3b9e685 --- /dev/null +++ b/argocd/manifests/dex/external-secret.yaml @@ -0,0 +1,55 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: dex-config + namespace: dex +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: dex-config + creationPolicy: Owner + template: + data: + config.yaml: | + issuer: https://dex.ops.eblu.me + storage: + type: sqlite3 + config: + file: /var/dex/dex.db + web: + http: 0.0.0.0:5556 + oauth2: + skipApprovalScreen: true + connectors: + - type: gitea + id: forgejo + name: Forgejo + config: + baseURL: https://forge.ops.eblu.me + clientID: "{{ .forgejoClientID }}" + clientSecret: "{{ .forgejoClientSecret }}" + redirectURI: https://dex.ops.eblu.me/callback + staticClients: + - id: grafana + name: Grafana + secret: "{{ .grafanaClientSecret }}" + redirectURIs: + - "https://grafana.ops.eblu.me/login/generic_oauth" + - "https://grafana.tail8d86e.ts.net/login/generic_oauth" + data: + - secretKey: forgejoClientID + remoteRef: + key: "Dex (blumeops)" + property: forgejo-client-id + - secretKey: forgejoClientSecret + remoteRef: + key: "Dex (blumeops)" + property: forgejo-client-secret + - secretKey: grafanaClientSecret + remoteRef: + key: "Dex (blumeops)" + property: grafana-client-secret diff --git a/argocd/manifests/dex/ingress-tailscale.yaml b/argocd/manifests/dex/ingress-tailscale.yaml new file mode 100644 index 0000000..4fc1958 --- /dev/null +++ b/argocd/manifests/dex/ingress-tailscale.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: dex-tailscale + namespace: dex + annotations: + tailscale.com/proxy-class: "default" + tailscale.com/proxy-group: "ingress" + gethomepage.dev/enabled: "true" + gethomepage.dev/name: "Dex" + gethomepage.dev/group: "Infrastructure" + gethomepage.dev/icon: "mdi-shield-key" + gethomepage.dev/description: "OIDC identity provider" + gethomepage.dev/href: "https://dex.ops.eblu.me" + gethomepage.dev/pod-selector: "app=dex" +spec: + ingressClassName: tailscale + defaultBackend: + service: + name: dex + port: + number: 5556 + tls: + - hosts: + - dex diff --git a/argocd/manifests/dex/kustomization.yaml b/argocd/manifests/dex/kustomization.yaml new file mode 100644 index 0000000..cffcba8 --- /dev/null +++ b/argocd/manifests/dex/kustomization.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: dex +resources: + - external-secret.yaml + - deployment.yaml + - service.yaml + - ingress-tailscale.yaml diff --git a/argocd/manifests/dex/service.yaml b/argocd/manifests/dex/service.yaml new file mode 100644 index 0000000..f29a20d --- /dev/null +++ b/argocd/manifests/dex/service.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: dex + namespace: dex +spec: + selector: + app: dex + ports: + - name: http + port: 5556 + targetPort: 5556 diff --git a/argocd/manifests/grafana-config/external-secret-dex-oauth.yaml b/argocd/manifests/grafana-config/external-secret-dex-oauth.yaml new file mode 100644 index 0000000..c2c4261 --- /dev/null +++ b/argocd/manifests/grafana-config/external-secret-dex-oauth.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: grafana-dex-oauth + namespace: monitoring +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: grafana-dex-oauth + creationPolicy: Owner + template: + data: + GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: "{{ .clientSecret }}" + data: + - secretKey: clientSecret + remoteRef: + key: "Dex (blumeops)" + property: grafana-client-secret diff --git a/argocd/manifests/grafana-config/kustomization.yaml b/argocd/manifests/grafana-config/kustomization.yaml index 18deacc..59e4e19 100644 --- a/argocd/manifests/grafana-config/kustomization.yaml +++ b/argocd/manifests/grafana-config/kustomization.yaml @@ -6,6 +6,7 @@ namespace: monitoring resources: - ingress-tailscale.yaml - external-secret-admin.yaml + - external-secret-dex-oauth.yaml - external-secret-teslamate-datasource.yaml # Dashboard ConfigMaps - discovered by Grafana sidecar via label grafana_dashboard=1 - dashboards/configmap-borgmatic.yaml diff --git a/argocd/manifests/grafana/values.yaml b/argocd/manifests/grafana/values.yaml index f38cd03..24c406c 100644 --- a/argocd/manifests/grafana/values.yaml +++ b/argocd/manifests/grafana/values.yaml @@ -12,6 +12,8 @@ admin: envFromSecrets: - name: grafana-teslamate-datasource optional: true + - name: grafana-dex-oauth + optional: true # Persistence with PVC for SQLite database persistence: @@ -24,10 +26,22 @@ persistence: # Grafana configuration via grafana.ini grafana.ini: server: - root_url: https://grafana.tail8d86e.ts.net + root_url: https://grafana.ops.eblu.me security: # Embedding disabled - iframe approach didn't work well for Homepage allow_embedding: false + auth.generic_oauth: + enabled: true + name: Dex + client_id: grafana + client_secret: $__env{GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET} + scopes: openid profile email + auth_url: https://dex.ops.eblu.me/auth + token_url: https://dex.ops.eblu.me/token + api_url: https://dex.ops.eblu.me/userinfo + allow_sign_up: true + role_attribute_path: "'Admin'" + auto_login: false analytics: check_for_updates: false reporting_enabled: false diff --git a/containers/dex/default.nix b/containers/dex/default.nix new file mode 100644 index 0000000..08f6926 --- /dev/null +++ b/containers/dex/default.nix @@ -0,0 +1,28 @@ +# Nix-built Dex OIDC identity provider +# Uses nixpkgs dex-oidc package with Kubernetes CRD storage backend +# Built with dockerTools.buildLayeredImage for efficient layer caching +{ pkgs ? import { } }: + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/dex"; + tag = "latest"; + + contents = [ + pkgs.dex-oidc + pkgs.cacert + pkgs.tzdata + ]; + + config = { + Entrypoint = [ "${pkgs.dex-oidc}/bin/dex" ]; + Cmd = [ "serve" "/etc/dex/cfg/config.yaml" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + ]; + ExposedPorts = { + "5556/tcp" = { }; + }; + User = "65534"; + }; +} diff --git a/docs/changelog.d/feature-dex-oidc.feature.md b/docs/changelog.d/feature-dex-oidc.feature.md new file mode 100644 index 0000000..a441fad --- /dev/null +++ b/docs/changelog.d/feature-dex-oidc.feature.md @@ -0,0 +1 @@ +Deploy Dex OIDC identity provider on ringtail with Grafana as first SSO client.