From 3830a954da61c283b68ac3b19ff15a20cd39d22e Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sun, 18 Oct 2026 23:26:48 +0200 Subject: [PATCH] Retire direct Authentik deployment path --- .gitea/workflows/deploy.yml | 48 ------ README.md | 62 ++----- authentik-media-redirect.yaml | 29 ---- authentik-namespace.yaml | 4 - authentik-outpost-path.yaml | 19 --- authentik-secrets.env.example | 4 - ceph-object-user.yaml | 8 - ci-deployer.yaml | 79 --------- create-ci-kubeconfig.sh | 15 -- install.sh | 133 --------------- render.sh | 8 - test.sh | 15 -- update-outpost-images.sh | 42 ----- values.yaml | 311 ---------------------------------- 14 files changed, 18 insertions(+), 759 deletions(-) delete mode 100644 .gitea/workflows/deploy.yml delete mode 100644 authentik-media-redirect.yaml delete mode 100644 authentik-namespace.yaml delete mode 100644 authentik-outpost-path.yaml delete mode 100644 authentik-secrets.env.example delete mode 100644 ceph-object-user.yaml delete mode 100644 ci-deployer.yaml delete mode 100755 create-ci-kubeconfig.sh delete mode 100755 install.sh delete mode 100755 render.sh delete mode 100755 test.sh delete mode 100644 update-outpost-images.sh delete mode 100644 values.yaml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml deleted file mode 100644 index 2a319bd..0000000 --- a/.gitea/workflows/deploy.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Validate and deploy Authentik -on: {push: {}, pull_request: {}} -env: - AUTHENTIK_CHART_VERSION: 2026.8.3 - HELM_VERSION: v4.2.2 - KUBECTL_VERSION: v1.36.3 - KUBERNETES_API: https://host.containers.internal:6443 - KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 - NAMESPACE: authentik -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: | - curl -fsSL -o "$RUNNER_TEMP/helm.tgz" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" - tar -xzf "$RUNNER_TEMP/helm.tgz" -C "$RUNNER_TEMP" --strip-components=1 linux-amd64/helm - echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - - run: bash test.sh - deploy: - if: gitea.ref == 'refs/heads/main' - needs: validate - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: | - curl -fsSL -o "$RUNNER_TEMP/helm.tgz" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" - tar -xzf "$RUNNER_TEMP/helm.tgz" -C "$RUNNER_TEMP" --strip-components=1 linux-amd64/helm - curl -fsSL -o "$RUNNER_TEMP/kubectl" "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" - chmod 700 "$RUNNER_TEMP/helm" "$RUNNER_TEMP/kubectl"; echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - - env: {KUBE_CONFIG_BASE64: "${{ secrets.KUBE_CONFIG_BASE64 }}"} - run: | - test -n "$KUBE_CONFIG_BASE64"; printf %s "$KUBE_CONFIG_BASE64" | base64 -d > "$RUNNER_TEMP/kubeconfig"; chmod 600 "$RUNNER_TEMP/kubeconfig" - export KUBECONFIG="$RUNNER_TEMP/kubeconfig"; kubectl config set-cluster cluster --server="$KUBERNETES_API" --tls-server-name="$KUBERNETES_TLS_SERVER_NAME" - - run: | - export KUBECONFIG="$RUNNER_TEMP/kubeconfig"; ./render.sh "$RUNNER_TEMP/authentik.yaml" - kubectl apply --server-side --force-conflicts --dry-run=server --validate=false -f "$RUNNER_TEMP/authentik.yaml" -f authentik-outpost-path.yaml -f authentik-media-redirect.yaml - kubectl apply --server-side --force-conflicts --validate=false -f "$RUNNER_TEMP/authentik.yaml" -f authentik-outpost-path.yaml -f authentik-media-redirect.yaml - for resource in deployment/authentik-server deployment/authentik-worker statefulset/authentik-postgresql; do - for attempt in {1..180}; do - IFS='|' read -r generation observed desired updated ready available <<< "$(kubectl -n "$NAMESPACE" get "$resource" -o jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.readyReplicas}|{.status.availableReplicas}')" - echo "$resource $attempt/180: $updated/$desired updated, $ready/$desired ready" - [[ "$observed" == "$generation" && "$updated" == "$desired" && "$ready" == "$desired" && "$available" == "$desired" ]] && break - [[ "$attempt" == 180 ]] && { echo "$resource rollout timed out" >&2; exit 1; } - sleep 5 - done - done - curl -fsS --max-time 15 https://auth.brunner.ninja/-/health/live/ >/dev/null diff --git a/README.md b/README.md index 7fc4f12..677db82 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,22 @@ -# Authentik on Kubernetes +# Authentik deployment moved to GitOps -Authentik `2026.8.3` is rendered from the pinned official chart. `values.yaml` -contains only public configuration; the ignored legacy `authentik-values.yaml` -is retained locally. Runtime credentials live in `authentik-runtime` and the -existing PostgreSQL Secret, and neither is readable by CI. +This repository is retained as historical deployment documentation and no +longer writes to Kubernetes. The homelab's Authentik release is reconciled by +Argo CD from the public +[`homelab-gitops/apps/authentik`](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/authentik) +directory using Authentik's maintained upstream Helm chart. -The LDAP, RADIUS, and RAC outposts are managed by Authentik and keep their -image pins in its database. `install.sh` runs `update-outpost-images.sh` after -the chart upgrade so their images match the server release. +The GitOps application pins the chart version, holds public homelab values and +cluster integrations, documents the existing Secret recovery boundary, and +includes the managed-outpost verification procedure. Credentials remain only +in the cluster-managed `authentik-runtime`, `authentik-postgresql`, and +`argocd-oidc` Secrets; they are not stored in either repository. -`authentik-blueprints` declaratively creates the Argo CD OIDC application, -provider, and group claim. Its client secret comes from -`ARGOCD_OIDC_CLIENT_SECRET` in `authentik-runtime`; `install.sh` generates it -once when absent and mirrors it into the labeled `argocd-oidc` Secret. Only the -existing `authentik Admins` group is mapped to Argo CD administrator access. +The LDAP, RADIUS, and RAC Deployments are generated by Authentik's outpost +controller. Their database `container_image` overrides are null, so they follow +the server version supplied by the upstream chart. Argo monitors their +availability without trying to own those generated Deployments. -The same blueprint creates an admin-only forward-auth application for -Kubernetes Dashboard. Its proxy provider contains no committed credential and -is served by the embedded proxy outpost. `install.sh` applies the blueprint and -idempotently attaches the provider to that outpost; it deliberately does not -replace the outpost's complete provider list because other repositories own -those applications. - -Copy `authentik-secrets.env.example` to the ignored `authentik-secrets.env`, -preserve the existing values, and run `./install.sh` for administrator secret -updates. Run `./test.sh` for a secret-free render and schema validation. -The installer refuses a PostgreSQL password mismatch instead of attempting a -blind rotation; rotate the database role and both Kubernetes Secrets together -during a dedicated maintenance window. - -The public Authentik and embedded-outpost routes have no Traefik request-rate -or concurrent-request limit: browser login flows and protected applications can -legitimately generate large bursts. Two server replicas, a PodDisruptionBudget, -and explicit resource requests keep one busy client or node from removing the -only healthy authentication endpoint. - -Run `./create-ci-kubeconfig.sh` and store its final line as the Gitea Actions -secret `KUBE_CONFIG_BASE64`. CI can update only exact existing Authentik chart, -Ingress, Middleware, and event-filter objects; it cannot read Secrets, create -objects, delete objects, or manage the Ceph object user. - -`authentik-client-credentials-event-filter` is a local compatibility patch -introduced on 2026-08-30 and reviewed against upstream `2026.8.3`. It replaces Authentik's private -`providers/oauth2/token/client_credentials.py` module to retain application -password grants while suppressing loopback (`::1`) login-event noise. Recheck -or remove it on every Authentik upgrade because it is not an upstream chart -feature or supported extension interface. +Use the Git history here only when investigating the retired direct Helm/Gitea +Actions deployment. Do not restore that workflow alongside Argo CD, because it +would create a second writer for the same Kubernetes resources. diff --git a/authentik-media-redirect.yaml b/authentik-media-redirect.yaml deleted file mode 100644 index 2788cb2..0000000 --- a/authentik-media-redirect.yaml +++ /dev/null @@ -1,29 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -#apiVersion: traefik.containo.us/v1alpha1 -kind: IngressRoute -metadata: - name: authentik-media-redirect - namespace: authentik -spec: - entryPoints: - - websecure - routes: - - match: Host(`auth.brunner.ninja`) && PathPrefix(`/media/`) - kind: Rule - services: - - name: rook-ceph-rgw-my-store - namespace: rook-ceph - port: 80 - middlewares: - - name: media-strip-prefix ---- -apiVersion: traefik.io/v1alpha1 -#apiVersion: traefik.containo.us/v1alpha1 -kind: Middleware -metadata: - name: media-strip-prefix - namespace: authentik -spec: - stripPrefix: - prefixes: - - /media diff --git a/authentik-namespace.yaml b/authentik-namespace.yaml deleted file mode 100644 index bb24d8d..0000000 --- a/authentik-namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: authentik diff --git a/authentik-outpost-path.yaml b/authentik-outpost-path.yaml deleted file mode 100644 index a3d21ae..0000000 --- a/authentik-outpost-path.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: authentik-outpost-path - namespace: authentik - annotations: - traefik.ingress.kubernetes.io/router.entrypoints: websecure - traefik.ingress.kubernetes.io/router.priority: "10000" -spec: - rules: - - http: - paths: - - path: /outpost.goauthentik.io/ - pathType: Prefix - backend: - service: - name: authentik-server - port: - number: 80 diff --git a/authentik-secrets.env.example b/authentik-secrets.env.example deleted file mode 100644 index 27cf71f..0000000 --- a/authentik-secrets.env.example +++ /dev/null @@ -1,4 +0,0 @@ -AUTHENTIK_SECRET_KEY=REPLACE_WITH_EXISTING_OR_RANDOM_SECRET_KEY -AUTHENTIK_POSTGRESQL__PASSWORD=REPLACE_WITH_EXISTING_POSTGRESQL_PASSWORD -AUTHENTIK_STORAGE__MEDIA__S3__ACCESS_KEY=REPLACE_WITH_ROOK_ACCESS_KEY -AUTHENTIK_STORAGE__MEDIA__S3__SECRET_KEY=REPLACE_WITH_ROOK_SECRET_KEY diff --git a/ceph-object-user.yaml b/ceph-object-user.yaml deleted file mode 100644 index 8259360..0000000 --- a/ceph-object-user.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: ceph.rook.io/v1 -kind: CephObjectStoreUser -metadata: - name: authentik - namespace: rook-ceph -spec: - store: my-store - diff --git a/ci-deployer.yaml b/ci-deployer.yaml deleted file mode 100644 index 16266dc..0000000 --- a/ci-deployer.yaml +++ /dev/null @@ -1,79 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: {name: authentik-deployer, namespace: authentik} -automountServiceAccountToken: false ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: {name: authentik-deployer} -rules: - - apiGroups: [networking.k8s.io] - resources: [networkpolicies] - resourceNames: [authentik-postgresql] - verbs: [get, patch, update] - - apiGroups: [policy] - resources: [poddisruptionbudgets] - resourceNames: [authentik-postgresql, authentik-server] - verbs: [get, patch, update] - - apiGroups: [""] - resources: [serviceaccounts] - resourceNames: [authentik, authentik-postgresql] - verbs: [get, patch, update] - - apiGroups: [""] - resources: [configmaps] - resourceNames: [authentik-postgresql-configuration, authentik-postgresql-extended-configuration, authentik-client-credentials-event-filter, authentik-blueprints] - verbs: [get, patch, update] - - apiGroups: [""] - resources: [services] - resourceNames: [authentik-postgresql-hl, authentik-postgresql, authentik-server] - verbs: [get, patch, update] - - apiGroups: [apps] - resources: [deployments] - resourceNames: [authentik-server, authentik-worker] - verbs: [get, patch, update] - - apiGroups: [apps] - resources: [statefulsets] - resourceNames: [authentik-postgresql] - verbs: [get, patch, update] - - apiGroups: [networking.k8s.io] - resources: [ingresses] - resourceNames: [authentik-server, authentik-outpost-path] - verbs: [get, patch, update] - - apiGroups: [traefik.io] - resources: [ingressroutes] - resourceNames: [authentik-media-redirect] - verbs: [get, patch, update] - - apiGroups: [traefik.io] - resources: [middlewares] - resourceNames: [media-strip-prefix] - verbs: [get, patch, update] - - apiGroups: [rbac.authorization.k8s.io] - resources: [clusterroles] - resourceNames: [authentik-authentik] - verbs: [get, patch, update, escalate, bind] - - apiGroups: [rbac.authorization.k8s.io] - resources: [clusterrolebindings] - resourceNames: [authentik-authentik] - verbs: [get, patch, update] - - apiGroups: [rbac.authorization.k8s.io] - resources: [roles] - resourceNames: [authentik] - verbs: [get, patch, update, escalate, bind] - - apiGroups: [rbac.authorization.k8s.io] - resources: [rolebindings] - resourceNames: [authentik] - verbs: [get, patch, update] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: {name: authentik-deployer} -roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: authentik-deployer} -subjects: [{kind: ServiceAccount, name: authentik-deployer, namespace: authentik}] ---- -apiVersion: v1 -kind: Secret -metadata: - name: authentik-deployer-token - namespace: authentik - annotations: {kubernetes.io/service-account.name: authentik-deployer} -type: kubernetes.io/service-account-token diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh deleted file mode 100755 index fe22503..0000000 --- a/create-ci-kubeconfig.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd); namespace=authentik -account=authentik-deployer; secret=authentik-deployer-token; remote=${KUBECTL_SSH_HOST:-} -k() { if [[ -n "${remote}" ]]; then ssh "${remote}" kubectl "$@"; else kubectl "$@"; fi; } -if [[ -n "${remote}" ]]; then k apply -f - < "${project_dir}/ci-deployer.yaml" >&2; else k apply -f "${project_dir}/ci-deployer.yaml" >&2; fi -for attempt in {1..30}; do data=$(k -n "$namespace" get secret "$secret" -o jsonpath='{.data.token}' 2>/dev/null || true); [[ -n "$data" ]] && break; sleep 1; done -[[ -n "${data:-}" ]] || { echo "Token was not issued" >&2; exit 1; } -work=$(mktemp -d); trap 'rm -rf "${work}"' EXIT -server=$(k config view --minify -o jsonpath='{.clusters[0].cluster.server}') -k -n "$namespace" get secret "$secret" -o jsonpath='{.data.ca\.crt}' | base64 -d > "$work/ca" -export KUBECONFIG="$work/config"; kubectl config set-cluster cluster --server="$server" --certificate-authority="$work/ca" --embed-certs=true >/dev/null -kubectl config set-credentials "$account" --token="$(printf %s "$data" | base64 -d)" >/dev/null -kubectl config set-context authentik --cluster=cluster --user="$account" --namespace="$namespace" >/dev/null; kubectl config use-context authentik >/dev/null -echo "Store as Gitea Actions secret KUBE_CONFIG_BASE64; do not commit." >&2; base64 -w0 "$KUBECONFIG"; printf '\n' diff --git a/install.sh b/install.sh deleted file mode 100755 index b93b0b3..0000000 --- a/install.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/bin/sh - -set -eu - -chart_version=2026.8.4 -project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -secret_file=${AUTHENTIK_SECRET_FILE:-$project_dir/authentik-secrets.env} - -for command in helm kubectl openssl base64 curl; do - if ! command -v "$command" >/dev/null 2>&1; then - echo "Required command not found: $command" >&2 - exit 1 - fi -done - -if [ "$(kubectl config current-context)" != 'kubernetes-admin@kubernetes' ]; then - echo 'Expected the homelab Kubernetes context; refusing to install Authentik.' >&2 - exit 1 -fi - -kubectl apply --filename "$project_dir/authentik-namespace.yaml" - -if [ -f "$secret_file" ]; then - if grep -q 'REPLACE_WITH_' "$secret_file"; then - echo "Refusing placeholder secrets in $secret_file" >&2 - exit 1 - fi - if kubectl --namespace authentik get secret authentik-postgresql >/dev/null 2>&1; then - candidate_password=$(awk -F= '$1 == "AUTHENTIK_POSTGRESQL__PASSWORD" {sub(/^[^=]*=/, ""); print; exit}' "$secret_file") - candidate_password_base64=$(printf '%s' "$candidate_password" | base64 | tr -d '\n') - existing_password_base64=$(kubectl --namespace authentik get secret authentik-postgresql --output='jsonpath={.data.password}') - if [ -z "$candidate_password" ] || [ "$candidate_password_base64" != "$existing_password_base64" ]; then - echo "Refusing an uncoordinated PostgreSQL password change; rotate the database role and both Secrets during maintenance." >&2 - exit 1 - fi - unset candidate_password candidate_password_base64 existing_password_base64 - fi - kubectl --namespace authentik create secret generic authentik-runtime \ - --from-env-file="$secret_file" --dry-run=client --output=yaml \ - | kubectl apply --filename=- -elif kubectl --namespace authentik get secret authentik-runtime >/dev/null 2>&1; then - echo "Keeping existing authentik/authentik-runtime Secret." -else - echo "Missing $secret_file and authentik/authentik-runtime Secret." >&2 - exit 1 -fi - -oidc_secret_file=$(mktemp) -oidc_patch_file=$(mktemp) -postgres_password_file=$(mktemp) -trap 'rm -f "$oidc_secret_file" "$oidc_patch_file" "$postgres_password_file"' EXIT HUP INT TERM -chmod 600 "$oidc_secret_file" "$oidc_patch_file" "$postgres_password_file" - -runtime_password_base64=$(kubectl --namespace authentik get secret authentik-runtime \ - --output='jsonpath={.data.AUTHENTIK_POSTGRESQL__PASSWORD}') -if [ -z "$runtime_password_base64" ]; then - echo "authentik-runtime is missing AUTHENTIK_POSTGRESQL__PASSWORD." >&2 - exit 1 -fi -if kubectl --namespace authentik get secret authentik-postgresql >/dev/null 2>&1; then - existing_password_base64=$(kubectl --namespace authentik get secret authentik-postgresql \ - --output='jsonpath={.data.password}') - if [ "$runtime_password_base64" != "$existing_password_base64" ]; then - echo "The Authentik runtime and PostgreSQL Secret passwords differ; refusing to restart workloads." >&2 - exit 1 - fi -else - printf '%s' "$runtime_password_base64" | base64 --decode > "$postgres_password_file" - kubectl --namespace authentik create secret generic authentik-postgresql \ - --from-file="password=$postgres_password_file" \ - --from-file="postgres-password=$postgres_password_file" -fi -unset runtime_password_base64 existing_password_base64 - -oidc_secret_base64=$(kubectl --namespace authentik get secret authentik-runtime \ - --output='jsonpath={.data.ARGOCD_OIDC_CLIENT_SECRET}') -if [ -n "$oidc_secret_base64" ]; then - printf '%s' "$oidc_secret_base64" | base64 --decode > "$oidc_secret_file" -else - oidc_secret=$(openssl rand -hex 32) - printf '%s' "$oidc_secret" > "$oidc_secret_file" - oidc_secret_base64=$(printf '%s' "$oidc_secret" | base64 | tr -d '\n') - printf '{"data":{"ARGOCD_OIDC_CLIENT_SECRET":"%s"}}\n' \ - "$oidc_secret_base64" > "$oidc_patch_file" - kubectl --namespace authentik patch secret authentik-runtime \ - --type=merge --patch-file="$oidc_patch_file" >/dev/null - unset oidc_secret -fi - -kubectl create namespace argocd --dry-run=client --output=yaml \ - | kubectl apply --filename=- -kubectl --namespace argocd create secret generic argocd-oidc \ - --from-file="client-secret=$oidc_secret_file" \ - --dry-run=client --output=yaml \ - | kubectl apply --filename=- -kubectl --namespace argocd label secret argocd-oidc \ - app.kubernetes.io/part-of=argocd --overwrite >/dev/null - -# The Authentik-aware error context and public authentik middleware chain are -# managed by ../traefik-error-pages so their diagnostics stay in one project. -kubectl apply --filename "$project_dir/ceph-object-user.yaml" -chart_archive=$(mktemp) -trap 'rm -f "$oidc_secret_file" "$oidc_patch_file" "$postgres_password_file" "$chart_archive"' EXIT HUP INT TERM -curl --fail --silent --show-error --location --output "$chart_archive" \ - "https://github.com/goauthentik/helm/releases/download/authentik-${chart_version}/authentik-${chart_version}.tgz" -helm upgrade --install --namespace authentik authentik "$chart_archive" \ - --version "$chart_version" --values "$project_dir/values.yaml" \ - --take-ownership --force-conflicts --rollback-on-failure \ - --wait --timeout 15m - -sh "$project_dir/update-outpost-images.sh" - -# Apply the Dashboard blueprint immediately, then make its provider available -# through the embedded outpost. The relationship is idempotent, but is not -# expressed in the blueprint because replacing the outpost's provider list -# would detach providers owned by other repositories. -dashboard_blueprint_attempt=1 -while ! kubectl --namespace authentik exec deployment/authentik-worker -- \ - ak apply_blueprint /blueprints/mounted/cm-authentik-blueprints/kubernetes-dashboard.yaml; do - if [ "$dashboard_blueprint_attempt" -ge 5 ]; then - echo "Dashboard blueprint failed after $dashboard_blueprint_attempt attempts." >&2 - exit 1 - fi - dashboard_blueprint_attempt=$((dashboard_blueprint_attempt + 1)) - echo "Dashboard blueprint conflicted; retrying attempt $dashboard_blueprint_attempt/5." >&2 - sleep 5 -done -kubectl --namespace authentik exec deployment/authentik-worker -- \ - ak shell --command \ - 'from authentik.outposts.models import Outpost; from authentik.providers.proxy.models import ProxyProvider; outpost = Outpost.objects.get(name="authentik Embedded Outpost"); provider = ProxyProvider.objects.get(name="Kubernetes Dashboard"); outpost.providers.add(provider)' - -kubectl apply --filename "$project_dir/authentik-outpost-path.yaml" -kubectl apply --filename "$project_dir/authentik-media-redirect.yaml" diff --git a/render.sh b/render.sh deleted file mode 100755 index 26afd37..0000000 --- a/render.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -version=${AUTHENTIK_CHART_VERSION:-2026.8.3} -output=${1:?Usage: render.sh OUTPUT_FILE} -chart=$(mktemp); trap 'rm -f "${chart}"' EXIT -curl -fsSL -o "${chart}" "https://github.com/goauthentik/helm/releases/download/authentik-${version}/authentik-${version}.tgz" -helm template authentik "${chart}" --namespace authentik --values "${project_dir}/values.yaml" > "${output}" diff --git a/test.sh b/test.sh deleted file mode 100755 index 8d44cd8..0000000 --- a/test.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd); work=$(mktemp -d); trap 'rm -rf "${work}"' EXIT -sh -n "$dir/install.sh" "$dir/update-outpost-images.sh"; bash -n "$dir/render.sh" "$dir/create-ci-kubeconfig.sh" "$dir/test.sh" -"$dir/render.sh" "$work/authentik.yaml" -grep -Eq '^kind: Secret$' "$work/authentik.yaml" && { echo "Chart rendered a Secret" >&2; exit 1; } -image=ghcr.io/yannh/kubeconform:v0.7.0 -for file in "$work/authentik.yaml" "$dir/authentik-outpost-path.yaml" "$dir/authentik-media-redirect.yaml" "$dir/ci-deployer.yaml"; do docker run --rm -i "$image" -strict -exit-on-error -ignore-missing-schemas -summary < "$file"; done -grep -q 'replicas: 2' "$work/authentik.yaml" -grep -q 'cpu: 500m' "$work/authentik.yaml" -if grep -Eq 'router.middlewares:|inFlightReq:|rateLimit:' "$work/authentik.yaml" "$dir/authentik-outpost-path.yaml"; then - echo "Public Authentik request limiting must remain disabled." >&2 - exit 1 -fi -echo "Authentik public desired state is valid and contains no Secrets." diff --git a/update-outpost-images.sh b/update-outpost-images.sh deleted file mode 100644 index dd16aa1..0000000 --- a/update-outpost-images.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh - -set -eu - -if [ "$(kubectl config current-context)" != 'kubernetes-admin@kubernetes' ]; then - echo 'Expected the homelab Kubernetes context; refusing to update outposts.' >&2 - exit 1 -fi - -# Authentik stores managed outpost image pins in its database. Keep them on -# the same release as the server; changing the Helm chart alone does not do so. -for entry in \ - 'ldap ghcr.io/goauthentik/ldap:2026.8.4' \ - 'radius ghcr.io/goauthentik/radius:2026.8.4' \ - 'rac ghcr.io/goauthentik/rac:2026.8.4' -do - name=${entry%% *} - image=${entry#* } - kubectl --namespace authentik exec deployment/authentik-worker -- \ - ak shell --command "from authentik.outposts.models import Outpost; o = Outpost.objects.get(name='$name', type='$name'); c = o.config; old = c.container_image; c.container_image = '$image'; o.config = c; o.save() if old != c.container_image else None; print('Outpost image:', o.name, c.container_image)" - attempt=0 - while [ "$(kubectl --namespace authentik get "deployment/ak-outpost-$name" --output='jsonpath={.spec.template.spec.containers[0].image}')" != "$image" ]; do - attempt=$((attempt + 1)) - if [ "$attempt" -ge 60 ]; then - echo "Timed out waiting for Authentik to update the $name deployment image." >&2 - exit 1 - fi - sleep 5 - done - # The outpost controller may recreate the Deployment during reconciliation, - # which makes `kubectl rollout status` fail with "object has been deleted". - attempt=0 - while [ "$(kubectl --namespace authentik get "deployment/ak-outpost-$name" --output='jsonpath={.status.updatedReplicas}/{.status.readyReplicas}/{.status.availableReplicas}' 2>/dev/null)" != '1/1/1' ]; do - attempt=$((attempt + 1)) - if [ "$attempt" -ge 180 ]; then - echo "Timed out waiting for the $name outpost to become available." >&2 - exit 1 - fi - sleep 5 - done - echo "Outpost $name is available on $image" -done diff --git a/values.yaml b/values.yaml deleted file mode 100644 index cc72596..0000000 --- a/values.yaml +++ /dev/null @@ -1,311 +0,0 @@ -global: - env: - - name: AUTHENTIK_STORAGE__MEDIA__BACKEND - value: s3 - - name: AUTHENTIK_STORAGE__MEDIA__S3__BUCKET_NAME - value: authentik - - name: AUTHENTIK_STORAGE__MEDIA__S3__ENDPOINT - value: https://s3.brunner.ninja/ - - name: AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN - value: s3.brunner.ninja/authentik - - name: AUTHENTIK_MEDIA_S3_REGION_NAME - value: my-store - - name: AUTHENTIK_EMAIL__HOST - value: smtp-relay.gmail.com - - name: AUTHENTIK_EMAIL__PORT - value: '587' - - name: AUTHENTIK_EMAIL__USE_TLS - value: 'true' - - name: AUTHENTIK_EMAIL__FROM - value: brunner.ninja Auth - - name: AUTHENTIK_ERROR_REPORTING__ENABLED - value: 'true' - - name: AUTHENTIK_POSTGRESQL__HOST - value: authentik-postgresql - - name: AUTHENTIK_POSTGRESQL__NAME - value: authentik - - name: AUTHENTIK_POSTGRESQL__USER - value: authentik - - name: AUTHENTIK_POSTGRESQL__PORT - value: '5432' - envFrom: - - secretRef: - name: authentik-runtime -authentik: - enabled: false -blueprints: - configMaps: - - authentik-blueprints -serviceAccount: - create: true - name: authentik -server: - # Keep authentication available during a pod or worker-node failure. The - # chart's default soft pod anti-affinity spreads these replicas when the - # cluster has capacity on more than one node. - replicas: 2 - pdb: - enabled: true - minAvailable: 1 - resources: - requests: - cpu: 500m - memory: 1Gi - limits: - # Deliberately leave CPU unlimited so login bursts can use idle cores. - memory: 2Gi - env: - - name: AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS - value: 127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fe80::/10,::1/128 - - name: AUTHENTIK_WEB__BASE_URL - value: https://auth.brunner.ninja - - name: AUTHENTIK_WEB__MAX_REQUESTS - value: '0' - podAnnotations: - authentik.brunner.ninja/client-credentials-event-filter: 2026.8.3-1 - volumeMounts: - - name: client-credentials-event-filter - mountPath: /authentik/providers/oauth2/token/client_credentials.py - subPath: client_credentials.py - readOnly: true - - name: dev-shm - mountPath: /dev/shm - volumes: - - name: client-credentials-event-filter - configMap: - name: authentik-client-credentials-event-filter - - name: dev-shm - emptyDir: - medium: Memory - sizeLimit: 512Mi - ingress: - enabled: true - hosts: - - auth.brunner.ninja - annotations: - traefik.ingress.kubernetes.io/router.entrypoints: websecure - livenessProbe: - timeoutSeconds: 10 - readinessProbe: - timeoutSeconds: 10 - startupProbe: - timeoutSeconds: 10 -worker: - enabled: true - livenessProbe: - timeoutSeconds: 10 - readinessProbe: - timeoutSeconds: 10 - startupProbe: - timeoutSeconds: 10 -geoip: - enabled: false -postgresql: - enabled: true - auth: - existingSecret: authentik-postgresql - secretKeys: - adminPasswordKey: postgres-password - userPasswordKey: password - primary: - persistence: - enabled: true - storageClass: rook-ceph-block - size: 20Gi -additionalObjects: - - apiVersion: v1 - kind: ConfigMap - metadata: - name: authentik-blueprints - namespace: authentik - data: - argocd.yaml: | - version: 1 - metadata: - name: Argo CD OIDC - labels: - blueprints.goauthentik.io/description: Argo CD login through Authentik - entries: - - model: authentik_providers_oauth2.scopemapping - id: argocd-groups - state: present - identifiers: - name: Argo CD groups - attrs: - scope_name: groups - description: Authentik group names for Argo CD RBAC - expression: | - return {"groups": [group.name for group in request.user.ak_groups.all()]} - - model: authentik_providers_oauth2.oauth2provider - id: argocd-provider - state: present - identifiers: - name: Argo CD - attrs: - authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] - invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] - client_type: confidential - grant_types: - - authorization_code - - refresh_token - client_id: argocd - client_secret: !Env ARGOCD_OIDC_CLIENT_SECRET - issuer_mode: per_provider - include_claims_in_id_token: true - signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] - redirect_uris: - - matching_mode: strict - url: https://cd.brunner.ninja/auth/callback - property_mappings: - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] - - !KeyOf argocd-groups - - model: authentik_core.application - state: present - identifiers: - slug: argocd - attrs: - name: Argo CD - provider: !KeyOf argocd-provider - launch_url: https://cd.brunner.ninja - kubernetes-dashboard.yaml: | - version: 1 - metadata: - name: Kubernetes Dashboard forward authentication - labels: - blueprints.goauthentik.io/description: Admin-only access to Kubernetes Dashboard - entries: - - model: authentik_providers_proxy.proxyprovider - id: kubernetes-dashboard-provider - state: present - identifiers: - name: Kubernetes Dashboard - attrs: - authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-explicit-consent]] - invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] - client_type: confidential - grant_types: - - authorization_code - - refresh_token - mode: forward_single - internal_host: "" - external_host: https://kubernetes.brunner.ninja - property_mappings: - - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/proxy/scope-proxy]] - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] - - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] - - model: authentik_core.application - id: kubernetes-dashboard-application - state: present - identifiers: - slug: kubernetes-dashboard - attrs: - name: Kubernetes Dashboard - provider: !KeyOf kubernetes-dashboard-provider - launch_url: https://kubernetes.brunner.ninja - - model: authentik_policies.policybinding - state: present - identifiers: - target: !KeyOf kubernetes-dashboard-application - group: !Find [authentik_core.group, [name, authentik Admins]] - order: 0 - attrs: - enabled: true - - apiVersion: v1 - kind: ConfigMap - metadata: - name: authentik-client-credentials-event-filter - namespace: authentik - data: - client_credentials.py: "from base64 import b64decode\nfrom datetime import datetime\n\ - \nfrom django.http import HttpRequest\nfrom django.utils import timezone\n\ - \nfrom authentik.common.oauth.constants import (\n CLIENT_ASSERTION,\n\ - \ CLIENT_ASSERTION_TYPE,\n CLIENT_ASSERTION_TYPE_JWT,\n)\nfrom authentik.core.models\ - \ import (\n USER_ATTRIBUTE_GENERATED,\n USER_PATH_SYSTEM_PREFIX,\n\ - \ USERNAME_MAX_LENGTH,\n Application,\n Token,\n TokenIntents,\n\ - \ User,\n UserTypes,\n)\nfrom authentik.events.middleware import audit_ignore\n\ - from authentik.events.models import Event, EventAction\nfrom authentik.flows.planner\ - \ import PLAN_CONTEXT_APPLICATION\nfrom authentik.providers.oauth2.errors\ - \ import TokenError\nfrom authentik.providers.oauth2.token.base_fed import\ - \ FederatedTokenRequest\nfrom authentik.root.middleware import ClientIPMiddleware\n\ - from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS\n\ - \n\ndef save_login_event_unless_loopback(request: HttpRequest, user: User,\ - \ **context):\n \"\"\"Do not persist internal loopback client-credentials\ - \ audit noise.\"\"\"\n if ClientIPMiddleware.get_client_ip(request) ==\ - \ \"::1\":\n return\n Event.new(action=EventAction.LOGIN, **context).from_http(request,\ - \ user=user)\n\n\nclass ClientCredentialsTokenRequest(FederatedTokenRequest):\n\ - \n def parse(self, request: HttpRequest) -> None:\n super().parse(request)\n\ - \ # client_credentials flow with client assertion\n if request.POST.get(CLIENT_ASSERTION_TYPE,\ - \ \"\") != \"\":\n return self.post_init_client_credentials_jwt(request)\n\ - \ # authentik-custom-ish client credentials flow\n if request.POST.get(\"\ - username\", \"\") != \"\":\n return self.post_init_client_credentials_creds(\n\ - \ request, request.POST.get(\"username\"), request.POST.get(\"\ - password\")\n )\n # Standard method which creates an automatic\ - \ user\n if self.client_secret == self.provider.client_secret:\n \ - \ return self.post_init_client_credentials_generated(request)\n \ - \ # Standard workaround method which stores username:password\n \ - \ # as client_secret\n try:\n user, _, password = b64decode(self.client_secret).decode(\"\ - utf-8\").partition(\":\")\n return self.post_init_client_credentials_creds(request,\ - \ user, password)\n except ValueError:\n raise TokenError(\"\ - invalid_grant\") from None\n\n def post_init_client_credentials_creds(\n\ - \ self, request: HttpRequest, username: str, password: str\n ):\n\ - \ # Authenticate user based on credentials\n user = User.objects.filter(username=username,\ - \ is_active=True).first()\n if not user:\n raise TokenError(\"\ - invalid_grant\")\n token: Token = Token.objects.filter(\n \ - \ key=password, intent=TokenIntents.INTENT_APP_PASSWORD, user=user\n \ - \ ).first()\n if not token or token.user.uid != user.uid:\n \ - \ raise TokenError(\"invalid_grant\")\n self.user = user\n\ - \ # Authorize user access\n app = Application.objects.filter(provider=self.provider).first()\n\ - \ if not app or not app.provider:\n raise TokenError(\"\ - invalid_grant\")\n self.check_policy_access(app, request)\n\n \ - \ save_login_event_unless_loopback(\n request,\n user,\n\ - \ **{\n PLAN_CONTEXT_METHOD: \"token\",\n \ - \ PLAN_CONTEXT_METHOD_ARGS: {\n \"identifier\"\ - : token.identifier,\n },\n PLAN_CONTEXT_APPLICATION:\ - \ app,\n },\n )\n\n def post_init_client_credentials_jwt(self,\ - \ request: HttpRequest):\n assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE,\ - \ \"\")\n if assertion_type != CLIENT_ASSERTION_TYPE_JWT:\n \ - \ self.logger.warning(\"Invalid assertion type\", assertion_type=assertion_type)\n\ - \ raise TokenError(\"invalid_grant\")\n\n client_secret\ - \ = request.POST.get(\"client_secret\", None)\n assertion = request.POST.get(CLIENT_ASSERTION,\ - \ client_secret)\n if not assertion:\n self.logger.warning(\"\ - Missing client assertion\")\n raise TokenError(\"invalid_grant\"\ - )\n\n federated_party = self.validate_jwt(assertion)\n\n if\ - \ not federated_party:\n self.logger.warning(\"No token could be\ - \ verified\")\n raise TokenError(\"invalid_grant\")\n\n \ - \ if \"exp\" in federated_party.parsed_token:\n exp = datetime.fromtimestamp(federated_party.parsed_token[\"\ - exp\"])\n # Non-timezone aware check since we assume `exp` is in\ - \ UTC\n if datetime.now() >= exp:\n self.logger.info(\"\ - JWT token expired\")\n raise TokenError(\"invalid_grant\")\n\ - \n app = Application.objects.filter(provider=self.provider).first()\n\ - \ if not app or not app.provider:\n self.logger.info(\"\ - client_credentials grant for provider without application\")\n \ - \ raise TokenError(\"invalid_grant\")\n\n if federated_party.user:\n\ - \ self.user = federated_party.user\n self.check_policy_access(app,\ - \ request, oauth_jwt=federated_party.parsed_token)\n if not federated_party.user:\n\ - \ self.user = self.create_user_from_jwt(federated_party, app, request)\n\ - \n method_args = {\n \"jwt\": federated_party.parsed_token,\n\ - \ federated_party.type: federated_party.party,\n }\n \ - \ save_login_event_unless_loopback(\n request,\n \ - \ self.user,\n **{\n PLAN_CONTEXT_METHOD: \"jwt\"\ - ,\n PLAN_CONTEXT_METHOD_ARGS: method_args,\n \ - \ PLAN_CONTEXT_APPLICATION: app,\n },\n )\n\n def post_init_client_credentials_generated(self,\ - \ request: HttpRequest):\n # Authorize user access\n app = Application.objects.filter(provider=self.provider).first()\n\ - \ if not app or not app.provider:\n raise TokenError(\"\ - invalid_grant\")\n with audit_ignore():\n self.user, _ =\ - \ User.objects.update_or_create(\n # trim username to ensure\ - \ the entire username is max 150 chars\n # (22 chars being\ - \ the length of the \"template\")\n username=f\"ak-{self.provider.name[:\ - \ USERNAME_MAX_LENGTH - 22]}-client_credentials\",\n defaults={\n\ - \ \"last_login\": timezone.now(),\n \ - \ \"name\": f\"Autogenerated user from application {app.name} (client credentials)\"\ - ,\n \"path\": f\"{USER_PATH_SYSTEM_PREFIX}/apps/{app.slug}\"\ - ,\n \"type\": UserTypes.SERVICE_ACCOUNT,\n \ - \ },\n )\n self.user.attributes[USER_ATTRIBUTE_GENERATED]\ - \ = True\n self.user.save()\n self.check_policy_access(app,\ - \ request)\n\n save_login_event_unless_loopback(\n request,\n\ - \ self.user,\n **{\n PLAN_CONTEXT_METHOD:\ - \ \"oauth_client_secret\",\n PLAN_CONTEXT_APPLICATION: app,\n\ - \ },\n )\n"