diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index d852b4ac40..01ac873696 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -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( diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index bafafd489a..5648a19a1e 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -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'}