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

dabla 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 198944a3ce3 Support certificate auth for Microsoft Graph filesystem 
(#71362)
198944a3ce3 is described below

commit 198944a3ce3753c7169296c3c1b7f37d175de141
Author: Vincent Hsiao <[email protected]>
AuthorDate: Tue Aug 11 00:49:25 2026 +0800

    Support certificate auth for Microsoft Graph filesystem (#71362)
---
 providers/microsoft/azure/docs/changelog.rst       |   1 +
 .../microsoft/azure/docs/filesystems/msgraph.rst   |   8 +-
 .../providers/microsoft/azure/fs/msgraph.py        |  64 ++++++++++++-
 .../tests/unit/microsoft/azure/fs/test_msgraph.py  | 106 ++++++++++++++++++++-
 4 files changed, 170 insertions(+), 9 deletions(-)

diff --git a/providers/microsoft/azure/docs/changelog.rst 
b/providers/microsoft/azure/docs/changelog.rst
index 523924e7e9b..bed6667dca8 100644
--- a/providers/microsoft/azure/docs/changelog.rst
+++ b/providers/microsoft/azure/docs/changelog.rst
@@ -33,6 +33,7 @@ Changelog
 Features
 ~~~~~~~~
 
+* ``Add certificate-based authentication support to Microsoft Graph filesystem 
(#69335)``
 * ``Add WasbRemoteLogIO.from_config and register wasb remote logging scheme 
(#70301)``
 
 Bug Fixes
diff --git a/providers/microsoft/azure/docs/filesystems/msgraph.rst 
b/providers/microsoft/azure/docs/filesystems/msgraph.rst
index 7ebea783b7e..90d8c5e40a7 100644
--- a/providers/microsoft/azure/docs/filesystems/msgraph.rst
+++ b/providers/microsoft/azure/docs/filesystems/msgraph.rst
@@ -35,13 +35,15 @@ Create a Microsoft Graph connection in Airflow with the 
following parameters:
 * **Connection Type**: msgraph
 * **Host**: Tenant ID
 * **Login**: Client ID
-* **Password**: Client Secret
+* **Password**: Client Secret, or certificate password when using 
certificate-based authentication
 
 The connection form provides additional configuration fields:
 
 * **Tenant ID**: Azure AD tenant identifier
 * **Drive ID**: Specific drive to access (optional - leave empty for general 
access)
 * **Scopes**: OAuth2 scopes (default: https://graph.microsoft.com/.default)
+* **Certificate path**: File path to a PEM certificate for certificate-based 
authentication
+* **Certificate data**: PEM certificate data for certificate-based 
authentication
 
 Additional OAuth2 parameters supported via connection extras:
 
@@ -52,6 +54,10 @@ Additional OAuth2 parameters supported via connection extras:
 * **code_challenge_method**: PKCE code challenge method (e.g., 'S256')
 * **username**: Username for password grant flow
 * **password**: Password for password grant flow
+* **certificate_path**: File path to a PEM certificate
+* **certificate_data**: PEM certificate data
+* **authority**: Microsoft Entra authority host
+* **disable_instance_discovery**: Disable Microsoft Entra instance discovery
 
 Connection extra field configuration example:
 
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 f0988f961b6..b6fb11f76a3 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
@@ -26,6 +26,42 @@ if TYPE_CHECKING:
     from fsspec import AbstractFileSystem
 
 schemes = ["msgraph", "sharepoint", "onedrive", "msgd"]
+DEFAULT_SCOPE = "https://graph.microsoft.com/.default";
+
+
+def _get_token_endpoint(tenant_id: str) -> str:
+    return f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token";
+
+
+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 scopes
+
+
+def _get_certificate_token(options: dict[str, Any]) -> dict[str, Any]:
+    from azure.identity import CertificateCredential
+
+    credential = CertificateCredential(
+        tenant_id=options["tenant_id"],
+        client_id=options["client_id"],
+        password=options.get("password") or options.get("client_secret"),
+        certificate_path=options.get("certificate_path"),
+        certificate_data=options["certificate_data"].encode() if 
options.get("certificate_data") else None,
+        authority=options.get("authority"),
+        disable_instance_discovery=options.get("disable_instance_discovery", 
False),
+    )
+    try:
+        access_token = credential.get_token(*_get_scopes(options))
+    finally:
+        credential.close()
+
+    return {
+        "access_token": access_token.token,
+        "token_type": "Bearer",
+        "expires_at": access_token.expires_on,
+    }
 
 
 def get_fs(conn_id: str | None, storage_options: dict[str, Any] | None = None) 
-> AbstractFileSystem:
@@ -62,6 +98,7 @@ def get_fs(conn_id: str | None, storage_options: dict[str, 
Any] | None = None) -
     fields = [
         "drive_id",
         "scope",
+        "scopes",
         "token_endpoint",
         "redirect_uri",
         "token_endpoint_auth_method",
@@ -69,6 +106,10 @@ def get_fs(conn_id: str | None, storage_options: dict[str, 
Any] | None = None) -
         "update_token",
         "username",
         "password",
+        "certificate_path",
+        "certificate_data",
+        "authority",
+        "disable_instance_discovery",
     ]
     for field in fields:
         value = get_field(conn_id=conn_id, conn_type=conn_type, extras=extras, 
field_name=field)
@@ -83,7 +124,19 @@ def get_fs(conn_id: str | None, storage_options: dict[str, 
Any] | None = None) -
 
     # Create oauth2 client parameters if authentication is provided
     oauth2_client_params = {}
-    if options.get("client_id") and options.get("client_secret") and 
options.get("tenant_id"):
+    if (
+        options.get("client_id")
+        and options.get("tenant_id")
+        and (options.get("certificate_path") or 
options.get("certificate_data"))
+    ):
+        token_endpoint = options.get("token_endpoint") or 
_get_token_endpoint(options["tenant_id"])
+        oauth2_client_params = {
+            "client_id": options["client_id"],
+            "token": _get_certificate_token(options),
+            "token_endpoint": token_endpoint,
+            "scope": " ".join(_get_scopes(options)),
+        }
+    elif options.get("client_id") and options.get("client_secret") and 
options.get("tenant_id"):
         oauth2_client_params = {
             "client_id": options["client_id"],
             "client_secret": options["client_secret"],
@@ -105,11 +158,12 @@ 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:
+            oauth2_client_params["scope"] = " ".join(_get_scopes(options))
+
         # Construct default token_endpoint from tenant_id if not explicitly 
provided
-        if "token_endpoint" not in oauth2_client_params and tenant_id:
-            oauth2_client_params["token_endpoint"] = (
-                
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token";
-            )
+        if "token_endpoint" not in oauth2_client_params:
+            oauth2_client_params["token_endpoint"] = 
_get_token_endpoint(options["tenant_id"])
 
     # Determine which filesystem to return based on drive_id
     drive_id = options.get("drive_id")
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 d95a772dda2..92a21422752 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
@@ -137,14 +137,18 @@ class TestMSGraphFS:
         mock_fs_instance = MagicMock()
         mock_msgdrivefs.return_value = mock_fs_instance
 
-        storage_options = {"drive_id": "storage_drive_id", "scope": 
"custom.scope"}
+        storage_options = {
+            "drive_id": "storage_drive_id",
+            "scope": "custom.scope",
+            "tenant_id": "storage_tenant_id",
+        }
         result = get_fs("msgraph_minimal", storage_options=storage_options)
 
         expected_oauth2_params = {
             "client_id": "test_client_id",
             "client_secret": "test_client_secret",
-            "tenant_id": "test_tenant_id",
-            "token_endpoint": 
"https://login.microsoftonline.com/test_tenant_id/oauth2/v2.0/token";,
+            "tenant_id": "storage_tenant_id",
+            "token_endpoint": 
"https://login.microsoftonline.com/storage_tenant_id/oauth2/v2.0/token";,
             "scope": "custom.scope",
         }
         mock_msgdrivefs.assert_called_once_with(
@@ -152,6 +156,102 @@ 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_with_certificate_path(
+        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"},
+        )
+        mock_get_connection.return_value = connection
+        mock_fs_instance = MagicMock()
+        mock_msgdrivefs.return_value = mock_fs_instance
+        mock_access_token = MagicMock(token="certificate-token", 
expires_on=1234567890)
+        mock_certificate_credential.return_value.get_token.return_value = 
mock_access_token
+
+        result = get_fs("msgraph_certificate")
+
+        mock_certificate_credential.assert_called_once_with(
+            tenant_id="test_tenant_id",
+            client_id="test_client_id",
+            password="certificate_password",
+            certificate_path="/tmp/cert.pem",
+            certificate_data=None,
+            authority=None,
+            disable_instance_discovery=False,
+        )
+        
mock_certificate_credential.return_value.get_token.assert_called_once_with(
+            "https://graph.microsoft.com/.default";
+        )
+        
mock_certificate_credential.return_value.close.assert_called_once_with()
+        mock_msgdrivefs.assert_called_once_with(
+            drive_id="test_drive_id",
+            oauth2_client_params={
+                "client_id": "test_client_id",
+                "token": {
+                    "access_token": "certificate-token",
+                    "token_type": "Bearer",
+                    "expires_at": 1234567890,
+                },
+                "token_endpoint": 
"https://login.microsoftonline.com/test_tenant_id/oauth2/v2.0/token";,
+                "scope": "https://graph.microsoft.com/.default";,
+            },
+        )
+        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_with_certificate_data_from_storage_options(
+        self, mock_msgdrivefs, mock_get_connection, 
mock_certificate_credential, mock_connection_minimal
+    ):
+        mock_get_connection.return_value = mock_connection_minimal
+        mock_fs_instance = MagicMock()
+        mock_msgdrivefs.return_value = mock_fs_instance
+        mock_access_token = MagicMock(token="storage-token", 
expires_on=1234567890)
+        mock_certificate_credential.return_value.get_token.return_value = 
mock_access_token
+
+        result = get_fs(
+            "msgraph_minimal",
+            storage_options={
+                "certificate_data": "certificate-data",
+                "scope": "custom.scope",
+                "token_endpoint": 
"https://login.microsoftonline.com/custom/oauth2/v2.0/token";,
+            },
+        )
+
+        mock_certificate_credential.assert_called_once_with(
+            tenant_id="test_tenant_id",
+            client_id="test_client_id",
+            password="test_client_secret",
+            certificate_path=None,
+            certificate_data=b"certificate-data",
+            authority=None,
+            disable_instance_discovery=False,
+        )
+        
mock_certificate_credential.return_value.get_token.assert_called_once_with("custom.scope")
+        mock_msgdrivefs.assert_called_once_with(
+            drive_id=None,
+            oauth2_client_params={
+                "client_id": "test_client_id",
+                "token": {
+                    "access_token": "storage-token",
+                    "token_type": "Bearer",
+                    "expires_at": 1234567890,
+                },
+                "token_endpoint": 
"https://login.microsoftonline.com/custom/oauth2/v2.0/token";,
+                "scope": "custom.scope",
+            },
+        )
+        assert result == mock_fs_instance
+
     
@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