"""Member Dashboard — read-only usage view + API-key management. Security model: - Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects the authenticated user's identity via the X-Remote-User header (username). - This app holds NO privileged credentials. It calls the member portal's broker endpoints, authenticated with a shared BROKER_SECRET, and scopes every request to the logged-in member. Environment (all provided by Cloudron env vars): PORTAL_BASE — base URL of the member portal (e.g. https://portal.inference.coop) BROKER_SECRET — shared secret for the portal's broker endpoints """ import os import logging import re import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from app import oidc logging.basicConfig(level=logging.INFO) logger = logging.getLogger("member-dashboard") PORTAL_BASE = os.environ.get("PORTAL_BASE", "").rstrip("/") BROKER_SECRET = os.environ.get("BROKER_SECRET", "") app = FastAPI() # --------------------------------------------------------------------------- # Identity — Cloudron OIDC session cookie (username). proxyAuth header is the # legacy fallback during the transition. # --------------------------------------------------------------------------- def get_user_identity(request: Request) -> str: """Return the logged-in user's identity (Cloudron username). Priority: a validated OIDC session cookie (when the oidc addon is active), then the proxyAuth header (legacy fallback during the transition). """ # OIDC session cookie (only when the addon is configured). if oidc.is_oidc_configured(): token = request.cookies.get(oidc.SESSION_COOKIE) if token: identity = oidc.read_session(token) if identity: return identity # Legacy proxyAuth header fallback. for header in ( "x-remote-user", "x-forwarded-user", "x-auth-request-user", "x-auth-request-email", "x-forwarded-email", ): val = request.headers.get(header) if val: return val.strip() return "" # --------------------------------------------------------------------------- # Broker calls (to the member portal) # --------------------------------------------------------------------------- def broker_headers(identity: str) -> dict: return {"X-Broker-Secret": BROKER_SECRET, "X-Member-User": identity} async def broker_get(identity: str, path: str) -> dict: async with httpx.AsyncClient(timeout=20.0) as client: r = await client.get(f"{PORTAL_BASE}{path}", headers=broker_headers(identity)) if r.status_code == 401: raise HTTPException(500, "Portal rejected broker credentials") if r.status_code == 403: raise HTTPException(403, "Not an active member") if r.status_code != 200: raise HTTPException(502, f"Portal error {r.status_code}") return r.json() async def broker_post(identity: str, path: str, payload: dict | None = None) -> dict: async with httpx.AsyncClient(timeout=20.0) as client: r = await client.post(f"{PORTAL_BASE}{path}", headers=broker_headers(identity), json=payload or {}) if r.status_code in (401, 403): raise HTTPException(r.status_code, r.text) if r.status_code not in (200, 201): raise HTTPException(502, f"Portal error {r.status_code}: {r.text}") return r.json() async def broker_delete(identity: str, path: str) -> dict: async with httpx.AsyncClient(timeout=20.0) as client: r = await client.delete(f"{PORTAL_BASE}{path}", headers=broker_headers(identity)) if r.status_code in (401, 403): raise HTTPException(r.status_code, r.text) if r.status_code not in (200, 204): raise HTTPException(502, f"Portal error {r.status_code}") return r.json() if r.content else {} # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.get("/healthz") async def healthz(): return {"status": "ok"} @app.get("/") async def index(request: Request): identity = get_user_identity(request) if not identity: if oidc.is_oidc_configured(): return RedirectResponse("/auth/openid/login", status_code=302) return HTMLResponse( "
Please log in via the dashboard login.
", status_code=401, ) return HTMLResponse(render_page(identity)) @app.get("/api/usage") async def api_usage(request: Request): identity = get_user_identity(request) if not identity: return JSONResponse({"error": "unauthenticated"}, status_code=401) try: data = await broker_get(identity, "/broker/usage") keys = await broker_get(identity, "/broker/keys") data["keys"] = keys.get("keys", []) return data except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) @app.post("/api/keys") async def api_create_key(request: Request): identity = get_user_identity(request) if not identity: return JSONResponse({"error": "unauthenticated"}, status_code=401) body = await request.json() name = (body.get("name") or "").strip() if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name): return JSONResponse({"error": "Invalid key name"}, status_code=400) try: return await broker_post(identity, "/broker/keys", {"name": name}) except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) @app.delete("/api/keys/{name}") async def api_revoke_key(name: str, request: Request): identity = get_user_identity(request) if not identity: return JSONResponse({"error": "unauthenticated"}, status_code=401) try: return await broker_delete(identity, f"/broker/keys/{name}") except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) # --------------------------------------------------------------------------- # OIDC routes — authorization-code flow against Cloudron's OIDC provider. # Active only when the `oidc` addon is configured (CLOUDRON_OIDC_* present). # During the transition the legacy proxyAuth header still works as a fallback. # --------------------------------------------------------------------------- @app.get("/auth/openid/login") async def oidc_login(request: Request): if not oidc.is_oidc_configured(): raise HTTPException(404, "OIDC not configured") login_url, state, nonce = oidc.build_login_url(request.headers.get("host", "")) resp = RedirectResponse(login_url, status_code=302) resp.set_cookie("oidc_state", state, max_age=600, httponly=True, samesite="lax") resp.set_cookie("oidc_nonce", nonce, max_age=600, httponly=True, samesite="lax") return resp @app.get("/auth/openid/callback") async def oidc_callback(request: Request): if not oidc.is_oidc_configured(): raise HTTPException(404, "OIDC not configured") code = request.query_params.get("code") state = request.query_params.get("state") expected_state = request.cookies.get("oidc_state") nonce = request.cookies.get("oidc_nonce") if not code or not state or not expected_state or not nonce: return HTMLResponse("Incomplete OIDC callback.
", status_code=400) if state != expected_state: return HTMLResponse("State mismatch (possible CSRF).
", status_code=400) try: identity = await oidc.exchange_code(code, request.headers.get("host", ""), nonce) except ValueError as e: logger.warning("OIDC login failed: %s", e) return HTMLResponse("Could not complete sign-in.
", status_code=400) session = oidc.write_session(identity) resp = RedirectResponse("/", status_code=302) resp.set_cookie(oidc.SESSION_COOKIE, session, max_age=oidc.SESSION_MAX_AGE, httponly=True, samesite="lax") resp.delete_cookie("oidc_state") resp.delete_cookie("oidc_nonce") return resp @app.get("/logout") async def logout(): resp = RedirectResponse("/", status_code=302) resp.delete_cookie(oidc.SESSION_COOKIE) return resp # --------------------------------------------------------------------------- # UI (brand-matched, self-contained, no external requests) # --------------------------------------------------------------------------- def _esc(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) def render_page(identity: str) -> str: return PAGE_HTML.replace("@@IDENTITY@@", _esc(identity)) PAGE_HTML = """@@IDENTITY@@Keys let you use the co-op's AI from your own tools. They draw from the same balance as chat.