mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
refac
This commit is contained in:
parent
7453968678
commit
5576e6ed8a
5 changed files with 112 additions and 135 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue