Keycloak “Invalid redirect_uri” hinter Caddy, Traefik und k3s – eine Spurensuche
Wenn nach dem Login bei Keycloak nur “Invalid parameter: redirect_uri” erscheint, liegt das Problem selten bei Keycloak selbst. In meinem Fall führte die Spurensuche durch drei Proxy-Schichten bis zu einer SNAT-Regel im Kubernetes-Netzwerk. Dieser Beitrag dokumentiert den Debugging-Weg von der Fehlermeldung bis zur Lösung.
Ausgangslage
Die Anwendung ist ein Spring Boot 3 Webprojekt, das per OAuth2/OIDC an ein selbstgehostetes Keycloak angebunden ist. Die Infrastruktur sieht so aus:
Browser
│ HTTPS
▼
Caddy (Reverse Proxy, TLS-Terminierung)
│ HTTP
▼
Traefik (k3s Ingress Controller)
│ HTTP
▼
Spring Boot Pod (Port 8080)
Beim Aufruf von https://myapp.fritz.box wurde ich korrekt zu Keycloak weitergeleitet – dort erschien jedoch nur eine Fehlerseite:
Invalid parameter: redirect_uri
Was bedeutet dieser Fehler?
Keycloak validiert bei jedem Authorization Request den Parameter redirect_uri gegen die im Client konfigurierten Valid Redirect URIs. Stimmt die URI nicht überein – auch nicht im Schema (http vs. https) – wird der Request abgelehnt.
Schritt 1: Die redirect_uri sichtbar machen
Ein curl -v -L auf die Anwendung zeigt die gesamte Redirect-Kette im Klartext:
$ curl -v -L https://myapp.fritz.box
Im location-Header des zweiten Redirects war der entscheidende Hinweis:
location: https://keycloak.fritz.box/.../auth?...&redirect_uri=http://myapp.fritz.box/login/oauth2/code/keycloak
Die redirect_uri verwendet http:// statt https://. Da im Keycloak-Client nur https://myapp.fritz.box/* als gültige Redirect-URI hinterlegt ist, schlägt die Validierung fehl.
Schritt 2: Warum http statt https?
Die Spring-Boot-Konfiguration verwendet {baseUrl} als Platzhalter für die redirect_uri:
# application.yml
spring:
security:
oauth2:
client:
registration:
keycloak:
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
Spring löst {baseUrl} anhand des eingehenden Requests auf. Da die App intern auf Port 8080 per HTTP lauscht, wird ohne weitere Konfiguration http:// verwendet. Damit Spring das ursprüngliche Schema des Clients erkennt, muss es die X-Forwarded-Proto-Header des vorgelagerten Reverse Proxy auswerten.
Dafür gibt es die Einstellung:
server:
forward-headers-strategy: framework
Diese stand allerdings nur im Profil application-postgres.yml und nicht in der allgemeinen application.yml. Ein schneller Fix – die Einstellung in die Haupt-YAML verschieben – und die App neu deployen. Doch der Fehler blieb.
Schritt 3: Funktioniert die App überhaupt mit den Headern?
Ein direkter Test im Pod bewies, dass Spring die Header korrekt auswertet:
$ kubectl exec <pod> -- wget -q -S \
--header="X-Forwarded-Proto: https" \
--header="X-Forwarded-Host: absence.fritz.box" \
-O /dev/null http://localhost:8080/
Location: https://myapp.fritz.box/oauth2/authorization/keycloak
Mit explizit gesetztem X-Forwarded-Proto: https baut Spring die korrekte https://-URL. Das Problem lag also nicht in der App, sondern darin, dass der Header auf dem Weg zum Pod verloren ging.
Schritt 4: Traefik und die X-Forwarded-Header
Caddy setzt bei reverse_proxy automatisch X-Forwarded-Proto, X-Forwarded-For und X-Forwarded-Host. Traefik hingegen überschreibt eingehende X-Forwarded-*-Header standardmäßig mit eigenen Werten – und da der Traffic am web-Entrypoint (HTTP) ankommt, setzt Traefik X-Forwarded-Proto: http.
Traefik kann angewiesen werden, bestehende Header von vertrauenswürdigen Quellen beizubehalten:
--entryPoints.web.forwardedHeaders.trustedIPs=192.168.178.71
Also die IP von Caddy als trusted konfiguriert, Traefik neu gestartet – und der Fehler blieb immer noch.
Schritt 5: Die unsichtbare SNAT-Regel
Der letzte Puzzlestein steckte im Kubernetes-Netzwerk. Der Traefik-Service war als LoadBalancer mit externalTrafficPolicy: Cluster konfiguriert:
spec:
type: LoadBalancer
externalTrafficPolicy: Cluster
Bei externalTrafficPolicy: Cluster leitet kube-proxy den Traffic über SNAT (Source Network Address Translation) weiter. Dabei wird die ursprüngliche Quell-IP von Caddy (192.168.178.71) durch eine Cluster-interne IP ersetzt (z.B. 10.42.x.x). Traefik sieht also nicht die IP von Caddy, sondern eine interne Adresse – die nicht in der trustedIPs-Liste steht. Folglich verwirft Traefik die eingehenden X-Forwarded-*-Header und überschreibt sie.
Die Lösung
Da externalTrafficPolicy: Local in einem Single-Node-k3s-Setup zwar funktionieren würde, aber bei Multi-Node-Szenarien zu Problemen führen kann, war die pragmatische Lösung: die gesamten privaten Netzwerkbereiche als vertrauenswürdig einstufen.
In k3s lässt sich die Traefik-Konfiguration sauber über eine HelmChartConfig-Resource anpassen, ohne das Deployment direkt zu editieren:
# helm/traefik-config.yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
ports:
web:
forwardedHeaders:
trustedIPs:
- "10.0.0.0/8"
- "192.168.0.0/16"
kubectl apply -f helm/traefik-config.yaml
k3s erkennt die geänderte Config und rollt Traefik automatisch mit dem neuen Parameter aus:
--entryPoints.web.forwardedHeaders.trustedIPs=10.0.0.0/8,192.168.0.0/16
Verifikation
Nach dem Rollout bestätigt ein erneuter curl-Test, dass beide Redirects nun https:// verwenden:
$ curl -s -D- -o /dev/null https://myapp.fritz.box/oauth2/authorization/keycloak \
| grep location:
location: https://keycloak.fritz.box/.../auth?...&redirect_uri=https://myapp.fritz.box/login/oauth2/code/keycloak
Der Keycloak-Login funktioniert.
Zusammenfassung
| Schicht | Problem | Fix |
|---|---|---|
| Spring Boot | forward-headers-strategy nur im Postgres-Profil | In die allgemeine application.yml verschoben |
| Traefik | Überschreibt X-Forwarded-Proto von Caddy | trustedIPs konfiguriert |
| kube-proxy | SNAT maskiert die Caddy-Quell-IP | Private Netzwerkbereiche als trusted eingetragen |
Die eigentliche Lektion: Bei mehreren Proxy-Schichten reicht es nicht, nur die erste und letzte Schicht zu konfigurieren. Jeder Hop auf dem Weg kann Header verändern oder verwerfen – und Kubernetes-Netzwerk-Policies wie externalTrafficPolicy können unsichtbar die Quell-IP umschreiben, sodass IP-basierte Trust-Regeln ins Leere laufen.