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

24 KiB
Raw Blame History

name, description
name description
onyva-login 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?" 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) — 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.

# ❌ 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")  # 💥
# ✅ 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). 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):

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.

# 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:

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.

@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):

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

{
  "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

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):

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)

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:

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:
    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

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.

# 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):

# 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-ReihenfolgeSessionMiddleware 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 JSONContent-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. 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 trimmenos.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-Routenreturn_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 neustartenuvicorn --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)