"""OIDC-Login der Admin-Plattform über login.onyva.dev (Authorization-Code + PKCE, wie im Core). Aktiv mit AUTH_ENABLED=true. Env: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_LOGOUT_REDIRECT_URI, APP_COOKIE_SECURE, sowie die Zugriffsregel (mind. eine): ADMIN_ALLOWED_EMAILS=a@x.de,b@y.de und/oder ADMIN_REQUIRED_ROLE=admin (gegen das Claim `app_roles`) Ohne Zugriffsregel wird jeder Login abgelehnt (Default-Deny) – die Admin-Plattform hält alle Secrets. """ from __future__ import annotations import base64 import hashlib import hmac import json import os import re import secrets import time from urllib.parse import urlencode import httpx from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app.settings import AUTH_ENABLED, SECRET_KEY COOKIE, STATE_COOKIE, STATE_TTL, SESSION_TTL = "oa_session", "oa_oauth", 600, 12 * 3600 router = APIRouter() PUBLIC = re.compile(r"^/(healthz|auth/.*|static/.*|api/tenants/[^/]+/bundle|api/core/releases)$") # bundle: Token · releases: CI-Token (POST), GET prüft die Session selbst DEV_USER = {"sub": "dev", "email": "dev@local", "name": "Dev (Auth aus)", "app_roles": ["admin"]} def cfg() -> dict: g = lambda k, d="": os.getenv(k, d).strip() # noqa: E731 return { "issuer": g("OIDC_ISSUER", "https://login.onyva.dev").rstrip("/"), "client_id": g("OIDC_CLIENT_ID"), "client_secret": g("OIDC_CLIENT_SECRET"), "redirect": g("OIDC_REDIRECT_URI", "http://localhost:8300/auth/callback"), "logout_redirect": g("OIDC_LOGOUT_REDIRECT_URI", "http://localhost:8300/"), "secure": g("APP_COOKIE_SECURE", "false").lower() == "true", "emails": {e.strip().lower() for e in g("ADMIN_ALLOWED_EMAILS").split(",") if e.strip()}, "role": g("ADMIN_REQUIRED_ROLE"), } def _sign(data: dict) -> str: payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode().rstrip("=") return f"{payload}.{hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()}" def _verify(token: str) -> dict | None: try: payload, _, sig = token.partition(".") if not sig or not hmac.compare_digest(sig, hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()): return None data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) return data if int(data.get("exp", 0)) > time.time() else None except Exception: # noqa: BLE001 return None def session_user(request: Request) -> dict | None: if not AUTH_ENABLED: return DEV_USER tok = request.cookies.get(COOKIE) return _verify(tok) if tok else None def is_allowed(user: dict) -> bool: c = cfg() if not c["emails"] and not c["role"]: return False if user.get("email", "").lower() in c["emails"]: return True return bool(c["role"]) and c["role"] in (user.get("app_roles") or []) async def middleware(request: Request, call_next): user = session_user(request) request.state.user = user or {} if user is None and not PUBLIC.match(request.url.path): if request.url.path.startswith(("/api/", "/preview/")): return JSONResponse({"detail": "Nicht angemeldet"}, status_code=401) return RedirectResponse("/auth/login", status_code=302) return await call_next(request) def actor_name(request: Request) -> str: u = getattr(request.state, "user", None) or {} return u.get("email") or u.get("name") or "unbekannt" # ── Routen ────────────────────────────────────────────────────────────────── def exchange_code(code: str, verifier: str) -> dict: """Token-Tausch beim IdP (in Tests ersetzbar).""" c = cfg() r = httpx.post(f"{c['issuer']}/oauth/token", timeout=20, data={ "grant_type": "authorization_code", "code": code, "redirect_uri": c["redirect"], "client_id": c["client_id"], "client_secret": c["client_secret"], "code_verifier": verifier}) if r.status_code >= 400: raise HTTPException(502, f"Token-Tausch fehlgeschlagen ({r.status_code})") return r.json() def _claims(id_token: str) -> dict: try: p = id_token.split(".")[1] return json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4))) except Exception: # noqa: BLE001 return {} @router.get("/auth/login") def login(): c = cfg() if not AUTH_ENABLED: return RedirectResponse("/", 302) if not c["client_id"] or not c["client_secret"]: raise HTTPException(503, "OIDC nicht konfiguriert (OIDC_CLIENT_ID / OIDC_CLIENT_SECRET).") state, verifier = secrets.token_urlsafe(24), secrets.token_urlsafe(64) challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() q = urlencode({"response_type": "code", "client_id": c["client_id"], "redirect_uri": c["redirect"], "scope": "openid profile email", "state": state, "code_challenge": challenge, "code_challenge_method": "S256"}) resp = RedirectResponse(f"{c['issuer']}/oauth/authorize?{q}", 302) resp.set_cookie(STATE_COOKIE, _sign({"state": state, "cv": verifier, "exp": int(time.time()) + STATE_TTL}), max_age=STATE_TTL, httponly=True, secure=c["secure"], samesite="lax") return resp @router.get("/auth/callback") def callback(request: Request, code: str = "", state: str = "", error: str = ""): c = cfg() if error: raise HTTPException(400, f"Login abgebrochen: {error}") pending = _verify(request.cookies.get(STATE_COOKIE, "") or "") if not pending or not hmac.compare_digest(str(pending.get("state", "")), state) or not code: return RedirectResponse("/auth/login", 303) # abgelaufen/Reload → neu starten tokens = exchange_code(code, pending["cv"]) cl = _claims(tokens.get("id_token", "")) user = {"sub": cl.get("sub", ""), "email": cl.get("email", ""), "name": cl.get("name", ""), "app_roles": cl.get("app_roles", [])} if not is_allowed(user): return HTMLResponse(f"

Kein Zugriff

{user['email'] or 'Dieses Konto'} " "ist für die Onyscribe-Admin-Plattform nicht freigeschaltet.

Abmelden

", status_code=403) resp = RedirectResponse("/", 303) resp.set_cookie(COOKIE, _sign({**user, "exp": int(time.time()) + SESSION_TTL}), max_age=SESSION_TTL, httponly=True, secure=c["secure"], samesite="lax") resp.delete_cookie(STATE_COOKIE) return resp @router.get("/auth/logout") def logout(): c = cfg() url = f"{c['issuer']}/logout?{urlencode({'post_logout_redirect_uri': c['logout_redirect']})}" if AUTH_ENABLED else "/" resp = RedirectResponse(url, 302) resp.delete_cookie(COOKIE) return resp @router.get("/auth/me") def me(request: Request): return {"user": getattr(request.state, "user", {}), "auth_enabled": AUTH_ENABLED}