Deploy Authentik identity provider (C2 Mikado) #227

Merged
eblume merged 23 commits from feature/deploy-authentik into main 2026-02-20 12:56:00 -08:00
25 changed files with 148 additions and 427 deletions
Showing only changes of commit 7ac7c6a3e5 - Show all commits

Decommission Dex: remove all references, replace with Authentik

- Delete dex manifests, ArgoCD app, container build, and reference doc
- Remove dex from Caddy reverse proxy config
- Create authentik.md reference doc
- Rewrite federated-login.md for Authentik architecture
- Update grafana, forgejo, ringtail, harden-zot-registry docs
- Update services-check: replace dex health/pod checks with authentik
- Fix all broken [[dex]] wiki-links

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Erich Blume 2026-02-20 12:51:09 -08:00

View file

@ -79,9 +79,6 @@ 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: authentik
host: "authentik.{{ caddy_domain }}"
backend: "https://authentik.tail8d86e.ts.net"

View file

@ -1,18 +0,0 @@
---
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

@ -1,53 +0,0 @@
---
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

@ -1,55 +0,0 @@
---
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

@ -1,26 +0,0 @@
---
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

@ -1,9 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dex
resources:
- external-secret.yaml
- deployment.yaml
- service.yaml
- ingress-tailscale.yaml

View file

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

View file

@ -1,22 +0,0 @@
---
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,8 +6,8 @@ namespace: monitoring
resources:
- ingress-tailscale.yaml
- external-secret-admin.yaml
- external-secret-authentik-oauth.yaml
- external-secret-teslamate-datasource.yaml
- external-secret-authentik-oauth.yaml
# Dashboard ConfigMaps - discovered by Grafana sidecar via label grafana_dashboard=1
- dashboards/configmap-borgmatic.yaml
- dashboards/configmap-devpi.yaml

View file

@ -1,28 +0,0 @@
# 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

@ -21,5 +21,5 @@ Understanding-oriented content explaining the "why" behind BlumeOps design decis
| Article | Description |
|---------|-------------|
| [[architecture]] | How all the pieces fit together |
| [[federated-login]] | How SSO works across BlumeOps (Dex + Forgejo) |
| [[federated-login]] | How SSO works across BlumeOps (Authentik) |
| [[security-model]] | Network security, secrets, and access control |

View file

@ -1,7 +1,7 @@
---
title: Federated Login
modified: 2026-02-19
last-reviewed: 2026-02-19
modified: 2026-02-20
last-reviewed: 2026-02-20
tags:
- explanation
- security
@ -23,65 +23,53 @@ Without centralized authentication, every service manages its own users independ
- **No single sign-on** — logging into Grafana doesn't help you access ArgoCD
- **Inconsistent security** — some services have auth, some don't, and there's no central audit trail
## The Solution: Dex + Forgejo
## The Solution: Authentik
BlumeOps uses a two-layer federated authentication model:
BlumeOps uses [[authentik]] as the central OIDC identity provider. Authentik is the **source of truth** for user identity — users are created and managed in Authentik, and services authenticate against it via OpenID Connect.
1. **[[dex]]** is the OIDC identity provider (IdP). Services like [[grafana]] delegate their login flow to Dex using the OpenID Connect protocol. Dex issues standardized tokens that carry user identity.
2. **[[forgejo]]** is the upstream identity source. Dex doesn't store users itself — it delegates authentication to Forgejo via OAuth2. Users log in with their Forgejo credentials.
This separation is intentional. Dex handles the OIDC protocol (token issuance, discovery endpoints, client registration), while Forgejo handles user management (accounts, passwords, 2FA). Each does what it's good at.
This is a deliberate choice: Authentik provides a full-featured identity management UI, Blueprint-driven GitOps configuration, and support for multiple authentication protocols. Services like [[grafana]] delegate their login flow to Authentik using OIDC, and Authentik issues standardized tokens that carry user identity.
## The Login Flow
When a user clicks "Sign in with Dex" on Grafana:
When a user clicks "Sign in with Authentik" on Grafana:
```
1. Grafana redirects browser to Dex (dex.ops.eblu.me/auth)
2. Dex redirects browser to Forgejo (forge.ops.eblu.me/login/oauth/authorize)
3. User logs in at Forgejo (or is already logged in)
4. Forgejo redirects back to Dex (dex.ops.eblu.me/callback)
5. Dex issues an OIDC token
6. Dex redirects back to Grafana (grafana.ops.eblu.me/login/generic_oauth)
7. Grafana accepts the token, user is logged in
1. Grafana redirects browser to Authentik (authentik.ops.eblu.me/application/o/authorize/)
2. User logs in at Authentik (or is already logged in)
3. Authentik issues an OIDC token
4. Authentik redirects back to Grafana (grafana.ops.eblu.me/login/generic_oauth)
5. Grafana accepts the token, user is logged in
```
After step 3, if the user is already logged into Forgejo, the remaining steps happen instantly — it feels like a single click.
## Why Not Just Use Forgejo Directly?
Forgejo supports OAuth2 provider mode, so services could authenticate against it directly. Dex adds a layer of indirection, which provides:
- **Protocol translation** — Dex speaks OIDC (a standardized protocol) to downstream services. Not all services speak the same OAuth2 dialect that Forgejo does, but most speak OIDC.
- **Connector flexibility** — Dex can federate to multiple identity sources simultaneously. If a Google or GitHub connector is added later, downstream services don't change at all — they still talk to Dex.
- **Separation of concerns** — Forgejo is a git forge first. Its OAuth2 provider is a secondary feature. Dex is purpose-built for identity federation and handles edge cases (token refresh, JWKS rotation, discovery) more robustly.
For a single-user homelab, the indirection is admittedly overkill today. But it keeps the architecture clean for future growth — adding a second identity source or a new downstream service is a config change, not an architecture change.
If the user is already logged into Authentik, the flow happens instantly — it feels like a single click.
## Break-Glass Access
Every service that uses Dex SSO also keeps a local admin login. If Dex goes down (or ringtail is offline), recovery works through:
Every service that uses Authentik SSO also keeps a local admin login. If Authentik goes down (or ringtail is offline), recovery works through:
1. SSH to indri
2. Log into ArgoCD with local admin password (from 1Password)
3. Fix whatever is broken
Dex is additive — it's a convenience layer, not a hard dependency. Services never lose their local auth capability.
Authentik is additive — it's a convenience layer, not a hard dependency. Services never lose their local auth capability.
## Cross-Cluster Communication
Dex runs on [[ringtail]]'s k3s cluster while most services run on indri's minikube. This is deliberate — the IdP is independent of the main services cluster. Communication happens via the Tailscale network:
Authentik runs on [[ringtail]]'s k3s cluster while most services run on indri's minikube. This is deliberate — the IdP is independent of the main services cluster. Communication happens via the Tailscale network:
- Grafana (minikube) → `dex.ops.eblu.me` → Caddy (indri) → Tailscale → Dex (ringtail k3s)
- Browser redirects go through `dex.ops.eblu.me` and `forge.ops.eblu.me`, both resolved via Caddy
- Grafana (minikube) → `authentik.ops.eblu.me` → Caddy (indri) → Tailscale → Authentik (ringtail k3s)
- Browser redirects go through `authentik.ops.eblu.me`, resolved via Caddy
No k8s-internal DNS crosses cluster boundaries. Everything uses the `*.ops.eblu.me` domain.
## Future Work
- **Forgejo OIDC:** Make Forgejo an OIDC client of Authentik (deferred — existing `eblume` account needs careful migration)
- **Additional services:** ArgoCD, Miniflux, Immich, Zot (see [[harden-zot-registry]])
## Related
- [[dex]] - OIDC identity provider reference
- [[forgejo]] - Upstream OAuth2 provider
- [[authentik]] - OIDC identity provider reference
- [[grafana]] - First OIDC client
- [[security-model]] - Network security and access control
- [[adopt-oidc-provider]] - Implementation plan (completed)
- [[deploy-authentik]] - Deployment how-to

View file

@ -1,6 +1,5 @@
---
title: Deploy Authentik Identity Provider
status: active
modified: 2026-02-20
requires:
- build-authentik-container
@ -16,7 +15,7 @@ tags:
# Deploy Authentik Identity Provider
Replace [[dex]] with [Authentik](https://goauthentik.io/) as the SSO identity provider. Authentik is the **source of truth** for user identity in BlumeOps. Users are created and managed in Authentik; services authenticate against it via OIDC. Forgejo federation is deferred to a future effort (existing `eblume` account has extensive automations that need careful migration).
Replace Dex with [Authentik](https://goauthentik.io/) as the SSO identity provider. Authentik is the **source of truth** for user identity in BlumeOps. Users are created and managed in Authentik; services authenticate against it via OIDC. Forgejo federation is deferred to a future effort (existing `eblume` account has extensive automations that need careful migration).
## Architecture Decisions
@ -33,7 +32,7 @@ Replace [[dex]] with [Authentik](https://goauthentik.io/) as the SSO identity pr
## What Was Done
1. Built Nix container image (`v1.1.0-nix`) — `pkgs.authentik` + `coreutils` + `bashInteractive`
1. Built Nix container image (`v1.1.2-nix`) — `pkgs.authentik` + `coreutils` + `bashInteractive` + entrypoint wrapper for blueprint symlinks
2. Created 1Password item "Authentik (blumeops)" with secret key and DB credentials
3. Provisioned `authentik` database and CNPG managed role on `blumeops-pg`
4. Deployed to ringtail k3s: server, worker, Redis (3 deployments)
@ -41,6 +40,8 @@ Replace [[dex]] with [Authentik](https://goauthentik.io/) as the SSO identity pr
6. Tailscale Ingress at `authentik.tail8d86e.ts.net`
7. Caddy reverse proxy at `authentik.ops.eblu.me`
8. Completed first-run wizard (admin account created)
9. Migrated Grafana OIDC from Dex to Authentik (Blueprint-driven)
10. Decommissioned Dex (ArgoCD app deleted, manifests removed, Caddy entry removed)
## URLs
@ -55,7 +56,7 @@ Replace [[dex]] with [Authentik](https://goauthentik.io/) as the SSO identity pr
## Related
- [[dex]] — Current IdP (to be replaced by [[migrate-grafana-to-authentik]])
- [[authentik]] — OIDC identity provider
- [[federated-login]] — How authentication works across BlumeOps
- [[adopt-oidc-provider]] — Dex deployment plan (completed)
- [[ringtail]] — Target cluster

View file

@ -1,6 +1,5 @@
---
title: Migrate Grafana to Authentik
status: active
modified: 2026-02-20
tags:
- how-to
@ -12,65 +11,38 @@ tags:
Move Grafana's OIDC authentication from Dex to Authentik, then decommission Dex.
## Context
## What Was Done
Discovered while attempting [[deploy-authentik]]: Authentik is deployed and running, but no services use it yet. Grafana is the first client to migrate. Once Grafana is off Dex, Dex has no remaining clients and can be decommissioned.
### Blueprint loading fix
## What to Do
The Nix-built container hardcoded `blueprints_dir` to its Nix store path, making custom blueprints invisible. Fixed by adding a wrapper entrypoint that symlinks built-in blueprint dirs from `/nix/store/*authentik-django*/blueprints/` into `/blueprints/` at container start, with `AUTHENTIK_BLUEPRINTS_DIR=/blueprints` set in the container env. The `/blueprints` dir is created world-writable by `extraCommands` so user 65534 can write symlinks. Also fixed the `!Env` tag syntax in the blueprint YAML — `!Env` takes a scalar, not a sequence (`!Env FOO` not `!Env [FOO]`).
### Authentik configuration (via API, then capture as Blueprint)
### Authentik configuration (via Blueprint)
1. Create an `admins` group in Authentik
2. Ensure user `blume.erich@gmail.com` is in the `admins` group
3. Create an OAuth2/OIDC provider for Grafana (client ID: `grafana`, redirect URIs for both `grafana.ops.eblu.me` and `grafana.tail8d86e.ts.net`)
4. Create an Application for Grafana linked to the provider, gated to the `admins` group
5. Store the client secret in 1Password "Authentik (blumeops)" as `grafana-client-secret`
6. Capture the configuration as an Authentik Blueprint YAML in the manifests
- Blueprint at `argocd/manifests/authentik/configmap-blueprint.yaml` defines: `admins` group, Grafana OAuth2 provider (client ID: `grafana`), Grafana application, and policy binding
- Blueprint mounted as ConfigMap into worker at `/blueprints/custom/`
- `grafana-client-secret` stored in 1Password "Authentik (blumeops)"
- API token stored as `api-token` in same item
### Grafana configuration
1. Update `argocd/manifests/grafana/values.yaml` — change `auth.generic_oauth` from Dex to Authentik endpoints
2. Replace `external-secret-dex-oauth.yaml` with one that pulls from "Authentik (blumeops)" instead of "Dex (blumeops)"
3. Sync Grafana via ArgoCD and verify SSO login works
- `values.yaml` updated to point at Authentik OIDC endpoints (`authentik.ops.eblu.me`)
- `external-secret-authentik-oauth.yaml` pulls client secret from "Authentik (blumeops)"
- Old Dex OAuth user deleted from Grafana (different `auth_id` caused "user already exists")
### Dex decommission
1. Delete ArgoCD app `dex`
2. Remove `argocd/manifests/dex/` and `argocd/apps/dex.yaml`
3. Remove `dex` entry from Caddy reverse proxy (`ansible/roles/caddy/defaults/main.yml`)
4. Provision Caddy to apply the change
- ArgoCD app `dex` deleted (cascade removed k8s resources from ringtail)
- Removed `argocd/manifests/dex/`, `argocd/apps/dex.yaml`, `external-secret-dex-oauth.yaml`
- Removed `dex` entry from Caddy reverse proxy config
## What Was Done So Far
## Lessons Learned
### Completed
- API token created and stored in 1Password "Authentik (blumeops)" field `api-token`
- `grafana-client-secret` generated and stored in 1Password "Authentik (blumeops)"
- Blueprint YAML created at `argocd/manifests/authentik/configmap-blueprint.yaml` defining: admins group, Grafana OAuth2 provider, Grafana application, and policy binding
- Blueprint ConfigMap mounted into worker at `/blueprints/custom/`
- ExternalSecret updated to pull `grafana-client-secret` from 1Password
- Grafana `values.yaml` updated to point at Authentik OIDC endpoints
- `external-secret-authentik-oauth.yaml` created to replace `external-secret-dex-oauth.yaml`
### Blocked: Blueprint not loading
**Root cause:** The Nix-built container hardcodes `blueprints_dir` to `/nix/store/3h1g...authentik-django-2025.10.1/blueprints` in its `default.yml`. Custom blueprints mounted at `/blueprints/custom/` are invisible because that path is not on the search path.
**Fix options:**
1. Set env var `AUTHENTIK_BLUEPRINTS_DIR=/blueprints` and mount custom blueprints alongside copies/symlinks of the built-in ones — risky, could break built-in blueprints if the path doesn't include them.
2. Mount the custom blueprint ConfigMap directly into the Nix store blueprints path (e.g., `/nix/store/.../blueprints/custom/`) — fragile, path changes on rebuild.
3. Use the API to apply the configuration and skip file-based blueprints for now. Store the API calls in a mise task for reproducibility.
4. Patch the Nix container to set a writable `blueprints_dir` or create a wrapper that symlinks.
**Recommendation:** Option 4 (patch container) or option 1 (override env var) are the cleanest. Need to test whether `AUTHENTIK_BLUEPRINTS_DIR` is respected and whether built-in blueprints still load from the Nix store path when overridden.
## Notes
- Authentik API token stored as `api-token` in 1Password "Authentik (blumeops)".
- The `admins` group and Grafana provider/application created via API during investigation were cleaned up (deleted).
- `buildLayeredImage`'s `extraCommands` can't access Nix store paths from `contents` — they're in separate layers. Use a runtime entrypoint wrapper for symlinks instead.
- Authentik `!Env` tag takes a bare scalar (`!Env FOO`), not a YAML sequence (`!Env [FOO]`). The `!Find` tag does use sequences.
- When migrating OAuth providers, the subject ID (`auth_id`) changes. Existing Grafana users must be deleted before the new provider can recreate them.
## Related
- [[deploy-authentik]] — Parent goal
- [[grafana]] — Grafana reference
- [[dex]] — Current IdP being replaced

View file

@ -67,7 +67,7 @@ Migration and transition plans for upcoming infrastructure changes.
## Authentik
Mikado chain for replacing Dex with Authentik. Track progress with `mise run docs-mikado deploy-authentik`.
Mikado chain for deploying Authentik. Track progress with `mise run docs-mikado deploy-authentik`.
- [[deploy-authentik]]
- [[build-authentik-container]]

View file

@ -10,7 +10,7 @@ tags:
# Plan: Adopt OIDC Identity Provider
> **Status:** Completed (2026-02-19) — Phase 1 (Dex + Grafana)
> **Status:** Completed (2026-02-19) — Phase 1 (Dex + Grafana). Dex was subsequently replaced by [[authentik]] (see [[deploy-authentik]]).
> **PR:** #222
## Background
@ -98,7 +98,7 @@ Key design decisions:
## Related
- [[dex]] - Service reference card
- [[authentik]] - Current OIDC identity provider (replaced Dex)
- [[federated-login]] - How authentication works across BlumeOps
- [[harden-zot-registry]] - Future OIDC client
- [[forgejo]] - Upstream OAuth2 provider

View file

@ -15,4 +15,4 @@ Plans that have been fully implemented and verified. Kept for historical referen
| [[adopt-dagger-ci]] | 2026-02-11 | Adopt Dagger as CI/CD build engine (Phases 13) |
| [[segment-home-network]] | 2026-02-14 | Manual three-network segmentation for UniFi Express 7 |
| [[operationalize-reolink-camera]] | 2026-02-15 | Deploy Frigate NVR stack with Mosquitto, Ntfy, and frigate-notify |
| [[adopt-oidc-provider]] | 2026-02-19 | Deploy Dex OIDC identity provider with Forgejo backend and Grafana SSO |
| [[adopt-oidc-provider]] | 2026-02-19 | Deploy OIDC identity provider with Grafana SSO (initially Dex, replaced by Authentik) |

View file

@ -65,7 +65,7 @@ Zot supports native OIDC authentication with a built-in API key feature designed
"oidc": {
"name": "BlumeOps",
"credentialsFile": "/Users/erichblume/.config/zot/oidc-credentials.json",
"issuer": "https://dex.ops.eblu.me",
"issuer": "https://authentik.ops.eblu.me",
"scopes": ["openid", "profile", "email"]
}
}
@ -97,7 +97,7 @@ Zot supports native OIDC authentication with a built-in API key feature designed
The OIDC credentials file (client ID and secret) is deployed by Ansible from 1Password — never committed to the repo.
**CI push flow after setup:**
1. Log in to zot UI via browser (OIDC redirect to Dex)
1. Log in to zot UI via browser (OIDC redirect to Authentik)
2. Generate an API key: `POST /zot/auth/apikey` with label `forgejo-ci`, scoped to `blumeops/**`
3. Store the key in 1Password (`op://blumeops/zot-ci-apikey/credential`)
4. CI uses the key: `docker login -u eblume -p zak_... registry.ops.eblu.me`
@ -122,7 +122,7 @@ The push-side approach is pragmatic: it prevents accidental overwrites in the no
The `ansible/roles/zot/` role needs:
- **New template:** `oidc-credentials.json.j2` (client ID and secret for the Dex OIDC client)
- **New template:** `oidc-credentials.json.j2` (client ID and secret for the Authentik OIDC client)
- **Updated config template:** `config.json.j2` gains `http.auth` (openid + apikey) and `accessControl` sections
- **Updated config template:** `config.json.j2` gains `externalUrl` set to `https://registry.ops.eblu.me` (required for OIDC callback redirects behind Caddy)
- **New variables:** `zot_oidc_client_id` and `zot_oidc_client_secret` sourced from 1Password in the playbook's `pre_tasks`
@ -146,7 +146,7 @@ The minikube containerd config (`ansible/roles/minikube/tasks/main.yml`) current
## Execution Steps
1. **Prerequisite: OIDC provider is running** (see [[adopt-oidc-provider]])
- Dex (or chosen provider) is deployed and serving `https://dex.ops.eblu.me`
- Authentik (or chosen provider) is deployed and serving `https://authentik.ops.eblu.me`
- A zot OIDC client is registered with the provider
2. **Update Ansible role**
@ -161,7 +161,7 @@ The minikube containerd config (`ansible/roles/minikube/tasks/main.yml`) current
- Verify unauthenticated push fails: `skopeo copy ... docker://registry.ops.eblu.me/blumeops/test:fail` (should get 401)
4. **Set up OIDC login and generate CI API key**
- Log in to zot UI via browser (OIDC flow through Dex)
- Log in to zot UI via browser (OIDC flow through Authentik)
- Generate an API key for CI use, store in 1Password
- Verify authenticated push works: `docker login -u eblume -p zak_... registry.ops.eblu.me`
@ -178,7 +178,7 @@ The minikube containerd config (`ansible/roles/minikube/tasks/main.yml`) current
- [ ] Anonymous pull works (k8s pods, containerd, curl)
- [ ] Pull-through caching still works (pull an uncached image from docker.io)
- [ ] Unauthenticated push is rejected (401)
- [ ] OIDC browser login works (redirect to Dex and back)
- [ ] OIDC browser login works (redirect to Authentik and back)
- [ ] API key generation works from zot UI
- [ ] Authenticated push with API key succeeds
- [ ] Pushing a duplicate version tag fails (immutability check)

View file

@ -68,7 +68,7 @@ Sync order: `1password-connect-ringtail` -> `external-secrets-crds-ringtail` ->
| [[frigate]] | `frigate` | NVR with GPU-accelerated detection (RTX 4080) |
| [[frigate]]-notify | `frigate` | MQTT-to-ntfy alert bridge |
| Mosquitto | `mqtt` | MQTT broker for Frigate events |
| [[dex]] | `dex` | OIDC identity provider (Forgejo-backed) |
| [[authentik]] | `authentik` | OIDC identity provider |
| [[ntfy]] | `ntfy` | Push notification server |
| nvidia-device-plugin | `nvidia-device-plugin` | Exposes GPU to pods via CDI + nvidia RuntimeClass |

View file

@ -37,7 +37,7 @@ Individual service reference cards with URLs and configuration details.
| [[zot]] | Container registry | indri |
| [[devpi]] | PyPI caching proxy | k8s |
| [[cv]] | Resume / CV site | k8s |
| [[dex]] | OIDC identity provider | k8s (ringtail) |
| [[authentik]] | OIDC identity provider | k8s (ringtail) |
| [[docs]] | Documentation site (Quartz) | k8s |
| [[flyio-proxy]] | Public reverse proxy (Fly.io + Tailscale) | Fly.io |
| [[automounter]] | SMB share automounter | indri |

View file

@ -0,0 +1,78 @@
---
title: Authentik
modified: 2026-02-20
tags:
- service
- security
- oidc
---
# Authentik
OIDC identity provider for BlumeOps. Authentik is the **source of truth** for user identity — users are created and managed in Authentik, and services authenticate against it via OIDC.
## Quick Reference
| Property | Value |
|----------|-------|
| **URL** | https://authentik.ops.eblu.me |
| **Admin UI** | https://authentik.ops.eblu.me/if/admin/ |
| **Tailscale URL** | https://authentik.tail8d86e.ts.net |
| **Namespace** | `authentik` |
| **Cluster** | k3s (ringtail) |
| **Image** | `registry.ops.eblu.me/blumeops/authentik:v1.1.2-nix` |
| **Manifests** | `argocd/manifests/authentik/` |
| **Container build** | `containers/authentik/default.nix` |
## Architecture
Authentik runs on [[ringtail]]'s k3s cluster, isolated from the main services on indri's minikube. This means the IdP is independent of the minikube cluster lifecycle.
Three deployments:
- **server** — HTTP/HTTPS interface, handles OIDC flows
- **worker** — Background tasks, blueprint application
- **redis** — Caching, sessions, task queue
## Database
Uses the shared CNPG `blumeops-pg` cluster on [[indri]], accessed cross-cluster via `pg.ops.eblu.me:5432`. Database `authentik` with managed role.
## Blueprints
Authentik configuration is managed via Blueprints (YAML) stored as a ConfigMap mounted into the worker at `/blueprints/custom/`. Current blueprints define:
- `admins` group
- Grafana OAuth2 provider (client ID: `grafana`)
- Grafana application with group-based policy binding
Blueprint file: `argocd/manifests/authentik/configmap-blueprint.yaml`
## OIDC Clients
| Client | Status |
|--------|--------|
| [[grafana]] | Active |
Future clients: [[forgejo]], [[argocd]], [[miniflux]], [[zot]]
## Secrets
Injected via [[external-secrets]] from the "Authentik (blumeops)" 1Password item.
| 1Password Field | Purpose |
|-----------------|---------|
| `secret-key` | Authentik secret key |
| `db-password` | PostgreSQL password |
| `grafana-client-secret` | OIDC client secret for Grafana |
| `api-token` | Authentik API token |
## Container Image
Nix-built via `dockerTools.buildLayeredImage`. The entrypoint wrapper symlinks built-in blueprint directories from the Nix store into `/blueprints/` at runtime, allowing custom blueprints to coexist with defaults. `AUTHENTIK_BLUEPRINTS_DIR=/blueprints` overrides the hardcoded Nix store path.
## Related
- [[federated-login]] - How authentication works across BlumeOps
- [[grafana]] - First OIDC client
- [[deploy-authentik]] - Deployment how-to
- [[external-secrets]] - Secrets injection from 1Password

View file

@ -1,89 +0,0 @@
---
title: Dex
modified: 2026-02-19
tags:
- service
- security
- oidc
---
# Dex
OIDC identity provider for BlumeOps. Dex federates authentication — downstream services (Grafana, future ArgoCD, etc.) delegate login to Dex, and Dex delegates to [[forgejo]] as the upstream OAuth2 provider.
## Quick Reference
| Property | Value |
|----------|-------|
| **URL** | https://dex.ops.eblu.me |
| **Tailscale URL** | https://dex.tail8d86e.ts.net |
| **Namespace** | `dex` |
| **Cluster** | k3s (ringtail) |
| **Image** | `registry.ops.eblu.me/blumeops/dex:v1.0.0-nix` |
| **Upstream** | https://github.com/dexidp/dex |
| **Manifests** | `argocd/manifests/dex/` |
| **Container build** | `containers/dex/default.nix` |
## Architecture
Dex runs on [[ringtail]]'s k3s cluster, isolated from the main services on indri's minikube. This means the IdP is independent of the minikube cluster lifecycle — if minikube goes down, Dex stays up and services can still authenticate once restored.
```
User Browser
|
v
Grafana (indri/minikube) --OIDC--> Dex (ringtail/k3s) --OAuth2--> Forgejo (indri/native)
^ |
| |
+---------------------- redirect back with token -------------------+
```
Cross-cluster communication works because Grafana reaches Dex via `https://dex.ops.eblu.me` (Caddy → Tailscale → ringtail), not k8s-internal DNS.
## Identity Source
Dex uses a **Gitea connector** pointed at [[forgejo]] (`https://forge.ops.eblu.me`). Users authenticate with their Forgejo credentials. There are no static passwords — user management happens entirely in Forgejo.
This means adding a new user to BlumeOps SSO is just creating a Forgejo account.
## Storage
SQLite3 with an `emptyDir` volume. This stores refresh tokens and auth codes. A pod restart invalidates active sessions (users re-login), which is acceptable for a homelab. No PVC needed.
## OIDC Clients
| Client | Redirect URIs | Status |
|--------|---------------|--------|
| [[grafana]] | `grafana.ops.eblu.me/login/generic_oauth`, `grafana.tail8d86e.ts.net/login/generic_oauth` | Active |
Future clients: [[argocd]], [[forgejo]], [[miniflux]], [[zot]]
## Secrets
All sensitive configuration is injected via [[external-secrets]] from the "Dex (blumeops)" 1Password item. The entire `config.yaml` is templated in the ExternalSecret — nothing sensitive is committed to git.
| 1Password Field | Purpose |
|-----------------|---------|
| `forgejo-client-id` | OAuth2 app client ID from Forgejo |
| `forgejo-client-secret` | OAuth2 app client secret from Forgejo |
| `grafana-client-secret` | OIDC client secret for Grafana |
## Endpoints
| Path | Purpose |
|------|---------|
| `/.well-known/openid-configuration` | OIDC discovery |
| `/auth` | Authorization (browser redirect) |
| `/token` | Token exchange |
| `/userinfo` | User info |
| `/keys` | JWKS (public keys) |
| `/callback` | OAuth2 callback from Forgejo |
| `/healthz` | Health check |
## Related
- [[federated-login]] - How authentication works across BlumeOps
- [[forgejo]] - Upstream OAuth2 provider
- [[grafana]] - First OIDC client
- [[routing]] - How Dex is exposed via Caddy
- [[external-secrets]] - Secrets injection from 1Password

View file

@ -77,11 +77,9 @@ The Ansible role authenticates to the Forgejo API using a Personal Access Token
This is a bootstrapping requirement - the PAT enables IaC for all other secrets.
## OAuth2 Provider for Dex
## Identity Provider
Forgejo acts as the upstream OAuth2 provider for [[dex]], the BlumeOps OIDC identity provider. An OAuth2 application is registered in Forgejo's Site Administration with a redirect URI pointing to Dex's callback (`https://dex.ops.eblu.me/callback`). Client credentials are stored in 1Password ("Dex (blumeops)").
This means Forgejo accounts are the source of truth for BlumeOps SSO identity. Adding a user to any Dex-integrated service (currently [[grafana]]) is just creating a Forgejo account.
[[authentik]] is the BlumeOps OIDC identity provider and source of truth for user identity. Forgejo will eventually authenticate against Authentik as an OIDC client, with user provisioning managed in Authentik. This migration is deferred — the existing `eblume` account has extensive automations that need careful migration.
## Future: Public Access
@ -104,5 +102,5 @@ See [[expose-service-publicly]] for the full howto and dynamic service checklist
## Related
- [[argocd]] - Uses Forgejo as git source
- [[dex]] - OIDC identity provider (Forgejo is the upstream OAuth2 source)
- [[authentik]] - OIDC identity provider
- [[zot]] - Container registry for built images

View file

@ -24,10 +24,10 @@ Dashboards and visualization for BlumeOps observability.
Grafana supports two login methods:
- **SSO via [[dex]]** — federated login through [[forgejo]] (`auth.generic_oauth`). Users click "Sign in with Dex", authenticate at Forgejo, and are redirected back as Admin.
- **Local admin** — break-glass login using the password from 1Password ("Grafana (blumeops)"). Always available if Dex is down.
- **SSO via [[authentik]]** — OIDC login through Authentik (`auth.generic_oauth`). Users click "Sign in with Authentik", authenticate at Authentik, and are redirected back as Admin.
- **Local admin** — break-glass login using the password from 1Password ("Grafana (blumeops)"). Always available if Authentik is down.
The OIDC client secret is injected via [[external-secrets]] (`grafana-dex-oauth` secret in monitoring namespace).
The OIDC client secret is injected via [[external-secrets]] (`grafana-authentik-oauth` secret in monitoring namespace).
## Datasources
@ -57,7 +57,7 @@ Optional annotation: `grafana_folder: "FolderName"`
## Related
- [[dex]] - OIDC identity provider for SSO
- [[authentik]] - OIDC identity provider for SSO
- [[prometheus]] - Metrics datasource
- [[loki]] - Logs datasource
- [[alloy|Alloy]] - Data collector

View file

@ -81,7 +81,7 @@ check_http "Immich" "https://photos.ops.eblu.me/"
check_http "Navidrome" "https://dj.ops.eblu.me/"
check_http "CV" "https://cv.ops.eblu.me/"
check_http "Ntfy" "https://ntfy.ops.eblu.me/v1/health"
check_http "Dex" "https://dex.ops.eblu.me/healthz"
check_http "Authentik" "https://authentik.ops.eblu.me/-/health/live/"
check_http "Frigate" "https://nvr.ops.eblu.me/api/version"
echo ""
@ -96,7 +96,7 @@ echo ""
echo "Ringtail k3s pods:"
check_service "mosquitto" "kubectl --context=k3s-ringtail -n mqtt get pods -l app=mosquitto -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "ntfy" "kubectl --context=k3s-ringtail -n ntfy get pods -l app=ntfy -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "dex" "kubectl --context=k3s-ringtail -n dex get pods -l app=dex -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "authentik" "kubectl --context=k3s-ringtail -n authentik get pods -l component=server -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "frigate" "kubectl --context=k3s-ringtail -n frigate get pods -l app=frigate -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "frigate-notify" "kubectl --context=k3s-ringtail -n frigate get pods -l app=frigate-notify -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "nvidia-device-plugin" "kubectl --context=k3s-ringtail -n nvidia-device-plugin get pods -l app=nvidia-device-plugin -o jsonpath='{.items[0].status.phase}' | grep -q Running"