From 54239294b7db24eefdb852958d72935703d75a3e Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 1 Sep 2026 18:55:44 -0700 Subject: [PATCH] authentik: require flows + application on proxy providers; harden SSO e2e The loki/grafana/seerr proxy providers were script-provisioned with no flows and no Application: null flows made the embedded outpost's Rust deserializer reject the whole config (all guarded vhosts down after any outpost reload), and the missing apps bounced post-login to authentik's user UI instead of the app. Backfilled both live; documented the two provisioning rules and updated the provider inventory. SSO e2e now asserts the app landing (pathname leaves /if/, no IdP markers in body) instead of only the hostname. --- serverAppConfigs-docs/Dankclaw/Seerr.md | 12 +++++ .../Infrastructure/Authentik.md | 54 ++++++++++++++----- tests/e2e/specs/13-sso.spec.ts | 9 +++- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/serverAppConfigs-docs/Dankclaw/Seerr.md b/serverAppConfigs-docs/Dankclaw/Seerr.md index 3a6c3f9..cae09e1 100644 --- a/serverAppConfigs-docs/Dankclaw/Seerr.md +++ b/serverAppConfigs-docs/Dankclaw/Seerr.md @@ -20,6 +20,18 @@ Seerr is the unified request manager for the *arr stack (the maintained `seerr-t Seerr is browser-facing; the only sanctioned user path is the guard chain (Caddy → Anubis → Authentik SSO). `PublishPort=127.0.0.1:5055:5055` keeps the app off the LAN entirely while still letting the homepage probe agent and host-local checks reach it — the same loopback convention as other guarded hosts (see [[Homepage Design Grilling]]). +## Guard chain fix (2026-09-02) + +The `seerr-proxy` provider was provisioned by script with **no flows and no +Application** — silently broken in two ways: the null flows wedged the +embedded outpost's config deserializer (taking every guarded vhost down at +the next outpost reload), and the missing app bounced post-login to +authentik's own user UI instead of Seerr. Both backfilled live: the three +standard flows copied from a known-good provider, plus an application +(`seerr-guard`) linked to the provider. Verified by the hardened SSO sweep +(`tests/e2e/specs/13-sso.spec.ts`) — see [[Authentik]] for the two +provisioning rules this incident established. + ## *arr wiring Seerr reaches the *arr services on FrankBook-Pro (192.168.0.112, gluetun-NetNS stack inside the Mac's podman machine VM) by IP-based base URLs (`http://192.168.0.112:`), configured inside Seerr's own web settings — not in env or quadlet. The `.lan` hostname variant was deliberately dropped: addn-hosts records are for fleet public-domain hosts, and IP bases match the Mac stack's verified LAN reachability. API keys live in each arr's config.xml on the Mac and are entered through Seerr's settings store. diff --git a/serverAppConfigs-docs/Infrastructure/Authentik.md b/serverAppConfigs-docs/Infrastructure/Authentik.md index 146a2a9..892cae6 100644 --- a/serverAppConfigs-docs/Infrastructure/Authentik.md +++ b/serverAppConfigs-docs/Infrastructure/Authentik.md @@ -98,28 +98,53 @@ routing table, and the Authentik proxy-provider list must be the same set of hosts. A guarded host with no provider 404s at login time; a provider with no DNS record breaks off-LAN clients. -Live provider inventory (provisioned via ORM, 2026-08-30), all attached to -outpost `7e39655b-…` ("authentik Embedded Outpost"), all with -`internal_host = http://caddy:8017` and all three flows set: +Live provider inventory (verified 2026-09-02), all attached to outpost +`7e39655b-…` ("authentik Embedded Outpost"), all with +`internal_host = http://caddy:8017`, all three flows set, and each linked to +an Application (see the provisioning rules below): | Provider pk | name | external_host | app slug | |---|---|---|---| | 1 | embedded-outpost | `https://www.franksdank.cloud` | caddy | | 2 | search-proxy | `https://search.franksdank.cloud` | search | -| 3 | hermes-proxy | `https://hermes.franksdank.cloud` | hermes | | 4 | prometheus-proxy | `https://prometheus.franksdank.cloud` | prometheus | | 5 | romm-proxy | `https://romm.franksdank.cloud` | romm | | 6 | files-proxy | `https://files.franksdank.cloud` | files | +| 7 | loki-proxy | `https://loki.franksdank.cloud/` | loki | +| 8 | grafana-proxy | `https://grafana.franksdank.cloud/` | grafana-guard | +| 10 | seerr-proxy | `https://seerr.franksdank.cloud/` | seerr-guard | -Provisioning pitfall (verified live): a ProxyProvider created with only -`authentication_flow` set — `authorization_flow`/`invalidation_flow` null — -makes the embedded outpost thread fail its own config fetch: the Rust side -logs `Failed to fetch outpost from API, retrying in 3 seconds` forever and -the entire `/outpost.goauthentik.io` space 404s for every host. Backfill all -three flows (pk1's values: `default-authentication-flow`, -`default-provider-authorization-implicit-consent`, -`default-provider-invalidation-flow`) and the retry loop recovers on its next -3-second cycle. +Provider 3 (`hermes-proxy`) still exists but is dormant — its vhost was +decommissioned from Caddy on 2026-08-31. Provider 9 (`grafana`) and 12 +(`Seerr OIDC`) are OAuth2 relying-party providers, not proxy providers; the +outpost does not deserialize them. Trailing slashes on `external_host` (the +runbook-recipe style) and bare origins both intercept correctly — the outpost +matches on hostname. + +Two provisioning rules, each verified live by an outage: + +1. **All three flows are required.** A ProxyProvider created via raw API/ORM + with any of `authentication_flow`/`authorization_flow`/`invalidation_flow` + null wedges the embedded outpost's config fetch: the Rust side rejects the + whole config JSON (serde: `invalid type: null, expected a formatted UUID + string`), logs `Failed to fetch outpost from API, retrying in 3 seconds` + forever, and **everything behind the outpost dies at the next outpost + reload** — including hosts whose providers were fine (verified 2026-09-02 + when three providers provisioned without any flows took down every guarded + vhost after a server restart). Backfill all three flows (copy from pk 1: + `default-authentication-flow`, + `default-provider-authorization-implicit-consent` (the implicit-consent + authorization flow, `default-invalidation-flow`) and the retry loop + recovers on its next 3-second cycle. The silent killer: while the outpost + process keeps its last good in-memory config, everything works — the + outage only surfaces on restart/reload. +2. **Every proxy provider needs an attached Application.** The outpost + resolves the post-login redirect through the provider's application; a + provider with no app completes the login (authentik session established) + but bounces to authentik's own user UI (`/if/user/#/library`) instead of + the app. No error is logged anywhere — the SSO "works" and the app never + appears. Create the Application with `provider_id` set (the UI does this + automatically; raw API/ORM drives must do it explicitly). ## OAuth2 relying-party quirks (this fork, verified live 2026-08-31) @@ -275,7 +300,8 @@ state and passed with no login prompt (2 tests, 2.9 s). | "Invalid signature" on the outpost | `AUTHENTIK_SECRET_KEY` differs between server and worker | Both services read the same `.env` var; re-deploy | | Postgres "password authentication failed" | `AUTHENTIK_PG_PASS` changed after first init | Reset via `pg_ctl` or re-init the data dir | | "Request has been denied: Invalid next URL" at sign-in | Flow entered with an absolute cross-host `next=` (old redirect design) | Do not hand-craft flow URLs; enter guarded hosts normally — the outpost carries state in its own cookie | -| `/outpost.goauthentik.io/*` 404s on every host | A provider row with null `authorization_flow`/`invalidation_flow` wedges the outpost config fetch | Backfill the flows; watch `podman logs authentik-server` for `Failed to fetch outpost from API` | +| `/outpost.goauthentik.io/*` 404s on every host | Any provider row with a null flow (`authentication`/`authorization`/`invalidation`) wedges the outpost config fetch — all guarded vhosts die at the next outpost reload | Backfill all three flows on every provider; watch `podman logs authentik-server` for `Failed to fetch outpost from API` | +| Login completes but lands on authentik's user UI (`/if/user/…`) instead of the app | Proxy provider has no attached Application (outpost cannot resolve the post-login redirect) | Create the Application with `provider_id` set — no error is logged, so test with a fresh browser context | | Off-LAN clients get "hostname could not be found" | Guarded host missing from the public DNS zone | Every host in the lockstep set needs a public A record (Porkbun) as well as the Pi-hole LAN answer | | Correct membership but one user still 403s at a header gate | Outpost claim cache: a `proxysession` row minted before the membership change serves old claims until expiry | Delete that user's `proxysession` rows (section above) — next visit re-authorizes silently | diff --git a/tests/e2e/specs/13-sso.spec.ts b/tests/e2e/specs/13-sso.spec.ts index 03ca4b3..ad53984 100644 --- a/tests/e2e/specs/13-sso.spec.ts +++ b/tests/e2e/specs/13-sso.spec.ts @@ -23,8 +23,15 @@ for (const vhost of GUARDED) { await expect .poll(() => new URL(page.url()).hostname, { timeout: 60_000 }) .toBe(new URL(vhost).hostname); + // A bounce to authentik's UI would satisfy the hostname assert above (the + // outpost serves /if/flow on the guarded vhost itself), so require the + // path to have left authentik's namespace and the page to not be the IdP. + await expect + .poll(() => new URL(page.url()).pathname.startsWith("/if/"), { timeout: 60_000 }) + .toBe(false); const body = await page.locator("body").innerText(); - expect(body).not.toContain("Sign in"); // authentik identification stage + expect(body).not.toContain("Powered by authentik"); // authentik flow shell + expect(body).not.toContain("Sign in"); // generic login form expect(body).not.toContain("Making sure you're not a bot"); const resp = await page.request.get(page.url()); expect(resp.status()).toBeLessThan(500);