From e0ffc399c6a27579e52af328f8add6aff27bdbc1 Mon Sep 17 00:00:00 2001 From: Genmin Date: Fri, 1 May 2026 15:56:01 -0700 Subject: [PATCH] fix: support separate MCP OAuth issuer URL --- backend/open_webui/main.py | 6 ++- backend/open_webui/routers/configs.py | 7 ++- backend/open_webui/test/utils/test_oauth.py | 47 ++++++++++++++++++++ backend/open_webui/utils/oauth.py | 20 ++++++--- src/lib/apis/configs/index.ts | 1 + src/lib/components/AddToolServerModal.svelte | 41 +++++++++++++++-- 6 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 backend/open_webui/test/utils/test_oauth.py diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index af570af0af..962a76daaf 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -2503,7 +2503,9 @@ async def register_client(request, client_id: str) -> bool: server_url = connection.get('url') auth_type = connection.get('auth_type', 'none') - oauth_server_key = (connection.get('config') or {}).get('oauth_server_key') + config = connection.get('config') or {} + oauth_server_key = config.get('oauth_server_key') + oauth_server_url = config.get('oauth_server_url') try: if auth_type == 'oauth_2.1_static': @@ -2526,6 +2528,7 @@ async def register_client(request, client_id: str) -> bool: server_url, oauth_client_id=oauth_client_id, oauth_client_secret=oauth_client_secret, + authorization_server_url=oauth_server_url, ) else: oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( @@ -2533,6 +2536,7 @@ async def register_client(request, client_id: str) -> bool: client_id, server_url, oauth_server_key, + authorization_server_url=oauth_server_url, ) except Exception as e: log.error(f'OAuth client re-registration failed for {client_id}: {e}') diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 02b16d8e5b..7fa8136aae 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -102,6 +102,7 @@ class OAuthClientRegistrationForm(BaseModel): client_id: str client_name: Optional[str] = None client_secret: Optional[str] = None + oauth_server_url: Optional[str] = None @router.post('/oauth/clients/register') @@ -124,10 +125,11 @@ async def register_oauth_client( form_data.url, oauth_client_id=form_data.client_id, oauth_client_secret=form_data.client_secret, + authorization_server_url=form_data.oauth_server_url, ) else: oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( - request, oauth_client_id, form_data.url + request, oauth_client_id, form_data.url, authorization_server_url=form_data.oauth_server_url ) return { 'status': True, @@ -368,7 +370,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn try: if form_data.type == 'mcp': if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'): - discovery_urls = await get_discovery_urls(form_data.url) + oauth_server_url = (form_data.config or {}).get('oauth_server_url') + discovery_urls = await get_discovery_urls(form_data.url, oauth_server_url) for discovery_url in discovery_urls: log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}') async with aiohttp.ClientSession( diff --git a/backend/open_webui/test/utils/test_oauth.py b/backend/open_webui/test/utils/test_oauth.py new file mode 100644 index 0000000000..53030cc9b2 --- /dev/null +++ b/backend/open_webui/test/utils/test_oauth.py @@ -0,0 +1,47 @@ +import pytest +from open_webui.utils.oauth import get_discovery_urls + + +@pytest.mark.asyncio +async def test_get_discovery_urls_prefers_authorization_server_url(): + urls = await get_discovery_urls( + 'https://gmailmcp.googleapis.com/mcp/v1', + 'https://accounts.google.com', + ) + + assert urls == [ + 'https://accounts.google.com/.well-known/oauth-authorization-server', + 'https://accounts.google.com/.well-known/openid-configuration', + ] + + +@pytest.mark.asyncio +async def test_get_discovery_urls_accepts_explicit_discovery_url(): + urls = await get_discovery_urls( + 'https://gmailmcp.googleapis.com/mcp/v1', + 'https://accounts.google.com/.well-known/openid-configuration', + ) + + assert urls == ['https://accounts.google.com/.well-known/openid-configuration'] + + +@pytest.mark.asyncio +async def test_get_discovery_urls_preserves_current_fallback_without_override(monkeypatch): + async def mock_authorization_server_discovery(server_url: str) -> list[str]: + assert server_url == 'https://gmailmcp.googleapis.com/mcp/v1' + return [] + + monkeypatch.setattr( + 'open_webui.utils.oauth.get_authorization_server_discovery_urls', + mock_authorization_server_discovery, + ) + + urls = await get_discovery_urls('https://gmailmcp.googleapis.com/mcp/v1') + + assert urls == [ + 'https://gmailmcp.googleapis.com/.well-known/oauth-authorization-server/mcp/v1', + 'https://gmailmcp.googleapis.com/.well-known/openid-configuration/mcp/v1', + 'https://gmailmcp.googleapis.com/mcp/v1/.well-known/openid-configuration', + 'https://gmailmcp.googleapis.com/.well-known/oauth-authorization-server', + 'https://gmailmcp.googleapis.com/.well-known/openid-configuration', + ] diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 4a7d79d87c..15acf916a1 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -356,10 +356,15 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]: def _build_well_known_urls(server_url: str) -> list[str]: """Build RFC 8414 / OIDC Discovery well-known URLs for a server URL.""" parsed, base_url = get_parsed_and_base_url(server_url) + path = parsed.path.rstrip('/') + if path.startswith('/.well-known/oauth-authorization-server') or path.startswith( + '/.well-known/openid-configuration' + ): + return [server_url.rstrip('/')] + urls = [] if parsed.path and parsed.path != '/': - path = parsed.path.rstrip('/') urls.extend( [ urllib.parse.urljoin(base_url, f'/.well-known/oauth-authorization-server{path}'), @@ -378,7 +383,10 @@ def _build_well_known_urls(server_url: str) -> list[str]: return urls -async def get_discovery_urls(server_url) -> list[str]: +async def get_discovery_urls(server_url: str, authorization_server_url: str | None = None) -> list[str]: + if authorization_server_url: + return _build_well_known_urls(authorization_server_url) + urls = await get_authorization_server_discovery_urls(server_url) urls.extend(_build_well_known_urls(server_url)) return urls @@ -391,6 +399,7 @@ async def get_oauth_client_info_with_dynamic_client_registration( client_id: str, oauth_server_url: str, oauth_server_key: Optional[str] = None, + authorization_server_url: Optional[str] = None, ) -> OAuthClientInformationFull: try: oauth_server_metadata = None @@ -406,7 +415,7 @@ async def get_oauth_client_info_with_dynamic_client_registration( ) # Attempt to fetch OAuth server metadata to get registration endpoint & scopes - discovery_urls = await get_discovery_urls(oauth_server_url) + discovery_urls = await get_discovery_urls(oauth_server_url, authorization_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 oauth_server_metadata_response: @@ -441,7 +450,7 @@ async def get_oauth_client_info_with_dynamic_client_registration( if oauth_server_metadata and oauth_server_metadata.registration_endpoint: registration_url = str(oauth_server_metadata.registration_endpoint) else: - _, base_url = get_parsed_and_base_url(oauth_server_url) + _, base_url = get_parsed_and_base_url(authorization_server_url or oauth_server_url) registration_url = urllib.parse.urljoin(base_url, '/register') registration_data = oauth_client_metadata.model_dump( @@ -502,6 +511,7 @@ async def get_oauth_client_info_with_static_credentials( oauth_server_url: str, oauth_client_id: str, oauth_client_secret: str, + authorization_server_url: Optional[str] = None, ) -> OAuthClientInformationFull: """ Build an OAuthClientInformationFull from user-provided static credentials. @@ -516,7 +526,7 @@ async def get_oauth_client_info_with_static_credentials( 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) + discovery_urls = await get_discovery_urls(oauth_server_url, authorization_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: diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 6b7bf6f47b..7859a4e787 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -378,6 +378,7 @@ type RegisterOAuthClientForm = { client_id: string; client_name?: string; client_secret?: string; + oauth_server_url?: string; }; export const registerOAuthClient = async ( diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 74571c3086..df0561ca4e 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -60,6 +60,7 @@ let oauthClientId = ''; let oauthClientSecret = ''; + let oauthServerUrl = ''; let enable = true; let loading = false; @@ -67,6 +68,13 @@ let showAccessControlModal = false; let showDeleteConfirmDialog = false; + const getOAuthServerUrlConfig = () => { + const trimmedOAuthServerUrl = oauthServerUrl.trim(); + return auth_type === 'oauth_2.1_static' && trimmedOAuthServerUrl + ? { oauth_server_url: trimmedOAuthServerUrl } + : {}; + }; + const registerOAuthClientHandler = async () => { if (url === '') { toast.error($i18n.t('Please enter a valid URL')); @@ -86,10 +94,16 @@ // client_id is the tool server ID (used as the internal lookup key for both flows). // For static, client_secret signals the backend to use the static credential path. // The actual OAuth client_id/secret come from the connection info at save time. - const formData: { url: string; client_id: string; client_secret?: string } = { + const formData: { + url: string; + client_id: string; + client_secret?: string; + oauth_server_url?: string; + } = { url: url, client_id: id, - ...(auth_type === 'oauth_2.1_static' ? { client_secret: oauthClientSecret } : {}) + ...(auth_type === 'oauth_2.1_static' ? { client_secret: oauthClientSecret } : {}), + ...getOAuthServerUrlConfig() }; const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => { @@ -164,7 +178,8 @@ key, config: { enable: enable, - access_grants: accessGrants + access_grants: accessGrants, + ...getOAuthServerUrlConfig() }, info: { id, @@ -222,6 +237,7 @@ if (data.config) { enable = data.config.enable ?? true; accessGrants = data.config.access_grants ?? []; + oauthServerUrl = data.config.oauth_server_url ?? ''; } toast.success($i18n.t('Import successful')); @@ -246,6 +262,9 @@ auth_type, headers: headers ? JSON.parse(headers) : undefined, key, + ...(Object.keys(getOAuthServerUrlConfig()).length + ? { config: getOAuthServerUrlConfig() } + : {}), info: { id: id, @@ -328,7 +347,8 @@ config: { enable: enable, function_name_filter_list: functionNameFilterList, - access_grants: accessGrants + access_grants: accessGrants, + ...getOAuthServerUrlConfig() }, info: { id: id, @@ -364,6 +384,7 @@ oauthClientInfo = null; oauthClientId = ''; oauthClientSecret = ''; + oauthServerUrl = ''; enable = true; functionNameFilterList = ''; @@ -390,6 +411,7 @@ oauthClientInfo = connection.info?.oauth_client_info ?? null; oauthClientId = connection.info?.oauth_client_id ?? ''; oauthClientSecret = connection.info?.oauth_client_secret ?? ''; + oauthServerUrl = connection.config?.oauth_server_url ?? ''; enable = connection.config?.enable ?? true; functionNameFilterList = connection.config?.function_name_filter_list ?? ''; @@ -730,6 +752,17 @@ placeholder={$i18n.t('Client Secret')} required={false} /> + + {/if}