feat(oauth2-proxy): forward-auth ext_authz enforcement mechanism + group floor

Build the deferred forward-auth ENFORCEMENT for non-OIDC UIs (pihole/*arr/
qbittorrent/homepage; Crafty next), gated by Keycloak via oauth2-proxy.

- helmrelease: add `--allowed-groups=platform-admins` (R-IDP-06 floor:
  authenticated AND in group) + `--skip-provider-button` (302 straight to
  Keycloak; verified the interstitial 403 sign-in page otherwise).
- forward-auth-cec.yaml: interim self-authored CiliumClusterwideEnvoyConfig
  ext_authz listener -> oauth2-proxy /oauth2/auth, failure_mode_allow:false,
  per-route opt-in via virtualHost. TEMPLATE — not Flux-wired (PM deploys with
  an L2 VIP + DNS repoint). The ext_authz stanza is verified in-cluster.
- README: document the mechanism, why per-route ext_authz on the shared Gateway
  is NOT clean on Cilium 1.16.5 (machine-owned cilium-gateway-shared CEC; native
  ExternalAuth/GEP-1494 in no released Cilium), the three options + recommendation,
  the opt-in steps, the in-cluster proof, and the human browser test.

Keycloak `oauth2-proxy` client already carries the callback redirect URI + a
bare `groups` claim (full.path=false) — no realm change needed.

Proof (cilium-envoy v1.30.8): a standalone envoy running this exact ext_authz
stanza against the live oauth2-proxy denied unauthenticated requests (403,
oauth2-proxy sign-in body; upstream never served), while a no-filter control
route on the same backend returned 200. R-IDP-02 property holds.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-28 15:18:47 -05:00
1 parent 3c7d7e0a1b
commit 3e7e9386a0
3 files changed
+257 -9

No files matched your search

+66 -4
View File
@@ -21,8 +21,70 @@ Pairs with [`../keycloak`](../keycloak) to realize the "SSO/auth contract": a Se
forward-auth` service is protected by oauth2-proxy at the Gateway (ext-auth); `auth: oidc` services go
native to Keycloak. Gateway ext-auth wiring is a **WS-NET** contract.
## TODO (later phases — Phase 6)
## Enforcement (ext_authz forward-auth)
- OIDC issuer URL to the Keycloak realm; client registration.
- Gateway forward-auth / ext-authz wiring (WS-NET); group-based authorization; MFA enforced in
Keycloak upstream.
The **authenticator** (oauth2-proxy) is deployed and wired to Keycloak: `provider=oidc`,
`oidc-issuer-url=.../realms/homelab`, `--reverse-proxy`, `--upstream=static://202`,
`--set-xauthrequest`, cookie/redirect domain `.home.keithjackson.dev`, and — added here —
`--allowed-groups=platform-admins` (R-IDP-06 floor) + `--skip-provider-button` (302 straight to
Keycloak). The Keycloak `oauth2-proxy` client already carries the `auth.home…/oauth2/callback`
redirect URI and a `groups` claim with `full.path=false` (bare `platform-admins`) — no realm change
needed.
**Mechanism.** Forward-auth is an Envoy `ext_authz` HTTP filter that calls oauth2-proxy's
`/oauth2/auth` decision endpoint: **202** for a valid session in `platform-admins` (request passes to
the upstream, `X-Auth-Request-*` identity headers forwarded), **401/403** otherwise. With
`failure_mode_allow: false` the gateway **fails closed** — a denied or errored check never reaches the
upstream UI (R-IDP-02). The filter + oauth2-proxy cluster live in
[`forward-auth-cec.yaml`](./forward-auth-cec.yaml).
### Why this is NOT wired into the shared Gateway (Cilium 1.16.5 constraint)
The shared Gateway's Envoy program is a **machine-owned, constantly-regenerated**
`CiliumEnvoyConfig` (`gateway/cilium-gateway-shared`, `ownerReferences->Gateway`, regenerated on every
HTTPRoute change). Cilium 1.16.5 exposes **no supported hook** to inject an `ext_authz` filter into
that generated listener, and native Gateway-API **ExternalAuth (GEP-1494) ships in no released Cilium
version yet** (PoC only). So there is **no clean per-route ext_authz on the shared Gateway today**.
**Options (PM decision):**
| # | Approach | Pros | Cons |
|---|---|---|---|
| A | **Interim self-authored CEC** — a dedicated ext_authz HTTPS listener on its own L2 VIP (`forward-auth-cec.yaml`), protected hosts moved off the shared Gateway and DNS repointed. | Works on 1.16.5 **today**; per-route opt-in; reuses the wildcard TLS via SDS; **verified below**. | Hand-authored TLS+routes; a 2nd ingress VIP + DNS; per-app maintenance; not Gateway-API-native. |
| B | **oauth2-proxy in reverse-proxy mode per app** — app HTTPRoute `backendRef` -> a per-app oauth2-proxy (`--upstream=http://app.svc`). | Fully Gateway-API-native (just a backendRef swap); robust; no CEC. | One oauth2-proxy instance/config per app; more pods; not the single-proxy ext_authz model. |
| C | **Upgrade Cilium** to a release with native `ExternalAuth` (GEP-1494) and gate routes with an HTTPRoute filter. | Cleanest, truly per-route, Crafty-friendly. | Not released yet; large jump from 1.16.5; must keep the Ansible-bootstrap/Flux **parity contract** in lock-step. |
**Recommendation:** ship the oauth2-proxy hardening now (done); adopt **A** as the interim enforcement
for the sensitive UIs when the Phase-2 L2 VIP pool lands; track **C** as the long-term target and drop
A once released.
### Opt-in (per route)
A route is protected **only** by being listed as a virtualHost on the ext_authz listener — apps not
listed keep hitting the shared Gateway. To protect an app (pihole today; **Crafty next**): add its
Service + EDS cluster + virtualHost to `forward-auth-cec.yaml`, **remove its HTTPRoute from the shared
Gateway**, and repoint its DNS at the ext_authz VIP. The ServiceDefinition already declares
`auth.mode: forward-auth` + `groups: [platform-admins]` (rendered onto the HTTPRoute annotations) — the
opt-in signal. Ready to extend to Crafty and the other non-OIDC UIs (PiHole/*arr/qBittorrent/Homepage).
### Proof (in-cluster, cilium-envoy v1.30.8, 2026-08-28)
A throwaway standalone `cilium-envoy` pod ran the exact `ext_authz` stanza from `forward-auth-cec.yaml`
against the live oauth2-proxy Service, with a no-filter **control** route on the same backend:
- **Control** (`:8081`, no ext_authz) -> **200** `CONTROL-OK`.
- **Protected** (`:8080`, ext_authz), unauthenticated -> **403**; body is oauth2-proxy's sign-in page,
**not** the upstream (`UPSTREAM-OK` never served). oauth2-proxy's own access log recorded the
`/oauth2/auth` call from the envoy pod. `failure_mode_allow: false` confirmed fail-closed.
This proves the R-IDP-02 property (unauthenticated cannot reach the upstream) on this exact Envoy. The
**allow path** (valid `platform-admins` session -> 202 -> upstream) needs a real browser round-trip.
### Browser test (for the human, once `forward-auth-cec.yaml` is deployed)
1. Incognito -> `https://pihole.home.keithjackson.dev` -> expect a 302 to
`https://keycloak.home.keithjackson.dev/realms/homelab/...` (not the PiHole UI).
2. Log in as a **platform-admins** user (MFA/OTP enforced) -> land back on the PiHole UI.
3. Log in as a **non-platform-admins** user -> expect **403** (authenticated but not authorized).
4. Confirm the session cookie is `.home.keithjackson.dev`-scoped (single sign-on across the other
protected UIs).