From 24582d3ffc8d4864ce3d82ef5aeac5543be95889 Mon Sep 17 00:00:00 2001 From: Shira Sassoon Date: Wed, 15 Jul 2026 13:44:12 +0300 Subject: [PATCH] feat: Add Azure CLI auth source (POC) Add azure_cli as a new identity type that delegates token acquisition to Azure CLI via azure-identity's AzureCliCredential. This allows tools calling fab to reuse an existing az login session instead of requiring a separate interactive fab auth login. Changes: - Add 'azure_cli' to AUTH_KEYS identity type allow-list - Add _acquire_token_from_azure_cli() using AzureCliCredential - Add --azure-cli flag to fab auth login - Add 'Azure CLI' option to interactive login menu - Show auth_source in fab auth status output - Add azure-identity>=1.15.0 dependency - Add 12 unit tests covering dispatch, scopes, errors, sanitization Security: error messages are sanitized to never leak token content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyproject.toml | 1 + src/fabric_cli/commands/auth/fab_auth.py | 19 +- src/fabric_cli/core/fab_auth.py | 48 ++++ src/fabric_cli/core/fab_constant.py | 2 +- src/fabric_cli/parsers/fab_auth_parser.py | 11 + tests/test_core/test_fab_auth_azure_cli.py | 262 +++++++++++++++++++++ 6 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 tests/test_core/test_fab_auth_azure_cli.py diff --git a/pyproject.toml b/pyproject.toml index f2c52dc5d..08dce9b8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "msal>=1.34,<2", "msal_extensions", "azure-core>=1.29.0", + "azure-identity>=1.15.0", "questionary", "prompt_toolkit>=3.0.41", "cachetools>=5.5.0", diff --git a/src/fabric_cli/commands/auth/fab_auth.py b/src/fabric_cli/commands/auth/fab_auth.py index 4e1039d3b..ad3270679 100644 --- a/src/fabric_cli/commands/auth/fab_auth.py +++ b/src/fabric_cli/commands/auth/fab_auth.py @@ -16,6 +16,7 @@ def init(args: Namespace) -> Any: auth_options = [ "Interactive with a web browser", + "Azure CLI (reuse existing 'az login' session)", "Service principal authentication with secret", "Service principal authentication with certificate", "Service principal authentication with federated credential", @@ -27,7 +28,15 @@ def init(args: Namespace) -> Any: # Clean up stale context files when logging in Context().cleanup_context_files(cleanup_all_stale=True, cleanup_current=False) - if args.identity: + if getattr(args, "azure_cli", False): + FabAuth().set_access_mode("azure_cli", args.tenant) + FabAuth().set_azure_cli(args.tenant) + FabAuth().get_access_token(scope=fab_constant.SCOPE_FABRIC_DEFAULT) + FabAuth().get_access_token(scope=fab_constant.SCOPE_ONELAKE_DEFAULT) + FabAuth().get_access_token(scope=fab_constant.SCOPE_AZURE_DEFAULT) + Context().context = FabAuth().get_tenant() + + elif args.identity: FabAuth().set_access_mode("managed_identity") FabAuth().set_managed_identity(args.username) FabAuth().get_access_token(scope=fab_constant.SCOPE_FABRIC_DEFAULT) @@ -73,6 +82,13 @@ def init(args: Namespace) -> Any: FabAuth().get_access_token(scope=fab_constant.SCOPE_ONELAKE_DEFAULT) FabAuth().get_access_token(scope=fab_constant.SCOPE_AZURE_DEFAULT) Context().context = FabAuth().get_tenant() + elif selected_auth.startswith("Azure CLI"): + FabAuth().set_access_mode("azure_cli", args.tenant) + FabAuth().set_azure_cli(args.tenant) + FabAuth().get_access_token(scope=fab_constant.SCOPE_FABRIC_DEFAULT) + FabAuth().get_access_token(scope=fab_constant.SCOPE_ONELAKE_DEFAULT) + FabAuth().get_access_token(scope=fab_constant.SCOPE_AZURE_DEFAULT) + Context().context = FabAuth().get_tenant() elif selected_auth.startswith("Service principal authentication"): fab_logger.log_warning( "Ensure tenant setting is enabled for Service Principal auth" @@ -275,6 +291,7 @@ def __mask_token(scope): auth_data = { "logged_in": is_logged_in, + "auth_source": auth.get_identity_type() or "N/A", "account": upn, "principal_id": oid, "tenant_id": tid, diff --git a/src/fabric_cli/core/fab_auth.py b/src/fabric_cli/core/fab_auth.py index 8d1979d2f..6a2e5c4ac 100644 --- a/src/fabric_cli/core/fab_auth.py +++ b/src/fabric_cli/core/fab_auth.py @@ -418,6 +418,52 @@ def set_managed_identity(self, client_id=None): } ) + def set_azure_cli(self, tenant_id=None): + """Configure Azure CLI as the authentication source.""" + self._set_auth_properties( + { + con.IDENTITY_TYPE: "azure_cli", + } + ) + if tenant_id: + self.set_tenant(tenant_id) + + def _acquire_token_from_azure_cli(self, scope: list[str]) -> dict: + """Acquire a token using Azure CLI's AzureCliCredential.""" + try: + from azure.identity import AzureCliCredential, CredentialUnavailableError + except ImportError: + raise FabricCLIError( + "Azure CLI auth requires the 'azure-identity' package. " + "Install it with: pip install azure-identity", + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + tenant_id = self.get_tenant_id() + try: + credential = AzureCliCredential(tenant_id=tenant_id) if tenant_id else AzureCliCredential() + # AzureCliCredential.get_token expects scopes as positional args + azure_token = credential.get_token(scope[0]) + return { + "access_token": azure_token.token, + "expires_on": azure_token.expires_on, + } + except CredentialUnavailableError: + raise FabricCLIError( + "Azure CLI is not installed or not logged in. " + "Run 'az login' to authenticate, then retry.", + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + except Exception as e: + # Sanitize: never include token content in error messages + error_msg = str(e) + if "accessToken" in error_msg or "token" in error_msg.lower(): + error_msg = "Azure CLI token acquisition failed. Run 'az account get-access-token' manually to diagnose." + raise FabricCLIError( + f"Azure CLI authentication failed: {error_msg}", + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + def print_auth_info(self): utils_ui.print_grey(json.dumps(self._get_auth_info(), indent=2)) @@ -480,6 +526,8 @@ def acquire_token(self, scope: list[str], interactive_renew=True) -> dict: ErrorMessages.Auth.managed_identity_token_failed(), status_code=con.ERROR_AUTHENTICATION_FAILED, ) + elif identity_type == "azure_cli": + token = self._acquire_token_from_azure_cli(scope) elif env_var_token: token = { "access_token": env_var_token, diff --git a/src/fabric_cli/core/fab_constant.py b/src/fabric_cli/core/fab_constant.py index 4fd00e7b9..49a19d992 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -63,7 +63,7 @@ AUTH_KEYS = { FAB_TENANT_ID: [], - IDENTITY_TYPE: ["user", "service_principal", "managed_identity"], + IDENTITY_TYPE: ["user", "service_principal", "managed_identity", "azure_cli"], } FAB_HOST_APP_ENV_VAR = "FAB_HOST_APP" diff --git a/src/fabric_cli/parsers/fab_auth_parser.py b/src/fabric_cli/parsers/fab_auth_parser.py index a908e09e5..d4e2738eb 100644 --- a/src/fabric_cli/parsers/fab_auth_parser.py +++ b/src/fabric_cli/parsers/fab_auth_parser.py @@ -30,6 +30,10 @@ def register_parser(subparsers: _SubParsersAction) -> None: "$ auth login\n", "# command_line mode", "$ fab auth login\n", + "# command_line mode using Azure CLI auth", + "$ fab auth login --azure-cli\n", + "# command_line mode using Azure CLI auth with specific tenant", + "$ fab auth login --azure-cli --tenant \n", "# command_line mode using service principal auth", "$ fab auth login -u -p --tenant \n", "# command_line mode using system assigned managed identity auth", @@ -84,6 +88,13 @@ def register_parser(subparsers: _SubParsersAction) -> None: required=False, help="Federated token that can be used for OIDC token exchange. Optional, only for service principal auth", ) + login_parser.add_argument( + "--azure-cli", + required=False, + action="store_true", + dest="azure_cli", + help="Use Azure CLI authentication (reuse existing 'az login' session)", + ) login_parser.usage = f"{utils_error_parser.get_usage_prog(login_parser)}" login_parser.set_defaults(func=lazy_command(_auth_module_path, 'init')) diff --git a/tests/test_core/test_fab_auth_azure_cli.py b/tests/test_core/test_fab_auth_azure_cli.py new file mode 100644 index 000000000..75095efed --- /dev/null +++ b/tests/test_core/test_fab_auth_azure_cli.py @@ -0,0 +1,262 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import time +from unittest.mock import MagicMock, patch + +import pytest + +from fabric_cli.core import fab_constant as con +from fabric_cli.core.fab_auth import FabAuth +from fabric_cli.core.fab_exceptions import FabricCLIError + + +@pytest.fixture(autouse=True) +def temp_dir_fixture(monkeypatch, tmp_path): + """Create a temporary directory and configure FabAuth to use it.""" + monkeypatch.setattr( + "fabric_cli.core.fab_state_config.config_location", lambda: str(tmp_path) + ) + # Clear env vars that would interfere + monkeypatch.delenv("FAB_TOKEN", raising=False) + monkeypatch.delenv("FAB_TOKEN_ONELAKE", raising=False) + monkeypatch.delenv("FAB_TOKEN_AZURE", raising=False) + return str(tmp_path) + + +@pytest.fixture +def auth_instance(temp_dir_fixture): + """Get a fresh FabAuth instance.""" + # Clear singleton for test isolation + FabAuth.__wrapped__ = None # type: ignore + from fabric_cli.core import fab_auth as fab_auth_module + + if FabAuth in fab_auth_module.singleton.__wrapped__: # type: ignore + del fab_auth_module.singleton.__wrapped__[FabAuth] # type: ignore + return FabAuth() + + +@pytest.fixture +def fresh_auth(temp_dir_fixture, monkeypatch): + """Get a fresh FabAuth instance with singleton cleared.""" + # Reset singleton instances dict + import fabric_cli.core.fab_auth as auth_module + + # Access the closure variable of the singleton decorator + singleton_instances = auth_module.singleton.__code__.co_consts # noqa + # Simpler approach: just patch the module-level reference + monkeypatch.setattr( + "fabric_cli.core.fab_auth.FabAuth.__init__.__globals__", + {}, + raising=False, + ) + # Re-instantiate + auth = FabAuth.__new__(FabAuth) + auth.__init__() + return auth + + +class TestAzureCliIdentityType: + """Test that azure_cli is a valid identity type.""" + + def test_azure_cli_in_auth_keys(self): + """azure_cli should be in the allowed identity types.""" + assert "azure_cli" in con.AUTH_KEYS[con.IDENTITY_TYPE] + + def test_set_access_mode_accepts_azure_cli(self, temp_dir_fixture): + """set_access_mode should accept azure_cli without raising.""" + auth = FabAuth() + auth.set_access_mode("azure_cli") + assert auth.get_identity_type() == "azure_cli" + + def test_set_azure_cli_sets_identity_type(self, temp_dir_fixture): + """set_azure_cli should configure identity_type to azure_cli.""" + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_identity_type() == "azure_cli" + + def test_set_azure_cli_with_tenant(self, temp_dir_fixture): + """set_azure_cli with tenant_id should store the tenant.""" + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli(tenant_id="test-tenant-id") + assert auth.get_tenant_id() == "test-tenant-id" + + +class TestAzureCliTokenAcquisition: + """Test token acquisition via AzureCliCredential.""" + + @patch("azure.identity.AzureCliCredential") + def test_acquire_token_dispatches_to_azure_cli( + self, mock_credential_class, temp_dir_fixture + ): + """acquire_token should use AzureCliCredential for azure_cli identity.""" + mock_token = MagicMock() + mock_token.token = "fake-token-123" + mock_token.expires_on = int(time.time()) + 3600 + + mock_credential = MagicMock() + mock_credential.get_token.return_value = mock_token + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + + result = auth.acquire_token(con.SCOPE_FABRIC_DEFAULT) + + assert result["access_token"] == "fake-token-123" + mock_credential.get_token.assert_called_once_with( + "https://api.fabric.microsoft.com/.default" + ) + + @patch("azure.identity.AzureCliCredential") + def test_acquire_token_from_azure_cli_success( + self, mock_credential_class, temp_dir_fixture + ): + """_acquire_token_from_azure_cli should return token dict on success.""" + mock_token = MagicMock() + mock_token.token = "az-cli-token-abc" + mock_token.expires_on = int(time.time()) + 3600 + + mock_credential = MagicMock() + mock_credential.get_token.return_value = mock_token + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + + result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + assert result["access_token"] == "az-cli-token-abc" + mock_credential.get_token.assert_called_once_with( + "https://api.fabric.microsoft.com/.default" + ) + + @patch("azure.identity.AzureCliCredential") + def test_acquire_token_from_azure_cli_with_tenant( + self, mock_credential_class, temp_dir_fixture + ): + """_acquire_token_from_azure_cli should pass tenant_id to credential.""" + mock_token = MagicMock() + mock_token.token = "tenant-specific-token" + mock_token.expires_on = int(time.time()) + 3600 + + mock_credential = MagicMock() + mock_credential.get_token.return_value = mock_token + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli(tenant_id="my-tenant-id") + + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + mock_credential_class.assert_called_once_with(tenant_id="my-tenant-id") + + @patch("azure.identity.AzureCliCredential") + def test_acquire_token_from_azure_cli_credential_unavailable( + self, mock_credential_class, temp_dir_fixture + ): + """Should raise FabricCLIError when Azure CLI is not logged in.""" + from azure.identity import CredentialUnavailableError + + mock_credential = MagicMock() + mock_credential.get_token.side_effect = CredentialUnavailableError( + "Azure CLI not logged in" + ) + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + + with pytest.raises(FabricCLIError) as exc_info: + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + assert "not installed or not logged in" in str(exc_info.value) + + @patch( + "azure.identity.AzureCliCredential", + side_effect=ImportError("No module named 'azure.identity'"), + ) + def test_acquire_token_from_azure_cli_missing_package( + self, mock_import, temp_dir_fixture + ): + """Should raise FabricCLIError when azure-identity is not installed.""" + auth = FabAuth() + auth.set_access_mode("azure_cli") + + # Need to actually test the import failure path + with patch.dict("sys.modules", {"azure.identity": None}): + with patch( + "builtins.__import__", side_effect=ImportError("no azure.identity") + ): + with pytest.raises(FabricCLIError) as exc_info: + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + assert "azure-identity" in str(exc_info.value) + + @patch("azure.identity.AzureCliCredential") + def test_acquire_token_sanitizes_error_messages( + self, mock_credential_class, temp_dir_fixture + ): + """Error messages should never contain token content.""" + mock_credential = MagicMock() + mock_credential.get_token.side_effect = Exception( + "Failed with accessToken: eyJ0eXAi..." + ) + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + + with pytest.raises(FabricCLIError) as exc_info: + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + # Should not contain the raw token + assert "eyJ0eXAi" not in str(exc_info.value) + assert "manually to diagnose" in str(exc_info.value) + + +class TestAzureCliScopeHandling: + """Test that different scopes are correctly passed to Azure CLI.""" + + @patch("azure.identity.AzureCliCredential") + def test_onelake_scope(self, mock_credential_class, temp_dir_fixture): + """OneLake scope should be passed correctly.""" + mock_token = MagicMock() + mock_token.token = "storage-token" + mock_token.expires_on = int(time.time()) + 3600 + + mock_credential = MagicMock() + mock_credential.get_token.return_value = mock_token + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + + auth._acquire_token_from_azure_cli(con.SCOPE_ONELAKE_DEFAULT) + + mock_credential.get_token.assert_called_once_with( + "https://storage.azure.com/.default" + ) + + @patch("azure.identity.AzureCliCredential") + def test_azure_management_scope(self, mock_credential_class, temp_dir_fixture): + """Azure management scope should be passed correctly.""" + mock_token = MagicMock() + mock_token.token = "mgmt-token" + mock_token.expires_on = int(time.time()) + 3600 + + mock_credential = MagicMock() + mock_credential.get_token.return_value = mock_token + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + + auth._acquire_token_from_azure_cli(con.SCOPE_AZURE_DEFAULT) + + mock_credential.get_token.assert_called_once_with( + "https://management.azure.com/.default" + )