diff --git a/fastmcp_slim/fastmcp/server/auth/providers/azure.py b/fastmcp_slim/fastmcp/server/auth/providers/azure.py index c1b094f9c..89853d8b2 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/azure.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/azure.py @@ -501,8 +501,12 @@ class AzureProvider(OAuthProxy): Returns: List of scopes for Azure token endpoint """ - # Prefix scopes for this API - prefixed_scopes = self._prefix_scopes_for_azure(scopes or []) + # Prefix scopes for this API. Some clients omit the scope parameter on + # the MCP authorization request; use the provider's configured scopes + # just like the authorize URL path does. + prefixed_scopes = self._prefix_scopes_for_azure( + scopes or self.required_scopes or [] + ) # Add OIDC scopes only (not other API scopes) to avoid AADSTS28000 if self.additional_authorize_scopes: @@ -528,9 +532,13 @@ class AzureProvider(OAuthProxy): """ logger.debug("Base scopes from storage: %s", scopes) + # Some clients omit the scope parameter on the MCP authorization request; + # use the provider's configured scopes just like the authorize URL path does. + requested_scopes = scopes or self.required_scopes or [] + # Filter out any additional_authorize_scopes that may have been stored additional_scopes_set = set(self.additional_authorize_scopes or []) - base_scopes = [s for s in scopes if s not in additional_scopes_set] + base_scopes = [s for s in requested_scopes if s not in additional_scopes_set] # Prefix base scopes with identifier_uri for Azure prefixed_scopes = self._prefix_scopes_for_azure(base_scopes) diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 959d9c369..1bca397dd 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -445,6 +445,25 @@ class TestAzureProvider: assert "Mail.Read" in upstream_url assert "User.Read" in upstream_url + def test_prepare_scopes_for_token_exchange_falls_back_to_required_scopes( + self, memory_storage: MemoryStore + ): + """Clients may omit scope; Azure still needs an API scope with offline_access.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + base_url="https://myserver.com", + identifier_uri="api://my-api", + required_scopes=["read"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + result = provider._prepare_scopes_for_token_exchange([]) + + assert result == ["api://my-api/read", "offline_access"] + def test_base_authority_defaults_to_public_cloud(self, memory_storage: MemoryStore): """Test that base_authority defaults to login.microsoftonline.com.""" provider = AzureProvider( @@ -724,10 +743,10 @@ class TestAzureProvider: "https://graph.microsoft.com/.default" in result ) # Not prefixed (contains ://) - def test_prepare_scopes_for_upstream_refresh_empty_scopes( + def test_prepare_scopes_for_upstream_refresh_empty_scopes_falls_back_to_required( self, memory_storage: MemoryStore ): - """Test behavior with empty scopes list.""" + """Clients may omit scope; refresh should still request the configured API scope.""" provider = AzureProvider( client_id="test_client", client_secret="test_secret", @@ -740,13 +759,13 @@ class TestAzureProvider: client_storage=memory_storage, ) - # Empty scopes should still add OIDC scopes (not User.Read) result = provider._prepare_scopes_for_upstream_refresh([]) + assert "api://my-api/read" in result assert "User.Read" not in result # Not OIDC assert "openid" in result assert "offline_access" in result # Auto-included - assert len(result) == 2 # Only OIDC scopes: openid + offline_access + assert len(result) == 3 def test_prepare_scopes_for_upstream_refresh_no_additional_scopes( self, memory_storage: MemoryStore