From 6411f94cce2f2b3f713bb51f5fdff4445d9cf336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:03:05 +0200 Subject: [PATCH] feat(authentik): split-horizon host rewrite + proxy-app mode/group bindings * `authentik_host_rewrite_domains`: extra hostnames that reach the authentik container but make it generate URLs (OIDC issuer, reset links) as if requested from the canonical `authentik_domains[0]`. Each entry gets its own traefik router and a URL-based loadbalancer service that disables passHostHeader and pins X-Forwarded-Host via middleware, so server-to-server calls on internal FQDNs keep traffic in the LAN while the iss claim stays aligned with the public host. Uses a network alias on the canonical FQDN so traefik (sharing the network) resolves the URL upstream to this very container. * proxy-app blueprint: - `mode` (default `forward_single`) lets callers pick between proxy, forward_single and forward_domain providers in one template. - `allowed_groups`: when set, emit one PolicyBinding per group on the application; authentik OR-evaluates bindings, so users in any listed group pass and others are denied. Existing inventories with an empty list see no behavioural change. --- roles/authentik/defaults/main.yml | 9 +++++ .../blueprints/blueprint-proxy-app.yaml.j2 | 27 +++++++++++++ .../authentik/templates/docker-compose.yml.j2 | 39 ++++++++++++++++++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/roles/authentik/defaults/main.yml b/roles/authentik/defaults/main.yml index 3ff71be..880734e 100644 --- a/roles/authentik/defaults/main.yml +++ b/roles/authentik/defaults/main.yml @@ -17,6 +17,15 @@ authentik_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ authentik_service_ # server-to-server traffic so backend calls don't hairpin via DMZ. authentik_domains: - "authentik.local.test" + +# Hostnames that should reach authentik but make it generate URLs (OIDC +# issuer, password reset links, etc.) as if requested from the canonical +# `authentik_domains[0]` instead. Used for split-horizon setups where an +# internal FQDN (e.g. `auth.int.example.com`) keeps server-to-server +# traffic in the LAN but the iss claim must still match the public +# hostname that browsers see. Traefik handles each entry via a separate +# router that rewrites the Host header before forwarding to authentik. +authentik_host_rewrite_domains: [] authentik_image: "ghcr.io/goauthentik/server:2026.2.2" authentik_port: 9000 authentik_secret_key: "changeme-generate-a-random-string" diff --git a/roles/authentik/templates/blueprints/blueprint-proxy-app.yaml.j2 b/roles/authentik/templates/blueprints/blueprint-proxy-app.yaml.j2 index 5e29756..acfc7a9 100644 --- a/roles/authentik/templates/blueprints/blueprint-proxy-app.yaml.j2 +++ b/roles/authentik/templates/blueprints/blueprint-proxy-app.yaml.j2 @@ -20,6 +20,16 @@ entries: internal_host: "{{ item.internal_host }}" external_host: "{{ item.external_host }}" +{# Provider mode controls how authentik treats the proxy app: + - proxy : the outpost itself proxies traffic to internal_host + - forward_single : a single app behind an external reverse proxy + (traefik forwardauth talks to authentik per-domain) + - forward_domain : wildcard mode — one provider guards every host on a + cookie domain; configure forward_auth_mode=domain on + the outpost in that case. Default to forward_single + since that's the common ForwardAuth-with-traefik + pattern. #} + mode: {{ item.mode | default('forward_single') }} {% if item.skip_path_regex is defined and item.skip_path_regex|length > 0 %} skip_path_regex: | @@ -34,3 +44,20 @@ entries: name: "{{ item.name | default(item.slug) }}" slug: {{ item.slug }} provider: !KeyOf proxy-provider-{{ item.slug }} + +{% if item.allowed_groups is defined and item.allowed_groups | length > 0 %} +{# Restrict access to listed groups: one PolicyBinding per group, all bound + to the application. Authentik treats multiple bindings on the same target + as OR (a user matching any binding passes), and a request from a user in + none of the bound groups is denied. #} +{% for group_name in item.allowed_groups %} + - model: authentik_policies.policybinding + identifiers: + target: !KeyOf app-{{ item.slug }} + order: {{ loop.index0 }} + group: !Find [authentik_core.group, [name, "{{ group_name }}"]] + attrs: + enabled: true + negate: false +{% endfor %} +{% endif %} diff --git a/roles/authentik/templates/docker-compose.yml.j2 b/roles/authentik/templates/docker-compose.yml.j2 index f0193ec..e5b8a11 100644 --- a/roles/authentik/templates/docker-compose.yml.j2 +++ b/roles/authentik/templates/docker-compose.yml.j2 @@ -43,12 +43,19 @@ services: postgres: condition: service_healthy networks: - - {{ authentik_backend_network }} - - {{ authentik_traefik_network }} + {{ authentik_backend_network }}: {} + # Network alias so traefik (which shares this network) can resolve + # the canonical FQDN to this container directly. The URL-based + # service below uses that to send upstream traffic with a fixed + # Host header equal to the canonical hostname. + {{ authentik_traefik_network }}: + aliases: + - {{ authentik_domains[0] }} labels: - traefik.enable=true - traefik.docker.network={{ authentik_traefik_network }} - traefik.http.routers.{{ authentik_service_name }}.rule={% for d in authentik_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} + - traefik.http.routers.{{ authentik_service_name }}.service={{ authentik_service_name }} {% if authentik_use_ssl %} - traefik.http.routers.{{ authentik_service_name }}.entrypoints=websecure - traefik.http.routers.{{ authentik_service_name }}.tls=true @@ -59,6 +66,34 @@ services: - traefik.http.routers.{{ authentik_service_name }}.entrypoints=web {% endif %} - traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }} +{% if authentik_host_rewrite_domains | length > 0 %} + # Server-to-server entry: a separate service points at this very + # container by the canonical FQDN (resolved via the network alias + # above) and disables passHostHeader so the upstream Host header + # becomes `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer + # URLs from X-Forwarded-Host (not Host), so we also pin that header + # via middleware. Together this keeps the iss claim aligned with + # the public hostname browsers see during login, even when the + # request itself arrived on an internal *.int.* FQDN. + - traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://{{ authentik_domains[0] }}:{{ authentik_port }} + - traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.passhostheader=false + - traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }} +{% for d in authentik_host_rewrite_domains %} + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.rule=Host(`{{ d }}`) + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.priority=100 + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.service={{ authentik_service_name }}-rewrite + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.middlewares={{ authentik_service_name }}-xfh-rewrite +{% if authentik_use_ssl %} + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.entrypoints=websecure + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} +{% else %} + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.entrypoints=web +{% endif %} +{% endfor %} +{% endif %} worker: image: {{ authentik_image }}