This commit is contained in:
Timothy Jaeryang Baek 2026-06-25 14:34:22 +01:00
parent 7453968678
commit 5576e6ed8a
5 changed files with 112 additions and 135 deletions

View file

@ -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

View file

@ -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),

View file

@ -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',
)

View file

@ -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)

View file

@ -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 @@
</div>
{/if}
{#if type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="oauth-resource-parameter"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('OAuth Resource Parameter')}</label
>
<div class="flex flex-1 items-center">
<select
id="oauth-resource-parameter"
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={oauthResourceParameter}
>
<option value="auto">{$i18n.t('Automatic')}</option>
<option value="include">{$i18n.t('Include')}</option>
<option value="omit">{$i18n.t('Omit')}</option>
</select>
</div>
</div>
</div>
{/if}
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">