This is an automated email from the ASF dual-hosted git repository.

Miretpl pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new d993a7c61d9 Fix Microsoft Graph filesystem auth by defaulting OAuth2 
scope (#70879)
d993a7c61d9 is described below

commit d993a7c61d962065b54452f067e6595515da1cc5
Author: Haseeb Malik <[email protected]>
AuthorDate: Sun Aug 16 05:18:14 2026 -0400

    Fix Microsoft Graph filesystem auth by defaulting OAuth2 scope (#70879)
    
    * Fix Microsoft Graph filesystem auth by defaulting OAuth2 scope
    
    * Handle list and comma-separated scopes in msgraph filesystem auth
    
    * Drop empty strings when parsing msgraph filesystem scopes
---
 .../providers/microsoft/azure/fs/msgraph.py        |  7 ++-
 .../tests/unit/microsoft/azure/fs/test_msgraph.py  | 65 ++++++++++++++++++++++
 2 files changed, 70 insertions(+), 2 deletions(-)

diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/fs/msgraph.py 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/fs/msgraph.py
index b6fb11f76a3..fc6707d235c 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/fs/msgraph.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/fs/msgraph.py
@@ -36,7 +36,7 @@ def _get_token_endpoint(tenant_id: str) -> str:
 def _get_scopes(options: dict[str, Any]) -> list[str]:
     scopes = options.get("scope") or options.get("scopes") or DEFAULT_SCOPE
     if isinstance(scopes, str):
-        return scopes.split()
+        return [scope.strip() for scope in scopes.replace(",", " ").split() if 
scope.strip()]
     return scopes
 
 
@@ -158,7 +158,10 @@ def get_fs(conn_id: str | None, storage_options: dict[str, 
Any] | None = None) -
             if param in options:
                 oauth2_client_params[param] = options[param]
 
-        if "scopes" in options and "scope" not in oauth2_client_params:
+        # authlib expects a singular, space-delimited "scope"; the connection 
form only
+        # offers "scopes", which the hook treats as comma-separated, so 
translate it and
+        # always default so authlib never authenticates without a scope.
+        if "scope" not in oauth2_client_params:
             oauth2_client_params["scope"] = " ".join(_get_scopes(options))
 
         # Construct default token_endpoint from tenant_id if not explicitly 
provided
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/fs/test_msgraph.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/fs/test_msgraph.py
index 92a21422752..72c54a45287 100644
--- a/providers/microsoft/azure/tests/unit/microsoft/azure/fs/test_msgraph.py
+++ b/providers/microsoft/azure/tests/unit/microsoft/azure/fs/test_msgraph.py
@@ -64,6 +64,7 @@ class TestMSGraphFS:
                 "client_id": "test_client_id",
                 "client_secret": "test_client_secret",
                 "tenant_id": "test_tenant_id",
+                "scope": "https://graph.microsoft.com/.default";,
                 "token_endpoint": 
"https://login.microsoftonline.com/test_tenant_id/oauth2/v2.0/token";,
             },
         )
@@ -252,6 +253,70 @@ class TestMSGraphFS:
         )
         assert result == mock_fs_instance
 
+    @patch("azure.identity.CertificateCredential", autospec=True)
+    
@patch("airflow.providers.microsoft.azure.fs.msgraph.BaseHook.get_connection", 
autospec=True)
+    @patch("msgraphfs.MSGDriveFS", autospec=True)
+    def test_get_fs_certificate_rewrites_comma_separated_scopes(
+        self, mock_msgdrivefs, mock_get_connection, mock_certificate_credential
+    ):
+        connection = Connection(
+            conn_id="msgraph_certificate",
+            conn_type="msgraph",
+            login="test_client_id",
+            password="certificate_password",
+            host="test_tenant_id",
+            extra={
+                "drive_id": "test_drive_id",
+                "certificate_path": "/tmp/cert.pem",
+                "scopes": "User.Read,Files.Read",
+            },
+        )
+        mock_get_connection.return_value = connection
+        mock_msgdrivefs.return_value = MagicMock()
+        mock_certificate_credential.return_value.get_token.return_value = 
MagicMock(
+            token="certificate-token", expires_on=1234567890
+        )
+
+        get_fs("msgraph_certificate")
+
+        
mock_certificate_credential.return_value.get_token.assert_called_once_with("User.Read",
 "Files.Read")
+        assert mock_msgdrivefs.call_args[1]["oauth2_client_params"]["scope"] 
== "User.Read Files.Read"
+
+    @pytest.mark.parametrize(
+        ("extra", "expected_scope"),
+        [
+            pytest.param({"scope": "explicit.scope"}, "explicit.scope", 
id="explicit-scope-wins"),
+            pytest.param({"scopes": "form.scope"}, "form.scope", 
id="falls-back-to-scopes-form-field"),
+            pytest.param(
+                {"scopes": "User.Read,Files.Read"},
+                "User.Read Files.Read",
+                id="rewrites-comma-separated-scopes-to-space-delimited",
+            ),
+            pytest.param(
+                {"scopes": ["User.Read", "Files.Read"]},
+                "User.Read Files.Read",
+                id="joins-list-scopes-into-space-delimited",
+            ),
+            pytest.param({}, "https://graph.microsoft.com/.default";, 
id="defaults-to-graph-scope"),
+        ],
+    )
+    
@patch("airflow.providers.microsoft.azure.fs.msgraph.BaseHook.get_connection")
+    @patch("msgraphfs.MSGDriveFS")
+    def test_get_fs_resolves_scope(self, mock_msgdrivefs, mock_get_connection, 
extra, expected_scope):
+        mock_get_connection.return_value = Connection(
+            conn_id="msgraph_scope",
+            conn_type="msgraph",
+            login="test_client_id",
+            password="test_client_secret",
+            host="test_tenant_id",
+            extra=extra,
+        )
+        mock_msgdrivefs.return_value = MagicMock()
+
+        get_fs("msgraph_scope")
+
+        assert mock_msgdrivefs.call_args[1]["oauth2_client_params"]["scope"] 
== expected_scope
+
     
@patch("airflow.providers.microsoft.azure.fs.msgraph.BaseHook.get_connection")
     @patch("msgraphfs.MSGDriveFS")
     def test_get_fs_incomplete_credentials(self, mock_msgdrivefs, 
mock_get_connection):

Reply via email to