#!/usr/bin/env bats # Seam: Authentik (SSO / OIDC provider) — the homelab's auth layer. # # Authentik is a 3-service stack (PostgreSQL + server + worker) on dankclaw. # It protects the vhosts that have no application-level authentication # (SearXNG, Hermes, Prometheus, RomM, Copyparty, the homepage) with its # embedded proxy outpost. # # The Caddyfile carries an `auth.franksdank.cloud` vhost for the admin UI, and # an internal `:8016` listener that Anubis dials after verifying a client. # :8016 proxies every request whole to the Authentik server with the Host # header preserved; the embedded outpost routes on the ORIGINAL Host against # each proxy provider's external_host (meta headers do NOT route it — # disproved live, 2026-08-29) and forwards authenticated traffic back to the # post-auth listener `:8017`. # # See ../docs/Infrastructure/Authentik.md. load helpers COMPOSE="$REPO_ROOT/dankclaw/compose.yaml" CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile" ENV_EXAMPLE="$REPO_ROOT/dankclaw/.env.example" # --- Quadlet units: the 3-service stack ------------------------------------ # The trio left compose: they are boot-persistent Quadlet units installed to # ~/.config/containers/systemd/ by deploy-remote.sh (deploy_authentik). UNIT_DIR="$REPO_ROOT/dankclaw/authentik/quadlet" @test "the three authentik quadlet units exist" { for unit in authentik-postgres authentik-server authentik-worker; do [ -f "$UNIT_DIR/$unit.container" ] done } @test "authentik-postgres uses the official postgres image" { grep -q 'Image=docker.io/library/postgres:16-alpine' "$UNIT_DIR/authentik-postgres.container" } @test "server and worker use the official goauthentik image" { grep -q 'Image=ghcr.io/goauthentik/server:' "$UNIT_DIR/authentik-server.container" grep -q 'Image=ghcr.io/goauthentik/server:' "$UNIT_DIR/authentik-worker.container" } @test "the same image runs both roles; the Exec command selects the role" { grep -qE '^Exec=server$' "$UNIT_DIR/authentik-server.container" grep -qE '^Exec=worker$' "$UNIT_DIR/authentik-worker.container" } @test "startup order is postgres -> server -> worker via unit ordering" { grep -q 'After=network.target authentik-postgres.service' "$UNIT_DIR/authentik-server.container" grep -q 'Wants=authentik-postgres.service' "$UNIT_DIR/authentik-server.container" grep -q 'Wants=authentik-server.service' "$UNIT_DIR/authentik-worker.container" } @test "the server publishes 9000 on loopback only; LAN reaches it through Caddy" { grep -q 'PublishPort=127.0.0.1:9000:9000' "$UNIT_DIR/authentik-server.container" } @test "authentik-postgres healthcheck uses pg_isready" { grep -q 'HealthCmd=.*pg_isready' "$UNIT_DIR/authentik-postgres.container" } # --- Units: environment (secrets stay in the host .env, expanded at start) -- @test "the units consume AUTHENTIK_PG_PASS via systemd expansion, not literals" { grep -q 'POSTGRES_PASSWORD=${AUTHENTIK_PG_PASS}' "$UNIT_DIR/authentik-postgres.container" grep -q 'PASSWORD=${AUTHENTIK_PG_PASS}' "$UNIT_DIR/authentik-server.container" grep -q 'PASSWORD=${AUTHENTIK_PG_PASS}' "$UNIT_DIR/authentik-worker.container" } @test "the units consume AUTHENTIK_SECRET_KEY via systemd expansion, not literals" { for unit in authentik-server authentik-worker; do grep -q 'AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}' "$UNIT_DIR/$unit.container" done } @test "no secret material is committed in the quadlet units" { ! grep -rqE 'SECRET_KEY=[^$]' "$UNIT_DIR" } @test "deploy-remote.sh routes the authentik trio to the quadlet handler, not compose up" { grep -q 'authentik-postgres|authentik-server|authentik-worker)' "$REPO_ROOT/scripts/deploy-remote.sh" grep -q 'deploy_authentik()' "$REPO_ROOT/scripts/deploy-remote.sh" ! grep -qE '^[[:space:]]+comfyui\|.*authentik' "$REPO_ROOT/scripts/deploy-remote.sh" } # --- Caddyfile: the auth vhost --- @test "the Caddyfile has an auth.franksdank.cloud vhost" { grep -qE '^auth\.franksdank\.cloud[[:space:]]*\{' "$CADDYFILE" } @test "the auth vhost proxies to authentik-server:9000" { local block block=$(awk '/^auth\.franksdank\.cloud/,/^}/' "$CADDYFILE") echo "$block" | grep -q 'reverse_proxy authentik-server:9000' } # --- Caddyfile: guarded vhosts proxy the whole request through Authentik ---- @test "guarded vhosts proxy the whole request through authentik on the internal listener" { awk '/^http:\/\/:8016/,/^\}/' "$CADDYFILE" | grep -q 'reverse_proxy authentik-server:9000' } @test "the internal listener preserves Host and never emits cross-host next=" { # The outpost routes on the original Host: no header_up Host override... ! grep -q 'header_up Host' "$CADDYFILE" # ...and the rejected design (absolute cross-host next= into the flow UI) # must never return — it produces "Invalid next URL" at flow completion. ! grep -qE 'if/flow/[^ ]*next=https' "$CADDYFILE" } @test "authenticated traffic returns to Caddy on the post-auth listener" { grep -qE '^http://:8017 \{' "$CADDYFILE" } # --- Homepage: www holds the homepage; naked redirects onto it --- @test "the homepage vhost is www, guarded by Anubis" { local block block=$(awk '/^www\.franksdank\.cloud[[:space:]]*\{/,/^}/' "$CADDYFILE") echo "$block" | grep -q 'reverse_proxy anubis:8923' } @test "the naked domain redirects to www at the TLS edge" { local block block=$(awk '/^franksdank\.cloud[[:space:]]*\{/,/^}/' "$CADDYFILE") echo "$block" | grep -q 'redir https://www.franksdank.cloud/ 308' } @test "dankclaw.franksdank.cloud redirects straight onto www" { local block block=$(awk '/^dankclaw\.franksdank\.cloud/,/^}/' "$CADDYFILE") echo "$block" | grep -q 'redir https://www.franksdank.cloud/ 308' } @test "dankserver.franksdank.cloud redirects straight onto www" { local block block=$(awk '/^dankserver\.franksdank\.cloud/,/^}/' "$CADDYFILE") echo "$block" | grep -q 'redir https://www.franksdank.cloud/ 308' } @test "the bare-IP catch-all redirects onto www" { local block block=$(awk '/^:80 \{/,/^}/' "$CADDYFILE") echo "$block" | grep -q 'redir https://www.franksdank.cloud/ 308' } # --- .env.example: authentik placeholders --- @test ".env.example carries AUTHENTIK_PG_PASS" { grep -q 'AUTHENTIK_PG_PASS=' "$ENV_EXAMPLE" } @test ".env.example carries AUTHENTIK_SECRET_KEY" { grep -q 'AUTHENTIK_SECRET_KEY=' "$ENV_EXAMPLE" } @test ".env.example authentik placeholders are not real secrets" { # Placeholders must be obvious (REPLACE_WITH or empty), not 64-char strings. local pg sk pg=$(grep '^AUTHENTIK_PG_PASS=' "$ENV_EXAMPLE" | cut -d= -f2-) sk=$(grep '^AUTHENTIK_SECRET_KEY=' "$ENV_EXAMPLE" | cut -d= -f2-) [ ${#pg} -lt 32 ] || { echo "AUTHENTIK_PG_PASS looks like a real secret (len=${#pg})"; return 1; } [ ${#sk} -lt 32 ] || { echo "AUTHENTIK_SECRET_KEY looks like a real secret (len=${#sk})"; return 1; } }