Files
onyva-login-skill/skills/onyva-login/SKILL.md
Timo Uttenweiler 366edf55d9 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
2026-05-20 10:57:15 +00:00

496 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: onyva-login
description: Integriert den zentralen Onyva Dev Login (OIDC/SSO) in ein bestehendes Projekt. Nutze diesen Skill wenn jemand "onyva login integrieren", "auth integrieren" oder "/onyva-login" schreibt.
---
Deine Aufgabe ist es, den zentralen Onyva Dev Login (OIDC/SSO-Server unter `https://login.onyva.dev`) in das aktuelle Projekt zu integrieren.
## Vorgehen
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` (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. **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. **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.
---
## Technische Vorgaben
### Auth-Server
- Issuer / Basis-URL: `https://login.onyva.dev`
- Discovery: `https://login.onyva.dev/.well-known/openid-configuration`
| Endpunkt | Methode | Zweck |
|---|---|---|
| `/oauth/authorize` | GET | Login starten |
| `/oauth/token` | POST | Code gegen Tokens tauschen |
| `/oauth/userinfo` | GET | Nutzerinfos per Bearer Token |
| `/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, **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
?response_type=code
&client_id={OIDC_CLIENT_ID}
&redirect_uri={OIDC_REDIRECT_URI}
&scope=openid profile email
&state={state}
&code_challenge={BASE64URL(SHA256(code_verifier))}
&code_challenge_method=S256
```
**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):
# 0. Sonderfall: Auth deaktiviert (kann state UND code-frei kommen)
if "auth_disabled" in request.params:
session["user"] = {"auth_disabled": True}
return redirect("/")
# 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": code,
"redirect_uri": OIDC_REDIRECT_URI,
"client_id": OIDC_CLIENT_ID,
"client_secret": OIDC_CLIENT_SECRET,
}
# 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_claims(tokens["id_token"]) # ohne Signaturprüfung ok für interne Nutzung
session["user"] = {
"sub": claims["sub"], # "user:42"
"email": claims["email"],
"name": claims["name"],
"user_type": claims["user_type"], # "employee" | "customer"
"app_roles": claims["app_roles"], # ["admin"] | ["editor"] | ["viewer"] | []
}
return redirect("/")
```
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
{
"sub": "user:42",
"email": "nutzer@onyva.de",
"name": "Max Mustermann",
"user_type": "employee",
"app_roles": ["admin"],
"iss": "https://login.onyva.dev",
"aud": "mein-client-id",
"exp": 1700000000
}
```
### Logout
```python
def logout(request):
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, 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 |
|---|---|---|
| `required` | 403 wenn keine Mitgliedschaft | Fehlermeldung anzeigen |
| `optional` | Code für jeden angemeldeten Nutzer; `app_roles` ggf. leer | App entscheidet selbst |
| `disabled` | Sofort zurück mit `?auth_disabled=1` | Kein Login-Dialog, alle Funktionen offen |
### 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 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= # 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
- **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`
- **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
- [ ] 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 (`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)