From 80ac9820552840e694791cfdda4957fbe047fb34 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Sun, 6 Sep 2026 21:51:25 -0600 Subject: [PATCH] Support OpenWebUI: verify X-OpenWebUI-User-Jwt (HS256) or fall back to X-User-Email + portal secret --- app/main.py | 43 +++++++++++++++++++++++++++++++++++-------- requirements.txt | 1 + 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/app/main.py b/app/main.py index a4f410a..1ccb23c 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ import sqlite3 import secrets import httpx +import jwt from fastapi import FastAPI, Request, Response, HTTPException from fastapi.responses import JSONResponse @@ -35,11 +36,18 @@ LITELLM_BASE = os.environ.get("LITELLM_BASE", "https://gateway.inference.coop") LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "") OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "") -# Shared secret that LibreChat sends as a header on every request, so the -# portal can verify the request genuinely came through LibreChat (which is -# behind Cloudron SSO) rather than a direct, spoofed request. +# Shared secret that the chat frontend uses to authenticate requests to the +# portal. LibreChat sends it as an X-Portal-Secret header; OpenWebUI signs the +# user's email as a JWT (see FORWARD_USER_INFO_HEADER_JWT_SECRET below). PORTAL_SECRET = os.environ.get("PORTAL_SECRET", "") +# OpenWebUI can sign the forwarded user identity as a JWT using this shared +# secret. When set, the portal verifies the JWT (HS256) to confirm the email +# genuinely came from OpenWebUI (behind Cloudron SSO) rather than a spoofed +# header. This is stronger than the plain X-Portal-Secret header because the +# email itself is tamper-proof. +OWUI_JWT_SECRET = os.environ.get("OWUI_JWT_SECRET", "") + # Secret token required in the Open Collective webhook URL path. Open # Collective's generic webhooks are not HMAC-signed, so a secret in the URL # is the standard way to authenticate them. @@ -497,13 +505,32 @@ async def list_models(): @app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) async def inject_key(request: Request, path: str): """Read the member's email from a header, inject their key, forward to LiteLLM.""" - # Verify the request came through LibreChat (shared secret), so a direct - # caller can't spoof the X-User-Email header and use another member's key. - _verify_portal_secret(request) + # Verify the request came through the chat frontend, so a direct caller + # can't spoof the user-email header and use another member's key. + # + # Two supported auth paths: + # 1. OpenWebUI signs the user identity as a JWT (X-OpenWebUI-User-Jwt) + # with a shared secret — the email is tamper-proof, so no separate + # secret header is needed. + # 2. LibreChat sends a plain X-User-Email header plus an X-Portal-Secret + # shared-secret header. + email = "" + jwt_header = request.headers.get("x-openwebui-user-jwt", "") + if jwt_header and OWUI_JWT_SECRET: + try: + claims = jwt.decode(jwt_header, OWUI_JWT_SECRET, algorithms=["HS256"]) + email = claims.get("email", "") + except jwt.PyJWTError: + raise HTTPException(401, "Invalid user identity token") + else: + _verify_portal_secret(request) + email = ( + request.headers.get("x-user-email", "") + or request.headers.get("x-openwebui-user-email", "") + ) - email = request.headers.get("x-user-email", "") if not email: - raise HTTPException(401, "No member identity (x-user-email header)") + raise HTTPException(401, "No member identity (user-email header)") # Look up the member's key from the persistent store member_key = get_member_key(email) diff --git a/requirements.txt b/requirements.txt index a2834bb..e0ad96a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 httpx==0.27.2 pydantic==2.9.2 +PyJWT==2.9.0