Zum Inhalt

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_secret wird vom Backend angehängt; die SPA sendet nur code und code_verifier.
  • Verwahrung des Refresh-Tokens — HttpOnly, Secure, SameSite=Lax, auf den Pfad /oidc beschränkt und bei jedem Refresh rotiert.
  • Origin-Schranke — eine Refresh-Anfrage mit einem Origin-Header, der nicht in allowed_origins steht, wird mit 403 abgewiesen.
  • 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:

  1. 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.
  2. Dienst — wittgenstein-oidc-backend prüft das Token erneut (validate) oder vertraut dem Gateway (trust) und erzwingt dann Rollen pro Route.
  3. Zustandsändernde Aufrufe — POSTs zwischen Diensten nutzen ein CSRF-Double-Submit-Token (Cookie und X-CSRF-Token-Header müssen übereinstimmen).
  4. 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.