This commit is contained in:
Timothy Jaeryang Baek
2026-07-27 04:36:51 -04:00
parent 56183fcb17
commit b190dcf3ca
2 changed files with 101 additions and 19 deletions
+26 -10
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import time
import uuid
from typing import Optional
@@ -12,6 +13,7 @@ from open_webui.models.users import User, UserModel, UserProfileImageResponse, U
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, field_validator
from sqlalchemy import Boolean, Column, String, Text, delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
@@ -109,12 +111,13 @@ class AuthsTable:
role: str = 'pending',
oauth: dict | None = None,
db: AsyncSession | None = None,
user_id: str | None = None,
) -> UserModel | None:
"""Create an Auth + User pair inside a single transaction."""
async with get_async_db_context(db) as session:
log.info('insert_new_auth')
new_id = str(uuid.uuid4())
new_id = user_id or str(uuid.uuid4())
credential = Auth(
id=new_id,
@@ -124,17 +127,30 @@ class AuthsTable:
)
session.add(credential)
created_user = await Users.insert_new_user(
new_id,
name,
email,
profile_image_url,
role,
try:
profile_image_url = validate_profile_image_url(profile_image_url)
except ValueError:
profile_image_url = '/user.png'
now = int(time.time())
created_user = UserModel(
id=new_id,
email=email,
name=name,
role=role,
profile_image_url=profile_image_url,
last_active_at=now,
created_at=now,
updated_at=now,
oauth=oauth,
db=session,
)
# persist both records
await session.commit()
session.add(User(**created_user.model_dump()))
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise
return created_user if credential and created_user else None
async def authenticate_user(
+75 -9
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import asyncio
import datetime
import hashlib
import hmac
import logging
import re
import time
@@ -9,7 +11,7 @@ import urllib
import uuid
from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
from aiohttp import ClientSession
from aiohttp import BasicAuth, ClientSession
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, Response
from ldap3 import NONE, Connection, Server, Tls
@@ -27,6 +29,7 @@ from open_webui.env import (
ENABLE_OAUTH_TOKEN_EXCHANGE,
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS,
WEBUI_AUTH,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
@@ -35,6 +38,7 @@ from open_webui.env import (
WEBUI_AUTH_TRUSTED_GROUPS_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_TRUSTED_ROLE_HEADER,
WEBUI_SECRET_KEY,
)
from open_webui.internal.db import get_async_session
from open_webui.models.auths import (
@@ -77,6 +81,7 @@ from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.rate_limit import RateLimiter
from open_webui.utils.redis import get_redis_client
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
@@ -98,6 +103,16 @@ token_exchange_rate_limiter = (
else None
)
def trusted_header_user_id(email: str) -> str:
digest = hmac.digest(
WEBUI_SECRET_KEY.encode(),
f'open-webui:trusted-header:{email.lower()}'.encode(),
hashlib.sha256,
)
return str(uuid.UUID(bytes=digest[:16], version=4))
ADMIN_CONFIG_KEYS = {
'SHOW_ADMIN_DETAILS': 'auth.admin.show',
'ADMIN_EMAIL': 'auth.admin.email',
@@ -738,14 +753,18 @@ async def signin(
pass
if not await Users.get_user_by_email(email.lower(), db=db):
await signup_handler(
request,
email,
str(uuid.uuid4()),
name,
db=db,
source='trusted_header',
)
try:
await signup_handler(
request,
email,
str(uuid.uuid4()),
name,
db=db,
source='trusted_header',
user_id=trusted_header_user_id(email),
)
except IntegrityError:
pass
user = await Auths.authenticate_user_by_email(email, db=db)
if user:
@@ -826,6 +845,7 @@ async def signup_handler(
*,
db: AsyncSession,
source: str = 'api',
user_id: str | None = None,
) -> UserModel:
"""
Core user-creation logic shared by the signup endpoint and
@@ -846,6 +866,7 @@ async def signup_handler(
profile_image_url=profile_image_url,
role=await Config.get('ui.default_user_role'),
db=db,
user_id=user_id,
)
if not user:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
@@ -1520,6 +1541,37 @@ class TokenExchangeForm(BaseModel):
token: str # OAuth access token from external provider
async def get_token_client_id(client, token: str) -> str | None:
"""Return the OAuth client_id a token was minted for, when the provider supports introspection."""
try:
metadata = await client.load_server_metadata()
introspection_endpoint = metadata.get('introspection_endpoint')
if not introspection_endpoint:
log.warning('Token exchange trusted-client check requires an introspection_endpoint')
return None
async with ClientSession(trust_env=True) as session:
async with session.post(
introspection_endpoint,
data={'token': token, 'token_type_hint': 'access_token'},
auth=BasicAuth(client.client_id, client.client_secret or ''),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
log.warning(f'Token introspection returned {r.status}')
return None
introspection = await r.json()
if not introspection.get('active'):
log.warning('Token introspection reports the token is inactive')
return None
return introspection.get('client_id')
except Exception as e:
log.warning(f'Token introspection failed: {e}')
return None
@router.post('/oauth/{provider}/token/exchange', response_model=SessionUserResponse)
async def token_exchange(
request: Request,
@@ -1563,6 +1615,20 @@ async def token_exchange(
detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
)
if OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS:
token_client_id = await get_token_client_id(client, form_data.token)
if not token_client_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unable to determine which client the token was issued to',
)
if token_client_id not in OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS:
log.warning('Token exchange denied: token was issued to an untrusted client for %s', provider)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Validate the token by calling the userinfo endpoint
try:
token_data = {'access_token': form_data.token, 'token_type': 'Bearer'}