mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-20 00:11:27 +00:00
fix: support separate MCP OAuth issuer URL
This commit is contained in:
parent
8dae237a0b
commit
e0ffc399c6
6 changed files with 110 additions and 12 deletions
|
|
@ -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}')
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
47
backend/open_webui/test/utils/test_oauth.py
Normal file
47
backend/open_webui/test/utils/test_oauth.py
Normal 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',
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -378,6 +378,7 @@ type RegisterOAuthClientForm = {
|
|||
client_id: string;
|
||||
client_name?: string;
|
||||
client_secret?: string;
|
||||
oauth_server_url?: string;
|
||||
};
|
||||
|
||||
export const registerOAuthClient = async (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue