Deploy Dex OIDC identity provider with Grafana SSO (#222)

## 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
This commit is contained in:
Erich Blume 2026-02-19 20:24:24 -08:00
commit 0cdc143227
12 changed files with 244 additions and 1 deletions

View file

@ -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"

18
argocd/apps/dex.yaml Normal file
View file

@ -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

View file

@ -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: {}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,13 @@
---
apiVersion: v1
kind: Service
metadata:
name: dex
namespace: dex
spec:
selector:
app: dex
ports:
- name: http
port: 5556
targetPort: 5556

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 <nixpkgs> { } }:
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";
};
}

View file

@ -0,0 +1 @@
Deploy Dex OIDC identity provider on ringtail with Grafana as first SSO client.