diff --git a/skills/onyva-login/SKILL.md b/skills/onyva-login/SKILL.md index bb12e82..1265398 100644 --- a/skills/onyva-login/SKILL.md +++ b/skills/onyva-login/SKILL.md @@ -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, **43–128 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 `