From 478bc9e3f1430ff380b0caa43a5a65232aa8e439 Mon Sep 17 00:00:00 2001 From: Justin Williams Date: Mon, 1 Jun 2026 15:52:18 -0500 Subject: [PATCH] fix(oauth): use Protected Resource Metadata scopes in static OAuth 2.1 flow (#24690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static credentials OAuth flow currently sets scope=None, relying on the OAuth provider's default scopes. This breaks providers like GitHub that default to minimal/public-only access when no scope is requested. This change reads scopes_supported from the Protected Resource Metadata document (RFC 9728) and uses them in the authorization request. Unlike the Authorization Server's scopes_supported (a full catalog of every scope the AS can grant), the PRM scopes_supported represents what the specific resource requires — making it safe to request without breaking providers like Entra ID that reject broad scope requests. Fixes the regression introduced in 349ea4ea where all scope handling was removed from the static flow. --- backend/open_webui/utils/oauth.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 13cfc43de8..c570074e11 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -293,6 +293,7 @@ class ProtectedResourceMetadata: resource: str | None = None authorization_servers: list[str] = field(default_factory=list) + scopes_supported: list[str] = field(default_factory=list) def get_discovery_urls(self, server_url: str) -> list[str]: """Build all candidate OAuth discovery URLs from this metadata and the server URL.""" @@ -315,6 +316,7 @@ async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceM """ authorization_servers = [] resource = None + scopes = [] try: async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( @@ -359,6 +361,10 @@ async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceM log.debug(f'Discovered resource indicator: {resource}') servers = resource_metadata.get('authorization_servers', []) + scopes = resource_metadata.get('scopes_supported', []) + if scopes: + log.debug(f'Discovered resource scopes: {scopes}') + if servers: authorization_servers = servers log.debug(f'Discovered authorization servers: {servers}') @@ -369,7 +375,7 @@ async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceM except Exception as e: log.debug(f'MCP Protected Resource discovery failed: {e}') - return ProtectedResourceMetadata(resource=resource, authorization_servers=authorization_servers) + return ProtectedResourceMetadata(resource=resource, authorization_servers=authorization_servers, scopes_supported=scopes) def _build_well_known_urls(server_url: str) -> list[str]: @@ -553,12 +559,11 @@ async def get_oauth_client_info_with_static_credentials( log.error(f'Error parsing OAuth metadata from {url}: {e}') continue - # Let the OAuth provider apply its default scopes. - # We intentionally do NOT join all scopes_supported here — that list - # represents every scope the server *can* grant, not what the client - # should request. Requesting all of them is almost always wrong and - # can break providers like Entra ID that require resource-specific scopes. - scope = None + # Use scopes from the Protected Resource Metadata (RFC 9728) if available. + # Unlike the Authorization Server's scopes_supported (which is a full catalog + # of every scope the server can grant), the PRM scopes_supported represents + # what this specific resource requires — making it safe to request them all. + scope = ' '.join(resource_metadata.scopes_supported) if resource_metadata.scopes_supported else None # Determine token_endpoint_auth_method token_endpoint_auth_method = 'client_secret_post'