Archived
Retire direct Authentik deployment path
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: authentik
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,8 +0,0 @@
|
||||
apiVersion: ceph.rook.io/v1
|
||||
kind: CephObjectStoreUser
|
||||
metadata:
|
||||
name: authentik
|
||||
namespace: rook-ceph
|
||||
spec:
|
||||
store: my-store
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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}"
|
||||
@@ -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."
|
||||
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user