Introduce CI/CD
Validate and deploy Authentik / validate (push) Successful in 18s
Validate and deploy Authentik / deploy (push) Successful in 26s

This commit is contained in:
2026-09-05 11:51:27 +02:00
parent 9a3df8e9f3
commit d988c8831a
15 changed files with 1792 additions and 1 deletions
+54
View File
@@ -0,0 +1,54 @@
name: Validate and deploy Authentik
on: {push: {}, pull_request: {}}
env:
AUTHENTIK_CHART_VERSION: 2026.8.0
AUTHENTIK_CHART_SHA256: fb51f1ab970a15e37f8d2b4fe2767cea33afe7e6f84bee646d2690f31fcde7e5
HELM_VERSION: v4.2.2
HELM_SHA256: 9adafecab4d406853bba163a70e9f104f47dbbf65ce24b7653bae7e36150bcb6
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"
printf '%s %s\n' "$HELM_SHA256" "$RUNNER_TEMP/helm.tgz" | sha256sum -c
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"
printf '%s %s\n' "$HELM_SHA256" "$RUNNER_TEMP/helm.tgz" | sha256sum -c
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"
curl -fsSL -o "$RUNNER_TEMP/kubectl.sha" "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256"
printf '%s %s\n' "$(cat "$RUNNER_TEMP/kubectl.sha")" "$RUNNER_TEMP/kubectl" | sha256sum -c
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
+8
View File
@@ -0,0 +1,8 @@
# Private legacy values retained locally for migration only
authentik-values.yaml
authentik-secrets.env
*.kubeconfig
kubeconfig*
*-secret.yaml
.env
.DS_Store
+30 -1
View File
@@ -1,2 +1,31 @@
# authentik-deployment
# Authentik on Kubernetes
Authentik `2026.8.0` 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.
`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.
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.
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. 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.
+29
View File
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: authentik
+19
View File
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,4 @@
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
@@ -0,0 +1,8 @@
apiVersion: ceph.rook.io/v1
kind: CephObjectStoreUser
metadata:
name: authentik
namespace: rook-ceph
spec:
store: my-store
+79
View File
@@ -0,0 +1,79 @@
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]
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
@@ -0,0 +1,15 @@
#!/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'
Executable
+107
View File
@@ -0,0 +1,107 @@
#!/bin/sh
set -eu
chart_version=2026.8.0
chart_sha256=fb51f1ab970a15e37f8d2b4fe2767cea33afe7e6f84bee646d2690f31fcde7e5
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 sha256sum; do
if ! command -v "$command" >/dev/null 2>&1; then
echo "Required command not found: $command" >&2
exit 1
fi
done
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"
printf '%s %s\n' "$chart_sha256" "$chart_archive" | sha256sum --check >&2
helm upgrade --install --namespace authentik authentik "$chart_archive" \
--version "$chart_version" --values "$project_dir/values.yaml" \
--wait --timeout 15m
kubectl apply --filename "$project_dir/authentik-outpost-path.yaml"
kubectl apply --filename "$project_dir/authentik-media-redirect.yaml"
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
version=${AUTHENTIK_CHART_VERSION:-2026.8.0}
digest=${AUTHENTIK_CHART_SHA256:-fb51f1ab970a15e37f8d2b4fe2767cea33afe7e6f84bee646d2690f31fcde7e5}
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"
printf '%s %s\n' "${digest}" "${chart}" | sha256sum -c >&2
helm template authentik "${chart}" --namespace authentik --values "${project_dir}/values.yaml" > "${output}"
Executable
+9
View File
@@ -0,0 +1,9 @@
#!/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"; 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@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c
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
echo "Authentik public desired state is valid and contains no Secrets."
+1170
View File
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
global:
image:
digest: sha256:21000cebe8e51eca0620034096586d675cedec8925ac750f7f8966d86eeb0da0
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:
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.0-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
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
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
- 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"