feat: oauth 2.1 static mcp
Co-Authored-By: Joost Mul <6438009+donmul@users.noreply.github.com>
This commit is contained in:
co-authored by
Joost Mul
parent
631bd20c35
commit
601bb78358
@@ -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:
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -342,6 +342,7 @@ type RegisterOAuthClientForm = {
|
||||
url: string;
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
client_secret?: string;
|
||||
};
|
||||
|
||||
export const registerOAuthClient = async (
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if auth_type === 'oauth_2.1'}
|
||||
{#if ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-col justify-end items-center shrink-0">
|
||||
<Tooltip
|
||||
@@ -660,6 +682,7 @@
|
||||
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
||||
{#if type === 'mcp'}
|
||||
<option value="oauth_2.1">{$i18n.t('OAuth 2.1')}</option>
|
||||
<option value="oauth_2.1_static">{$i18n.t('OAuth 2.1 (Static)')}</option>
|
||||
{/if}
|
||||
{/if}
|
||||
</select>
|
||||
@@ -696,6 +719,19 @@
|
||||
>
|
||||
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
|
||||
</div>
|
||||
{:else if auth_type === 'oauth_2.1_static'}
|
||||
<div class="flex flex-col gap-1.5 w-full mt-0.5">
|
||||
<SensitiveInput
|
||||
bind:value={oauthClientId}
|
||||
placeholder={$i18n.t('Client ID')}
|
||||
required={false}
|
||||
/>
|
||||
<SensitiveInput
|
||||
bind:value={oauthClientSecret}
|
||||
placeholder={$i18n.t('Client Secret')}
|
||||
required={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -847,7 +883,7 @@
|
||||
{/if}
|
||||
|
||||
{#if !direct}
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
<hr class=" border-gray-100/50 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<label
|
||||
@@ -872,7 +908,7 @@
|
||||
|
||||
{#if type === 'mcp'}
|
||||
<div
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2"
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2 mt-1"
|
||||
>
|
||||
<span class="font-medium">
|
||||
{$i18n.t('Warning')}:
|
||||
|
||||
@@ -333,6 +333,8 @@
|
||||
"Click on the user role button to change a user's role.": "",
|
||||
"Click to copy ID": "",
|
||||
"Clipboard write permission denied. Please check your browser settings to grant the necessary access.": "",
|
||||
"Client ID": "",
|
||||
"Client Secret": "",
|
||||
"Clone": "",
|
||||
"Clone Chat": "",
|
||||
"Clone of {{TITLE}}": "",
|
||||
@@ -1381,6 +1383,7 @@
|
||||
"November": "",
|
||||
"OAuth": "",
|
||||
"OAuth 2.1": "",
|
||||
"OAuth 2.1 (Static)": "",
|
||||
"OAuth ID": "",
|
||||
"October": "",
|
||||
"Off": "",
|
||||
@@ -1495,6 +1498,7 @@
|
||||
"Please enter a message or attach a file.": "",
|
||||
"Please enter a prompt": "",
|
||||
"Please enter a valid ID": "",
|
||||
"Please enter Client ID and Client Secret": "",
|
||||
"Please enter a valid JSON spec": "",
|
||||
"Please enter a valid path": "",
|
||||
"Please enter a valid URL": "",
|
||||
|
||||
Reference in New Issue
Block a user