Files
home-network-k3s/decisions/adr-0022-forward-auth-per-app-oauth2-proxy-sidecars.md
jakman85andClaude Opus 5.5 250fb54154 docs: bring the specification in line with the accepted implementation
The operator ruled the implementation repo's main (50c8719) the latest
and accepted state. Sync the spec to it.

Accepted changes are reflected: one bare-metal environment, TrueNAS bulk
storage, R2 off-cluster backup, CloudNativePG, single-node VPN gateway
with Cilium fail-closed, inbound WireGuard, per-app auth sidecars,
Renovate, the CI gate as approver, and the pm-agent. New ADR-0017..0025;
ADR-0016 corrected and accepted; R-* IDs unchanged, eight amended.

Where the implementation has simply not met a requirement, the
requirement stands and the shortfall is recorded in docs/16-known-gaps.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:46:00 -06:00

4.3 KiB

ADR-0022 — Forward-auth via per-app oauth2-proxy sidecars

  • Status: Accepted
  • Date: 2026-11-08
  • Accepted by: operator ruling of 2026-11-08 that the implementation is the accepted state; effective on merge.
  • Serves: R-IDP-02, R-IDP-06
  • Supersedes: ADR-0014 — enforcement point and app list only: the "at the Gateway" clause and the list of forward-auth apps in its Decision. Everything else in ADR-0014 stands.

Context

ADR-0014 put a single oauth2-proxy at the Gateway as an external-authorization check in front of every app without usable OIDC. That design needs a per-route authorization hook on the shared Gateway, and the platform's Gateway implementation does not offer a usable one:

  • Cilium generates and owns the shared Gateway's Envoy configuration, regenerating it on every route change, with no supported way to inject an authorization filter into it.
  • Cilium's native Gateway API ExternalAuth filter needs the experimental Gateway API CRD channel, a cluster-wide change, and shipped with a fail-open bug in the very property R-IDP-02 forbids. It was evaluated and deferred.
  • Without a gate, the bare UIs stayed reachable unauthenticated on the LAN.

Crafty's web UI was already gated by a co-located oauth2-proxy, which proved the pattern on this platform. Detail: docs/plans/cilium-native-externalauth-evaluation.md in the implementation repo.

Decision

Forward-auth is enforced by a per-app oauth2-proxy running as a reverse-proxy sidecar in the app's pod, in the data path, rather than by a shared check at the Gateway.

  • The app's route targets the sidecar's port. Network policy opens only that port to the Gateway, so the bare UI port is unreachable from it and a failed or absent gate fails closed.
  • Each gate is its own instance: its own IdP client, its own SOPS-encrypted credentials, and the authorized groups declared in the service's auth block.
  • Session cookies are per-instance: a unique cookie name per gate, scoped to the app's own host. Instances hold different cookie secrets, so a shared name on a zone-wide domain makes them evict one another's sessions.
  • An app MAY declare the path prefixes of its programmatic API, so unauthenticated calls there are refused rather than redirected to an HTML login page.
  • The catalog renderer generates the sidecar for every forward-auth service with an exposed UI.

App list (replaces ADR-0014's). Gated this way: PiHole, Sonarr, Radarr, Jackett, qBittorrent and the Crafty web UI. Homepage has no usable OIDC and requires a gate under R-IDP-02. Prometheus, Alertmanager and Hubble UI are not exposed, so no gate applies; any future exposure MUST be gated.

Alternatives considered

  • Self-authored Envoy listener with an authorization filter on its own VIP — works, and was proven against the live proxy, but needs hand-maintained TLS and routes per app, a second ingress address and DNS repointing. Rejected; retained in the implementation repo as an unwired template.
  • Cilium native ExternalAuth — the cleanest shape and the long-term target, one per-route filter and no per-app proxies. Deferred: experimental CRDs, an immature security-critical filter, thin documentation. A future ADR may adopt it and retire the sidecars.

Consequences

  • Enforcement is Gateway-API-native (a backend swap) and does not depend on the Gateway's generated configuration.
  • More moving parts: one proxy, one IdP client and one credential set per gated app. Adding a gated app needs all three.
  • Sessions are per-host. A live Keycloak session makes the bounce silent, so SSO still holds, but a browser performs one redirect per app.
  • An expired session answers an unauthenticated XHR with a redirect that resolves as a successful HTML response, which is why API paths are declarable and why each gate exposes metrics for scraping (10).
  • Crafty is platform-managed outside the renderer, so its gate is hand-maintained and must follow the same per-instance cookie rule.
  • A UI whose pod the renderer does not generate, such as Homepage, gets a gate only by explicit work (known gaps).
  • 15 carries the resulting design; ADR-0014's text is not edited.