Retire direct Authentik deployment path

This commit is contained in:
2026-10-18 23:26:48 +02:00
parent 17cce0cb47
commit 3830a954da
14 changed files with 18 additions and 759 deletions
-48
View File
@@ -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
+18 -44
View File
@@ -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.
-29
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: authentik
-19
View File
@@ -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
-4
View File
@@ -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
-8
View File
@@ -1,8 +0,0 @@
apiVersion: ceph.rook.io/v1
kind: CephObjectStoreUser
metadata:
name: authentik
namespace: rook-ceph
spec:
store: my-store
-79
View File
@@ -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
-15
View File
@@ -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'
-133
View File
@@ -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"
-8
View File
@@ -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}"
-15
View File
@@ -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."
-42
View File
@@ -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
-311
View File
@@ -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 <auth@brunner.ninja>
- 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"