diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 2b7cfa3f93..3757e61a8b 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -234,6 +234,7 @@ from open_webui.utils.oauth import ( OAuthClientInformationFull, OAuthClientManager, OAuthManager, + apply_connection_oauth_options, decrypt_data, encrypt_data, get_oauth_client_info_with_dynamic_client_registration, @@ -556,6 +557,9 @@ async def initialize_runtime_config(app: FastAPI): oauth_client_info = await recover_static_oauth_client_metadata( tool_server_connection, oauth_client_info ) + oauth_client_info = apply_connection_oauth_options( + tool_server_connection, oauth_client_info + ) app.state.oauth_client_manager.add_client( f'mcp:{server_id}', OAuthClientInformationFull(**oauth_client_info), @@ -2274,6 +2278,11 @@ async def register_client(request, client_id: str) -> bool: return False oauth_client_manager.remove_client(client_id) + oauth_client_info = OAuthClientInformationFull( + **apply_connection_oauth_options( + connection, oauth_client_info.model_dump(mode='json') + ) + ) oauth_client_manager.add_client(client_id, oauth_client_info) log.info(f'Re-registered OAuth client {client_id} for tool server') return True diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index a210b72894..f8fa412cce 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -17,6 +17,7 @@ from open_webui.utils.headers import get_custom_headers from open_webui.utils.mcp.client import MCPClient from open_webui.utils.oauth import ( OAuthClientInformationFull, + apply_connection_oauth_options, decrypt_data, encrypt_data, get_discovery_urls, @@ -261,6 +262,7 @@ async def set_tool_servers_config( oauth_client_info = await recover_static_oauth_client_metadata( connection, oauth_client_info ) + oauth_client_info = apply_connection_oauth_options(connection, oauth_client_info) request.app.state.oauth_client_manager.add_client( f'{server_type}:{server_id}', OAuthClientInformationFull(**oauth_client_info), diff --git a/backend/open_webui/test/test_events.py b/backend/open_webui/test/test_events.py deleted file mode 100644 index b8ca8ff38e..0000000000 --- a/backend/open_webui/test/test_events.py +++ /dev/null @@ -1,119 +0,0 @@ -from types import SimpleNamespace - -import pytest - -from open_webui import events - - -class DummyRequest: - def __init__(self): - self.app = SimpleNamespace(state=SimpleNamespace(instance_id='instance-1', WEBUI_NAME='Test WebUI')) - - -@pytest.mark.asyncio -async def test_build_event_derives_resource_operation_and_sanitizes(): - request = DummyRequest() - - event = events.build_event( - request, - events.EVENTS.KNOWLEDGE_FILE_ADDED, - actor={ - 'id': 'user-1', - 'name': 'Ada', - 'email': 'ada@example.com', - 'role': 'admin', - 'api_key': 'secret', - }, - subject_id='file-1', - data={ - 'safe': 'value', - 'token': 'hidden', - 'nested': {'refresh_token': 'hidden', 'name': 'visible'}, - 'content': 'x' * (events.MAX_STRING_LENGTH + 10), - }, - ) - - payload = event.model_dump() - - assert payload['schema'] == events.EVENT_VERSION - assert payload['event'] == 'knowledge.file.added' - assert payload['resource'] == 'knowledge.file' - assert payload['operation'] == 'added' - assert payload['instance_id'] == 'instance-1' - assert payload['actor'] == { - 'id': 'user-1', - 'name': 'Ada', - 'email': 'ada@example.com', - 'role': 'admin', - 'type': 'user', - } - assert 'token' not in payload['data'] - assert 'refresh_token' not in payload['data']['nested'] - assert payload['data']['nested']['name'] == 'visible' - assert payload['data']['content'].endswith('...') - - -def test_build_event_accepts_events_enum(): - request = DummyRequest() - - event = events.build_event( - request, - events.EVENTS.MESSAGE_CREATED, - actor={'id': 'user-1'}, - subject_id='message-1', - ) - - payload = event.model_dump() - assert payload['event'] == 'message.created' - assert payload['resource'] == 'message' - assert payload['operation'] == 'created' - assert events.EVENTS.MESSAGE_CREATED.value in events.EVENT_CATALOG - - -@pytest.mark.asyncio -async def test_webhook_sink_sends_canonical_json(monkeypatch): - request = DummyRequest() - sent = {} - - async def fake_config_get(key, default=None): - assert key == 'webhook_url' - return 'https://example.com/events' - - async def fake_post_webhook(name, url, message, event_data): - sent.update({'name': name, 'url': url, 'message': message, 'event_data': event_data}) - return True - - monkeypatch.setattr(events.Config, 'get', fake_config_get) - monkeypatch.setattr(events, 'post_webhook', fake_post_webhook) - - event = events.build_event( - request, - events.EVENTS.USER_CREATED, - actor={'id': 'admin-1', 'name': 'Admin', 'role': 'admin'}, - subject_id='user-1', - ) - - await events.WebhookEventSink().handle_event(request.app, event) - - assert sent['name'] == 'Test WebUI' - assert sent['url'] == 'https://example.com/events' - assert sent['event_data'] == event.model_dump() - assert sent['event_data']['event'] == 'user.created' - - -@pytest.mark.asyncio -async def test_publish_event_swallows_sink_failure(monkeypatch): - request = DummyRequest() - - class FailingSink: - async def handle_event(self, app, event): - raise RuntimeError('boom') - - monkeypatch.setattr(events, 'EVENT_SINKS', [FailingSink()]) - - await events.publish_event( - request, - events.EVENTS.USER_CREATED, - actor={'id': 'admin-1'}, - subject_id='user-1', - ) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index fedb137887..4049980702 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -92,9 +92,13 @@ class OAuthClientMetadata(MCPOAuthClientMetadata): pass +OAuthResourceParameterMode = Literal['auto', 'include', 'omit'] + + class OAuthClientInformationFull(OAuthClientMetadata): issuer: Optional[str] = None # URL of the OAuth server that issued this client resource: Optional[str] = None # RFC 8707 resource indicator for JWT audience + oauth_resource_parameter: OAuthResourceParameterMode = 'auto' client_id: str client_secret: str | None = None @@ -109,6 +113,8 @@ from open_webui.env import GLOBAL_LOG_LEVEL logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) +OAUTH_RESOURCE_PARAMETER_MODES = {'auto', 'include', 'omit'} + OAUTH_RUNTIME_CONFIG = { 'DEFAULT_USER_ROLE': ('ui.default_user_role', DEFAULT_USER_ROLE), 'ENABLE_OAUTH_SIGNUP': ('oauth.enable_signup', ENABLE_OAUTH_SIGNUP), @@ -665,6 +671,61 @@ def resolve_oauth_client_info(connection: dict) -> dict: return data +def normalize_oauth_resource_parameter(value: str | None) -> OAuthResourceParameterMode: + if value in OAUTH_RESOURCE_PARAMETER_MODES: + return value + return 'auto' + + +def get_connection_oauth_resource_parameter(connection: dict) -> OAuthResourceParameterMode: + info = connection.get('info') or {} + config = connection.get('config') or {} + return normalize_oauth_resource_parameter( + info.get('oauth_resource_parameter') or config.get('oauth_resource_parameter') + ) + + +def apply_connection_oauth_options(connection: dict, oauth_client_info: dict) -> dict: + return { + **oauth_client_info, + 'oauth_resource_parameter': get_connection_oauth_resource_parameter(connection), + } + + +def scope_has_resource_indicator(scope: str | None) -> bool: + if not scope: + return False + return any( + scope_value.startswith(('https://', 'http://', 'api://')) + for scope_value in scope.split() + ) + + +def should_send_oauth_resource(client_info: OAuthClientInformationFull | None) -> bool: + if not client_info or not client_info.resource: + return False + + mode = normalize_oauth_resource_parameter(client_info.oauth_resource_parameter) + if mode == 'omit': + return False + if mode == 'include': + return True + + return not scope_has_resource_indicator(client_info.scope) + + +def build_oauth_request_params(client_info: OAuthClientInformationFull | None) -> dict: + if not client_info: + return {} + + params = {} + if client_info.scope: + params['scope'] = client_info.scope + if should_send_oauth_resource(client_info): + params['resource'] = client_info.resource + return params + + async def recover_static_oauth_client_metadata(connection: dict, oauth_client_info: dict) -> dict: if connection.get('auth_type') != 'oauth_2.1_static': return oauth_client_info @@ -774,6 +835,7 @@ class OAuthClientManager: oauth_client_info = await recover_static_oauth_client_metadata( connection, oauth_client_info ) + oauth_client_info = apply_connection_oauth_options(connection, oauth_client_info) return self.add_client(expected_client_id, OAuthClientInformationFull(**oauth_client_info))['client'] except Exception as e: log.error(f'Failed to lazily add OAuth client {expected_client_id} from config: {e}') @@ -807,12 +869,7 @@ class OAuthClientManager: redirect_uri = str(client_info.redirect_uris[0]) try: - kwargs = {} - if client_info.scope: - kwargs['scope'] = client_info.scope - if client_info.resource: - kwargs['resource'] = client_info.resource - + kwargs = build_oauth_request_params(client_info) auth_data = await client.create_authorization_url(redirect_uri=redirect_uri, **kwargs) authorization_url = auth_data.get('url') @@ -992,9 +1049,8 @@ class OAuthClientManager: 'refresh_token': token_data['refresh_token'], 'client_id': client.client_id, } - # RFC 8707: include resource indicator so refreshed tokens retain correct audience client_info = await self.get_client_info(client_id) - if client_info and client_info.resource: + if should_send_oauth_resource(client_info): refresh_data['resource'] = client_info.resource if hasattr(client, 'client_secret') and client.client_secret: @@ -1050,11 +1106,7 @@ class OAuthClientManager: redirect_uri = client_info.redirect_uris[0] if client_info.redirect_uris else None redirect_uri_str = str(redirect_uri) if redirect_uri else None # Pass explicit scope/resource parameters for providers that require them. - kwargs = {} - if client_info.scope: - kwargs['scope'] = client_info.scope - if client_info.resource: - kwargs['resource'] = client_info.resource + kwargs = build_oauth_request_params(client_info) return await client.authorize_redirect(request, redirect_uri_str, **kwargs) async def handle_callback(self, request, client_id: str, user_id: str, response): @@ -1070,9 +1122,8 @@ class OAuthClientManager: # The Authlib client already has these configured during add_client(). # Passing them again causes Authlib to concatenate them (e.g., "ID1,ID1"), # which results in 401 errors from the token endpoint. (Fix for #19823) - # RFC 8707: pass resource indicator for correct JWT audience on token exchange token_kwargs = {} - if client_info and client_info.resource: + if should_send_oauth_resource(client_info): token_kwargs['resource'] = client_info.resource token = await client.authorize_access_token(request, **token_kwargs) diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 255739332e..2cd17ed5be 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -61,6 +61,7 @@ let oauthClientId = ''; let oauthClientSecret = ''; let oauthServerUrl = ''; + let oauthResourceParameter = 'auto'; let enable = true; let loading = false; @@ -225,6 +226,7 @@ id = data.info.id ?? ''; name = data.info.name ?? ''; description = data.info.description ?? ''; + oauthResourceParameter = data.info.oauth_resource_parameter ?? 'auto'; } if (data.config) { @@ -258,7 +260,10 @@ info: { id: id, name: name, - description: description + description: description, + ...(type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) + ? { oauth_resource_parameter: oauthResourceParameter } + : {}) } } ]); @@ -342,6 +347,9 @@ id: id, name: name, description: description, + ...(type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) + ? { oauth_resource_parameter: oauthResourceParameter } + : {}), ...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}), ...(auth_type === 'oauth_2.1_static' ? { @@ -377,6 +385,7 @@ oauthClientId = ''; oauthClientSecret = ''; oauthServerUrl = ''; + oauthResourceParameter = 'auto'; enable = true; functionNameFilterList = ''; @@ -404,6 +413,7 @@ oauthClientId = connection.info?.oauth_client_id ?? ''; oauthClientSecret = connection.info?.oauth_client_secret ?? ''; oauthServerUrl = connection.info?.oauth_server_url ?? ''; + oauthResourceParameter = connection.info?.oauth_resource_parameter ?? 'auto'; enable = connection.config?.enable ?? true; functionNameFilterList = connection.config?.function_name_filter_list ?? ''; @@ -874,6 +884,30 @@ {/if} + {#if type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)} +
+
+ + +
+ +
+
+
+ {/if} + {#if !direct}