From 0e837b57b8efd4d397211695a073d8484c1efeed Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 18:26:16 +0000 Subject: [PATCH] authentik: add public guest auto-login flow + dedicated outpost + traefik public middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1+2 of default-deny ingress plan. Adds the infrastructure for an `auth = "public"` ingress tier that auto-binds anonymous requests to a `guest` Authentik user (no UI prompt), so public sites are still recorded as authenticated by Authentik for audit purposes — but as `guest`, not by leaking the standard catchall flow. - guest user in `Public Guests` group (NOT `Allow Login Users`). - `public-auto-login` flow: stage_binding policy sets `pending_user = guest`, `evaluate_on_plan = false` + `re_evaluate_policies = true` so flow_plan is populated when the policy mutates it; `authentication = none` lets anonymous requests enter. - `Provider for Public` proxy provider (forward_domain, cookie_domain viktorbarzin.me) with `authentication_flow = public-auto-login`. - Dedicated `public` outpost: only the public provider bound, deployed as `ak-outpost-public` Deployment+Service in the `authentik` namespace by Authentik's K8s controller. - `public-auth.viktorbarzin.me` ingress exposes the public outpost's `/outpost.goauthentik.io/*` so OAuth callbacks land on it (the embedded outpost doesn't know about the public provider, so `authentik.viktorbarzin.me` callbacks would fail). - `authentik-forward-auth-public` traefik middleware points at the public outpost service (not via the auth-proxy nginx fallback). The plan's `?app=public` dispatch idea was tested and rejected — the embedded outpost dispatches purely by Host header, so a dedicated outpost was the only way to isolate the public flow without conflicts. No ingresses use the new middleware yet — Phase 3+4 (the ingress_factory `auth` variable refactor + audit pass) wires it up. This commit is additive and behaviour-neutral. Co-Authored-By: Claude Opus 4.7 --- stacks/authentik/guest.tf | 214 +++++++++++++++++++ stacks/traefik/modules/traefik/middleware.tf | 43 +++- 2 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 stacks/authentik/guest.tf diff --git a/stacks/authentik/guest.tf b/stacks/authentik/guest.tf new file mode 100644 index 00000000..f2430ae1 --- /dev/null +++ b/stacks/authentik/guest.tf @@ -0,0 +1,214 @@ +# ============================================================================= +# Public Guest user + auto-login flow + public proxy provider + dedicated +# outpost. +# +# Backs the `auth = "public"` tier of the ingress_factory module. Architecture: +# +# * `guest` user (in `Public Guests` group, NOT `Allow Login Users`). +# * `public-auto-login` flow: anonymous user enters → expression policy sets +# `pending_user = guest` → user_login stage logs them in. No UI shown. +# * `Provider for Public` proxy provider (forward_domain, cookie_domain +# `viktorbarzin.me`) with `authentication_flow = public-auto-login`. +# * Dedicated `Public Outpost` Deployment+Service (managed by Authentik's +# K8s controller). Bound to the public provider only — there is no other +# provider claiming `viktorbarzin.me` on this outpost, so every request +# it sees runs the public flow regardless of host. +# * `public-auth.viktorbarzin.me` ingress exposes the public outpost's +# `/outpost.goauthentik.io/*` path so OAuth callbacks land on it (the +# embedded outpost doesn't know about the public provider, so callbacks +# can't go to authentik.viktorbarzin.me). +# +# Traffic flow for a stranger hitting an `auth = "public"` ingress: +# 1. Traefik's `authentik-forward-auth-public` middleware → public outpost. +# 2. No session cookie → 302 to `https://authentik.viktorbarzin.me/...` +# with redirect_uri = `https://public-auth.viktorbarzin.me/.../callback`. +# 3. Authentik runs `public-auto-login` (no UI), issues session. +# 4. 302 → public-auth.viktorbarzin.me callback → public outpost validates +# state and sets `authentik_proxy_` cookie on `viktorbarzin.me`. +# 5. 302 → original URL → Traefik retries forward_auth → public outpost +# validates cookie → 200 with `X-authentik-username: guest`. +# +# A user already logged into anything else on viktorbarzin.me (the catchall) +# still gets recognised here — Authentik prefers an existing session and the +# public provider's authorization_flow auto-approves anyone, so their real +# username shows up in `X-authentik-username`. Strangers get `guest`. +# ============================================================================= + +resource "authentik_user" "guest" { + username = "guest" + name = "Guest" + path = "users/system" + is_active = true + type = "internal" + # No password set: the user_login stage in `public_auto_login` logs the + # request in via pending_user pre-set by an expression policy. There is no + # UI path for `guest` to authenticate via password — the user is also kept + # out of `Allow Login Users`, so even a leaked password cannot be used to + # complete the standard login flow. + lifecycle { + ignore_changes = [attributes, email] + } +} + +resource "authentik_group" "public_guests" { + name = "Public Guests" + users = [authentik_user.guest.id] + # NOT a child of "Allow Login Users" — keeps a hypothetical leaked password + # from promoting `guest` to a real user via the standard login flow. +} + +# Pre-stage policy: sets pending_user = guest before user_login stage runs. +# Mutates `request.context["flow_plan"].context["pending_user"]` — the +# canonical pattern (the user_login stage reads pending_user from +# `flow_plan.context`). Direct `request.context["pending_user"]` mutations +# don't propagate, since policy request.context is not the same dict as +# flow_plan.context. +resource "authentik_policy_expression" "set_guest_user" { + name = "set-public-guest-user" + execution_logging = true + expression = trimspace(<<-EOT + request.context["flow_plan"].context["pending_user"] = ak_user_by(username="guest") + return True + EOT + ) +} + +# Dedicated user_login stage for the public flow. 4-week session matches the +# default authentication stage; means a stranger only goes through the auto- +# bind once per ~month per device. +resource "authentik_stage_user_login" "public_guest_login" { + name = "public-guest-login" + session_duration = "weeks=4" +} + +# `authentication = "none"` lets anonymous requests run the flow. +# `designation = "authentication"` because the flow's outcome is "request is +# now authenticated as guest"; the public proxy provider's authorization_flow +# then runs implicit consent. +resource "authentik_flow" "public_auto_login" { + name = "Public Auto Login" + slug = "public-auto-login" + title = "Public Guest Login" + designation = "authentication" + authentication = "none" +} + +resource "authentik_flow_stage_binding" "public_login" { + target = authentik_flow.public_auto_login.uuid + stage = authentik_stage_user_login.public_guest_login.id + order = 10 + # Re-evaluate at stage runtime: at plan time, flow_plan may not yet be in + # request.context, so the expression policy's mutation would no-op. With + # evaluate_on_plan=false + re_evaluate_policies=true, the policy fires + # right before the stage runs, when flow_plan is fully populated. + evaluate_on_plan = false + re_evaluate_policies = true +} + +resource "authentik_policy_binding" "set_guest_before_login" { + target = authentik_flow_stage_binding.public_login.id + policy = authentik_policy_expression.set_guest_user.id + order = 0 +} + +# ----------------------------------------------------------------------------- +# Public proxy provider — forward_domain so it claims any host on +# viktorbarzin.me. Used only on the dedicated `public` outpost (where it is +# the sole bound provider), so there's no dispatch ambiguity with the +# catchall (which lives on the embedded outpost). +# ----------------------------------------------------------------------------- +resource "authentik_provider_proxy" "public" { + name = "Provider for Public" + mode = "forward_domain" + external_host = "https://public-auth.viktorbarzin.me" + cookie_domain = "viktorbarzin.me" + + # When a request hits with NO Authentik session, this flow runs first and + # auto-binds the request to the `guest` user (no UI prompt). + authentication_flow = authentik_flow.public_auto_login.uuid + # Once authenticated (or already authenticated), implicit-consent auto-approves. + authorization_flow = data.authentik_flow.default_authorization_implicit_consent.id + invalidation_flow = data.authentik_flow.default_provider_invalidation.id + + access_token_validity = "weeks=4" + + lifecycle { + ignore_changes = [property_mappings, jwt_federation_sources, skip_path_regex, internal_host, basic_auth_enabled, basic_auth_password_attribute, basic_auth_username_attribute, intercept_header_auth] + } +} + +resource "authentik_application" "public" { + name = "Public" + slug = "public" + protocol_provider = authentik_provider_proxy.public.id + # No bound policies. policy_engine_mode = "any" + zero bindings = everyone + # passes (the auto-login flow has already established `guest` as the user). + policy_engine_mode = "any" + + lifecycle { + ignore_changes = [meta_description, meta_launch_url, meta_icon, group, backchannel_providers, open_in_new_tab] + } +} + +# Dedicated outpost so the public provider can claim viktorbarzin.me without +# colliding with the catchall (which already claims viktorbarzin.me on the +# embedded outpost). Authentik's K8s controller deploys this as +# `ak-outpost-public` (Deployment + Service in the `authentik` namespace). +resource "authentik_outpost" "public" { + name = "public" + type = "proxy" + protocol_providers = [authentik_provider_proxy.public.id] + service_connection = "99e227a7-4562-4888-9660-4c27da678c50" + config = jsonencode({ + log_level = "info" + docker_labels = null + authentik_host = "https://authentik.viktorbarzin.me/" + docker_network = null + container_image = null + docker_map_ports = true + refresh_interval = "minutes=5" + kubernetes_replicas = 1 + kubernetes_namespace = "authentik" + authentik_host_browser = "" + object_naming_template = "ak-outpost-%(name)s" + authentik_host_insecure = false + kubernetes_service_type = "ClusterIP" + kubernetes_ingress_path_type = null + kubernetes_image_pull_secrets = [] + kubernetes_ingress_class_name = null + kubernetes_disabled_components = [] + kubernetes_ingress_annotations = {} + kubernetes_ingress_secret_name = "authentik-outpost-tls" + kubernetes_httproute_annotations = {} + kubernetes_httproute_parent_refs = [] + kubernetes_json_patches = { + deployment = [ + { + op = "add" + path = "/spec/template/spec/containers/0/resources" + value = { limits = { memory = "256Mi" }, requests = { cpu = "10m", memory = "64Mi" } } + }, + ] + } + }) +} + +# Ingress for `public-auth.viktorbarzin.me` — exposes the public outpost's +# /outpost.goauthentik.io/* path so OAuth callbacks land on it. The +# `Provider for Public` external_host points here, so all redirect_uris in +# the OAuth flow resolve to this hostname. +module "ingress_public_outpost" { + source = "../../modules/kubernetes/ingress_factory" + namespace = "authentik" + name = "public-outpost" + host = "public-auth" + service_name = "ak-outpost-public" + port = 9000 + ingress_path = ["/outpost.goauthentik.io"] + tls_secret_name = var.tls_secret_name + dns_type = "proxied" + anti_ai_scraping = false + exclude_crowdsec = true + homepage_enabled = false + depends_on = [authentik_outpost.public] +} diff --git a/stacks/traefik/modules/traefik/middleware.tf b/stacks/traefik/modules/traefik/middleware.tf index 632b3639..bd09b67e 100644 --- a/stacks/traefik/modules/traefik/middleware.tf +++ b/stacks/traefik/modules/traefik/middleware.tf @@ -26,7 +26,8 @@ resource "kubernetes_manifest" "middleware_rate_limit" { depends_on = [helm_release.traefik] } -# Authentik forward auth middleware +# Authentik forward auth middleware (default — login required). +# Used by ingress_factory `auth = "required"`. resource "kubernetes_manifest" "middleware_authentik_forward_auth" { manifest = { apiVersion = "traefik.io/v1alpha1" @@ -54,6 +55,46 @@ resource "kubernetes_manifest" "middleware_authentik_forward_auth" { depends_on = [helm_release.traefik] } +# Authentik forward auth — public tier. Calls the dedicated public outpost +# (`ak-outpost-public.authentik.svc`) where the `Public` proxy provider is the +# only bound provider, so every request runs the `public-auto-login` flow and +# auto-binds anonymous users to the `guest` user. Users with an existing +# Authentik session keep their real identity in `X-authentik-username`. +# Used by ingress_factory `auth = "public"`. +# +# This is intentionally a different upstream from the standard middleware +# (which targets the embedded outpost via the auth-proxy nginx fallback). The +# `?app=` query param is NOT a working dispatch knob in current Authentik — +# the embedded outpost dispatches by Host header alone, and the catchall's +# forward_domain mode already claims viktorbarzin.me, so the only way to +# isolate the public flow is via a dedicated outpost. +resource "kubernetes_manifest" "middleware_authentik_forward_auth_public" { + manifest = { + apiVersion = "traefik.io/v1alpha1" + kind = "Middleware" + metadata = { + name = "authentik-forward-auth-public" + namespace = kubernetes_namespace.traefik.metadata[0].name + } + spec = { + forwardAuth = { + address = "http://ak-outpost-public.authentik.svc.cluster.local:9000/outpost.goauthentik.io/auth/traefik" + trustForwardHeader = true + authResponseHeaders = [ + "X-authentik-username", + "X-authentik-uid", + "X-authentik-email", + "X-authentik-name", + "X-authentik-groups", + "Set-Cookie", + ] + } + } + } + + depends_on = [helm_release.traefik] +} + # IP allowlist for local-only access resource "kubernetes_manifest" "middleware_local_only" { manifest = {