"""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). Reads the validated OIDC session cookie. No header fallback — the app no longer trusts a client-supplied identity header (that path existed only during the proxyAuth → OIDC transition and is now removed). """ if not oidc.is_oidc_configured(): return "" token = request.cookies.get(oidc.SESSION_COOKIE) if not token: return "" return oidc.read_session(token) or "" # --------------------------------------------------------------------------- # 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", []) # Co-op-wide model popularity (aggregate, anonymized) for the leaderboard. try: board = await broker_get(identity, "/broker/model-leaderboard?days=30") data["coop_models"] = board.get("models", []) except HTTPException: data["coop_models"] = [] # best-effort; never block the main view 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(request: Request): """Log out: clear our session, then show an explicit logged-out page. Cloudron's OIDC provider removed its RP-initiated logout endpoint (forum: "no app supports this properly"), so we cannot end the SSO session programmatically. Redirecting to / would silently re-authenticate through the still-valid SSO cookie — the "logout does nothing" bug. Instead we clear our cookies and show a page with a manual link to sign out of Cloudron SSO (my.inference.coop/logout) for a full sign-out. """ html = """Your dashboard session has been cleared.
Note: you may still be signed in to Inference Cooperative SSO (chat, portal, forum share that sign-in). To fully sign out everywhere, log out of your Cloudron account too.
@@IDENTITY@@Keys let you use the co-op's AI from your own tools. They draw from the same balance as chat.