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 committed 2026-09-01 18:55:44 -07:00
1 parent ffae9b5e21
commit 54239294b7
3 files changed
+60 -15

No files matched your search

+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]]). 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 ## *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. 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 hosts. A guarded host with no provider 404s at login time; a provider with no
DNS record breaks off-LAN clients. DNS record breaks off-LAN clients.
Live provider inventory (provisioned via ORM, 2026-08-30), all attached to Live provider inventory (verified 2026-09-02), all attached to outpost
outpost `7e39655b-…` ("authentik Embedded Outpost"), all with `7e39655b-…` ("authentik Embedded Outpost"), all with
`internal_host = http://caddy:8017` and all three flows set: `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 | | Provider pk | name | external_host | app slug |
|---|---|---|---| |---|---|---|---|
| 1 | embedded-outpost | `https://www.franksdank.cloud` | caddy | | 1 | embedded-outpost | `https://www.franksdank.cloud` | caddy |
| 2 | search-proxy | `https://search.franksdank.cloud` | search | | 2 | search-proxy | `https://search.franksdank.cloud` | search |
| 3 | hermes-proxy | `https://hermes.franksdank.cloud` | hermes |
| 4 | prometheus-proxy | `https://prometheus.franksdank.cloud` | prometheus | | 4 | prometheus-proxy | `https://prometheus.franksdank.cloud` | prometheus |
| 5 | romm-proxy | `https://romm.franksdank.cloud` | romm | | 5 | romm-proxy | `https://romm.franksdank.cloud` | romm |
| 6 | files-proxy | `https://files.franksdank.cloud` | files | | 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 Provider 3 (`hermes-proxy`) still exists but is dormant — its vhost was
`authentication_flow` set — `authorization_flow`/`invalidation_flow` null — decommissioned from Caddy on 2026-08-31. Provider 9 (`grafana`) and 12
makes the embedded outpost thread fail its own config fetch: the Rust side (`Seerr OIDC`) are OAuth2 relying-party providers, not proxy providers; the
logs `Failed to fetch outpost from API, retrying in 3 seconds` forever and outpost does not deserialize them. Trailing slashes on `external_host` (the
the entire `/outpost.goauthentik.io` space 404s for every host. Backfill all runbook-recipe style) and bare origins both intercept correctly — the outpost
three flows (pk1's values: `default-authentication-flow`, matches on hostname.
`default-provider-authorization-implicit-consent`,
`default-provider-invalidation-flow`) and the retry loop recovers on its next Two provisioning rules, each verified live by an outage:
3-second cycle.
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) ## 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 | | "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 | | 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 | | "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 | | 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 | | 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 await expect
.poll(() => new URL(page.url()).hostname, { timeout: 60_000 }) .poll(() => new URL(page.url()).hostname, { timeout: 60_000 })
.toBe(new URL(vhost).hostname); .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(); 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"); expect(body).not.toContain("Making sure you're not a bot");
const resp = await page.request.get(page.url()); const resp = await page.request.get(page.url());
expect(resp.status()).toBeLessThan(500); expect(resp.status()).toBeLessThan(500);