feat: oauth 2.1 static mcp

Co-Authored-By: Joost Mul <6438009+donmul@users.noreply.github.com>
This commit is contained in:
Timothy Jaeryang Baek
2026-03-24 17:58:21 -05:00
co-authored by Joost Mul
parent 631bd20c35
commit 601bb78358
6 changed files with 166 additions and 28 deletions
+25 -8
View File
@@ -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:
+18 -6
View File
@@ -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}')
+69 -1
View File
@@ -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')