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

Not authenticated

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

Login failed

Incomplete OIDC callback.

", status_code=400) if state != expected_state: return HTMLResponse("

Login failed

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

Login failed

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 = """ Inference Cooperative · Your membership

Inference Cooperative

Your membership
Signed in as @@IDENTITY@@

Usage

— of your balance used
—
remaining
—
tokens used
—
requests
Shared across chat and API.

API keys

Keys let you use the co-op's AI from your own tools. They draw from the same balance as chat.

No API keys yet.
""" @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): if exc.status_code == 403: return JSONResponse({"error": exc.detail}, status_code=403) return JSONResponse({"error": exc.detail}, status_code=exc.status_code)