OIDC & Token-Sicherheit
Jeder Wittgenstein-Dienst delegiert die Identität über OpenID Connect (OIDC) mit PKCE an Keycloak, und jede auf der Plattform gebaute Anwendung erhält dieselbe gehärtete Token-Verarbeitung von Haus aus über zwei gemeinsame Bibliotheken:
| Bibliothek | Läuft in | Aufgabe |
|---|---|---|
wittgenstein-oidc-backend (Python) |
API / BFF | Token-Austausch als vertraulicher Client, Verwahrung des Refresh-Tokens, JWT-Prüfung, Rollenprüfung, JIT-Benutzerprovisionierung |
@wittgenstein/oidc-client (npm) |
Browser-SPA | PKCE-Login, /oidc-Endpunkt im selben Origin, automatischer Refresh und Wiederholung bei 401 |
Ziel ist eine einzige, geprüfte Implementierung der Teile von OIDC, bei denen sich leicht subtile Fehler einschleichen — damit keine Anwendung Token-Speicherung, Refresh oder Signaturprüfung neu erfinden muss.
Sicherheit durch Konstruktion
Eine Anwendung, die beide Bibliotheken einsetzt, legt nie ein Refresh-Token gegenüber JavaScript offen, liefert nie ein Client-Secret an den Browser aus und akzeptiert nie ein unsigniertes Token, wo ein signiertes verlangt wird. Das sind Eigenschaften der Bibliotheken, keine Konventionen, die jedes Team im Kopf behalten muss.
Bedrohungsmodell im Überblick
| Bedrohung | Gegenmaßnahme |
|---|---|
| XSS stiehlt eine langlebige Sitzung | Das Refresh-Token lebt nur in einem HttpOnly-Cookie; JavaScript sieht nur ein kurzlebiges Access-Token |
| Abfangen des Authorization-Codes | PKCE (S256) bindet den Code an den Browser, der den Ablauf gestartet hat |
| Client-Secret aus der SPA geleakt | Das Backend ist der vertrauliche Client; das Secret verlässt nie den Server |
| Cross-Site-Anfrage erzwingt einen Token-Refresh | SameSite=Lax-Cookie plus eine Origin-Allowlist beim Refresh-Grant |
| Gestohlene Zugangsdaten per Password-Grant wiederverwendet | Direct Access Grants (ROPC) deaktiviert bei SPA-Clients |
| Gefälschtes oder manipuliertes JWT | RS256-Signaturprüfung gegen das JWKS des Realms, inklusive Issuer-Prüfung |
| Brute-Force-Login-Versuche | Brute-Force-Erkennung und Sperre auf Realm-Ebene |
| Veraltete oder widerrufene Sitzung bleibt bestehen | Ein abgelehnter Refresh entfernt das Cookie; die SPA fällt auf den Login-Redirect zurück |
| Schwacher Ein-Faktor-Login | E-Mail-Verifizierung und MFA werden am IdP durchgesetzt |
Referenzarchitektur
Der Browser spricht ausschließlich mit seinem eigenen Origin. Die Route /oidc wird vom Backend der Anwendung bedient, das der eigentliche OIDC-Client ist.
sequenceDiagram
participant User as Benutzer
participant SPA as React-SPA<br/>(@wittgenstein/oidc-client)
participant BFF as Backend<br/>(wittgenstein-oidc-backend)
participant KC as Keycloak
User->>SPA: Klickt auf "Anmelden"
SPA->>SPA: Erzeugt code_verifier + code_challenge (S256)
SPA->>KC: Redirect zu /auth (code_challenge)
KC-->>User: Login-Seite: Passwort, dann MFA
User->>KC: Zugangsdaten + zweiter Faktor
KC-->>SPA: Redirect zu /callback?code=...
SPA->>BFF: POST /oidc/.../token (code, code_verifier)
BFF->>KC: POST /token (code, code_verifier, client_id + client_secret)
KC-->>BFF: access_token, id_token, refresh_token
BFF-->>SPA: access_token, id_token + Set-Cookie: refresh_token (HttpOnly, Secure, SameSite=Lax)
Note over SPA,BFF: Das Refresh-Token erreicht nie JavaScript
SPA->>BFF: API-Aufruf mit Bearer access_token
BFF-->>SPA: 401 (Access-Token abgelaufen)
SPA->>BFF: POST /oidc/.../token (grant_type=refresh_token)
BFF->>KC: refresh_token aus dem Cookie + Client-Secret
KC-->>BFF: neue Tokens (Refresh-Token rotiert)
BFF-->>SPA: neues access_token + rotiertes Cookie
SPA->>BFF: Wiederholt den ursprünglichen Aufruf
Backend: wittgenstein-oidc-backend
Der Token-Endpunkt in einem Aufruf
create_oidc_bff_router baut den /oidc-Router im selben Origin. Er tauscht den Authorization-Code aus, führt Refresh-Grants durch, entfernt das Refresh-Token aus dem JSON-Body und legt es in ein Cookie und leitet JWKS/Userinfo/Logout weiter.
from fastapi import FastAPI
from wittgenstein_oidc_backend import create_oidc_bff_router
app = FastAPI()
app.include_router(
create_oidc_bff_router(
authority="https://auth.example.com/realms/acme",
client_id="acme-app",
client_secret=settings.oidc_client_secret, # bleibt serverseitig
allowed_origins=["https://app.example.com"], # sichert den Refresh-Grant ab
),
prefix="/oidc",
)
Das erhalten Sie ohne weiteren Code:
- Vertraulicher Austausch — das
client_secretwird vom Backend angehängt; die SPA sendet nurcodeundcode_verifier. - Verwahrung des Refresh-Tokens —
HttpOnly,Secure,SameSite=Lax, auf den Pfad/oidcbeschränkt und bei jedem Refresh rotiert. - Origin-Schranke — eine Refresh-Anfrage mit einem
Origin-Header, der nicht inallowed_originssteht, wird mit403abgewiesen. - Fail-closed-Entfernung — lehnt Keycloak einen Refresh ab (abgelaufen, widerrufen, wiederverwendet), wird das tote Cookie gelöscht und der nächste Versuch läuft über einen vollständigen Login.
Tokens prüfen
decode_token spiegelt die beiden Modi der JWT-Richtlinie von AgentGateway wider, sodass ein Dienst am Gateway, im Dienst selbst oder an beiden Stellen geschützt werden kann:
| Modus | Verhalten | Einsatz, wenn |
|---|---|---|
"validate" |
Vollständige RS256-Signaturprüfung gegen das Realm-JWKS (mit TTL-Cache) plus Issuer-Prüfung | Das Token den Dienst direkt erreicht |
"trust" |
Nur Dekodierung, keine Signaturprüfung | Ein vorgelagertes Gateway das Token bereits validiert hat |
import functools
from wittgenstein_oidc_backend import JwksClient, OidcMiddleware, decode_token, has_role
issuer = "https://auth.example.com/realms/acme"
jwks = JwksClient(f"{issuer}/protocol/openid-connect/certs")
decode = functools.partial(decode_token, mode="validate", jwks_client=jwks, issuer=issuer)
app.add_middleware(OidcMiddleware, decode=decode) # parst das Token einmal pro Anfrage
@app.post("/access")
@has_role(["admin", "manager"]) # 401 ohne gültiges Token, 403 ohne die Rolle
async def update_access(): ...
trust ist Opt-in
Der Modus trust ist nie ein stiller Standard. Verwenden Sie ihn nur hinter einem Gateway, das Signaturen prüft; andernfalls validate.
Identitätsprovisionierung
jit_provision_user(payload, store) findet oder erstellt den lokalen Benutzer hinter einer Keycloak-Identität und synchronisiert dessen Rollen. Die Funktion basiert auf einem kleinen UserStore-Protokoll, funktioniert daher mit jedem ORM und fügt der Bibliothek keine Datenbankabhängigkeit hinzu.
Frontend: @wittgenstein/oidc-client
Die SPA hält nie ein Refresh-Token, daher erfolgt die stille Erneuerung bei Bedarf — nur dann, wenn die API meldet, dass das Access-Token nicht mehr gültig ist:
import { OidcProvider, createAuthFetch } from "@wittgenstein/oidc-client";
let apiFetch = fetch;
<OidcProvider
authority="https://auth.example.com/realms/acme"
clientId="acme-app"
onManager={(userManager) => { apiFetch = createAuthFetch(userManager); }}
>
{children}
</OidcProvider>
createAuthFetch hängt das Access-Token an und führt bei 401/403 einmal einen Refresh über das Backend durch und wiederholt die Anfrage, statt den Benutzer abzumelden. Wer mitten in einem langen Formular steckt, verliert seine Eingaben nicht, wenn ein Token abläuft.
Verteidigung in der Tiefe
Die Token-Verarbeitung ist nur eine von mehreren Schichten:
- Edge — TLS wird an AgentGateway terminiert, das JWTs gegen das Keycloak-JWKS validiert (über den
extProc-Sidecar) und vertrauenswürdige Identitäts-Header an die Upstreams weitergibt. - Dienst —
wittgenstein-oidc-backendprüft das Token erneut (validate) oder vertraut dem Gateway (trust) und erzwingt dann Rollen pro Route. - Zustandsändernde Aufrufe —
POSTs zwischen Diensten nutzen ein CSRF-Double-Submit-Token (Cookie undX-CSRF-Token-Header müssen übereinstimmen). - Identity Provider — Brute-Force-Erkennung, E-Mail-Verifizierung und MFA sowie ein Realm-/Client-Modell pro Anwendung.
Selbst überprüfen
Bestätigen Sie die genannten Eigenschaften in der Shell. Ersetzen Sie den Host durch Ihre eigene Umgebung.
$ curl -s -o /dev/null -D - -X POST https://app.example.com/oidc/protocol/openid-connect/token \
-d "grant_type=refresh_token" | grep -i -E "^(HTTP|set-cookie)"
HTTP/2 400
$ # es wurde kein Refresh-Cookie gesendet, also lehnt das Backend ab, statt zu raten
$ curl -s -X POST https://app.example.com/oidc/protocol/openid-connect/token \
-H "Origin: https://evil.example.net" -d "grant_type=refresh_token"
{"error":"origin not allowed"}
$ # die Origin-Schranke weist Cross-Site-Refresh-Versuche ab
$ curl -s https://auth.example.com/realms/acme/.well-known/openid-configuration | jq '.code_challenge_methods_supported'
[
"plain",
"S256"
]
Dekodieren, nicht vertrauen
Zum Debuggen decodieren Sie ein Token lokal (jq -R 'split(".")[1] | @base64d | fromjson') statt in einem Online-Dienst. Access-Tokens sind Zugangsdaten.