Pass raw identity (username) to broker; portal resolves to email

This commit is contained in:
inference-bot committed 2026-09-14 12:59:51 -06:00
1 parent 5264c3e9f7
commit 105ff499f5
1 file changed
+34 -45
+34 -45
View File
@@ -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(
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
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)