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.
This commit is contained in:
Frank
2026-09-01 18:55:44 -07:00
parent ffae9b5e21
commit 54239294b7
3 changed files with 60 additions and 15 deletions
+12
View File
@@ -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:<port>`), 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.
@@ -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 |
+8 -1
View File
@@ -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);