skills/onyva-login/SKILL.md aktualisiert

Major SKILL.md update: lessons learned from real integrations

- Add IdP-initiated SSO handling (Admin-Panel sends ?code= only, no state)
  → state missing must NOT trigger 400, otherwise admin-panel app tiles break
- Add Middleware-Reihenfolge warning (SessionMiddleware LIFO footgun)
- Add Auth-Guard distinction: HTML-Navigation 307 vs JSON-API 401
- Add Frontend 401-handling pattern (hard navigation, not fetch redirect)
- Add stdlib-only HTTP + JWT-decode helpers (urllib.request + base64url padding)
- Add Session-Storage options table (in-memory / SessionMiddleware / Redis)
- Add CLIENT_SECRET generation guidance (admin-panel vs locally)
- Add Smoke-Test mit curl (5 calls catching 90% of typical bugs)
- Add "replace existing auth" checklist (remove old constants/routes/HTML)
- Cookie flags: explain why samesite=lax (not strict) for OIDC callback
- Logout: two separate functions, FastAPI doesn't support decorator stacking
- Hard restart guidance for middleware iteration
- Many additional Fallstricke entries from real bugs
This commit is contained in:
2026-05-20 10:57:15 +00:00
parent 522740bc9e
commit 366edf55d9

View File

@@ -7,17 +7,28 @@ Deine Aufgabe ist es, den zentralen Onyva Dev Login (OIDC/SSO-Server unter `http
## Vorgehen
1. **Analysiere das Projekt zuerst** — Lies relevante Dateien (Framework, Routing, vorhandene Auth-Logik, Env-Konfiguration), bevor du Code schreibst.
1. **Analysiere das Projekt zuerst** — Lies relevante Dateien (Framework, Routing, vorhandene Auth-Logik, Env-Konfiguration), bevor du Code schreibst. Achte besonders auf bereits genutzten HTTP-Stack (`httpx`/`requests`/stdlib `urllib`) und vorhandene Session-Middleware.
2. **Frage nach fehlenden Infos** — Du brauchst:
- `OIDC_CLIENT_ID` und `OIDC_CLIENT_SECRET` (aus dem Admin-Panel von login.onyva.dev)
- `OIDC_REDIRECT_URI` (wohin der Auth-Server nach dem Login zurückleitet — muss im Admin-Panel eingetragen sein)
- `OIDC_CLIENT_ID` (registrierter Slug im Admin-Panel, **kein Zufallswert** — z.B. `mein-app-name`)
- `OIDC_CLIENT_SECRET` (siehe ["Wer erzeugt CLIENT_SECRET?"](#wer-erzeugt-client_secret) unten)
- `OIDC_REDIRECT_URI` (wohin der Auth-Server nach dem Login zurückleitet — muss im Admin-Panel **exakt** eingetragen sein, inkl. Port und Pfad, **ohne** trailing slash)
- `OIDC_LOGOUT_REDIRECT_URI` (wohin nach dem zentralen Logout weitergeleitet wird)
- Falls noch nicht bekannt: welche Routen geschützt sein sollen und welche öffentlich bleiben
3. **Implementiere die Integration** nach den technischen Vorgaben unten.
3. **Bei bestehendem Auth-System: alles Alte raus, bevor das Neue rein kommt.** Sonst existieren beide Systeme parallel. Typische Stellen:
- Alte Auth-Konstanten (`APP_USERNAME`, `APP_PASSWORD`, eigene Session-Secrets)
- Login-HTML/Form-Funktionen
- `POST /login`-Route und der dahinterliegende Credential-Check
- Session-Token-Helper (HMAC-Signaturen o.ä.) — **außer** du kannst das HMAC-Schema für den neuen Session-Cookie wiederverwenden
- Alte `.env`-Variablen aus `.env.example` entfernen
- Verweise im Frontend (z.B. Logo-Pfade in der Login-Page, Logout-Button-Targets)
4. **Passe `.env.example`** (oder äquivalente Konfiguration) mit allen nötigen Variablen an.
4. **Implementiere die Integration** nach den technischen Vorgaben unten.
5. **Passe `.env.example`** (oder äquivalente Konfiguration) mit allen nötigen Variablen an.
6. **Smoke-Test mit curl** (siehe [Smoke-Test in einer Zeile](#smoke-test-in-einer-zeile)) — die fünf Calls fangen 90% der typischen Bugs ab, bevor der User es im Browser klickt.
---
@@ -36,9 +47,35 @@ Deine Aufgabe ist es, den zentralen Onyva Dev Login (OIDC/SSO-Server unter `http
| `/oauth/jwks.json` | GET | Public Keys (RS256) |
| `/logout` | GET | Zentrales Logout |
### ⚠ Starlette/FastAPI: Middleware-Reihenfolge (der häufigste Footgun)
`app.add_middleware()` macht intern ein **`insert(0, ...)`** — heißt: das **zuletzt registrierte** Middleware liegt im Stack **außen**. Wenn deine Auth-Guard `request.session` lesen will, muss `SessionMiddleware` außen um die Guard liegen → **`SessionMiddleware` NACH dem `@app.middleware("http")`-Decorator registrieren.**
```python
# ❌ FALSCH — SessionMiddleware zuerst → liegt INNEN → Guard knallt mit
# AssertionError: SessionMiddleware must be installed to access request.session
app.add_middleware(SessionMiddleware, secret_key=...)
@app.middleware("http")
async def auth_guard(request, call_next):
user = request.session.get("user") # 💥
```
```python
# ✅ RICHTIG — Guard zuerst, SessionMiddleware danach → liegt AUSSEN
@app.middleware("http")
async def auth_guard(request, call_next):
user = request.session.get("user") # funktioniert
...
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, ...)
```
Symptom in den Logs: `AssertionError: SessionMiddleware must be installed to access request.session` für jeden geschützten Request. Wenn du das siehst → 99% Reihenfolge.
### Login starten
Generiere `state` (≥16 Zeichen, zufällig) und `code_verifier` (PKCE), speichere beide serverseitig in der Session. Dann Weiterleitung zu:
Generiere `state` (≥16 Zeichen, zufällig) und `code_verifier` (PKCE, **43128 Zeichen**), speichere beide kurzlebig zwischen Login-Start und Callback (siehe [Session-Storage](#session-storage-für-state--pkce-verifier)). Dann Weiterleitung zu:
```
GET https://login.onyva.dev/oauth/authorize
@@ -53,33 +90,95 @@ GET https://login.onyva.dev/oauth/authorize
**Wichtig:** `state` muss nachweislich in der URL stehen — ohne `state` kein `state` im Callback.
Konkretes Python-stdlib-Beispiel (funktioniert ohne Zusatz-Dependencies):
```python
import secrets, hashlib, base64
from urllib.parse import urlencode
# OIDC_ISSUER defensiv trimmen — .env kann versehentlich mit '/' enden
OIDC_ISSUER = os.getenv("OIDC_ISSUER", "https://login.onyva.dev").rstrip("/")
def auth_login():
state = secrets.token_urlsafe(24)
code_verifier = secrets.token_urlsafe(64) # 86 Zeichen — innerhalb 43-128
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
save_pending(state, code_verifier) # siehe Session-Storage
params = urlencode({
"response_type": "code",
"client_id": OIDC_CLIENT_ID,
"redirect_uri": OIDC_REDIRECT_URI,
"scope": "openid profile email",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
})
return redirect(f"{OIDC_ISSUER}/oauth/authorize?{params}")
```
### Session-Storage für State + PKCE-Verifier
Du musst `state` und `code_verifier` zwischen Login-Start und Callback überleben lassen. Drei realistische Optionen — wähle nach Setup:
| Option | Geeignet für | Aufwand |
|---|---|---|
| **In-Memory `dict` mit `threading.Lock`** | Single-Instance-Apps; geht beim Neustart verloren (egal — der User klickt einfach nochmal Login) | Keine neuen Deps |
| **`SessionMiddleware` (Starlette/FastAPI)** | Stateless, multi-instance, persistiert über Neustarts | Benötigt `itsdangerous` |
| **Externer Store (Redis/DB)** | Mehrere App-Instanzen ohne Cookie-Sessions | Infrastruktur |
Pragma: für die meisten internen Onyva-Apps reicht Option 1. Wenn du `SessionMiddleware` verwendest, kannst du dieselbe Session sowohl für PKCE-State als auch für die User-Session nach Login nutzen — dann brauchst du nichts extra.
### Callback verarbeiten
**Zwei Eintrittsformen** in den Callback — beide musst du unterstützen:
- **SP-initiated** (Standard): User klickt in deiner App auf „Login" → `/auth/login` baut state + PKCE → Redirect zu `/oauth/authorize` → Auth-Server schickt zurück mit `?code=...&state=...`. State + Verifier sind in deiner Session.
- **IdP-initiated** (vom Admin-Panel von login.onyva.dev): User klickt dort auf eine App-Kachel → Auth-Server schickt **direkt** mit `?code=...` (ohne `state`, ohne dass du `/auth/login` durchlaufen hast). Session hat nichts gespeichert.
Erkennung: `state` fehlt im Query → IdP-initiated. In diesem Fall **State-Check überspringen** und beim Token-Tausch **kein `code_verifier`** mitschicken. Sicherheitsrechtfertigung: bei IdP-initiated kommt der Code aus einer vertrauenswürdigen Quelle (login.onyva.dev), die der User soeben aktiv gewählt hat — die State-CSRF-Begründung greift hier nicht. Siehe ["IdP-initiated"-Fallstrick](#wichtige-fallstricke).
```python
# Pseudocode — an das Framework anpassen
def callback(request):
# 1. State validieren
if request.params.get("state") != session.pop("oauth_state", None):
return error("State mismatch")
# 2. Sonderfall: Auth deaktiviert
# 0. Sonderfall: Auth deaktiviert (kann state UND code-frei kommen)
if "auth_disabled" in request.params:
session["user"] = {"auth_disabled": True}
return redirect("/")
# 3. Token-Tausch (application/x-www-form-urlencoded, kein JSON)
resp = http_post("https://login.onyva.dev/oauth/token", data={
# 1. code ist IMMER Pflicht — fehlt er, ist es kein gültiger Callback
code = request.params.get("code")
if not code:
return error("Kein Authorization Code", status=400)
# 2. state ist NUR bei SP-initiated da. Fehlt er → IdP-initiated (Admin-Panel-Kachel)
received_state = request.params.get("state")
idp_initiated = received_state is None
code_verifier = pop_pending(received_state) if received_state else None
# 3. State validieren — nur bei SP-initiated. Bei IdP-initiated NICHT mit
# "State-Mismatch" abbrechen, sonst kann das Admin-Panel keine User reinschicken.
if not idp_initiated and code_verifier is None:
return error("State mismatch")
# 4. Token-Tausch (application/x-www-form-urlencoded, kein JSON)
token_body = {
"grant_type": "authorization_code",
"code": request.params["code"],
"code": code,
"redirect_uri": OIDC_REDIRECT_URI,
"client_id": OIDC_CLIENT_ID,
"client_secret": OIDC_CLIENT_SECRET,
"code_verifier": session.pop("pkce_verifier"),
})
}
# PKCE-Verifier NUR beim SP-initiated Flow mitschicken — bei IdP-initiated
# haben wir keinen Verifier und der Server erwartet ihn auch nicht.
if not idp_initiated and code_verifier:
token_body["code_verifier"] = code_verifier
resp = http_post("https://login.onyva.dev/oauth/token", data=token_body)
tokens = resp.json()
# 4. Claims aus id_token lesen
claims = decode_jwt(tokens["id_token"]) # Verifikation optional für interne Nutzung
claims = decode_jwt_claims(tokens["id_token"]) # ohne Signaturprüfung ok für interne Nutzung
session["user"] = {
"sub": claims["sub"], # "user:42"
"email": claims["email"],
@@ -92,6 +191,109 @@ def callback(request):
Nach erfolgreichem Callback niemals auf `/auth/*` zurückleiten — `return_to` immer auf `/` oder eine echte App-Seite normalisieren.
#### stdlib-only HTTP + JWT-Decode
Wenn das Projekt kein `httpx`/`requests` hat, reicht stdlib komplett aus:
```python
import json, base64
from urllib import request as urllib_request
from urllib import error as urllib_error
from urllib.parse import urlencode
def http_post_form(url: str, data: dict) -> dict:
req = urllib_request.Request(
url,
data=urlencode(data).encode(),
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib_request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except urllib_error.HTTPError as exc:
# 4xx/5xx vom Auth-Server — body hat den eigentlichen Fehler
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Token endpoint {exc.code}: {body}")
def decode_jwt_claims(token: str) -> dict:
"""Liest den Payload ohne Signaturprüfung — ok für interne Apps.
Bei extern verteilten Tokens: gegen jwks.json verifizieren."""
payload_b64 = token.split(".")[1]
payload_b64 += "=" * (-len(payload_b64) % 4) # base64url braucht Padding!
return json.loads(base64.urlsafe_b64decode(payload_b64).decode())
```
### Auth-Guard: HTML-Navigation vs. JSON-API
Der Guard muss unauthentifizierte Requests **unterschiedlich** behandeln:
- **HTML-Navigation** (Browser-GET einer Seite) → `307 Redirect` nach `/auth/login`
- **JSON-/API-Call** (`fetch()`, XHR) → `401 Unauthorized`, kein Redirect
Grund: `fetch()` und `XMLHttpRequest` folgen 3xx-Redirects transparent. Wenn du eine API mit 307 → `/auth/login` antwortest, navigiert der Client zur OIDC-Authorize-Page (HTML) — der Request schlägt als "JSON-Parse-Fehler" fehl, und die richtige Aktion (User zum Login schicken) bleibt aus. Mit 401 weiß der Frontend-Code, dass er **selbst** eine Top-Level-Navigation auslösen muss.
```python
@app.middleware("http")
async def auth_guard(request: Request, call_next):
path = request.url.path
if path in PUBLIC_PATHS or path.startswith("/auth/"):
return await call_next(request)
if not AUTH_ENABLED:
request.state.user = DEV_USER
return await call_next(request)
user = request.session.get("user")
if not user:
accept = request.headers.get("accept", "")
# Nur echte HTML-GET-Navigation redirecten — alles andere 401
if request.method == "GET" and "text/html" in accept:
return RedirectResponse(url="/auth/login")
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
request.state.user = user
return await call_next(request)
```
### Frontend: 401 abfangen
Im API-Wrapper / XHR-Handler 401 explizit prüfen und mit **harter** Navigation reagieren (`window.location.href`, nicht `fetch()` oder History-API):
```js
async function api(url, init) {
const r = await fetch(url, init);
if (r.status === 401) {
window.location.href = '/auth/login';
throw new Error('Session abgelaufen');
}
if (!r.ok) throw new Error(await r.text());
return r.json();
}
// Bei XHR analog im load-Event:
xhr.addEventListener('load', () => {
if (xhr.status === 401) {
window.location.href = '/auth/login';
return; // wichtig: nicht resolve/reject aufrufen, Seite wird neu geladen
}
// ... normaler Response-Handling
});
```
### User-Session nach erfolgreichem Login
Die Claims müssen über mehrere Requests hinweg verfügbar bleiben. Optionen, vom einfachsten zum komplexesten:
- **Signierter Cookie mit JSON-Payload** (HMAC-SHA256) — stateless, ohne neue Deps. Klein genug (<4KB), wenn du nur die wichtigen Claims speicherst.
- **`SessionMiddleware`** — wenn ohnehin schon eingebunden, einfach `session["user"] = {...}`.
- **Server-side Session-Store** — wenn Daten >4KB oder oft invalidiert werden müssen.
Cookie-Flags:
- `httponly=True` immer
- `samesite="lax"`**nicht** `strict`, sonst geht der OIDC-Callback verloren (Cross-Site-Navigation von login.onyva.dev zurück auf deine App)
- `secure=True` in Produktion (https), `False` lokal (http)
- `max_age` setzen (z.B. `60*60*8` = 8h), nicht endlose Sessions
### JWT Claims
```json
@@ -111,15 +313,34 @@ Nach erfolgreichem Callback niemals auf `/auth/*` zurückleiten — `return_to`
```python
def logout(request):
session.clear()
session.clear() # bzw. response.delete_cookie(AUTH_COOKIE)
return redirect(
f"https://login.onyva.dev/logout"
f"?post_logout_redirect_uri={urllib.parse.quote(OIDC_LOGOUT_REDIRECT_URI)}"
f"?post_logout_redirect_uri={urllib.parse.quote(OIDC_LOGOUT_REDIRECT_URI, safe='')}"
)
```
`post_logout_redirect_uri` muss absolut sein (`https://...`). Der Auth-Server löscht die zentrale Session — der Nutzer ist danach auf allen Onyva-Apps ausgeloggt.
**Legacy-Buttons unterstützen:** Hat die bestehende App noch ein `<form action="/logout" method="post">`, registriere **zwei** Routen mit demselben Body — FastAPI lässt **nicht** zwei Decorators auf einer Funktion stacken (der zweite überschreibt den ersten):
```python
async def _do_logout(request: Request):
request.session.clear()
return RedirectResponse(
f"{OIDC_ISSUER}/logout"
f"?post_logout_redirect_uri={urllib.parse.quote(OIDC_LOGOUT_REDIRECT_URI, safe='')}"
)
@app.get("/auth/logout")
async def auth_logout_get(request: Request):
return await _do_logout(request)
@app.post("/logout") # Legacy
async def auth_logout_post(request: Request):
return await _do_logout(request)
```
### Auth-Mode-Verhalten
| auth_mode | Auth-Server-Verhalten | Client-Verhalten |
@@ -131,40 +352,144 @@ def logout(request):
### AUTH_ENABLED=false (Entwicklungsmodus)
```python
DEV_USER = {
"sub": "user:0", "email": "dev@local", "name": "Dev",
"user_type": "employee", "app_roles": ["admin"],
}
def require_auth(request):
if not AUTH_ENABLED:
return {"sub": "user:0", "email": "dev@local", "name": "Dev", "user_type": "employee", "app_roles": ["admin"]}
# ... normaler Flow
return DEV_USER
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
return user
```
Der Guard im Middleware-Code muss das **auch** prüfen — sonst redirected er trotzdem zum Login:
```python
if not AUTH_ENABLED:
request.state.user = DEV_USER
return await call_next(request)
```
### Wer erzeugt CLIENT_SECRET?
Zwei valide Modelle, je nach Admin-Panel-Workflow:
1. **Admin-Panel erzeugt** — du legst nur `CLIENT_ID` (Slug) an, das Panel zeigt dir das Secret einmalig nach dem Anlegen.
2. **Du erzeugst, Admin-Panel akzeptiert** — du generierst lokal:
```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
```
und trägst denselben String im Admin-Panel als Secret ein.
Beide funktionieren. Wichtig nur: `.env` und Admin-Panel müssen **exakt** denselben Wert haben.
Frage den User explizit, welcher der beiden Wege gilt, bevor du Werte erzeugst. Im Zweifel: in `.env` mit Platzhalter (`OIDC_CLIENT_SECRET=PLEASE-REPLACE-FROM-ADMIN-PANEL`), damit der Server lauffähig ist und der User den Wert nachträglich einträgt.
### Env-Variablen
```env
OIDC_ISSUER=https://login.onyva.dev
OIDC_CLIENT_ID= # aus Admin-Panel
OIDC_CLIENT_SECRET= # aus Admin-Panel
OIDC_REDIRECT_URI= # muss exakt im Admin-Panel stehen; bei /api-Prefix: https://app.example.com/api/auth/callback
OIDC_CLIENT_ID= # registrierter Slug, kein Zufallswert
OIDC_CLIENT_SECRET= # aus Admin-Panel oder lokal erzeugt + dort eingetragen
OIDC_REDIRECT_URI= # exakt im Admin-Panel; bei /api-Prefix: https://app.example.com/api/auth/callback
OIDC_LOGOUT_REDIRECT_URI= # z.B. https://app.example.com/
SESSION_SECRET= # eigener Wert für SessionMiddleware-Cookie-Signatur (≥32 Zeichen)
AUTH_ENABLED=true
```
### Beim Iterieren am Auth: harter Neustart, kein Reload
Der Middleware-Stack wird in Starlette **einmal** gebaut (beim ersten Request nach Startup). `uvicorn --reload` triggert zwar einen Neustart bei Code-Änderung, aber wenn du dir nicht sicher bist, dass der Stack wirklich neu aufgebaut wurde (oder du in derselben Datei iterativ Imports/Reihenfolge änderst): Prozess hart beenden.
```bash
# Windows (PowerShell)
Get-Process python | Stop-Process -Force
# Linux/Mac
pkill -f 'uvicorn'
```
Dann uvicorn neu starten und Smoke-Test laufen lassen. Wenn der Smoke-Test nicht das tut, was du gerade geschrieben hast — hast du noch einen alten Prozess offen.
### Smoke-Test in einer Zeile
Nach Implementierung diese fünf Calls absetzen (Server muss laufen, hier Port 8000):
```bash
# 1. Unauth HTML-Request → 307 zu /auth/login
curl -sI -H 'Accept: text/html' http://localhost:8000/ | grep -E 'HTTP|Location'
# erwartet: HTTP/1.1 307 Temporary Redirect
# Location: /auth/login
# 2. Unauth JSON-API-Request → 401, KEIN Redirect
curl -sI -X POST -H 'Accept: application/json' http://localhost:8000/some-api | grep HTTP
# erwartet: HTTP/1.1 401 Unauthorized
# 3. /auth/login → 307 zu login.onyva.dev/oauth/authorize?... (mit state + code_challenge)
curl -sI http://localhost:8000/auth/login | grep -E 'HTTP|Location'
# erwartet: Location: https://login.onyva.dev/oauth/authorize?response_type=code&...&state=...&code_challenge=...&code_challenge_method=S256
# 4. Öffentlicher Pfad (z.B. Logo) → 200
curl -sI http://localhost:8000/logo.png | grep HTTP
# erwartet: HTTP/1.1 200 OK
# 5a. /auth/callback ohne state, ohne code → 400 ("Kein Authorization Code")
curl -sI 'http://localhost:8000/auth/callback' | grep HTTP
# erwartet: HTTP/1.1 400 Bad Request
# 5b. /auth/callback ohne state, MIT Dummy-Code → 502 (Token-Tausch lehnt ab)
# Wichtig: NICHT 400 "State-Mismatch" — sonst kann das Admin-Panel nicht
# per IdP-initiated SSO in die App schicken.
curl -sI 'http://localhost:8000/auth/callback?code=DUMMY' | grep HTTP
# erwartet: HTTP/1.1 502 Bad Gateway (mit Body: "Invalid or expired code")
# FALSCH: HTTP/1.1 400 Bad Request ← IdP-initiated SSO ist kaputt
# 5c. /auth/callback mit state aber ohne passenden Session-State → 400
curl -sI 'http://localhost:8000/auth/callback?code=x&state=invalid' | grep HTTP
# erwartet: HTTP/1.1 400 Bad Request ("State-Mismatch")
```
Wenn 1+2+3 wie erwartet zurückkommen, ist die Integration in 90% der Fälle korrekt verdrahtet — die restliche 10% sind dann real-Login-Flow-Themen (falsches Secret, Redirect URI nicht registriert, etc.) und passieren erst beim ersten echten Browser-Login.
## Wichtige Fallstricke
- **State ist Pflicht** — wird er nicht mitgeschickt, kommt er nicht zurück und der Callback schlägt fehl
- **Redirect URI exakt** — `/api/auth/callback``/auth/callback`; im Admin-Panel muss genau dieselbe URL stehen
- **Middleware-Reihenfolge** — `SessionMiddleware` muss NACH dem `@app.middleware("http")`-Guard registriert werden (siehe oben). Häufigste Ursache für `AssertionError: SessionMiddleware must be installed`.
- **State ist Pflicht — außer bei IdP-initiated SSO** — wenn das Admin-Panel von login.onyva.dev den User direkt mit `?code=...` (ohne `state`) zum Callback schickt, **fehlt absichtlich** der State. Der Callback muss diesen Fall erkennen (`state` im Query fehlt → IdP-initiated → State-Check + PKCE-Verifier überspringen, sonst „State-Mismatch im Auth-Callback" sobald jemand die App über das Admin-Panel öffnet). Bei SP-initiated bleibt der State-Check Pflicht.
- **Redirect URI exakt** — `/api/auth/callback` ≠ `/auth/callback`, `:8000` ≠ `:8276`, `…/callback/` ≠ `…/callback` (trailing slash!). Im Admin-Panel muss genau dieselbe URL stehen, sonst lehnt der Auth-Server den Token-Tausch ab.
- **CLIENT_ID ist kein Geheimnis und nicht zufällig** — ein im Admin-Panel registrierter Slug (z.B. `transkript-revier`). Nur das CLIENT_SECRET ist eine generierte Zufallszeichenkette.
- **Token-Endpunkt: Form, kein JSON** — `Content-Type: application/x-www-form-urlencoded`
- **Session-Middleware** — bei Starlette/FastAPI: `itsdangerous` als Dependency erforderlich
- **base64url braucht Padding beim Dekodieren** — JWT-Payloads oder PKCE-Werte: `s += "=" * (-len(s) % 4)` vor `base64.urlsafe_b64decode(...)`
- **`code_verifier` Länge** — PKCE-Spec verlangt 43128 Zeichen. `secrets.token_urlsafe(64)` liefert 86, passt.
- **API-401 vs HTML-Redirect** — siehe [Auth-Guard](#auth-guard-html-navigation-vs-json-api). 3xx auf einen `fetch()`-Call führt zu unsinnigen "JSON-Parse-Fehlern".
- **`samesite=lax`, nicht `strict`** — sonst geht das Session-Cookie beim Cross-Site-Redirect von login.onyva.dev verloren.
- **`OIDC_ISSUER` trimmen** — `os.getenv("OIDC_ISSUER").rstrip("/")` vor URL-Bau, sonst `…//oauth/authorize`.
- **`@app.get + @app.post`-Stacking funktioniert nicht** — für Legacy-Logout zwei separate Funktionen mit gemeinsamem Body.
- **Session-Middleware** — bei Starlette/FastAPI: `itsdangerous` als Dependency erforderlich. Falls die App das nicht hat, alternativ in-memory dict für PKCE-State.
- **Öffentliche Routen ausdrücklich ausnehmen** — Upload-Links, Webhooks, Health-Checks müssen vom Auth Guard ausgeschlossen bleiben
- **Kein Redirect auf Auth-Routen** — `return_to` niemals auf `/auth/*` setzen
- **Alte Auth muss komplett raus** — beim Ersetzen eines bestehenden Systems: alte Konstanten, Login-HTML, `POST /login`-Route, Helper und Env-Variablen vollständig entfernen, sonst hängt der User weiterhin am alten Flow
- **Bei Auth-Iterationen hart neustarten** — `uvicorn --reload` reicht oft, aber wenn Middleware-Code/Reihenfolge geändert wurde und der Smoke-Test seltsame Resultate liefert: Prozess killen und neu starten.
## Schnellcheckliste
- [ ] **Bestehende Auth komplett entfernt** (Konstanten, Routen, HTML, Env-Variablen)
- [ ] Env-Variablen konfiguriert und `.env.example` aktualisiert
- [ ] Redirect URI im Admin-Panel von login.onyva.dev eingetragen
- [ ] Login-Route: `state` + PKCE generiert, in Session gespeichert, in Authorize-URL enthalten
- [ ] Callback-Route: `state` validiert, Token-Tausch, Claims in Session
- [ ] Logout-Route: lokale Session gelöscht, Weiterleitung zu zentralem Logout
- [ ] CLIENT_ID + CLIENT_SECRET + Redirect URI im Admin-Panel von login.onyva.dev eingetragen (exakte URL, ohne trailing slash)
- [ ] **SessionMiddleware nach dem `@app.middleware`-Guard registriert** (LIFO!)
- [ ] Login-Route: `state` + PKCE generiert, kurzlebig gespeichert, in Authorize-URL enthalten
- [ ] Callback-Route: `state` validiert (mit Pop, nicht nur Lookup), Token-Tausch (Form, nicht JSON), Claims in Session
- [ ] Callback unterstützt **IdP-initiated SSO** — fehlt `state` im Query, State-Check + PKCE-Verifier überspringen (sonst kein Login über das Admin-Panel)
- [ ] User-Session-Cookie: `httponly`, `samesite=lax`, `secure` in Produktion
- [ ] Guard: HTML-GET → 307 redirect, alles andere → 401 JSON
- [ ] Frontend: `fetch()`/XHR fängt 401 ab und macht `window.location.href = '/auth/login'`
- [ ] Logout-Route: lokale Session gelöscht, Weiterleitung zu zentralem Logout — falls Legacy: `POST /logout` mit zweiter Funktion unterstützen
- [ ] Auth Guard für geschützte Routen
- [ ] Öffentliche Routen explizit ausgenommen
- [ ] `AUTH_ENABLED=false` Bypass für Entwicklung
- [ ] Öffentliche Routen explizit ausgenommen (`PUBLIC_PATHS`-Set o.ä.)
- [ ] `AUTH_ENABLED=false` Bypass für Entwicklung (sowohl im Guard als auch in `require_auth`)
- [ ] `auth_disabled=1` Callback-Fall abgedeckt
- [ ] Smoke-Test (5 curl-Calls) durchgelaufen
- [ ] Server hart neu gestartet (alte Prozesse beendet)