fix: support separate MCP OAuth issuer URL

This commit is contained in:
Genmin 2026-05-01 15:56:01 -07:00
parent 8dae237a0b
commit e0ffc399c6
6 changed files with 110 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -378,6 +378,7 @@ type RegisterOAuthClientForm = {
client_id: string;
client_name?: string;
client_secret?: string;
oauth_server_url?: string;
};
export const registerOAuthClient = async (

View file

@ -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}
/>
<label for="oauth-server-url" class="sr-only"
>{$i18n.t('Authorization Server URL')}</label
>
<input
id="oauth-server-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={oauthServerUrl}
placeholder={$i18n.t('Authorization Server or Discovery URL (optional)')}
autocomplete="off"
/>
</div>
{/if}
</div>