Browse Source

Merge pull request #2574 from cheahjs/feat/oauth

feat: experimental SSO support for Google, Microsoft, and OIDC
Timothy Jaeryang Baek 10 tháng trước cách đây
mục cha
commit
d17dc59246
52 tập tin đã thay đổi với 633 bổ sung13 xóa
  1. 49 0
      backend/apps/webui/internal/migrations/017_add_user_oauth_sub.py
  2. 2 0
      backend/apps/webui/main.py
  3. 4 1
      backend/apps/webui/models/auths.py
  4. 25 0
      backend/apps/webui/models/users.py
  5. 0 1
      backend/apps/webui/routers/auths.py
  6. 139 0
      backend/config.py
  7. 174 2
      backend/main.py
  8. 1 0
      backend/requirements.txt
  9. 1 0
      pyproject.toml
  10. 9 3
      requirements-dev.lock
  11. 9 3
      requirements.lock
  12. 2 0
      src/lib/i18n/locales/ar-BH/translation.json
  13. 2 0
      src/lib/i18n/locales/bg-BG/translation.json
  14. 2 0
      src/lib/i18n/locales/bn-BD/translation.json
  15. 2 0
      src/lib/i18n/locales/ca-ES/translation.json
  16. 2 0
      src/lib/i18n/locales/ceb-PH/translation.json
  17. 2 0
      src/lib/i18n/locales/de-DE/translation.json
  18. 2 0
      src/lib/i18n/locales/dg-DG/translation.json
  19. 2 0
      src/lib/i18n/locales/en-GB/translation.json
  20. 2 0
      src/lib/i18n/locales/en-US/translation.json
  21. 2 0
      src/lib/i18n/locales/es-ES/translation.json
  22. 2 0
      src/lib/i18n/locales/fa-IR/translation.json
  23. 2 0
      src/lib/i18n/locales/fi-FI/translation.json
  24. 2 0
      src/lib/i18n/locales/fr-CA/translation.json
  25. 2 0
      src/lib/i18n/locales/fr-FR/translation.json
  26. 2 0
      src/lib/i18n/locales/he-IL/translation.json
  27. 2 0
      src/lib/i18n/locales/hi-IN/translation.json
  28. 2 0
      src/lib/i18n/locales/hr-HR/translation.json
  29. 2 0
      src/lib/i18n/locales/it-IT/translation.json
  30. 2 0
      src/lib/i18n/locales/ja-JP/translation.json
  31. 2 0
      src/lib/i18n/locales/ka-GE/translation.json
  32. 2 0
      src/lib/i18n/locales/ko-KR/translation.json
  33. 2 0
      src/lib/i18n/locales/lt-LT/translation.json
  34. 2 0
      src/lib/i18n/locales/nb-NO/translation.json
  35. 2 0
      src/lib/i18n/locales/nl-NL/translation.json
  36. 2 0
      src/lib/i18n/locales/pa-IN/translation.json
  37. 2 0
      src/lib/i18n/locales/pl-PL/translation.json
  38. 2 0
      src/lib/i18n/locales/pt-BR/translation.json
  39. 2 0
      src/lib/i18n/locales/pt-PT/translation.json
  40. 2 0
      src/lib/i18n/locales/ru-RU/translation.json
  41. 2 0
      src/lib/i18n/locales/sr-RS/translation.json
  42. 2 0
      src/lib/i18n/locales/sv-SE/translation.json
  43. 2 0
      src/lib/i18n/locales/tk-TW/translation.json
  44. 2 0
      src/lib/i18n/locales/tr-TR/translation.json
  45. 2 0
      src/lib/i18n/locales/uk-UA/translation.json
  46. 2 0
      src/lib/i18n/locales/vi-VN/translation.json
  47. 2 0
      src/lib/i18n/locales/zh-CN/translation.json
  48. 2 0
      src/lib/i18n/locales/zh-TW/translation.json
  49. 5 0
      src/lib/stores/index.ts
  50. 14 0
      src/routes/(app)/admin/+page.svelte
  51. 6 1
      src/routes/+layout.svelte
  52. 121 2
      src/routes/auth/+page.svelte

+ 49 - 0
backend/apps/webui/internal/migrations/017_add_user_oauth_sub.py

@@ -0,0 +1,49 @@
+"""Peewee migrations -- 017_add_user_oauth_sub.py.
+
+Some examples (model - class or model name)::
+
+    > Model = migrator.orm['table_name']            # Return model in current state by name
+    > Model = migrator.ModelClass                   # Return model in current state by name
+
+    > migrator.sql(sql)                             # Run custom SQL
+    > migrator.run(func, *args, **kwargs)           # Run python function with the given args
+    > migrator.create_model(Model)                  # Create a model (could be used as decorator)
+    > migrator.remove_model(model, cascade=True)    # Remove a model
+    > migrator.add_fields(model, **fields)          # Add fields to a model
+    > migrator.change_fields(model, **fields)       # Change fields
+    > migrator.remove_fields(model, *field_names, cascade=True)
+    > migrator.rename_field(model, old_field_name, new_field_name)
+    > migrator.rename_table(model, new_table_name)
+    > migrator.add_index(model, *col_names, unique=False)
+    > migrator.add_not_null(model, *field_names)
+    > migrator.add_default(model, field_name, default)
+    > migrator.add_constraint(model, name, sql)
+    > migrator.drop_index(model, *col_names)
+    > migrator.drop_not_null(model, *field_names)
+    > migrator.drop_constraints(model, *constraints)
+
+"""
+
+from contextlib import suppress
+
+import peewee as pw
+from peewee_migrate import Migrator
+
+
+with suppress(ImportError):
+    import playhouse.postgres_ext as pw_pext
+
+
+def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
+    """Write your migrations here."""
+
+    migrator.add_fields(
+        "user",
+        oauth_sub=pw.TextField(null=True, unique=True),
+    )
+
+
+def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
+    """Write your rollback migrations here."""
+
+    migrator.remove_fields("user", "oauth_sub")

+ 2 - 0
backend/apps/webui/main.py

@@ -2,6 +2,8 @@ from fastapi import FastAPI, Depends
 from fastapi.routing import APIRoute
 from fastapi.responses import StreamingResponse
 from fastapi.middleware.cors import CORSMiddleware
+from starlette.middleware.sessions import SessionMiddleware
+
 from apps.webui.routers import (
     auths,
     users,

+ 4 - 1
backend/apps/webui/models/auths.py

@@ -105,6 +105,7 @@ class AuthsTable:
         name: str,
         profile_image_url: str = "/user.png",
         role: str = "pending",
+        oauth_sub: Optional[str] = None,
     ) -> Optional[UserModel]:
         log.info("insert_new_auth")
 
@@ -115,7 +116,9 @@ class AuthsTable:
         )
         result = Auth.create(**auth.model_dump())
 
-        user = Users.insert_new_user(id, name, email, profile_image_url, role)
+        user = Users.insert_new_user(
+            id, name, email, profile_image_url, role, oauth_sub
+        )
 
         if result and user:
             return user

+ 25 - 0
backend/apps/webui/models/users.py

@@ -28,6 +28,8 @@ class User(Model):
     settings = JSONField(null=True)
     info = JSONField(null=True)
 
+    oauth_sub = TextField(null=True, unique=True)
+
     class Meta:
         database = DB
 
@@ -53,6 +55,8 @@ class UserModel(BaseModel):
     settings: Optional[UserSettings] = None
     info: Optional[dict] = None
 
+    oauth_sub: Optional[str] = None
+
 
 ####################
 # Forms
@@ -83,6 +87,7 @@ class UsersTable:
         email: str,
         profile_image_url: str = "/user.png",
         role: str = "pending",
+        oauth_sub: Optional[str] = None,
     ) -> Optional[UserModel]:
         user = UserModel(
             **{
@@ -94,6 +99,7 @@ class UsersTable:
                 "last_active_at": int(time.time()),
                 "created_at": int(time.time()),
                 "updated_at": int(time.time()),
+                "oauth_sub": oauth_sub,
             }
         )
         result = User.create(**user.model_dump())
@@ -123,6 +129,13 @@ class UsersTable:
         except:
             return None
 
+    def get_user_by_oauth_sub(self, sub: str) -> Optional[UserModel]:
+        try:
+            user = User.get(User.oauth_sub == sub)
+            return UserModel(**model_to_dict(user))
+        except:
+            return None
+
     def get_users(self, skip: int = 0, limit: int = 50) -> List[UserModel]:
         return [
             UserModel(**model_to_dict(user))
@@ -174,6 +187,18 @@ class UsersTable:
         except:
             return None
 
+    def update_user_oauth_sub_by_id(
+        self, id: str, oauth_sub: str
+    ) -> Optional[UserModel]:
+        try:
+            query = User.update(oauth_sub=oauth_sub).where(User.id == id)
+            query.execute()
+
+            user = User.get(User.id == id)
+            return UserModel(**model_to_dict(user))
+        except:
+            return None
+
     def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
         try:
             query = User.update(**updated).where(User.id == id)

+ 0 - 1
backend/apps/webui/routers/auths.py

@@ -10,7 +10,6 @@ import re
 import uuid
 import csv
 
-
 from apps.webui.models.auths import (
     SigninForm,
     SignupForm,

+ 139 - 0
backend/config.py

@@ -305,6 +305,135 @@ JWT_EXPIRES_IN = PersistentConfig(
     "JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1")
 )
 
+####################################
+# OAuth config
+####################################
+
+ENABLE_OAUTH_SIGNUP = PersistentConfig(
+    "ENABLE_OAUTH_SIGNUP",
+    "oauth.enable_signup",
+    os.environ.get("ENABLE_OAUTH_SIGNUP", "False").lower() == "true",
+)
+
+OAUTH_MERGE_ACCOUNTS_BY_EMAIL = PersistentConfig(
+    "OAUTH_MERGE_ACCOUNTS_BY_EMAIL",
+    "oauth.merge_accounts_by_email",
+    os.environ.get("OAUTH_MERGE_ACCOUNTS_BY_EMAIL", "False").lower() == "true",
+)
+
+OAUTH_PROVIDERS = {}
+
+GOOGLE_CLIENT_ID = PersistentConfig(
+    "GOOGLE_CLIENT_ID",
+    "oauth.google.client_id",
+    os.environ.get("GOOGLE_CLIENT_ID", ""),
+)
+
+GOOGLE_CLIENT_SECRET = PersistentConfig(
+    "GOOGLE_CLIENT_SECRET",
+    "oauth.google.client_secret",
+    os.environ.get("GOOGLE_CLIENT_SECRET", ""),
+)
+
+GOOGLE_OAUTH_SCOPE = PersistentConfig(
+    "GOOGLE_OAUTH_SCOPE",
+    "oauth.google.scope",
+    os.environ.get("GOOGLE_OAUTH_SCOPE", "openid email profile"),
+)
+
+MICROSOFT_CLIENT_ID = PersistentConfig(
+    "MICROSOFT_CLIENT_ID",
+    "oauth.microsoft.client_id",
+    os.environ.get("MICROSOFT_CLIENT_ID", ""),
+)
+
+MICROSOFT_CLIENT_SECRET = PersistentConfig(
+    "MICROSOFT_CLIENT_SECRET",
+    "oauth.microsoft.client_secret",
+    os.environ.get("MICROSOFT_CLIENT_SECRET", ""),
+)
+
+MICROSOFT_CLIENT_TENANT_ID = PersistentConfig(
+    "MICROSOFT_CLIENT_TENANT_ID",
+    "oauth.microsoft.tenant_id",
+    os.environ.get("MICROSOFT_CLIENT_TENANT_ID", ""),
+)
+
+MICROSOFT_OAUTH_SCOPE = PersistentConfig(
+    "MICROSOFT_OAUTH_SCOPE",
+    "oauth.microsoft.scope",
+    os.environ.get("MICROSOFT_OAUTH_SCOPE", "openid email profile"),
+)
+
+OAUTH_CLIENT_ID = PersistentConfig(
+    "OAUTH_CLIENT_ID",
+    "oauth.oidc.client_id",
+    os.environ.get("OAUTH_CLIENT_ID", ""),
+)
+
+OAUTH_CLIENT_SECRET = PersistentConfig(
+    "OAUTH_CLIENT_SECRET",
+    "oauth.oidc.client_secret",
+    os.environ.get("OAUTH_CLIENT_SECRET", ""),
+)
+
+OPENID_PROVIDER_URL = PersistentConfig(
+    "OPENID_PROVIDER_URL",
+    "oauth.oidc.provider_url",
+    os.environ.get("OPENID_PROVIDER_URL", ""),
+)
+
+OAUTH_SCOPES = PersistentConfig(
+    "OAUTH_SCOPES",
+    "oauth.oidc.scopes",
+    os.environ.get("OAUTH_SCOPES", "openid email profile"),
+)
+
+OAUTH_PROVIDER_NAME = PersistentConfig(
+    "OAUTH_PROVIDER_NAME",
+    "oauth.oidc.provider_name",
+    os.environ.get("OAUTH_PROVIDER_NAME", "SSO"),
+)
+
+
+def load_oauth_providers():
+    OAUTH_PROVIDERS.clear()
+    if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
+        OAUTH_PROVIDERS["google"] = {
+            "client_id": GOOGLE_CLIENT_ID.value,
+            "client_secret": GOOGLE_CLIENT_SECRET.value,
+            "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
+            "scope": GOOGLE_OAUTH_SCOPE.value,
+        }
+
+    if (
+        MICROSOFT_CLIENT_ID.value
+        and MICROSOFT_CLIENT_SECRET.value
+        and MICROSOFT_CLIENT_TENANT_ID.value
+    ):
+        OAUTH_PROVIDERS["microsoft"] = {
+            "client_id": MICROSOFT_CLIENT_ID.value,
+            "client_secret": MICROSOFT_CLIENT_SECRET.value,
+            "server_metadata_url": f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration",
+            "scope": MICROSOFT_OAUTH_SCOPE.value,
+        }
+
+    if (
+        OAUTH_CLIENT_ID.value
+        and OAUTH_CLIENT_SECRET.value
+        and OPENID_PROVIDER_URL.value
+    ):
+        OAUTH_PROVIDERS["oidc"] = {
+            "client_id": OAUTH_CLIENT_ID.value,
+            "client_secret": OAUTH_CLIENT_SECRET.value,
+            "server_metadata_url": OPENID_PROVIDER_URL.value,
+            "scope": OAUTH_SCOPES.value,
+            "name": OAUTH_PROVIDER_NAME.value,
+        }
+
+
+load_oauth_providers()
+
 ####################################
 # Static DIR
 ####################################
@@ -733,6 +862,16 @@ WEBUI_SECRET_KEY = os.environ.get(
     ),  # DEPRECATED: remove at next major version
 )
 
+WEBUI_SESSION_COOKIE_SAME_SITE = os.environ.get(
+    "WEBUI_SESSION_COOKIE_SAME_SITE",
+    os.environ.get("WEBUI_SESSION_COOKIE_SAME_SITE", "lax"),
+)
+
+WEBUI_SESSION_COOKIE_SECURE = os.environ.get(
+    "WEBUI_SESSION_COOKIE_SECURE",
+    os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false").lower() == "true",
+)
+
 if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
     raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
 

+ 174 - 2
backend/main.py

@@ -1,4 +1,9 @@
+import base64
+import uuid
 from contextlib import asynccontextmanager
+
+from authlib.integrations.starlette_client import OAuth
+from authlib.oidc.core import UserInfo
 from bs4 import BeautifulSoup
 import json
 import markdown
@@ -24,7 +29,8 @@ from fastapi.middleware.wsgi import WSGIMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 from starlette.exceptions import HTTPException as StarletteHTTPException
 from starlette.middleware.base import BaseHTTPMiddleware
-from starlette.responses import StreamingResponse, Response
+from starlette.middleware.sessions import SessionMiddleware
+from starlette.responses import StreamingResponse, Response, RedirectResponse
 
 
 from apps.socket.main import app as socket_app
@@ -53,9 +59,11 @@ from apps.webui.main import (
 from pydantic import BaseModel
 from typing import List, Optional, Iterator, Generator, Union
 
+from apps.webui.models.auths import Auths
 from apps.webui.models.models import Models, ModelModel
 from apps.webui.models.tools import Tools
 from apps.webui.models.functions import Functions
+from apps.webui.models.users import Users
 
 from apps.webui.utils import load_toolkit_module_by_id, load_function_module_by_id
 
@@ -64,6 +72,8 @@ from utils.utils import (
     get_verified_user,
     get_current_user,
     get_http_authorization_cred,
+    get_password_hash,
+    create_token,
 )
 from utils.task import (
     title_generation_template,
@@ -74,6 +84,7 @@ from utils.misc import (
     get_last_user_message,
     add_or_update_system_message,
     stream_message_template,
+    parse_duration,
 )
 
 from apps.rag.utils import get_rag_context, rag_template
@@ -106,9 +117,16 @@ from config import (
     SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD,
     TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
     SAFE_MODE,
+    OAUTH_PROVIDERS,
+    ENABLE_OAUTH_SIGNUP,
+    OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
+    WEBUI_SECRET_KEY,
+    WEBUI_SESSION_COOKIE_SAME_SITE,
+    WEBUI_SESSION_COOKIE_SECURE,
     AppConfig,
 )
-from constants import ERROR_MESSAGES
+from constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
+from utils.webhook import post_webhook
 
 if SAFE_MODE:
     print("SAFE MODE ENABLED")
@@ -1725,6 +1743,12 @@ async def get_app_config():
                 "engine": audio_app.state.config.STT_ENGINE,
             },
         },
+        "oauth": {
+            "providers": {
+                name: config.get("name", name)
+                for name, config in OAUTH_PROVIDERS.items()
+            }
+        },
     }
 
 
@@ -1806,6 +1830,154 @@ async def get_app_latest_release_version():
         )
 
 
+############################
+# OAuth Login & Callback
+############################
+
+oauth = OAuth()
+
+for provider_name, provider_config in OAUTH_PROVIDERS.items():
+    oauth.register(
+        name=provider_name,
+        client_id=provider_config["client_id"],
+        client_secret=provider_config["client_secret"],
+        server_metadata_url=provider_config["server_metadata_url"],
+        client_kwargs={
+            "scope": provider_config["scope"],
+        },
+    )
+
+# SessionMiddleware is used by authlib for oauth
+if len(OAUTH_PROVIDERS) > 0:
+    app.add_middleware(
+        SessionMiddleware,
+        secret_key=WEBUI_SECRET_KEY,
+        session_cookie="oui-session",
+        same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
+        https_only=WEBUI_SESSION_COOKIE_SECURE,
+    )
+
+
+@app.get("/oauth/{provider}/login")
+async def oauth_login(provider: str, request: Request):
+    if provider not in OAUTH_PROVIDERS:
+        raise HTTPException(404)
+    redirect_uri = request.url_for("oauth_callback", provider=provider)
+    return await oauth.create_client(provider).authorize_redirect(request, redirect_uri)
+
+
+# OAuth login logic is as follows:
+# 1. Attempt to find a user with matching subject ID, tied to the provider
+# 2. If OAUTH_MERGE_ACCOUNTS_BY_EMAIL is true, find a user with the email address provided via OAuth
+#    - This is considered insecure in general, as OAuth providers do not always verify email addresses
+# 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user
+#    - Email addresses are considered unique, so we fail registration if the email address is alreayd taken
+@app.get("/oauth/{provider}/callback")
+async def oauth_callback(provider: str, request: Request, response: Response):
+    if provider not in OAUTH_PROVIDERS:
+        raise HTTPException(404)
+    client = oauth.create_client(provider)
+    try:
+        token = await client.authorize_access_token(request)
+    except Exception as e:
+        log.warning(f"OAuth callback error: {e}")
+        raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+    user_data: UserInfo = token["userinfo"]
+
+    sub = user_data.get("sub")
+    if not sub:
+        log.warning(f"OAuth callback failed, sub is missing: {user_data}")
+        raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+    provider_sub = f"{provider}@{sub}"
+    email = user_data.get("email", "").lower()
+    # We currently mandate that email addresses are provided
+    if not email:
+        log.warning(f"OAuth callback failed, email is missing: {user_data}")
+        raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+
+    # Check if the user exists
+    user = Users.get_user_by_oauth_sub(provider_sub)
+
+    if not user:
+        # If the user does not exist, check if merging is enabled
+        if OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value:
+            # Check if the user exists by email
+            user = Users.get_user_by_email(email)
+            if user:
+                # Update the user with the new oauth sub
+                Users.update_user_oauth_sub_by_id(user.id, provider_sub)
+
+    if not user:
+        # If the user does not exist, check if signups are enabled
+        if ENABLE_OAUTH_SIGNUP.value:
+            # Check if an existing user with the same email already exists
+            existing_user = Users.get_user_by_email(user_data.get("email", "").lower())
+            if existing_user:
+                raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
+
+            picture_url = user_data.get("picture", "")
+            if picture_url:
+                # Download the profile image into a base64 string
+                try:
+                    async with aiohttp.ClientSession() as session:
+                        async with session.get(picture_url) as resp:
+                            picture = await resp.read()
+                            base64_encoded_picture = base64.b64encode(picture).decode(
+                                "utf-8"
+                            )
+                            guessed_mime_type = mimetypes.guess_type(picture_url)[0]
+                            if guessed_mime_type is None:
+                                # assume JPG, browsers are tolerant enough of image formats
+                                guessed_mime_type = "image/jpeg"
+                            picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}"
+                except Exception as e:
+                    log.error(f"Error downloading profile image '{picture_url}': {e}")
+                    picture_url = ""
+            if not picture_url:
+                picture_url = "/user.png"
+            user = Auths.insert_new_auth(
+                email=email,
+                password=get_password_hash(
+                    str(uuid.uuid4())
+                ),  # Random password, not used
+                name=user_data.get("name", "User"),
+                profile_image_url=picture_url,
+                role=webui_app.state.config.DEFAULT_USER_ROLE,
+                oauth_sub=provider_sub,
+            )
+
+            if webui_app.state.config.WEBHOOK_URL:
+                post_webhook(
+                    webui_app.state.config.WEBHOOK_URL,
+                    WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
+                    {
+                        "action": "signup",
+                        "message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
+                        "user": user.model_dump_json(exclude_none=True),
+                    },
+                )
+        else:
+            raise HTTPException(
+                status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
+            )
+
+    jwt_token = create_token(
+        data={"id": user.id},
+        expires_delta=parse_duration(webui_app.state.config.JWT_EXPIRES_IN),
+    )
+
+    # Set the cookie token
+    response.set_cookie(
+        key="token",
+        value=token,
+        httponly=True,  # Ensures the cookie is not accessible via JavaScript
+    )
+
+    # Redirect back to the frontend with the JWT token
+    redirect_url = f"{request.base_url}auth#token={jwt_token}"
+    return RedirectResponse(url=redirect_url)
+
+
 @app.get("/manifest.json")
 async def get_manifest_json():
     return {

+ 1 - 0
backend/requirements.txt

@@ -58,6 +58,7 @@ rank-bm25==0.2.2
 faster-whisper==1.0.2
 
 PyJWT[crypto]==2.8.0
+authlib==1.3.0
 
 black==24.4.2
 langfuse==2.33.0

+ 1 - 0
pyproject.toml

@@ -59,6 +59,7 @@ dependencies = [
     "faster-whisper==1.0.2",
 
     "PyJWT[crypto]==2.8.0",
+    "authlib==1.3.0",
 
     "black==24.4.2",
     "langfuse==2.33.0",

+ 9 - 3
requirements-dev.lock

@@ -31,6 +31,8 @@ asgiref==3.8.1
     # via opentelemetry-instrumentation-asgi
 attrs==23.2.0
     # via aiohttp
+authlib==1.3.0
+    # via open-webui
 av==11.0.0
     # via faster-whisper
 backoff==2.2.1
@@ -93,6 +95,7 @@ coloredlogs==15.0.1
 compressed-rtf==1.0.6
     # via extract-msg
 cryptography==42.0.7
+    # via authlib
     # via msoffcrypto-tool
     # via pyjwt
 ctranslate2==4.2.1
@@ -395,6 +398,7 @@ pandas==2.2.2
     # via open-webui
 passlib==1.7.4
     # via open-webui
+    # via passlib
 pathspec==0.12.1
     # via black
 pcodedmp==1.2.6
@@ -453,6 +457,7 @@ pygments==2.18.0
     # via rich
 pyjwt==2.8.0
     # via open-webui
+    # via pyjwt
 pymysql==1.1.0
     # via open-webui
 pypandoc==1.13
@@ -554,9 +559,6 @@ scipy==1.13.0
     # via sentence-transformers
 sentence-transformers==2.7.0
     # via open-webui
-setuptools==69.5.1
-    # via ctranslate2
-    # via opentelemetry-instrumentation
 shapely==2.0.4
     # via rapidocr-onnxruntime
 shellingham==1.5.4
@@ -651,6 +653,7 @@ uvicorn==0.22.0
     # via chromadb
     # via fastapi
     # via open-webui
+    # via uvicorn
 uvloop==0.19.0
     # via uvicorn
 validators==0.28.1
@@ -678,3 +681,6 @@ youtube-transcript-api==0.6.2
     # via open-webui
 zipp==3.18.1
     # via importlib-metadata
+setuptools==69.5.1
+    # via ctranslate2
+    # via opentelemetry-instrumentation

+ 9 - 3
requirements.lock

@@ -31,6 +31,8 @@ asgiref==3.8.1
     # via opentelemetry-instrumentation-asgi
 attrs==23.2.0
     # via aiohttp
+authlib==1.3.0
+    # via open-webui
 av==11.0.0
     # via faster-whisper
 backoff==2.2.1
@@ -93,6 +95,7 @@ coloredlogs==15.0.1
 compressed-rtf==1.0.6
     # via extract-msg
 cryptography==42.0.7
+    # via authlib
     # via msoffcrypto-tool
     # via pyjwt
 ctranslate2==4.2.1
@@ -395,6 +398,7 @@ pandas==2.2.2
     # via open-webui
 passlib==1.7.4
     # via open-webui
+    # via passlib
 pathspec==0.12.1
     # via black
 pcodedmp==1.2.6
@@ -453,6 +457,7 @@ pygments==2.18.0
     # via rich
 pyjwt==2.8.0
     # via open-webui
+    # via pyjwt
 pymysql==1.1.0
     # via open-webui
 pypandoc==1.13
@@ -554,9 +559,6 @@ scipy==1.13.0
     # via sentence-transformers
 sentence-transformers==2.7.0
     # via open-webui
-setuptools==69.5.1
-    # via ctranslate2
-    # via opentelemetry-instrumentation
 shapely==2.0.4
     # via rapidocr-onnxruntime
 shellingham==1.5.4
@@ -651,6 +653,7 @@ uvicorn==0.22.0
     # via chromadb
     # via fastapi
     # via open-webui
+    # via uvicorn
 uvloop==0.19.0
     # via uvicorn
 validators==0.28.1
@@ -678,3 +681,6 @@ youtube-transcript-api==0.6.2
     # via open-webui
 zipp==3.18.1
     # via importlib-metadata
+setuptools==69.5.1
+    # via ctranslate2
+    # via opentelemetry-instrumentation

+ 2 - 0
src/lib/i18n/locales/ar-BH/translation.json

@@ -126,6 +126,7 @@
 	"Content": "الاتصال",
 	"Context Length": "طول السياق",
 	"Continue Response": "متابعة الرد",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "تم نسخ عنوان URL للدردشة المشتركة إلى الحافظة",
 	"Copy": "نسخ",
 	"Copy last code block": "انسخ كتلة التعليمات البرمجية الأخيرة",
@@ -378,6 +379,7 @@
 	"Notifications": "إشعارات",
 	"November": "نوفمبر",
 	"num_thread (Ollama)": "num_thread (أولاما)",
+	"OAuth ID": "",
 	"October": "اكتوبر",
 	"Off": "أغلاق",
 	"Okay, Let's Go!": "حسنا دعنا نذهب!",

+ 2 - 0
src/lib/i18n/locales/bg-BG/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Съдържание",
 	"Context Length": "Дължина на Контекста",
 	"Continue Response": "Продължи отговора",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Копирана е връзката за чат!",
 	"Copy": "Копирай",
 	"Copy last code block": "Копиране на последен код блок",
@@ -378,6 +379,7 @@
 	"Notifications": "Десктоп Известия",
 	"November": "Ноември",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Октомври",
 	"Off": "Изкл.",
 	"Okay, Let's Go!": "ОК, Нека започваме!",

+ 2 - 0
src/lib/i18n/locales/bn-BD/translation.json

@@ -126,6 +126,7 @@
 	"Content": "বিষয়বস্তু",
 	"Context Length": "কনটেক্সটের দৈর্ঘ্য",
 	"Continue Response": "যাচাই করুন",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "শেয়ারকৃত কথা-ব্যবহারের URL ক্লিপবোর্ডে কপি করা হয়েছে!",
 	"Copy": "অনুলিপি",
 	"Copy last code block": "সর্বশেষ কোড ব্লক কপি করুন",
@@ -378,6 +379,7 @@
 	"Notifications": "নোটিফিকেশনসমূহ",
 	"November": "নভেম্বর",
 	"num_thread (Ollama)": "num_thread (ওলামা)",
+	"OAuth ID": "",
 	"October": "অক্টোবর",
 	"Off": "বন্ধ",
 	"Okay, Let's Go!": "ঠিক আছে, চলুন যাই!",

+ 2 - 0
src/lib/i18n/locales/ca-ES/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Contingut",
 	"Context Length": "Mida del context",
 	"Continue Response": "Continuar la resposta",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "S'ha copiat l'URL compartida al porta-retalls!",
 	"Copy": "Copiar",
 	"Copy last code block": "Copiar l'últim bloc de codi",
@@ -378,6 +379,7 @@
 	"Notifications": "Notificacions",
 	"November": "Novembre",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Octubre",
 	"Off": "Desactivat",
 	"Okay, Let's Go!": "D'acord, som-hi!",

+ 2 - 0
src/lib/i18n/locales/ceb-PH/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Kontento",
 	"Context Length": "Ang gitas-on sa konteksto",
 	"Continue Response": "",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "",
 	"Copy": "",
 	"Copy last code block": "Kopyaha ang katapusang bloke sa code",
@@ -378,6 +379,7 @@
 	"Notifications": "Mga pahibalo sa desktop",
 	"November": "",
 	"num_thread (Ollama)": "",
+	"OAuth ID": "",
 	"October": "",
 	"Off": "Napuo",
 	"Okay, Let's Go!": "Okay, lakaw na!",

+ 2 - 0
src/lib/i18n/locales/de-DE/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Info",
 	"Context Length": "Context Length",
 	"Continue Response": "Antwort fortsetzen",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Geteilte Chat-URL in die Zwischenablage kopiert!",
 	"Copy": "Kopieren",
 	"Copy last code block": "Letzten Codeblock kopieren",
@@ -378,6 +379,7 @@
 	"Notifications": "Desktop-Benachrichtigungen",
 	"November": "November",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Oktober",
 	"Off": "Aus",
 	"Okay, Let's Go!": "Okay, los geht's!",

+ 2 - 0
src/lib/i18n/locales/dg-DG/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Content",
 	"Context Length": "Context Length",
 	"Continue Response": "",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "",
 	"Copy": "",
 	"Copy last code block": "Copy last code block",
@@ -378,6 +379,7 @@
 	"Notifications": "Notifications",
 	"November": "",
 	"num_thread (Ollama)": "",
+	"OAuth ID": "",
 	"October": "",
 	"Off": "Off",
 	"Okay, Let's Go!": "Okay, Let's Go!",

+ 2 - 0
src/lib/i18n/locales/en-GB/translation.json

@@ -126,6 +126,7 @@
 	"Content": "",
 	"Context Length": "",
 	"Continue Response": "",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "",
 	"Copy": "",
 	"Copy last code block": "",
@@ -378,6 +379,7 @@
 	"Notifications": "",
 	"November": "",
 	"num_thread (Ollama)": "",
+	"OAuth ID": "",
 	"October": "",
 	"Off": "",
 	"Okay, Let's Go!": "",

+ 2 - 0
src/lib/i18n/locales/en-US/translation.json

@@ -126,6 +126,7 @@
 	"Content": "",
 	"Context Length": "",
 	"Continue Response": "",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "",
 	"Copy": "",
 	"Copy last code block": "",
@@ -378,6 +379,7 @@
 	"Notifications": "",
 	"November": "",
 	"num_thread (Ollama)": "",
+	"OAuth ID": "",
 	"October": "",
 	"Off": "",
 	"Okay, Let's Go!": "",

+ 2 - 0
src/lib/i18n/locales/es-ES/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Contenido",
 	"Context Length": "Longitud del contexto",
 	"Continue Response": "Continuar Respuesta",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "¡URL de chat compartido copiado al portapapeles!",
 	"Copy": "Copiar",
 	"Copy last code block": "Copia el último bloque de código",
@@ -378,6 +379,7 @@
 	"Notifications": "Notificaciones",
 	"November": "Noviembre",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Octubre",
 	"Off": "Desactivado",
 	"Okay, Let's Go!": "Bien, ¡Vamos!",

+ 2 - 0
src/lib/i18n/locales/fa-IR/translation.json

@@ -126,6 +126,7 @@
 	"Content": "محتوا",
 	"Context Length": "طول زمینه",
 	"Continue Response": "ادامه پاسخ",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "URL چت به کلیپ بورد کپی شد!",
 	"Copy": "کپی",
 	"Copy last code block": "کپی آخرین بلوک کد",
@@ -378,6 +379,7 @@
 	"Notifications": "اعلان",
 	"November": "نوامبر",
 	"num_thread (Ollama)": "num_thread (اولاما)",
+	"OAuth ID": "",
 	"October": "اکتبر",
 	"Off": "خاموش",
 	"Okay, Let's Go!": "باشه، بزن بریم!",

+ 2 - 0
src/lib/i18n/locales/fi-FI/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Sisältö",
 	"Context Length": "Kontekstin pituus",
 	"Continue Response": "Jatka vastausta",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Jaettu keskustelulinkki kopioitu leikepöydälle!",
 	"Copy": "Kopioi",
 	"Copy last code block": "Kopioi viimeisin koodilohko",
@@ -378,6 +379,7 @@
 	"Notifications": "Ilmoitukset",
 	"November": "marraskuu",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "lokakuu",
 	"Off": "Pois",
 	"Okay, Let's Go!": "Eikun menoksi!",

+ 2 - 0
src/lib/i18n/locales/fr-CA/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Contenu",
 	"Context Length": "Longueur du contexte",
 	"Continue Response": "Continuer la réponse",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "URL de chat partagé copié dans le presse-papier !",
 	"Copy": "Copier",
 	"Copy last code block": "Copier le dernier bloc de code",
@@ -378,6 +379,7 @@
 	"Notifications": "Notifications de bureau",
 	"November": "Novembre",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Octobre",
 	"Off": "Éteint",
 	"Okay, Let's Go!": "Okay, Allons-y !",

+ 2 - 0
src/lib/i18n/locales/fr-FR/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Contenu",
 	"Context Length": "Longueur du contexte",
 	"Continue Response": "Continuer la Réponse",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "URL du chat copié dans le presse-papiers !",
 	"Copy": "Copier",
 	"Copy last code block": "Copier le dernier bloc de code",
@@ -378,6 +379,7 @@
 	"Notifications": "Notifications de bureau",
 	"November": "Novembre",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Octobre",
 	"Off": "Désactivé",
 	"Okay, Let's Go!": "D'accord, allons-y !",

+ 2 - 0
src/lib/i18n/locales/he-IL/translation.json

@@ -126,6 +126,7 @@
 	"Content": "תוכן",
 	"Context Length": "אורך הקשר",
 	"Continue Response": "המשך תגובה",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "העתקת כתובת URL של צ'אט משותף ללוח!",
 	"Copy": "העתק",
 	"Copy last code block": "העתק את בלוק הקוד האחרון",
@@ -378,6 +379,7 @@
 	"Notifications": "התראות",
 	"November": "נובמבר",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "אוקטובר",
 	"Off": "כבוי",
 	"Okay, Let's Go!": "בסדר, בואו נתחיל!",

+ 2 - 0
src/lib/i18n/locales/hi-IN/translation.json

@@ -126,6 +126,7 @@
 	"Content": "सामग्री",
 	"Context Length": "प्रसंग की लंबाई",
 	"Continue Response": "प्रतिक्रिया जारी रखें",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "साझा चैट URL को क्लिपबोर्ड पर कॉपी किया गया!",
 	"Copy": "कॉपी",
 	"Copy last code block": "अंतिम कोड ब्लॉक कॉपी करें",
@@ -378,6 +379,7 @@
 	"Notifications": "सूचनाएं",
 	"November": "नवंबर",
 	"num_thread (Ollama)": "num_thread (ओलामा)",
+	"OAuth ID": "",
 	"October": "अक्टूबर",
 	"Off": "बंद",
 	"Okay, Let's Go!": "ठीक है, चलिए चलते हैं!",

+ 2 - 0
src/lib/i18n/locales/hr-HR/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Sadržaj",
 	"Context Length": "Dužina konteksta",
 	"Continue Response": "Nastavi odgovor",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "URL dijeljenog razgovora kopiran u međuspremnik!",
 	"Copy": "Kopiraj",
 	"Copy last code block": "Kopiraj zadnji blok koda",
@@ -378,6 +379,7 @@
 	"Notifications": "Obavijesti",
 	"November": "Studeni",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Listopad",
 	"Off": "Isključeno",
 	"Okay, Let's Go!": "U redu, idemo!",

+ 2 - 0
src/lib/i18n/locales/it-IT/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Contenuto",
 	"Context Length": "Lunghezza contesto",
 	"Continue Response": "Continua risposta",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "URL della chat condivisa copiato negli appunti!",
 	"Copy": "Copia",
 	"Copy last code block": "Copia ultimo blocco di codice",
@@ -378,6 +379,7 @@
 	"Notifications": "Notifiche desktop",
 	"November": "Novembre",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Ottobre",
 	"Off": "Disattivato",
 	"Okay, Let's Go!": "Ok, andiamo!",

+ 2 - 0
src/lib/i18n/locales/ja-JP/translation.json

@@ -126,6 +126,7 @@
 	"Content": "コンテンツ",
 	"Context Length": "コンテキストの長さ",
 	"Continue Response": "続きの応答",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "共有チャットURLをクリップボードにコピーしました!",
 	"Copy": "コピー",
 	"Copy last code block": "最後のコードブロックをコピー",
@@ -378,6 +379,7 @@
 	"Notifications": "デスクトップ通知",
 	"November": "11月",
 	"num_thread (Ollama)": "num_thread(オラマ)",
+	"OAuth ID": "",
 	"October": "10月",
 	"Off": "オフ",
 	"Okay, Let's Go!": "OK、始めましょう!",

+ 2 - 0
src/lib/i18n/locales/ka-GE/translation.json

@@ -126,6 +126,7 @@
 	"Content": "კონტენტი",
 	"Context Length": "კონტექსტის სიგრძე",
 	"Continue Response": "პასუხის გაგრძელება",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "ყავს ჩათის URL-ი კლიპბორდში!",
 	"Copy": "კოპირება",
 	"Copy last code block": "ბოლო ბლოკის კოპირება",
@@ -378,6 +379,7 @@
 	"Notifications": "შეტყობინება",
 	"November": "ნოემბერი",
 	"num_thread (Ollama)": "num_thread (ოლამა)",
+	"OAuth ID": "",
 	"October": "ოქტომბერი",
 	"Off": "გამორთვა",
 	"Okay, Let's Go!": "კარგი, წავედით!",

+ 2 - 0
src/lib/i18n/locales/ko-KR/translation.json

@@ -126,6 +126,7 @@
 	"Content": "내용",
 	"Context Length": "내용 길이",
 	"Continue Response": "대화 계속",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "공유 채팅 URL이 클립보드에 복사되었습니다!",
 	"Copy": "복사",
 	"Copy last code block": "마지막 코드 블록 복사",
@@ -378,6 +379,7 @@
 	"Notifications": "알림",
 	"November": "11월",
 	"num_thread (Ollama)": "num_thread (올라마)",
+	"OAuth ID": "",
 	"October": "10월",
 	"Off": "끄기",
 	"Okay, Let's Go!": "좋아요, 시작합시다!",

+ 2 - 0
src/lib/i18n/locales/lt-LT/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Turinys",
 	"Context Length": "Konteksto ilgis",
 	"Continue Response": "Tęsti atsakymą",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Nukopijavote pokalbio nuorodą",
 	"Copy": "Kopijuoti",
 	"Copy last code block": "Kopijuoti paskutinį kodo bloką",
@@ -378,6 +379,7 @@
 	"Notifications": "Pranešimai",
 	"November": "lapkritis",
 	"num_thread (Ollama)": "",
+	"OAuth ID": "",
 	"October": "spalis",
 	"Off": "Išjungta",
 	"Okay, Let's Go!": "Gerai, važiuojam!",

+ 2 - 0
src/lib/i18n/locales/nb-NO/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Innhold",
 	"Context Length": "Kontekstlengde",
 	"Continue Response": "Fortsett svar",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Kopiert delt chat-URL til utklippstavlen!",
 	"Copy": "Kopier",
 	"Copy last code block": "Kopier siste kodeblokk",
@@ -378,6 +379,7 @@
 	"Notifications": "Varsler",
 	"November": "November",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Oktober",
 	"Off": "Av",
 	"Okay, Let's Go!": "Ok, la oss gå!",

+ 2 - 0
src/lib/i18n/locales/nl-NL/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Inhoud",
 	"Context Length": "Context Lengte",
 	"Continue Response": "Doorgaan met Antwoord",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "URL van gedeelde gesprekspagina gekopieerd naar klembord!",
 	"Copy": "Kopieer",
 	"Copy last code block": "Kopieer laatste code blok",
@@ -378,6 +379,7 @@
 	"Notifications": "Desktop Notificaties",
 	"November": "November",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Oktober",
 	"Off": "Uit",
 	"Okay, Let's Go!": "Okay, Laten we gaan!",

+ 2 - 0
src/lib/i18n/locales/pa-IN/translation.json

@@ -126,6 +126,7 @@
 	"Content": "ਸਮੱਗਰੀ",
 	"Context Length": "ਸੰਦਰਭ ਲੰਬਾਈ",
 	"Continue Response": "ਜਵਾਬ ਜਾਰੀ ਰੱਖੋ",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "ਸਾਂਝੇ ਕੀਤੇ ਗੱਲਬਾਤ URL ਨੂੰ ਕਲਿੱਪਬੋਰਡ 'ਤੇ ਕਾਪੀ ਕਰ ਦਿੱਤਾ!",
 	"Copy": "ਕਾਪੀ ਕਰੋ",
 	"Copy last code block": "ਆਖਰੀ ਕੋਡ ਬਲਾਕ ਨੂੰ ਕਾਪੀ ਕਰੋ",
@@ -378,6 +379,7 @@
 	"Notifications": "ਸੂਚਨਾਵਾਂ",
 	"November": "ਨਵੰਬਰ",
 	"num_thread (Ollama)": "num_thread (ਓਲਾਮਾ)",
+	"OAuth ID": "",
 	"October": "ਅਕਤੂਬਰ",
 	"Off": "ਬੰਦ",
 	"Okay, Let's Go!": "ਠੀਕ ਹੈ, ਚੱਲੋ ਚੱਲੀਏ!",

+ 2 - 0
src/lib/i18n/locales/pl-PL/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Zawartość",
 	"Context Length": "Długość kontekstu",
 	"Continue Response": "Kontynuuj odpowiedź",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Skopiowano URL czatu do schowka!",
 	"Copy": "Kopiuj",
 	"Copy last code block": "Skopiuj ostatni blok kodu",
@@ -378,6 +379,7 @@
 	"Notifications": "Powiadomienia",
 	"November": "Listopad",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Październik",
 	"Off": "Wyłączony",
 	"Okay, Let's Go!": "Okej, zaczynamy!",

+ 2 - 0
src/lib/i18n/locales/pt-BR/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Conteúdo",
 	"Context Length": "Comprimento do Contexto",
 	"Continue Response": "Continuar resposta",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "URL de bate-papo compartilhado copiada com sucesso!",
 	"Copy": "Copiar",
 	"Copy last code block": "Copiar último bloco de código",
@@ -378,6 +379,7 @@
 	"Notifications": "Notificações da Área de Trabalho",
 	"November": "Novembro",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Outubro",
 	"Off": "Desligado",
 	"Okay, Let's Go!": "Ok, Vamos Lá!",

+ 2 - 0
src/lib/i18n/locales/pt-PT/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Conteúdo",
 	"Context Length": "Comprimento do Contexto",
 	"Continue Response": "Continuar resposta",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "URL de Conversa partilhado copiada com sucesso!",
 	"Copy": "Copiar",
 	"Copy last code block": "Copiar último bloco de código",
@@ -378,6 +379,7 @@
 	"Notifications": "Notificações da Área de Trabalho",
 	"November": "Novembro",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Outubro",
 	"Off": "Desligado",
 	"Okay, Let's Go!": "Ok, Vamos Lá!",

+ 2 - 0
src/lib/i18n/locales/ru-RU/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Содержание",
 	"Context Length": "Длина контексту",
 	"Continue Response": "Продолжить ответ",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Копирование общей ссылки чат в буфер обмена!",
 	"Copy": "Копировать",
 	"Copy last code block": "Копировать последний блок кода",
@@ -378,6 +379,7 @@
 	"Notifications": "Уведомления на рабочем столе",
 	"November": "Ноябрь",
 	"num_thread (Ollama)": "num_thread (Оллама)",
+	"OAuth ID": "",
 	"October": "Октябрь",
 	"Off": "Выключено.",
 	"Okay, Let's Go!": "Давайте начнём!",

+ 2 - 0
src/lib/i18n/locales/sr-RS/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Садржај",
 	"Context Length": "Дужина контекста",
 	"Continue Response": "Настави одговор",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Адреса дељеног ћаскања ископирана у оставу!",
 	"Copy": "Копирај",
 	"Copy last code block": "Копирај последњи блок кода",
@@ -378,6 +379,7 @@
 	"Notifications": "Обавештења",
 	"November": "Новембар",
 	"num_thread (Ollama)": "нум _тхреад (Оллама)",
+	"OAuth ID": "",
 	"October": "Октобар",
 	"Off": "Искључено",
 	"Okay, Let's Go!": "У реду, хајде да кренемо!",

+ 2 - 0
src/lib/i18n/locales/sv-SE/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Innehåll",
 	"Context Length": "Kontextlängd",
 	"Continue Response": "Fortsätt svar",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Kopierad delad chatt-URL till urklipp!",
 	"Copy": "Kopiera",
 	"Copy last code block": "Kopiera sista kodblock",
@@ -378,6 +379,7 @@
 	"Notifications": "Notifikationer",
 	"November": "november",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "oktober",
 	"Off": "Av",
 	"Okay, Let's Go!": "Okej, nu kör vi!",

+ 2 - 0
src/lib/i18n/locales/tk-TW/translation.json

@@ -126,6 +126,7 @@
 	"Content": "",
 	"Context Length": "",
 	"Continue Response": "",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "",
 	"Copy": "",
 	"Copy last code block": "",
@@ -378,6 +379,7 @@
 	"Notifications": "",
 	"November": "",
 	"num_thread (Ollama)": "",
+	"OAuth ID": "",
 	"October": "",
 	"Off": "",
 	"Okay, Let's Go!": "",

+ 2 - 0
src/lib/i18n/locales/tr-TR/translation.json

@@ -126,6 +126,7 @@
 	"Content": "İçerik",
 	"Context Length": "Bağlam Uzunluğu",
 	"Continue Response": "Yanıta Devam Et",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Paylaşılan sohbet URL'si panoya kopyalandı!",
 	"Copy": "Kopyala",
 	"Copy last code block": "Son kod bloğunu kopyala",
@@ -378,6 +379,7 @@
 	"Notifications": "Bildirimler",
 	"November": "Kasım",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Ekim",
 	"Off": "Kapalı",
 	"Okay, Let's Go!": "Tamam, Hadi Başlayalım!",

+ 2 - 0
src/lib/i18n/locales/uk-UA/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Зміст",
 	"Context Length": "Довжина контексту",
 	"Continue Response": "Продовжити відповідь",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Скопійовано URL-адресу спільного чату в буфер обміну!",
 	"Copy": "Копіювати",
 	"Copy last code block": "Копіювати останній блок коду",
@@ -378,6 +379,7 @@
 	"Notifications": "Сповіщення",
 	"November": "Листопад",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Жовтень",
 	"Off": "Вимк",
 	"Okay, Let's Go!": "Гаразд, давайте почнемо!",

+ 2 - 0
src/lib/i18n/locales/vi-VN/translation.json

@@ -126,6 +126,7 @@
 	"Content": "Nội dung",
 	"Context Length": "Độ dài ngữ cảnh (Context Length)",
 	"Continue Response": "Tiếp tục trả lời",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "Đã sao chép link chia sẻ trò chuyện vào clipboard!",
 	"Copy": "Sao chép",
 	"Copy last code block": "Sao chép khối mã cuối cùng",
@@ -378,6 +379,7 @@
 	"Notifications": "Thông báo trên máy tính (Notification)",
 	"November": "Tháng 11",
 	"num_thread (Ollama)": "num_thread (Ollama)",
+	"OAuth ID": "",
 	"October": "Tháng 10",
 	"Off": "Tắt",
 	"Okay, Let's Go!": "Được rồi, Bắt đầu thôi!",

+ 2 - 0
src/lib/i18n/locales/zh-CN/translation.json

@@ -126,6 +126,7 @@
 	"Content": "内容",
 	"Context Length": "上下文长度",
 	"Continue Response": "继续生成",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "已复制此对话分享链接至剪贴板!",
 	"Copy": "复制",
 	"Copy last code block": "复制最后一个代码块中的代码",
@@ -378,6 +379,7 @@
 	"Notifications": "桌面通知",
 	"November": "十一月",
 	"num_thread (Ollama)": "num_thread(Ollama)",
+	"OAuth ID": "",
 	"October": "十月",
 	"Off": "关闭",
 	"Okay, Let's Go!": "确认,开始使用!",

+ 2 - 0
src/lib/i18n/locales/zh-TW/translation.json

@@ -126,6 +126,7 @@
 	"Content": "內容",
 	"Context Length": "上下文長度",
 	"Continue Response": "繼續回答",
+	"Continue with {{provider}}": "",
 	"Copied shared chat URL to clipboard!": "已複製共享聊天連結到剪貼簿!",
 	"Copy": "複製",
 	"Copy last code block": "複製最後一個程式碼區塊",
@@ -378,6 +379,7 @@
 	"Notifications": "通知",
 	"November": "11 月",
 	"num_thread (Ollama)": "num_thread(Ollama)",
+	"OAuth ID": "",
 	"October": "10 月",
 	"Off": "關閉",
 	"Okay, Let's Go!": "好的,啟動吧!",

+ 5 - 0
src/lib/stores/index.ts

@@ -149,6 +149,11 @@ type Config = {
 		enable_admin_export: boolean;
 		enable_community_sharing: boolean;
 	};
+	oauth: {
+		providers: {
+			[key: string]: string;
+		};
+	};
 };
 
 type PromptSuggestion = {

+ 14 - 0
src/routes/(app)/admin/+page.svelte

@@ -195,6 +195,18 @@
 							<span class="invisible">▲</span>
 						{/if}
 					</th>
+					<th
+						scope="col"
+						class="px-3 py-2 cursor-pointer select-none"
+						on:click={() => setSortKey('oauth_sub')}
+					>
+						{$i18n.t('OAuth ID')}
+						{#if sortKey === 'oauth_sub'}
+							{sortOrder === 'asc' ? '▲' : '▼'}
+						{:else}
+							<span class="invisible">▲</span>
+						{/if}
+					</th>
 					<th
 						scope="col"
 						class="px-3 py-2 cursor-pointer select-none"
@@ -283,6 +295,8 @@
 						</td>
 						<td class=" px-3 py-2"> {user.email} </td>
 
+						<td class=" px-3 py-2"> {user.oauth_sub ?? ''} </td>
+
 						<td class=" px-3 py-2">
 							{dayjs(user.last_active_at * 1000).fromNow()}
 						</td>

+ 6 - 1
src/routes/+layout.svelte

@@ -18,6 +18,7 @@
 		USAGE_POOL
 	} from '$lib/stores';
 	import { goto } from '$app/navigation';
+	import { page } from '$app/stores';
 	import { Toaster, toast } from 'svelte-sonner';
 
 	import { getBackendConfig } from '$lib/apis';
@@ -141,7 +142,11 @@
 						await goto('/auth');
 					}
 				} else {
-					await goto('/auth');
+					// Don't redirect if we're already on the auth page
+					// Needed because we pass in tokens from OAuth logins via URL fragments
+					if ($page.url.pathname !== '/auth') {
+						await goto('/auth');
+					}
 				}
 			}
 		} else {

+ 121 - 2
src/routes/auth/+page.svelte

@@ -1,12 +1,13 @@
 <script>
 	import { goto } from '$app/navigation';
-	import { userSignIn, userSignUp } from '$lib/apis/auths';
+	import { getSessionUser, userSignIn, userSignUp } from '$lib/apis/auths';
 	import Spinner from '$lib/components/common/Spinner.svelte';
 	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
 	import { WEBUI_NAME, config, user, socket } from '$lib/stores';
 	import { onMount, getContext } from 'svelte';
 	import { toast } from 'svelte-sonner';
 	import { generateInitialsImage, canvasPixelTest } from '$lib/utils';
+	import { page } from '$app/stores';
 
 	const i18n = getContext('i18n');
 
@@ -21,7 +22,9 @@
 		if (sessionUser) {
 			console.log(sessionUser);
 			toast.success($i18n.t(`You're now logged in.`));
-			localStorage.token = sessionUser.token;
+			if (sessionUser.token) {
+				localStorage.token = sessionUser.token;
+			}
 
 			$socket.emit('user-join', { auth: { token: sessionUser.token } });
 			await user.set(sessionUser);
@@ -57,10 +60,35 @@
 		}
 	};
 
+	const checkOauthCallback = async () => {
+		if (!$page.url.hash) {
+			return;
+		}
+		const hash = $page.url.hash.substring(1);
+		if (!hash) {
+			return;
+		}
+		const params = new URLSearchParams(hash);
+		const token = params.get('token');
+		if (!token) {
+			return;
+		}
+		const sessionUser = await getSessionUser(token).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+		if (!sessionUser) {
+			return;
+		}
+		localStorage.token = token;
+		await setSessionUser(sessionUser);
+	};
+
 	onMount(async () => {
 		if ($user !== undefined) {
 			await goto('/');
 		}
+		await checkOauthCallback();
 		loaded = true;
 		if (($config?.features.auth_trusted_header ?? false) || $config?.features.auth === false) {
 			await signInHandler();
@@ -219,6 +247,97 @@
 							{/if}
 						</div>
 					</form>
+
+					{#if Object.keys($config?.oauth?.providers ?? {}).length > 0}
+						<div class="inline-flex items-center justify-center w-full">
+							<hr class="w-64 h-px my-8 bg-gray-200 border-0 dark:bg-gray-700" />
+							<span
+								class="absolute px-3 font-medium text-gray-900 -translate-x-1/2 bg-white left-1/2 dark:text-white dark:bg-gray-950"
+								>{$i18n.t('or')}</span
+							>
+						</div>
+						<div class="flex flex-col space-y-2">
+							{#if $config?.oauth?.providers?.google}
+								<button
+									class="flex items-center px-6 border-2 dark:border-gray-800 duration-300 dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 w-full rounded-2xl dark:text-white text-sm py-3 transition"
+									on:click={() => {
+										window.location.href = `${WEBUI_BASE_URL}/oauth/google/login`;
+									}}
+								>
+									<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="size-6 mr-3">
+										<path
+											fill="#EA4335"
+											d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
+										/><path
+											fill="#4285F4"
+											d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
+										/><path
+											fill="#FBBC05"
+											d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
+										/><path
+											fill="#34A853"
+											d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
+										/><path fill="none" d="M0 0h48v48H0z" />
+									</svg>
+									<span>{$i18n.t('Continue with {{provider}}', { provider: 'Google' })}</span>
+								</button>
+							{/if}
+							{#if $config?.oauth?.providers?.microsoft}
+								<button
+									class="flex items-center px-6 border-2 dark:border-gray-800 duration-300 dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 w-full rounded-2xl dark:text-white text-sm py-3 transition"
+									on:click={() => {
+										window.location.href = `${WEBUI_BASE_URL}/oauth/microsoft/login`;
+									}}
+								>
+									<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" class="size-6 mr-3">
+										<rect x="1" y="1" width="9" height="9" fill="#f25022" /><rect
+											x="1"
+											y="11"
+											width="9"
+											height="9"
+											fill="#00a4ef"
+										/><rect x="11" y="1" width="9" height="9" fill="#7fba00" /><rect
+											x="11"
+											y="11"
+											width="9"
+											height="9"
+											fill="#ffb900"
+										/>
+									</svg>
+									<span>{$i18n.t('Continue with {{provider}}', { provider: 'Microsoft' })}</span>
+								</button>
+							{/if}
+							{#if $config?.oauth?.providers?.oidc}
+								<button
+									class="flex items-center px-6 border-2 dark:border-gray-800 duration-300 dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 w-full rounded-2xl dark:text-white text-sm py-3 transition"
+									on:click={() => {
+										window.location.href = `${WEBUI_BASE_URL}/oauth/oidc/login`;
+									}}
+								>
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										fill="none"
+										viewBox="0 0 24 24"
+										stroke-width="1.5"
+										stroke="currentColor"
+										class="size-6 mr-3"
+									>
+										<path
+											stroke-linecap="round"
+											stroke-linejoin="round"
+											d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"
+										/>
+									</svg>
+
+									<span
+										>{$i18n.t('Continue with {{provider}}', {
+											provider: $config?.oauth?.providers?.oidc ?? 'SSO'
+										})}</span
+									>
+								</button>
+							{/if}
+						</div>
+					{/if}
 				</div>
 			{/if}
 		</div>