Add Authentik Argo CD application

This commit is contained in:
2026-10-18 23:13:29 +02:00
parent c14283c901
commit 11ce791efa
10 changed files with 558 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# Authentik homelab deployment
This directory contains only the homelab values and cluster integrations for
the maintained upstream Authentik chart. The chart owns the Authentik server,
worker, PostgreSQL, Services, Ingress, RBAC, probes, and persistent volume
claim. The local resources retain the existing media route, embedded-outpost
path, Ceph object user, and an alert for the three Authentik-managed outposts.
## Secrets and recovery boundary
The public repository never contains credentials. The deployment references
the existing `authentik/authentik-runtime` and
`authentik/authentik-postgresql` Secrets. The existing `argocd/argocd-oidc`
Secret is also retained outside Git and must contain the same OIDC client
secret as `ARGOCD_OIDC_CLIENT_SECRET` in `authentik-runtime`.
Restore those Secrets before synchronizing Authentik into an empty cluster.
PostgreSQL password rotation must update the database role and both Secret
references together during a maintenance window.
## Managed outposts
Authentik, not Argo CD, creates the `ak-outpost-ldap`,
`ak-outpost-radius`, and `ak-outpost-rac` Deployments. Their database
`container_image` overrides are intentionally null so Authentik uses its
upstream `ghcr.io/goauthentik/%(type)s:%(version)s` template. That keeps each
outpost on the same version as the server after a chart upgrade without a
second version list in Git.
After every Authentik upgrade, verify that all three Deployments use the chart
application version and report one updated, ready, and available replica. The
`AuthentikManagedOutpostsUnavailable` alert detects missing or unavailable
generated outposts, which are outside Argo CD's direct resource inventory.
Run `apps/authentik/verify-outposts.sh` for the version, database-pin, image,
and replica checks; it derives the expected version from the Argo Application.
The Kubernetes Dashboard provider is attached to the embedded outpost in the
Authentik database. This avoids a blueprint replacing the embedded outpost's
complete provider list, because other applications also own providers there.
@@ -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
@@ -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
@@ -0,0 +1,7 @@
apiVersion: ceph.rook.io/v1
kind: CephObjectStoreUser
metadata:
name: authentik
namespace: rook-ceph
spec:
store: my-store
@@ -0,0 +1,32 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: authentik-outposts
namespace: authentik
labels:
release: prometheus
spec:
groups:
- name: authentik-outposts
rules:
- alert: AuthentikManagedOutpostsUnavailable
expr: |-
sum(
kube_deployment_status_replicas_available{
namespace="authentik",
deployment=~"ak-outpost-(ldap|radius|rac)"
}
) != 3
or
absent(
kube_deployment_status_replicas_available{
namespace="authentik",
deployment=~"ak-outpost-(ldap|radius|rac)"
}
)
for: 10m
labels:
severity: critical
annotations:
summary: Authentik managed outposts are unavailable
description: Expected one available LDAP, RADIUS, and RAC outpost replica.
+311
View File
@@ -0,0 +1,311 @@
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: "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"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
version=$(awk '
$1 == "chart:" && $2 == "authentik" { chart = 1; next }
chart && $1 == "targetRevision:" { print $2; exit }
' "$project_dir/bootstrap/authentik.yaml")
if [ -z "$version" ]; then
echo "Could not determine the Authentik chart version." >&2
exit 1
fi
pins=$(kubectl --namespace authentik exec deployment/authentik-worker -- \
ak shell --command \
'from authentik.outposts.models import Outpost; [print(f"{o.name}={o.config.container_image}") for o in Outpost.objects.filter(name__in=["ldap", "radius", "rac"]).order_by("name")]')
printf '%s\n' "$pins"
if printf '%s\n' "$pins" | grep -Ev '^(ldap|rac|radius)=None$' >/dev/null; then
echo "A managed outpost still has an explicit database image override." >&2
exit 1
fi
for name in ldap radius rac; do
expected="ghcr.io/goauthentik/$name:$version"
actual=$(kubectl --namespace authentik get "deployment/ak-outpost-$name" \
--output='jsonpath={.spec.template.spec.containers[0].image}')
replicas=$(kubectl --namespace authentik get "deployment/ak-outpost-$name" \
--output='jsonpath={.status.updatedReplicas}/{.status.readyReplicas}/{.status.availableReplicas}')
if [ "$actual" != "$expected" ] || [ "$replicas" != "1/1/1" ]; then
echo "ak-outpost-$name: expected $expected at 1/1/1, found $actual at $replicas" >&2
exit 1
fi
echo "ak-outpost-$name: $actual, $replicas"
done
+53
View File
@@ -0,0 +1,53 @@
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: authentik
namespace: argocd
spec:
description: Authentik identity provider and homelab integrations
sourceRepos:
- https://charts.goauthentik.io
- https://code.brunner.ninja/feedc0de/homelab-gitops.git
destinations:
- namespace: authentik
server: https://kubernetes.default.svc
- namespace: rook-ceph
server: https://kubernetes.default.svc
clusterResourceWhitelist:
- group: ""
kind: Namespace
- group: rbac.authorization.k8s.io
kind: ClusterRole
- group: rbac.authorization.k8s.io
kind: ClusterRoleBinding
namespaceResourceWhitelist:
- group: ""
kind: ConfigMap
- group: ""
kind: PersistentVolumeClaim
- group: ""
kind: Service
- group: ""
kind: ServiceAccount
- group: apps
kind: Deployment
- group: apps
kind: StatefulSet
- group: ceph.rook.io
kind: CephObjectStoreUser
- group: monitoring.coreos.com
kind: PrometheusRule
- group: networking.k8s.io
kind: Ingress
- group: networking.k8s.io
kind: NetworkPolicy
- group: policy
kind: PodDisruptionBudget
- group: rbac.authorization.k8s.io
kind: Role
- group: rbac.authorization.k8s.io
kind: RoleBinding
- group: traefik.io
kind: IngressRoute
- group: traefik.io
kind: Middleware
+31
View File
@@ -0,0 +1,31 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: authentik
namespace: argocd
spec:
project: authentik
sources:
- repoURL: https://charts.goauthentik.io
chart: authentik
targetRevision: 2026.8.4
helm:
releaseName: authentik
valueFiles:
- $values/apps/authentik/values.yaml
- repoURL: https://code.brunner.ninja/feedc0de/homelab-gitops.git
targetRevision: main
ref: values
- repoURL: https://code.brunner.ninja/feedc0de/homelab-gitops.git
targetRevision: main
path: apps/authentik/resources
destination:
server: https://kubernetes.default.svc
namespace: authentik
syncPolicy:
automated:
enabled: true
prune: false
selfHeal: true
syncOptions:
- CreateNamespace=true
+2
View File
@@ -9,6 +9,8 @@ resources:
- immich-sync.yaml
- immichframe.yaml
- anubis.yaml
- authentik-project.yaml
- authentik.yaml
- metallb-project.yaml
- metallb.yaml
- brgkepler-com.yaml