From 601bb783587a3e965cf88c148e4856b988655b13 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 24 Mar 2026 17:58:21 -0500 Subject: [PATCH] feat: oauth 2.1 static mcp Co-Authored-By: Joost Mul <6438009+donmul@users.noreply.github.com> --- backend/open_webui/main.py | 33 ++++++--- backend/open_webui/routers/configs.py | 24 +++++-- backend/open_webui/utils/oauth.py | 70 +++++++++++++++++++- src/lib/apis/configs/index.ts | 1 + src/lib/components/AddToolServerModal.svelte | 62 +++++++++++++---- src/lib/i18n/locales/en-US/translation.json | 4 ++ 6 files changed, 166 insertions(+), 28 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 280636c05..008178806 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -543,6 +543,7 @@ from open_webui.utils.auth import ( from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.oauth import ( get_oauth_client_info_with_dynamic_client_registration, + get_oauth_client_info_with_static_credentials, encrypt_data, decrypt_data, OAuthManager, @@ -2260,7 +2261,7 @@ if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0: server_id = tool_server_connection.get('info', {}).get('id') auth_type = tool_server_connection.get('auth_type', 'none') - if server_id and auth_type == 'oauth_2.1': + if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'): oauth_client_info = tool_server_connection.get('info', {}).get('oauth_client_info', '') try: @@ -2320,17 +2321,33 @@ async def register_client(request, client_id: str) -> bool: return False server_url = connection.get('url') + auth_type = connection.get('auth_type', 'none') oauth_server_key = (connection.get('config') or {}).get('oauth_server_key') try: - oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( - request, - client_id, - server_url, - oauth_server_key, - ) + if auth_type == 'oauth_2.1_static': + # Static credentials: rebuild from stored credentials + fresh metadata + existing_client_info = connection.get('info', {}).get('oauth_client_info', '') + if not existing_client_info: + log.error(f'No stored OAuth client info for static client {client_id}') + return False + existing_data = decrypt_data(existing_client_info) + oauth_client_info = await get_oauth_client_info_with_static_credentials( + request, + client_id, + server_url, + oauth_client_id=existing_data.get('client_id', ''), + oauth_client_secret=existing_data.get('client_secret', ''), + ) + else: + oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( + request, + client_id, + server_url, + oauth_server_key, + ) except Exception as e: - log.error(f'Dynamic client re-registration failed for {client_id}: {e}') + log.error(f'OAuth client re-registration failed for {client_id}: {e}') return False try: diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 0bfcf76d9..063c06e6b 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -24,6 +24,7 @@ from open_webui.models.oauth_sessions import OAuthSessions from open_webui.utils.oauth import ( get_discovery_urls, get_oauth_client_info_with_dynamic_client_registration, + get_oauth_client_info_with_static_credentials, encrypt_data, decrypt_data, OAuthClientInformationFull, @@ -99,6 +100,7 @@ class OAuthClientRegistrationForm(BaseModel): url: str client_id: str client_name: Optional[str] = None + client_secret: Optional[str] = None @router.post('/oauth/clients/register') @@ -113,9 +115,19 @@ async def register_oauth_client( if type: oauth_client_id = f'{type}:{form_data.client_id}' - oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( - request, oauth_client_id, form_data.url - ) + if form_data.client_secret: + # Static credentials: skip dynamic registration, build from provided credentials + oauth_client_info = await get_oauth_client_info_with_static_credentials( + request, + oauth_client_id, + form_data.url, + oauth_client_id=form_data.client_id, + oauth_client_secret=form_data.client_secret, + ) + else: + oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( + request, oauth_client_id, form_data.url + ) return { 'status': True, 'oauth_client_info': encrypt_data(oauth_client_info.model_dump(mode='json')), @@ -166,7 +178,7 @@ async def set_tool_servers_config( server_type = connection.get('type', 'openapi') auth_type = connection.get('auth_type', 'none') - if auth_type == 'oauth_2.1': + if auth_type in ('oauth_2.1', 'oauth_2.1_static'): # Remove existing OAuth clients for tool servers server_id = connection.get('info', {}).get('id') client_key = f'{server_type}:{server_id}' @@ -189,7 +201,7 @@ async def set_tool_servers_config( server_id = connection.get('info', {}).get('id') auth_type = connection.get('auth_type', 'none') - if auth_type == 'oauth_2.1' and server_id: + if auth_type in ('oauth_2.1', 'oauth_2.1_static') and server_id: try: oauth_client_info = connection.get('info', {}).get('oauth_client_info', '') oauth_client_info = decrypt_data(oauth_client_info) @@ -264,7 +276,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn """ try: if form_data.type == 'mcp': - if form_data.auth_type == 'oauth_2.1': + if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'): discovery_urls = await get_discovery_urls(form_data.url) for discovery_url in discovery_urls: log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}') diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 1a4ed3871..6608b6741 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -442,6 +442,74 @@ async def get_oauth_client_info_with_dynamic_client_registration( raise e +async def get_oauth_client_info_with_static_credentials( + request, + client_id: str, + oauth_server_url: str, + oauth_client_id: str, + oauth_client_secret: str, +) -> OAuthClientInformationFull: + """ + Build an OAuthClientInformationFull from user-provided static credentials. + Performs server metadata discovery to resolve authorization/token endpoints, + but skips dynamic client registration entirely. + """ + try: + oauth_server_metadata = None + oauth_server_metadata_url = None + + redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') + redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback' + + # Discover server metadata (authorization endpoint, token endpoint, scopes, etc.) + discovery_urls = await get_discovery_urls(oauth_server_url) + for url in discovery_urls: + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp: + if resp.status == 200: + try: + oauth_server_metadata = OAuthMetadata.model_validate(await resp.json()) + oauth_server_metadata_url = url + break + except Exception as e: + log.error(f'Error parsing OAuth metadata from {url}: {e}') + continue + + # Determine scope from server metadata if available + scope = None + if oauth_server_metadata and oauth_server_metadata.scopes_supported: + scope = ' '.join(oauth_server_metadata.scopes_supported) + + # Determine token_endpoint_auth_method + token_endpoint_auth_method = 'client_secret_post' + if ( + oauth_server_metadata + and oauth_server_metadata.token_endpoint_auth_methods_supported + and token_endpoint_auth_method not in oauth_server_metadata.token_endpoint_auth_methods_supported + ): + token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[0] + + oauth_client_info = OAuthClientInformationFull( + client_id=oauth_client_id, + client_secret=oauth_client_secret, + redirect_uris=[redirect_uri], + grant_types=['authorization_code', 'refresh_token'], + response_types=['code'], + scope=scope, + token_endpoint_auth_method=token_endpoint_auth_method, + issuer=oauth_server_metadata_url, + server_metadata=oauth_server_metadata, + ) + + log.info( + f'Static OAuth client info built for {oauth_client_id} using metadata from {oauth_server_metadata_url}' + ) + return oauth_client_info + except Exception as e: + log.error(f'Exception building static OAuth client info: {e}') + raise e + + class OAuthClientManager: def __init__(self, app): self.oauth = OAuth() @@ -496,7 +564,7 @@ class OAuthClientManager: for connection in connections or []: if connection.get('type', 'openapi') != 'mcp': continue - if connection.get('auth_type', 'none') != 'oauth_2.1': + if connection.get('auth_type', 'none') not in ('oauth_2.1', 'oauth_2.1_static'): continue server_id = connection.get('info', {}).get('id') diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 51236ff52..fb3a33469 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -342,6 +342,7 @@ type RegisterOAuthClientForm = { url: string; client_id: string; client_name?: string; + client_secret?: string; }; export const registerOAuthClient = async ( diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 822bc9cd1..2237c1afc 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -58,6 +58,9 @@ let oauthClientInfo = null; + let oauthClientId = ''; + let oauthClientSecret = ''; + let enable = true; let loading = false; let showAdvanced = false; @@ -75,14 +78,22 @@ return; } - const res = await registerOAuthClient( - localStorage.token, - { - url: url, - client_id: id - }, - 'mcp' - ).catch((err) => { + const formData: { url: string; client_id: string; client_secret?: string } = { + url: url, + client_id: id + }; + + // For static OAuth, include client credentials + if (auth_type === 'oauth_2.1_static') { + if (!oauthClientId || !oauthClientSecret) { + toast.error($i18n.t('Please enter Client ID and Client Secret')); + return; + } + formData.client_id = id; + formData.client_secret = oauthClientSecret; + } + + const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => { toast.error($i18n.t('Registration failed')); return null; }); @@ -267,7 +278,11 @@ return; } - if (type === 'mcp' && auth_type === 'oauth_2.1' && !oauthClientInfo) { + if ( + type === 'mcp' && + ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) && + !oauthClientInfo + ) { toast.error($i18n.t('Please register the OAuth client')); loading = false; return; @@ -320,7 +335,10 @@ id: id, name: name, description: description, - ...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}) + ...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}), + ...(auth_type === 'oauth_2.1_static' + ? { oauth_client_id: oauthClientId, oauth_client_secret: oauthClientSecret } + : {}) } }; @@ -345,6 +363,8 @@ description = ''; oauthClientInfo = null; + oauthClientId = ''; + oauthClientSecret = ''; enable = true; functionNameFilterList = ''; @@ -369,6 +389,8 @@ name = connection.info?.name ?? ''; description = connection.info?.description ?? ''; oauthClientInfo = connection.info?.oauth_client_info ?? null; + oauthClientId = connection.info?.oauth_client_id ?? ''; + oauthClientSecret = connection.info?.oauth_client_secret ?? ''; enable = connection.config?.enable ?? true; functionNameFilterList = connection.config?.function_name_filter_list ?? ''; @@ -607,7 +629,7 @@ - {#if auth_type === 'oauth_2.1'} + {#if ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)}
{$i18n.t('OAuth')} {#if type === 'mcp'} + {/if} {/if} @@ -696,6 +719,19 @@ > {$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
+ {:else if auth_type === 'oauth_2.1_static'} +
+ + +
{/if}
@@ -847,7 +883,7 @@ {/if} {#if !direct} -
+