Files
renovate-bot 517dfc9030
gate / gate (pull_request) Successful in 3m0s
gate / conformance (pull_request) Has been skipped
chore(deps): Update docker.io/library/alpine Docker tag to v3.24
2026-11-07 08:17:05 +00:00

144 lines
7.8 KiB
YAML

---
# oauth2-proxy — forward-auth for apps WITHOUT usable OIDC (PiHole, *arr UIs, qBittorrent, Homepage,
# Prometheus, Alertmanager, Hubble UI — ADR-0014). Authenticates users against the Keycloak `homelab`
# realm, then the Gateway ext-auth lets the request through only for an authenticated session
# (R-IDP-02: no management UI unauthenticated). Chart image is quay.io/oauth2-proxy (not Bitnami).
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: oauth2-proxy
namespace: oauth2-proxy
spec:
interval: 30m
chart:
spec:
chart: oauth2-proxy
version: ">=10.0.0 <11.0.0" # k8s-1.36 catch-up (app v7.15.x). Renovate maintains
sourceRef:
kind: HelmRepository
name: oauth2-proxy
namespace: oauth2-proxy
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
values:
replicaCount: ${OAUTH2_PROXY_REPLICAS} # overlay knob: dev 1 / prod 2 (HA) — R-ENV-02
config:
# client-id / client-secret / cookie-secret from the SOPS secret (name only). client-id +
# client-secret MUST match the realm's `oauth2-proxy` client (keycloak-realm-clients secret).
existingSecret: oauth2-proxy-credentials
extraArgs:
provider: oidc
oidc-issuer-url: https://keycloak.${BASE_DOMAIN}/realms/homelab
# Forward-auth (ext-auth) mode: no upstream app; return 202 for an authenticated session so the
# Gateway ext-authz can allow/deny. Auth headers are surfaced to the backend.
reverse-proxy: "true"
upstream: static://202
set-xauthrequest: "true"
pass-access-token: "true"
email-domain: "*"
# Single sign-in/callback host for all forward-auth-protected services on the platform zone.
redirect-url: https://auth.${BASE_DOMAIN}/oauth2/callback
whitelist-domain: ".${BASE_DOMAIN}"
cookie-domain: ".${BASE_DOMAIN}"
cookie-secure: "true"
# Expose group membership so per-service group authorization (R-IDP-06) can be enforced. The
# Keycloak `oauth2-proxy` client emits a `groups` claim (full.path=false => bare names like
# `platform-admins`, keycloak-realm-import.yaml). `--allowed-groups` below is the PLATFORM FLOOR:
# a valid Keycloak session is NOT enough — the user must ALSO be in `platform-admins`, or
# /oauth2/auth returns 403 and ext_authz denies (R-IDP-06). Every currently-onboarded
# forward-auth app (pihole, *arr, qbittorrent, homepage, prometheus, alertmanager, hubble)
# declares `groups: [platform-admins]` in its ServiceDefinition, so this floor matches them all.
# Per-ROUTE group differentiation (a future app allowing a different group) does NOT need a
# second proxy: the ext_authz filter appends `?allowed_groups=<group>` to /oauth2/auth from the
# HTTPRoute's `homelab.keithjackson.dev/auth-groups` annotation — oauth2-proxy honours the
# per-request query on top of this floor. See forward-auth-cec.yaml + README "opt-in".
oidc-groups-claim: groups
allowed-group: platform-admins # R-IDP-06 floor: authenticated AND in platform-admins (oauth2-proxy flag is singular --allowed-group, repeatable)
# Single Keycloak provider => skip the oauth2-proxy interstitial "Sign In" button and 302
# straight to Keycloak. Verified in-cluster: WITHOUT this, an ext_authz-denied browser request
# gets oauth2-proxy's HTML sign-in page (HTTP 403) instead of an immediate redirect (docs/15 UX).
skip-provider-button: "true"
scope: "openid email profile"
# ── Cold-start gate: do not start the proxy until Keycloak's discovery doc answers ──────────
# MEASURED on prod 2026-10-17, during the UPS move: oauth2-proxy performs OIDC discovery ONCE at
# startup and EXITS 1 if it fails. On a whole-cluster cold boot Keycloak is not serving yet, so
# the Gateway answers 503 and the proxy dies:
#
# [provider.go:55] Performing OIDC Discovery...
# [main.go:73] ERROR: Failed to initialise OAuth2 Proxy: ... failed to discover OIDC
# configuration: unexpected status "503": no healthy upstream
#
# It then sits in exponential CrashLoopBackOff long after Keycloak is healthy -- one replica was
# still down minutes later and only a manual pod delete cleared it. There is no retry flag; the
# process simply does not survive a cold start. A readinessProbe cannot help (the container is
# already dead), and CrashLoopBackOff backoff is not tunable per-pod.
#
# `extraInitContainers` is the chart's OWN documented hook for this -- its commented example in
# values.yaml is literally `- name: wait-for-idp` -- and the chart already uses an alpine init
# container for its built-in waitForRedis gate, so this adds no new base image to the platform.
# A pod in Init does not accrue CrashLoopBackOff backoff and retries promptly, which is the whole
# point.
#
# BOUNDED, AND FAILS OPEN. After ~5 minutes it gives up and starts the proxy anyway: the worst
# case is then exactly today's behaviour, never something new. An unbounded wait would trade a
# CrashLoop for a pod wedged in Init forever, which is not an improvement.
#
# SHELL VARIABLES ARE WRITTEN BARE ($i, $url, $(( ))), WITH NO BRACED FORM ANYWHERE. This file is
# rendered through Flux envsubst (platform declares postBuild.substituteFrom cluster-vars), which
# CONSUMES braced expansions -- so a braced shell variable renders to the empty string and
# silently breaks the loop. BASE_DOMAIN below is braced ON PURPOSE: it is a real cluster-vars
# knob and must be substituted. Same hazard, and the same convention, as the gitea and nextcloud
# OIDC bootstrap hooks. (ci: flux-postbuild-substitution enforces it.)
extraInitContainers:
- name: wait-for-idp
image: docker.io/library/alpine:3.24
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
set -u
url=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration
i=0
while [ "$i" -lt 60 ]; do
# busybox wget validates against alpine's ca-certificates-bundle, so a real TLS
# failure is a non-zero exit here rather than a false pass.
if wget -q -O /dev/null --timeout=5 "$url" 2>/dev/null; then
echo "[wait-for-idp] Keycloak discovery reachable; starting oauth2-proxy"
exit 0
fi
i=$(( i + 1 ))
sleep 5
done
echo "[wait-for-idp] discovery still unreachable after ~5m; starting anyway so this" >&2
echo "[wait-for-idp] gate can never be worse than the un-gated behaviour it replaces" >&2
exit 0
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
# Metrics for the ServiceMonitor (../oauth2-proxy servicemonitor in platform-config).
metrics:
enabled: true
# ENFORCEMENT (Phase 6): oauth2-proxy is the AUTHENTICATOR; the per-route ext_authz ENFORCEMENT is
# Gateway-side. On Cilium 1.16.5 the shared Gateway's Envoy config is a machine-owned, constantly
# regenerated CiliumEnvoyConfig (`gateway/cilium-gateway-shared`) that CANNOT be injected into,
# and native Gateway-API ExternalAuth (GEP-1494) ships in NO released Cilium version yet. The
# enforcement mechanism + the three wiring options are documented in README.md; the interim
# self-authored ext_authz listener is forward-auth-cec.yaml (a template, not yet Flux-wired).