Support OpenWebUI: verify X-OpenWebUI-User-Jwt (HS256) or fall back to X-User-Email + portal secret
This commit is contained in:
1 parent
57991cd9f7
commit
80ac982055
2 files changed
+36
-8
No files matched your search
+35
-8
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user