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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user