diff --git a/app/main.py b/app/main.py index 3a4b782..8650a35 100644 --- a/app/main.py +++ b/app/main.py @@ -30,52 +30,43 @@ app = FastAPI() # --------------------------------------------------------------------------- -# Identity — Cloudron proxyAuth injects the authenticated user's email. +# Identity — Cloudron proxyAuth injects the authenticated user's USERNAME. # --------------------------------------------------------------------------- -def get_user_email(request: Request) -> str: - """Return the logged-in user's email from Cloudron's proxyAuth headers. +def get_user_identity(request: Request) -> str: + """Return the logged-in user's identity (Cloudron username, or email if + Cloudron happens to send one). - Cloudron's nginx auth-request module injects X-Forwarded-User. We also - accept X-Remote-User and X-Auth-Request-Email as fallbacks, since the exact - header set has varied across Cloudron versions. + Cloudron's proxyAuth injects the USERNAME (e.g. "ntnsndr") via + X-Remote-User, not the email. We pass it through unchanged to the broker, + which resolves username → email (it holds the Cloudron admin token). """ for header in ( - "x-forwarded-user", "x-remote-user", + "x-forwarded-user", "x-auth-request-user", "x-auth-request-email", "x-forwarded-email", ): val = request.headers.get(header) if val: - val = val.strip().lower() - # Some proxies prefix "user:" or use a bare username; emails are the - # canonical identity here. - if "@" in val: - return val - logger.warning("Header %s had no email (@): %r", header, val) - # Last resort: Cloudron also sets x-auth-request-email. - logger.warning("No identity header found; user unknown") + return val.strip() return "" -def is_trusted(request: Request) -> bool: - """Only trust identity headers from Cloudron's proxy (behind us).""" - # Cloudron injects these headers itself; we trust them because the app is - # only reachable through Cloudron's proxy. This is a defense-in-depth note; - # the app is not directly exposed. - return True - - # --------------------------------------------------------------------------- # Broker calls (to the member portal) # --------------------------------------------------------------------------- -async def broker_get(email: str, path: str) -> dict: - headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email} +def broker_headers(identity: str) -> dict: + # Pass the raw identity (username or email); the portal resolves it to an + # email via its own Cloudron user list. + return {"X-Broker-Secret": BROKER_SECRET, "X-Member-User": identity} + + +async def broker_get(identity: str, path: str) -> dict: async with httpx.AsyncClient(timeout=15.0) as client: - r = await client.get(f"{PORTAL_BASE}{path}", headers=headers) + 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: @@ -85,10 +76,9 @@ async def broker_get(email: str, path: str) -> dict: return r.json() -async def broker_post(email: str, path: str, payload: dict | None = None) -> dict: - headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email} +async def broker_post(identity: str, path: str, payload: dict | None = None) -> dict: async with httpx.AsyncClient(timeout=15.0) as client: - r = await client.post(f"{PORTAL_BASE}{path}", headers=headers, json=payload or {}) + 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): @@ -96,10 +86,9 @@ async def broker_post(email: str, path: str, payload: dict | None = None) -> dic return r.json() -async def broker_delete(email: str, path: str) -> dict: - headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email} +async def broker_delete(identity: str, path: str) -> dict: async with httpx.AsyncClient(timeout=15.0) as client: - r = await client.delete(f"{PORTAL_BASE}{path}", headers=headers) + 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): @@ -118,23 +107,23 @@ async def healthz(): @app.get("/") async def index(request: Request): - email = get_user_email(request) - if not email: + identity = get_user_identity(request) + if not identity: return HTMLResponse( "
Please log in via the dashboard login.
", status_code=401, ) - return HTMLResponse(render_page(email)) + return HTMLResponse(render_page(identity)) @app.get("/api/usage") async def api_usage(request: Request): - email = get_user_email(request) - if not email: + identity = get_user_identity(request) + if not identity: return JSONResponse({"error": "unauthenticated"}, status_code=401) try: - data = await broker_get(email, "/broker/usage") - keys = await broker_get(email, "/broker/keys") + 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: @@ -143,26 +132,26 @@ async def api_usage(request: Request): @app.post("/api/keys") async def api_create_key(request: Request): - email = get_user_email(request) - if not email: + 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(email, "/broker/keys", {"name": name}) + 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): - email = get_user_email(request) - if not email: + identity = get_user_identity(request) + if not identity: return JSONResponse({"error": "unauthenticated"}, status_code=401) try: - return await broker_delete(email, f"/broker/keys/{name}") + return await broker_delete(identity, f"/broker/keys/{name}") except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code)