Voraussetzung: Zugang zum Namespace
library. Statt eines echten ng build-Outputs steht eine
winzige Hand-HTML-Datei stellvertretend für „die gebaute SPA“ – die
Ausliefer- und Config-Mechanik ist real und identisch. Schritt 3 braucht keinen
Cluster, nur openssl lokal.
1 Eine SPA per nginx ausliefern
Ein gebautes Frontend ist nur ein Ordner statischer Dateien – ausgeliefert von einem winzigen Nicht-Root-nginx.
cat <<'EOF' | oc apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: demo-spa-dist
namespace: library
data:
index.html: |
<!doctype html><html><body><h1>demo-spa</h1>
<p>Deep-Link-Test: <a href="/kunde/42">/kunde/42</a></p></body></html>
nginx.conf: |
server {
listen 8080;
root /usr/share/nginx/html;
location / { try_files $uri /index.html; }
}
EOF
cat <<'EOF' | oc apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-spa
namespace: library
spec:
replicas: 1
selector: { matchLabels: { app: demo-spa } }
template:
metadata: { labels: { app: demo-spa } }
spec:
containers:
- name: nginx
image: registry.access.redhat.com/ubi9/nginx-124:latest
ports: [{ containerPort: 8080 }]
volumeMounts:
- { name: dist, mountPath: /usr/share/nginx/html/index.html, subPath: index.html }
- { name: dist, mountPath: /etc/nginx/nginx.conf.d/spa.conf, subPath: nginx.conf }
volumes:
- name: dist
configMap: { name: demo-spa-dist }
EOF
oc expose deployment/demo-spa -n library --port=8080
oc create route edge demo-spa -n library --service=demo-spa
HOST=$(oc get route demo-spa -n library -o jsonpath='{.spec.host}')
curl -sk "https://$HOST/" | head -3
curl -sk -o /dev/null -w "%{http_code}\n" "https://$HOST/kunde/42"
# 200 statt 404 - try_files reicht den Deep-Link an index.html weiter
ubi9/nginx-124 läuft schon als Nicht-Root auf Port 8080 – das
Standard-nginx-Image würde nach /var/cache/nginx schreiben
wollen und unter restricted-v2 scheitern (H2). Ohne
try_files $uri /index.html gäbe /kunde/42 ein echtes 404,
weil auf dem Server keine solche Datei existiert – das Routing macht Angular/React
erst im Browser.
2 Pfad-Routing gegen CORS live vergleichen
Zwei Hosts brauchen CORS, ein Host mit Pfad-Routing nicht – live am Preflight sichtbar.
# eine kleine API ohne CORS-Header, als zweiter Host:
oc create deployment demo-api -n library --image=registry.k8s.io/e2e-test-images/agnhost:2.39 \
-- /agnhost netexec --http-port=8080
oc expose deployment/demo-api -n library --port=8080
oc create route edge demo-api -n library --service=demo-api
API_HOST=$(oc get route demo-api -n library -o jsonpath='{.spec.host}')
FRONT_HOST=$(oc get route demo-spa -n library -o jsonpath='{.spec.host}')
# Preflight simulieren, als kaeme der Request vom Frontend-Host:
curl -sk -X OPTIONS "https://$API_HOST/hostname" \
-H "Origin: https://$FRONT_HOST" \
-H "Access-Control-Request-Method: GET" -i | grep -i "access-control"
# leer - keine CORS-Header, der Browser wuerde die Antwort blocken
# jetzt Pfad-Routing: dieselbe API zusaetzlich unter /api auf dem Frontend-Host
oc patch route demo-spa -n library --type=merge \
-p '{"spec":{"path":"/"}}'
cat <<EOF | oc apply -f -
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: demo-spa-api
namespace: library
spec:
host: $FRONT_HOST
path: /api
to: { kind: Service, name: demo-api }
port: { targetPort: 8080 }
tls: { termination: edge }
EOF
curl -sk "https://$FRONT_HOST/api/hostname"
# same-origin - kein Preflight noetig, kein CORS-Header noetig
curl ignoriert CORS – die Regel gilt nur im Browser. Der Beweis
hier ist strukturell: ohne Access-Control-Allow-Origin im Preflight
würde ein echter Browser die Antwort verwerfen, obwohl der Server geantwortet hat.
Mit Pfad-Routing entfällt die Frage komplett, weil beide Aufrufe von derselben
Origin ausgehen.
3 Ein echtes PKCE-Codepaar erzeugen
Die SPA ist ein public OIDC-Client ohne Secret – PKCE ersetzt es durch ein selbst erzeugtes Codepaar.
# code_verifier: 32 zufaellige Bytes, URL-safe base64 VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') echo "code_verifier: $VERIFIER" # code_challenge: S256(verifier), ebenfalls URL-safe base64 CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=') echo "code_challenge: $CHALLENGE" # die Authorize-URL, die die SPA im Browser aufrufen wuerde (Schritt 1 des Flows): echo "https://keycloak.example/realms/library/protocol/openid-connect/auth?\ response_type=code&client_id=library-frontend&\ redirect_uri=https://\$FRONT_HOST/callback&\ code_challenge=$CHALLENGE&code_challenge_method=S256&state=demo&nonce=demo"
Ab hier übernimmt ein echter Browser plus eine echte Keycloak-Instanz – der
Nutzer meldet sich an, Keycloak leitet mit einem kurzen code zurück, die
SPA tauscht code + code_verifier gegen ein
access_token. PKCE schützt genau hier: ein abgefangener
code allein nützt nichts, ohne den verifier, den nur diese
SPA-Instanz kennt.
Nie der Implicit-Flow
response_type=token ist abgekündigt – das Token landet direkt in
der URL und damit in der Browser-History. Token-Ablage im Speicher, nie in
localStorage wegen XSS.
4 env.js aus einer ConfigMap laden
Build once, deploy everywhere: dasselbe Image, andere Laufzeit-Config – ohne Rebuild.
oc create configmap demo-spa-env -n library \
--from-literal=env.js='window.__APP_CONFIG__ = { apiUrl: "/api", realm: "library-test" };'
oc set volume deployment/demo-spa -n library --add \
--configmap-name=demo-spa-env --mount-path=/usr/share/nginx/html/env.js --sub-path=env.js
oc rollout status deployment/demo-spa -n library
HOST=$(oc get route demo-spa -n library -o jsonpath='{.spec.host}')
curl -sk "https://$HOST/env.js"
# dieselbe App, andere Umgebung - nur die ConfigMap aendert sich:
oc set data configmap/demo-spa-env -n library \
--from-literal=env.js='window.__APP_CONFIG__ = { apiUrl: "/api", realm: "library-prod" };'
oc rollout restart deployment/demo-spa -n library
curl -sk "https://$HOST/env.js"
# realm hat sich geaendert - kein neues Image, kein neuer Build
Ein Mount wird nicht heiß neu geladen – deshalb der
oc rollout restart nach jeder Änderung
(workload-feinschliff-openshift.html).
In env.js gehören nur öffentliche Werte – die
Datei geht ungefiltert an jeden Browser, nie Secrets.
5 Typische Fallen
| Symptom | meist |
|---|---|
| Deep-Link direkt aufgerufen gibt 404 | try_files $uri /index.html fehlt in der nginx-Config |
CrashLoopBackOff beim Standard-nginx-Image | versucht als root nach /var/cache/nginx zu schreiben – ein Nicht-Root-Image nehmen |
| „Network error“ im Browser, Server hat aber geantwortet | fehlender CORS-Header – der Browser blockt die Antwort, nicht der Server |
PKCE-Austausch scheitert mit invalid_grant | code_verifier stimmt nicht zum code_challenge, oder der code wurde schon einmal eingelöst |
| neue API-URL kommt nicht an | ConfigMap geändert, aber kein oc rollout restart – der Mount aktualisiert sich nicht von selbst |
6 Wie das Projekt es macht
library-frontend ist ein ng build-Output, serviert von
ubi9/nginx auf Port 8080, mit einer edge-Route. Getrennte Hosts
für Frontend und api-gateway, CORS auf der api-gateway per Spring
@CrossOrigin, Bearer-Token statt Cookie – damit entfällt die
SameSite/Allow-Credentials-Frage aus Schritt 2 komplett. Die
API-URL, das Keycloak-Realm und die Client-id kommen per env.js,
nicht ins Build.