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.
Ein gebautes Angular- oder React-Frontend ist nur ein Ordner statischer Dateien – HTML, JS, CSS, keine Server-Logik. Zwei Wege, sie auszuliefern: ein winziges nginx-Image mit den Assets oder ein nodejs-S2I-Build. Für eine reine SPA gewinnt nginx. Davor eine edge-Route (B4).
ng build liefert nur dist/ – HTML, JS, CSS. Am besten ein winziges nginx-Image (unter 40 MB) mit den Assets, einer try_files-Regel für das SPA-Routing und Port 8080 statt 80 (SCC, A7). Ein nodejs-S2I-Build lohnt nur, wenn wirklich ein Node-Server zur Laufzeit gebraucht wird. Davor eine edge-Route (B4) auf den Service. Gebaut wird außerhalb, die Laufzeit-Config kommt separat (J4).- ein gebautes Frontend (
ng build/npm run build) ist nur ein Ordner statischer Dateien (dist/) – keine Serverlogik, keine Umgebungslese - Weg A – nginx-Container:
dist/nach/usr/share/nginx/html, eigenenginx.conf, winziges Image, kein Node zur Laufzeit - die
nginx.confbrauchttry_files $uri /index.htmlfür das SPA-History-Routing – sonst geben Deep-Links 404 - Port 8080 statt 80 – unter
restricted-v2darf ein Nicht-Root-Container keinen Port unter 1024 binden (A7) - nimm ein Nicht-Root-Image:
ubi9/nginx-124odernginxinc/nginx-unprivileged– das Standard-nginx-Image will nach/var/cache/nginxschreiben (H2) - Weg B – nodejs-S2I (
ubi9/nodejs-20):assemblebaut,runstartet einen kleinen Node-Server – schwerer, weil Node zur Laufzeit läuft - für eine reine statische SPA gewinnt nginx – nodejs-S2I nur bei echtem Server-Bedarf (SSR, Next.js, API-Routen)
- die Route ist
edgeTLS (B4) und zeigt auf denService(B3) auf Port 8080 - Cache-Header:
index.htmlnie cachen, die gehashten Bundles (main.a1b2.js) sehr lange – sonst hängen alte Clients an alten Bundles - die SPA außerhalb bauen (CI, E1) und ein kleines Image mit den Assets nach
ghcr.iopushen – kein Build-Pod im Cluster (C2) - die Laufzeit-Config (Backend-URL, Keycloak-Realm) kommt nicht ins Build – sie wird per
env.jszur Laufzeit geladen (J4) - Bibliothek:
library-frontendist einng build-Output, serviert vonubi9/nginxauf 8080, mit eineredge-Route - OCP-Unterschied: keiner – nginx und Node laufen wie überall. OpenShift verlangt nur den Nicht-Root-Port und liefert die Route (B4)
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.
Wie erreicht die SPA das Backend? Entweder zwei Hosts (Frontend und API je eine Route) – dann redet der Browser cross-origin und die API braucht CORS. Oder ein Host mit Pfad-Routing (/ zum Frontend, /api zur API) – same-origin, kein CORS, ein Zertifikat. Pfad-Routing ist einfacher, koppelt aber die Deploys.
Access-Control-Allow-Origin …) – sonst blockt der Browser die Antwort. Teilen sie sich einen Host mit Pfad-Routing (/ und /api, per spec.path auf der Route, B4), ist alles same-origin: kein CORS, ein Zertifikat, Cookies funktionieren. CORS schützt den Nutzer, nicht den Server – ein Bearer-Token (J3) macht es einfacher.- Option A – zwei Hosts: Frontend und API je eine Route – die SPA ruft eine andere Origin, also cross-origin
- cross-origin → CORS: der Browser macht erst eine
OPTIONS-Preflight, die API mussAccess-Control-Allow-Origin/-Methods/-Headerssenden - fehlt ein CORS-Header, blockt der Browser die Antwort – die App sieht nur einen Netzwerkfehler, der Server hat trotzdem geantwortet
- Option B – ein Host, Pfad-Routing: eine Route je Pfad (edge,
spec.path, B4),/zum Frontend-Service,/apizur api-gateway (B3) - same-origin → kein CORS, kein Preflight, ein Zertifikat, Cookies funktionieren – die SPA ruft einfach relativ
/api/… - Abwägung: Pfad-Routing koppelt die zwei Deploys unter einem Host, getrennte Hosts sind sauberere Grenzen (aber CORS-Pflege)
- CORS ist eine Browser-Regel –
curlignoriert sie. Sie schützt den Nutzer vor fremden Seiten, nicht den Server - Wildcard
Allow-Origin: *geht nicht zusammen mitAllow-Credentials: true– bei Cookies den Origin explizit setzen - Cookies cross-site brauchen
SameSite=NonemitSecureplusAllow-Credentials: true– ein Bearer-Token (J3) umgeht das ganz - Alternative zu beidem: ein Reverse-Proxy im Frontend-Pod (nginx
location /api { proxy_pass http://api-gateway:8080; }) – die SPA sieht nur ihre eigene Origin - bei getrennten Hosts muss die SPA die API-URL kennen – nicht ins Build backen, sondern per
env.js(J4) - Bibliothek: getrennte Hosts + CORS auf der api-gateway (Spring
@CrossOrigin), Bearer-Token statt Cookie, API-URL perenv.js - OCP-Unterschied: keiner – CORS ist Browser + App. OpenShift liefert die Route(n) und das Pfad-Routing über
spec.path
# 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.
Die SPA ist ein public OIDC-Client – sie hat kein Client-Secret, denn alles im Browser-JS ist öffentlich. Sie nutzt den Authorization-Code-Flow mit PKCE: eine Kette von Redirects zwischen Browser, Keycloak und SPA, an deren Ende ein access_token (JWT) steht. Die api-gateway prüft nur noch die Signatur.
code_verifier bleibt im Browser, code_challenge geht mit zum /authorize. Keycloak schickt nach dem Login einen kurzen code zurück, den die SPA zusammen mit dem code_verifier gegen ein access_token (JWT) tauscht. Die api-gateway prüft nur die Signatur gegen Keycloaks JWKS – kein Server-State. redirect_uri, web_origins, Realm und Client-id müssen exakt passen (env.js, J4).- die SPA ist ein public OIDC-Client – kein Client-Secret, weil alles im Browser-JS öffentlich ist
- sie nutzt den Authorization-Code-Flow mit PKCE:
code_verifierbleibt im Browser,code_challenge = S256(verifier)geht mit - Schritt 1: Redirect zu Keycloaks
/authorizemitresponse_type=code,client_id,redirect_uri,code_challenge,state,nonce - Schritt 2: der Nutzer meldet sich bei Keycloak an – Keycloak ist hier der IdP der App (eigene Route, B4), nicht der Cluster-OAuth (A4)
- Schritt 3: Keycloak leitet zurück zur registrierten
redirect_urimit?code=SplxlOBeZQQYbYS6&state=f8a2c91d– nur ein kurzer Code, kein Token - Schritt 4: die SPA POSTet
code + code_verifieran/token→access_token(JWT) +id_token+refresh_token - Schritt 5: jede API-Anfrage trägt
Authorization: Bearer <access_token>, die api-gateway prüft die Signatur gegen die JWKS (kein Keycloak-Call) - PKCE schützt davor, dass ein abgefangener
codevon jemand anderem eingelöst wird – nur wer denverifierhat, kommt an das Token - nie der Implicit-Flow (
response_type=token) – abgekündigt, Token landet in der URL-History - was matchen muss:
redirect_uriexakt registriert,web_originsfür CORS des/token-Calls (J2), Realm + Client-id - Realm, Client-id und Keycloak-URL sind öffentlich → sie kommen per
env.jsin die SPA (J4), nicht ins Build - Token-Ablage: im Speicher am sichersten,
sessionStoragegeht, nielocalStoragewegen XSS – Refresh perrefresh_tokenoder stillemprompt=none-iframe - Logout = Keycloaks
/logoutaufrufen und die lokalen Tokens löschen - OCP-Unterschied: keiner – reines OIDC. Keycloak läuft als App im Cluster, unabhängig vom Cluster-OAuth-Server (A4)
# 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.
Eine statische SPA wird einmal gebaut und kann zur Laufzeit keine Environment-Variablen lesen – im Browser gibt es kein process.env. Das env.js-Muster löst das: das Build lädt vor dem App-Bundle ein env.js, das window.__APP_CONFIG__ setzt. Diese Datei ist nicht im Image – sie kommt beim Start aus einer ConfigMap (B8), als Volume-Mount. So läuft dasselbe Image überall mit anderer Config, ohne Rebuild.
index.html lädt vor dem App-Bundle ein env.js, das window.__APP_CONFIG__ setzt. Diese Datei ist nicht im Image, sondern kommt aus einer ConfigMap (B8), als Volume-Mount nach /usr/share/nginx/html/env.js. Dasselbe Image läuft in Dev, Stage und Prod – nur die ConfigMap unterscheidet sich. Änderung greift erst nach oc rollout restart deployment/library-frontend. In env.js gehören nur öffentliche Werte, nie Secrets.- eine statische SPA wird einmal gebaut und kann zur Laufzeit keine Env-Vars lesen – im Browser gibt es kein
process.env - der falsche Weg: URLs hart ins Bundle (
environment.prod.ts) → jede Umgebung braucht ihr eigenes Image, ein Fehler heißt neu bauen - Grundregel build once, deploy everywhere: das getestete Artefakt ist genau das, was ausgeliefert wird
- das env.js-Muster:
index.htmllädt<script src="env.js">vor dem App-Bundle env.jssetztwindow.__APP_CONFIG__auf{ apiUrl, keycloakUrl, realm }, die App liest das beim Startenv.jsist nicht im Build – im Image liegt nur ein Platzhalter- zur Laufzeit kommt die echte Datei aus einer ConfigMap (B8), als Volume-Mount nach
/usr/share/nginx/html/env.js– keinenv - pro Umgebung eine eigene ConfigMap: gleiche Keys, andere Werte, dasselbe Image
- Alternative: ein Entrypoint-Skript füllt per
envsubsteine Vorlage aus den Pod-env-Vars, oder ein/config-Sidecar - ConfigMap geändert →
oc rollout restart deployment/library-frontend, ein Mount wird nicht heiß neu geladen (B8, C6) - in
env.jsgehören nur öffentliche Werte – die Datei geht ungefiltert an jeden Browser, nie Secrets - dieselben Werte füttern J2 (API-URL fürs Pfad-Routing) und J3 (Keycloak-URL, Realm, Client-id)
- Server-Frameworks (Next.js SSR, Nuxt) brauchen das nicht – die lesen echte
env-Vars im Node-Prozess
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.