diff --git a/.changes/unreleased/added-20260812-122446.yaml b/.changes/unreleased/added-20260812-122446.yaml new file mode 100644 index 000000000..a5c813ba9 --- /dev/null +++ b/.changes/unreleased/added-20260812-122446.yaml @@ -0,0 +1,6 @@ +kind: added +body: Add support for Azure CLI authentication source +time: 2026-08-12T12:24:46.8603823+03:00 +custom: + Author: shirasassoon + AuthorLink: https://github.com/shirasassoon diff --git a/docs/commands/auth/index.md b/docs/commands/auth/index.md index 288d36d66..9c72bb4fa 100644 --- a/docs/commands/auth/index.md +++ b/docs/commands/auth/index.md @@ -8,11 +8,11 @@ Not resource-specific; applies to CLI authentication context. ## Available Commands -| Command | Description | Usage | -|----------------|---------------------------|-----------------------------------------------------------------------| -| `auth login` | Log in to Fabric CLI | `auth login [parameters]` | -| `auth logout` | Log out of current session| `auth logout` | -| `auth status` | Show authentication status| `auth status` | +| Command | Description | Usage | +| --- | --- | --- | +| `auth login` | Log in to Fabric CLI | `auth login [parameters]` | +| `auth logout` | Log out of current session | `auth logout` | +| `auth status` | Show authentication status | `auth status` | --- @@ -22,8 +22,28 @@ Authenticate with Fabric CLI. **Usage:** +#### Interactive login ``` -fab auth login [-u ] [-p ] [--federated-token ] [--certificate ] [--tenant ] +fab auth login +``` + +#### Azure CLI +``` +fab auth login --azure-cli [--tenant ] +``` + +#### Service principal +``` +# Service principal with secret +fab auth login -u -p --tenant + +# Service principal with certificate +fab auth login -u --certificate --tenant +``` + +#### Workload identity +``` +fab auth login -u --federated-token --tenant ``` **Parameters:** @@ -32,7 +52,8 @@ fab auth login [-u ] [-p ] [--federated-token ] - `-p, --password`: Client secret for service principal. Optional. - `--federated-token`: Federated token for workload identity. Optional. - `--certificate`: Path to certificate file. Optional. -- `--tenant`: Tenant ID. Optional. +- `--azure-cli`: Use an existing Azure CLI login session as the token provider. Requires Azure CLI to be installed and logged in (`az login`). Optional. +- `--tenant`: Tenant ID. Optional. When used with `--azure-cli`, pins Fabric CLI to the specified tenant. --- @@ -60,4 +81,4 @@ fab auth status --- -For more examples and detailed scenarios, see [Authentication Examples](../../examples/auth_examples.md). +For more examples and detailed scenarios, see [Authentication Examples](../../examples/auth_examples.md). \ No newline at end of file diff --git a/docs/examples/auth_examples.md b/docs/examples/auth_examples.md index bdab9d7af..3bc260cb5 100644 --- a/docs/examples/auth_examples.md +++ b/docs/examples/auth_examples.md @@ -25,6 +25,36 @@ fab auth login ``` +### Azure CLI Authentication + +Reuse an existing Azure CLI session instead of requiring a separate Fabric CLI login. Useful when tools or scripts already have `az login` done (e.g., in development environments or CI/CD pipelines with Azure CLI pre-authenticated). + +!!! info "Requires Azure CLI to be installed and logged in (`az login`)" + +#### Log in using Azure CLI in interactive mode + +``` +fab auth login +? How would you like to authenticate Fabric CLI? Azure CLI (existing 'az login' session) +``` + +#### Log in using Azure CLI directly from command line + +``` +fab auth login --azure-cli +``` + +#### Log in using Azure CLI with a specific tenant + +``` +fab auth login --azure-cli --tenant +``` + +!!! note "Tenant behavior" + - If `--tenant` is not specified, Fabric CLI captures and records the tenant from the current Azure CLI session at login time. + - Throughout the `fab` session, the Azure CLI's active tenant is checked against the recorded tenant. If you switch tenants in Azure CLI (e.g., `az login --tenant `), Fabric CLI will raise a tenant mismatch error and require you to re-authenticate, e.g., `fab auth login --azure-cli`. + + ### Service Principal Authentication !!! info "Requires 'Allow service principals to use Fabric APIs' tenant switch to be enabled in the admin portal" @@ -81,6 +111,7 @@ Log in using service principal with federated credential directly fab auth login -u --federated-token --tenant ``` + ### Managed Identity Authentication !!! info "Requires 'Allow service principals to use Fabric APIs' tenant switch must be enabled" diff --git a/pyproject.toml b/pyproject.toml index dee33a6ac..7bd17b6a5 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..6e2882faa 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 (existing 'az login' session)", "Service principal authentication with secret", "Service principal authentication with certificate", "Service principal authentication with federated credential", @@ -27,12 +28,18 @@ 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) + _acquire_default_access_tokens(FabAuth()) + Context().context = FabAuth().get_tenant() + tenant_id = FabAuth().get_tenant_id() or "unknown" + fab_ui.print_grey(f"✓ Authenticated via Azure CLI (tenant: {tenant_id})") + + 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) - FabAuth().get_access_token(scope=fab_constant.SCOPE_ONELAKE_DEFAULT) - FabAuth().get_access_token(scope=fab_constant.SCOPE_AZURE_DEFAULT) + _acquire_default_access_tokens(FabAuth()) Context().context = FabAuth().get_tenant() elif any([args.username, args.password]): @@ -54,9 +61,7 @@ def init(args: Namespace) -> Any: FabAuth().set_spn(args.username, password=args.password) elif args.federated_token: FabAuth().set_spn(args.username, client_assertion=args.federated_token) - 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) + _acquire_default_access_tokens(FabAuth()) Context().context = FabAuth().get_tenant() else: selected_auth = fab_ui.prompt_select_item( @@ -69,10 +74,17 @@ def init(args: Namespace) -> Any: try: if selected_auth == "Interactive with a web browser": FabAuth().set_access_mode("user", 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) + _acquire_default_access_tokens(FabAuth()) + 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) + _acquire_default_access_tokens(FabAuth()) Context().context = FabAuth().get_tenant() + tenant_id = FabAuth().get_tenant_id() or "unknown" + fab_ui.print_grey( + f"✓ Authenticated via Azure CLI (tenant: {tenant_id})" + ) elif selected_auth.startswith("Service principal authentication"): fab_logger.log_warning( "Ensure tenant setting is enabled for Service Principal auth" @@ -174,9 +186,7 @@ def init(args: Namespace) -> Any: FabAuth().set_spn(client_id, password=client_secret) elif federated_token: FabAuth().set_spn(client_id, client_assertion=federated_token) - 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) + _acquire_default_access_tokens(FabAuth()) Context().context = FabAuth().get_tenant() elif selected_auth == "Managed identity authentication": fab_logger.log_warning( @@ -191,9 +201,7 @@ def init(args: Namespace) -> Any: FabAuth().set_access_mode("managed_identity") FabAuth().set_managed_identity(client_id) - 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) + _acquire_default_access_tokens(FabAuth()) Context().context = FabAuth().get_tenant() except KeyboardInterrupt: @@ -266,15 +274,19 @@ def __mask_token(scope): # Check login status is_logged_in = fabric_secret != "N/A" + identity_type = auth.get_identity_type() or "N/A" login_status = ( "✓ Logged in to app.fabric.microsoft.com" if is_logged_in else "✗ Not logged in to app.fabric.microsoft.com" ) fab_ui.print_grey(login_status) + if identity_type == "azure_cli" and is_logged_in: + fab_ui.print_grey(f" Auth mode: Azure CLI (tenant: {tid})") auth_data = { "logged_in": is_logged_in, + "auth_source": identity_type, "account": upn, "principal_id": oid, "tenant_id": tid, @@ -291,3 +303,9 @@ def _get_token_info_from_bearer_token(bearer_token: str) -> Optional[dict[str, s return FabAuth()._get_claims_from_token( bearer_token, ["upn", "oid", "tid", "appid"] ) + + +def _acquire_default_access_tokens(auth: FabAuth) -> None: + auth.get_access_token(scope=fab_constant.SCOPE_FABRIC_DEFAULT) + auth.get_access_token(scope=fab_constant.SCOPE_ONELAKE_DEFAULT) + auth.get_access_token(scope=fab_constant.SCOPE_AZURE_DEFAULT) diff --git a/src/fabric_cli/core/fab_auth.py b/src/fabric_cli/core/fab_auth.py index 8d1979d2f..a502d9455 100644 --- a/src/fabric_cli/core/fab_auth.py +++ b/src/fabric_cli/core/fab_auth.py @@ -3,6 +3,9 @@ import json import os +import shutil +import subprocess +import time import uuid from binascii import hexlify from typing import Any, NamedTuple, Optional @@ -10,6 +13,7 @@ import jwt import msal import requests +from azure.identity import AzureCliCredential, CredentialUnavailableError from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization @@ -27,6 +31,9 @@ from fabric_cli.errors import ErrorMessages from fabric_cli.utils import fab_ui as utils_ui +_AZURE_CLI_TENANT_CACHE_TTL_SECONDS = 10 +_AZURE_CLI_TOKEN_REFRESH_BUFFER_SECONDS = 60 + def singleton(class_): instances = {} @@ -50,6 +57,11 @@ def __init__(self): # Reset the auth info self.app: msal.ClientApplication = None self._auth_info = {} + # In-memory token cache for Azure CLI tokens + self._azure_cli_token_cache: dict[str, dict] = {} + # Cached tenant ID from az account show + self._cached_az_tenant: Optional[str] = None + self._cached_az_tenant_time: float = 0.0 # Load the auth info and environment variables self._load_auth() @@ -418,6 +430,133 @@ def set_managed_identity(self, client_id=None): } ) + def set_azure_cli(self, tenant_id=None): + """Configure Azure CLI as the authentication source. + + If tenant_id is not provided, auto-captures the current tenant + from Azure CLI's active session via 'az account show'. + Always forces a fresh query (bypasses cache) since this is a login action. + """ + # Clear token cache on every login to prevent stale tokens from a + # previous tenant (or no-tenant) session from being reused. + self._azure_cli_token_cache.clear() + # Set tenant first — set_tenant() may call logout() which clears auth info + if tenant_id: + self.set_tenant(tenant_id) + else: + # Force refresh at login to avoid stale cached tenant + captured_tenant = self._get_azure_cli_tenant(force_refresh=True) + if captured_tenant: + self.set_tenant(captured_tenant) + # Set identity_type after tenant to survive any logout triggered by tenant change + self._set_auth_properties( + { + con.IDENTITY_TYPE: "azure_cli", + } + ) + + def _get_azure_cli_tenant(self, force_refresh: bool = False) -> Optional[str]: + """Query Azure CLI for the current tenant ID via 'az account show'. + + Caches the result to avoid repeated subprocess calls + during multi-scope token acquisition flows. + + Args: + force_refresh: If True, bypass the cache and query az directly. + """ + # Return cached result if fresh and not forced + if ( + not force_refresh + and self._cached_az_tenant is not None + and time.monotonic() - self._cached_az_tenant_time + < _AZURE_CLI_TENANT_CACHE_TTL_SECONDS + ): + return self._cached_az_tenant + + try: + az_path = shutil.which("az") + if not az_path: + return None + result = subprocess.run( + [az_path, "account", "show", "--query", "tenantId", "-o", "tsv"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + self._cached_az_tenant = result.stdout.strip() + self._cached_az_tenant_time = time.monotonic() + return self._cached_az_tenant + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + pass + return None + + def _acquire_token_from_azure_cli(self, scope: list[str]) -> dict: + """Acquire a token using Azure CLI's AzureCliCredential.""" + # Tenant drift check: compare stored tenant against current az session + stored_tenant = self.get_tenant_id() + if stored_tenant: + current_tenant = self._get_azure_cli_tenant() + if current_tenant and current_tenant != stored_tenant: + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_tenant_mismatch( + stored_tenant, current_tenant + ), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + # Check in-memory cache first + cache_key = scope[0] if scope else "" + cached = self._get_cached_azure_cli_token(cache_key) + if cached: + return cached + + try: + credential = ( + AzureCliCredential(tenant_id=stored_tenant) + if stored_tenant + else AzureCliCredential() + ) + # AzureCliCredential.get_token expects scopes as positional args + azure_token = credential.get_token(scope[0]) + token_result = { + "access_token": azure_token.token, + "expires_on": azure_token.expires_on, + } + # Cache the token + self._cache_azure_cli_token(cache_key, token_result) + return token_result + except CredentialUnavailableError: + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_not_available(), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + except Exception as e: + # Allowlist: SDK exceptions are pre-sanitized by azure-identity; unknown exceptions get a safe generic message + if type(e).__name__ in ("ClientAuthenticationError", "HttpResponseError"): + error_msg = str(e) + else: + error_msg = ErrorMessages.Auth.azure_cli_token_acquisition_failed() + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_auth_failed(error_msg), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + def _get_cached_azure_cli_token(self, cache_key: str) -> Optional[dict]: + """Return cached token if it exists and is not near expiry.""" + cached = self._azure_cli_token_cache.get(cache_key) + if ( + cached + and cached.get("expires_on", 0) + > time.time() + _AZURE_CLI_TOKEN_REFRESH_BUFFER_SECONDS + ): + return cached + return None + + def _cache_azure_cli_token(self, cache_key: str, token: dict) -> None: + """Cache a token by audience key.""" + self._azure_cli_token_cache[cache_key] = token + def print_auth_info(self): utils_ui.print_grey(json.dumps(self._get_auth_info(), indent=2)) @@ -480,6 +619,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, @@ -546,6 +687,11 @@ def logout(self): self.app = None + # Clear Azure CLI caches + self._azure_cli_token_cache.clear() + self._cached_az_tenant = None + self._cached_az_tenant_time = 0.0 + if os.path.exists(self.cache_file): os.remove(self.cache_file) diff --git a/src/fabric_cli/core/fab_constant.py b/src/fabric_cli/core/fab_constant.py index 1ad538113..d19811500 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -62,7 +62,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/errors/auth.py b/src/fabric_cli/errors/auth.py index e068b7d44..38dc44fe4 100644 --- a/src/fabric_cli/errors/auth.py +++ b/src/fabric_cli/errors/auth.py @@ -119,3 +119,29 @@ def cert_read_failed(error: str) -> str: @staticmethod def only_supported_with_user_authentication() -> str: return "This operation is only supported with user authentication" + + @staticmethod + def azure_cli_tenant_mismatch(stored_tenant: str, current_tenant: str) -> str: + return ( + f"Tenant mismatch: Fabric CLI is pinned to tenant '{stored_tenant}' " + f"but Azure CLI is now logged into tenant '{current_tenant}'. " + "Run 'fab auth login --azure-cli' to re-authenticate." + ) + + @staticmethod + def azure_cli_not_available() -> str: + return ( + "Azure CLI is not installed or not logged in. " + "Run 'az login' to authenticate, then retry." + ) + + @staticmethod + def azure_cli_auth_failed(error_msg: str) -> str: + return f"Azure CLI authentication failed: {error_msg}" + + @staticmethod + def azure_cli_token_acquisition_failed() -> str: + return ( + "Azure CLI token acquisition failed. " + "Run 'az account get-access-token' manually to diagnose." + ) diff --git a/src/fabric_cli/parsers/fab_auth_parser.py b/src/fabric_cli/parsers/fab_auth_parser.py index a908e09e5..2fc0a75f0 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,9 +88,16 @@ 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="Azure CLI authentication, must have an existing 'az login' session. Optional, only for Azure CLI auth", + ) login_parser.usage = f"{utils_error_parser.get_usage_prog(login_parser)}" - login_parser.set_defaults(func=lazy_command(_auth_module_path, 'init')) + login_parser.set_defaults(func=lazy_command(_auth_module_path, "init")) # Subcommand for 'logout' logout_examples = [ @@ -104,7 +115,7 @@ def register_parser(subparsers: _SubParsersAction) -> None: ) logout_parser.usage = f"{utils_error_parser.get_usage_prog(logout_parser)}" - logout_parser.set_defaults(func=lazy_command(_auth_module_path, 'logout')) + logout_parser.set_defaults(func=lazy_command(_auth_module_path, "logout")) # Subcommand for 'status' status_examples = [ @@ -121,7 +132,7 @@ def register_parser(subparsers: _SubParsersAction) -> None: ) status_parser.usage = f"{utils_error_parser.get_usage_prog(status_parser)}" - status_parser.set_defaults(func=lazy_command(_auth_module_path, 'status')) + status_parser.set_defaults(func=lazy_command(_auth_module_path, "status")) def show_help(args: Namespace) -> None: diff --git a/tests/test_commands/test_auth.py b/tests/test_commands/test_auth.py index 0de502188..7cca5e5a9 100644 --- a/tests/test_commands/test_auth.py +++ b/tests/test_commands/test_auth.py @@ -949,6 +949,71 @@ def test_init_when_user_cancels_the_prompt( assert_prompt_cancelled(capsys) +class TestAuthAzureCli: + """Command-level tests for Azure CLI auth paths.""" + + def test_init_with_azure_cli_flag(self, mock_fab_auth, mock_fab_context): + """fab auth login --azure-cli should set azure_cli mode.""" + args = prepare_auth_args({"azure_cli": True}) + mock_set_azure_cli = MagicMock() + + with patch.object( + mock_fab_auth["instance"], "set_azure_cli", mock_set_azure_cli + ): + result = fab_auth.init(args) + + mock_fab_auth_instance = mock_fab_auth.get("instance") + mock_fab_auth_instance.set_access_mode.assert_called_with( + "azure_cli", None + ) + mock_set_azure_cli.assert_called_once_with(None) + assert result is True + + def test_init_with_azure_cli_flag_and_tenant( + self, mock_fab_auth, mock_fab_context + ): + """fab auth login --azure-cli --tenant should pass tenant.""" + args = prepare_auth_args({"azure_cli": True, "tenant": "my-tenant"}) + mock_set_azure_cli = MagicMock() + + with patch.object( + mock_fab_auth["instance"], "set_azure_cli", mock_set_azure_cli + ): + result = fab_auth.init(args) + + mock_fab_auth_instance = mock_fab_auth.get("instance") + mock_fab_auth_instance.set_access_mode.assert_called_with( + "azure_cli", "my-tenant" + ) + mock_set_azure_cli.assert_called_once_with("my-tenant") + assert result is True + + def test_init_with_interactive_azure_cli_selection( + self, mock_fab_auth, mock_fab_context + ): + """Interactive menu Azure CLI selection should set azure_cli mode.""" + mock_set_azure_cli = MagicMock() + + with patch( + "fabric_cli.utils.fab_ui.prompt_select_item", + return_value="Azure CLI (existing 'az login' session)", + ): + with patch.object( + mock_fab_auth["instance"], "set_azure_cli", mock_set_azure_cli + ): + args = prepare_auth_args() + result = fab_auth.init(args) + + mock_fab_auth_instance = mock_fab_auth.get("instance") + mock_fab_auth_instance.set_access_mode.assert_called_with( + "azure_cli", None + ) + mock_set_azure_cli.assert_called_once_with(None) + assert_get_access_token(mock_fab_auth_instance) + assert_fab_context(mock_fab_context) + assert result is True + + # Helpers @@ -971,6 +1036,7 @@ def prepare_auth_args(args=None): "identity", "certificate", "federated_token", + "azure_cli", ] } ) 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..d270ff805 --- /dev/null +++ b/tests/test_core/test_fab_auth_azure_cli.py @@ -0,0 +1,630 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import subprocess +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 +from fabric_cli.errors import ErrorMessages + + +@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) + ) + # Ensure shutil.which("az") resolves in tests (Windows uses az.cmd) + monkeypatch.setattr( + "shutil.which", + lambda cmd: f"/usr/bin/{cmd}" if cmd == "az" else None, + ) + # Clear env vars that would interfere with auth + for var in ( + "FAB_TOKEN", + "FAB_TOKEN_ONELAKE", + "FAB_TOKEN_AZURE", + "FAB_TENANT_ID", + "FAB_SPN_CLIENT_ID", + "FAB_SPN_CLIENT_SECRET", + "FAB_SPN_CERT_PATH", + "FAB_MANAGED_IDENTITY", + ): + monkeypatch.delenv(var, raising=False) + # Clear singleton caches between tests + auth = FabAuth() + auth._azure_cli_token_cache.clear() + auth._cached_az_tenant = None + auth._cached_az_tenant_time = 0.0 + auth._auth_info = {} + auth.app = None + # Update file paths to use the test's tmp_path + monkeypatch.setattr(auth, "auth_file", str(tmp_path / "auth.json")) + monkeypatch.setattr(auth, "cache_file", str(tmp_path / "cache.bin")) + return str(tmp_path) + + +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(tenant_id="test-tenant") + 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" + + @patch("subprocess.run") + def test_set_azure_cli_auto_captures_tenant( + self, mock_run, temp_dir_fixture + ): + """set_azure_cli without tenant_id should auto-capture from az account show.""" + mock_run.return_value = MagicMock( + returncode=0, stdout="auto-captured-tenant-id\n" + ) + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_tenant_id() == "auto-captured-tenant-id" + mock_run.assert_called_once_with( + ["/usr/bin/az", "account", "show", "--query", "tenantId", "-o", "tsv"], + capture_output=True, + text=True, + timeout=10, + ) + + @patch("subprocess.run") + def test_set_azure_cli_explicit_tenant_overrides_auto( + self, mock_run, temp_dir_fixture + ): + """Explicit tenant_id should be used even if az has a different one.""" + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli(tenant_id="explicit-tenant") + assert auth.get_tenant_id() == "explicit-tenant" + mock_run.assert_not_called() + + +class TestAzureCliTokenAcquisition: + """Test token acquisition via AzureCliCredential.""" + + @patch("fabric_cli.core.fab_auth.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") + # Clear cache for clean test + auth._azure_cli_token_cache.clear() + + 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("fabric_cli.core.fab_auth.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") + auth._azure_cli_token_cache.clear() + + 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("fabric_cli.core.fab_auth.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._azure_cli_token_cache.clear() + + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + mock_credential_class.assert_called_once_with(tenant_id="my-tenant-id") + + @patch("fabric_cli.core.fab_auth.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 fabric_cli.core.fab_auth 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") + auth._azure_cli_token_cache.clear() + + with pytest.raises(FabricCLIError) as exc_info: + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + assert ErrorMessages.Auth.azure_cli_not_available() in str(exc_info.value) + assert exc_info.value.status_code == con.ERROR_AUTHENTICATION_FAILED + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_sdk_exception_surfaces_message( + self, mock_credential_class, temp_dir_fixture + ): + """SDK exceptions (pre-sanitized by azure-identity) surface their message.""" + mock_credential = MagicMock() + error = type("ClientAuthenticationError", (Exception,), {})("Tenant not found") + mock_credential.get_token.side_effect = error + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth._azure_cli_token_cache.clear() + + with pytest.raises(FabricCLIError) as exc_info: + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + assert "Tenant not found" in str(exc_info.value) + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_unknown_exception_returns_safe_message( + self, mock_credential_class, temp_dir_fixture + ): + """Non-SDK exceptions should always return a safe generic message.""" + mock_credential = MagicMock() + mock_credential.get_token.side_effect = RuntimeError( + "accessToken: eyJ0eXAi..." + ) + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth._azure_cli_token_cache.clear() + + with pytest.raises(FabricCLIError) as exc_info: + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + assert "eyJ0eXAi" not in str(exc_info.value) + assert "manually to diagnose" in str(exc_info.value) + + +class TestAzureCliTenantDrift: + """Test tenant drift detection during token acquisition.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + @patch("subprocess.run") + def test_tenant_drift_blocks_token_acquisition( + self, mock_run, mock_credential_class, temp_dir_fixture + ): + """Should block when stored tenant differs from current az session.""" + mock_run.return_value = MagicMock( + returncode=0, stdout="different-tenant\n" + ) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli(tenant_id="original-tenant") + auth._azure_cli_token_cache.clear() + + with pytest.raises(FabricCLIError) as exc_info: + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + expected_msg = ErrorMessages.Auth.azure_cli_tenant_mismatch( + "original-tenant", "different-tenant" + ) + assert expected_msg in str(exc_info.value) + mock_credential_class.assert_not_called() + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + @patch("subprocess.run") + def test_tenant_match_allows_token_acquisition( + self, mock_run, mock_credential_class, temp_dir_fixture + ): + """Should allow when stored tenant matches current az session.""" + mock_run.return_value = MagicMock( + returncode=0, stdout="same-tenant\n" + ) + mock_token = MagicMock() + mock_token.token = "valid-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="same-tenant") + auth._azure_cli_token_cache.clear() + + result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert result["access_token"] == "valid-token" + + +class TestAzureCliTokenCache: + """Test in-memory token caching for Azure CLI tokens.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_cached_token_avoids_repeated_credential_calls( + self, mock_credential_class, temp_dir_fixture + ): + """Second call with same scope should use cache, not call get_token again.""" + mock_token = MagicMock() + mock_token.token = "cached-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._azure_cli_token_cache.clear() + + result1 = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + result2 = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + assert result1["access_token"] == "cached-token" + assert result2["access_token"] == "cached-token" + # get_token should only be called once (second call uses cache) + mock_credential.get_token.assert_called_once() + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_expired_cache_triggers_refresh( + self, mock_credential_class, temp_dir_fixture + ): + """Expired cached token should trigger a new credential token request.""" + mock_token = MagicMock() + mock_token.token = "fresh-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") + # Pre-populate cache with expired token + auth._azure_cli_token_cache.clear() + auth._azure_cli_token_cache[con.SCOPE_FABRIC_DEFAULT[0]] = { + "access_token": "old-token", + "expires_on": int(time.time()) - 10, # already expired + } + + result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert result["access_token"] == "fresh-token" + mock_credential.get_token.assert_called_once() + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_near_expiry_within_buffer_triggers_refresh( + self, mock_credential_class, temp_dir_fixture + ): + """Token valid but expiring within 60s buffer should trigger refresh.""" + mock_token = MagicMock() + mock_token.token = "refreshed-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._azure_cli_token_cache.clear() + # Token expires in 30s — still valid but within 60s buffer + auth._azure_cli_token_cache[con.SCOPE_FABRIC_DEFAULT[0]] = { + "access_token": "almost-expired-token", + "expires_on": int(time.time()) + 30, + } + + result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert result["access_token"] == "refreshed-token" + mock_credential.get_token.assert_called_once() + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_different_scopes_cached_separately( + self, mock_credential_class, temp_dir_fixture + ): + """Different scopes should have separate cache entries.""" + call_count = 0 + + def make_token(*args): + nonlocal call_count + call_count += 1 + token = MagicMock() + token.token = f"token-{call_count}" + token.expires_on = int(time.time()) + 3600 + return token + + mock_credential = MagicMock() + mock_credential.get_token.side_effect = make_token + mock_credential_class.return_value = mock_credential + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth._azure_cli_token_cache.clear() + + r1 = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + r2 = auth._acquire_token_from_azure_cli(con.SCOPE_ONELAKE_DEFAULT) + + assert r1["access_token"] == "token-1" + assert r2["access_token"] == "token-2" + assert mock_credential.get_token.call_count == 2 + + +class TestAzureCliScopeHandling: + """Test that different scopes are correctly passed to Azure CLI.""" + + @patch("fabric_cli.core.fab_auth.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._azure_cli_token_cache.clear() + + auth._acquire_token_from_azure_cli(con.SCOPE_ONELAKE_DEFAULT) + + mock_credential.get_token.assert_called_once_with( + "https://storage.azure.com/.default" + ) + + @patch("fabric_cli.core.fab_auth.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._azure_cli_token_cache.clear() + + auth._acquire_token_from_azure_cli(con.SCOPE_AZURE_DEFAULT) + + mock_credential.get_token.assert_called_once_with( + "https://management.azure.com/.default" + ) + + +class TestAzureCliCacheInvalidation: + """Test cache invalidation on logout and forced refresh at login.""" + + @patch("subprocess.run") + def test_logout_clears_tenant_cache(self, mock_run, temp_dir_fixture): + """logout() should clear the cached tenant.""" + mock_run.return_value = MagicMock( + returncode=0, stdout="cached-tenant\n" + ) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth._cached_az_tenant == "cached-tenant" + + auth.logout() + assert auth._cached_az_tenant is None + assert auth._cached_az_tenant_time == 0.0 + assert auth._azure_cli_token_cache == {} + + @patch("subprocess.run") + def test_login_forces_fresh_tenant_query(self, mock_run, temp_dir_fixture): + """set_azure_cli should bypass cache and query az fresh.""" + # First call returns tenant-A + mock_run.return_value = MagicMock( + returncode=0, stdout="tenant-A\n" + ) + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_tenant_id() == "tenant-A" + + # Simulate user switching az tenant, then re-logging in + mock_run.return_value = MagicMock( + returncode=0, stdout="tenant-B\n" + ) + auth.set_access_mode("azure_cli") + auth.set_azure_cli() # Should force refresh, get tenant-B + assert auth.get_tenant_id() == "tenant-B" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + @patch("subprocess.run") + def test_single_subprocess_across_three_login_scopes( + self, mock_run, mock_credential_class, temp_dir_fixture + ): + """Login should call az account show only once across 3 scope validations.""" + mock_run.return_value = MagicMock( + returncode=0, stdout="login-tenant\n" + ) + + mock_token = MagicMock() + mock_token.token = "login-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() # 1 subprocess call (force_refresh) + + # 3 scope validations — each calls drift check, but cache should hit + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + auth._acquire_token_from_azure_cli(con.SCOPE_ONELAKE_DEFAULT) + auth._acquire_token_from_azure_cli(con.SCOPE_AZURE_DEFAULT) + + # az account show called once at login, cached for drift checks + assert mock_run.call_count == 1 + + @patch("subprocess.run") + def test_identity_type_preserved_after_tenant_change( + self, mock_run, temp_dir_fixture + ): + """identity_type should remain azure_cli after tenant changes.""" + mock_run.return_value = MagicMock( + returncode=0, stdout="tenant-A\n" + ) + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_identity_type() == "azure_cli" + + # Re-login with different tenant + mock_run.return_value = MagicMock( + returncode=0, stdout="tenant-B\n" + ) + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_identity_type() == "azure_cli" + assert auth.get_tenant_id() == "tenant-B" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_login_clears_token_cache_from_no_tenant_state( + self, mock_credential_class, temp_dir_fixture + ): + """set_azure_cli should clear token cache even when transitioning from no tenant.""" + mock_token = MagicMock() + mock_token.token = "stale-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") + # Acquire a token with no tenant stored + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert auth._azure_cli_token_cache.get(con.SCOPE_FABRIC_DEFAULT[0]) is not None + + # Login with explicit tenant — cache must be cleared + auth.set_azure_cli(tenant_id="new-tenant") + assert auth._azure_cli_token_cache.get(con.SCOPE_FABRIC_DEFAULT[0]) is None + + +class TestAzureCliTenantDiscoveryFailures: + """Test _get_azure_cli_tenant failure paths.""" + + @patch("subprocess.run") + def test_nonzero_return_code_returns_none(self, mock_run, temp_dir_fixture): + """Should return None when az account show fails.""" + mock_run.return_value = MagicMock(returncode=1, stdout="") + auth = FabAuth() + auth._cached_az_tenant = None + auth._cached_az_tenant_time = 0.0 + assert auth._get_azure_cli_tenant(force_refresh=True) is None + + @patch("subprocess.run") + def test_empty_stdout_returns_none(self, mock_run, temp_dir_fixture): + """Should return None when az returns empty stdout.""" + mock_run.return_value = MagicMock(returncode=0, stdout=" \n") + auth = FabAuth() + auth._cached_az_tenant = None + auth._cached_az_tenant_time = 0.0 + assert auth._get_azure_cli_tenant(force_refresh=True) is None + + @patch("subprocess.run", side_effect=subprocess.TimeoutExpired("az", 10)) + def test_timeout_returns_none(self, mock_run, temp_dir_fixture): + """Should return None on subprocess timeout.""" + auth = FabAuth() + auth._cached_az_tenant = None + auth._cached_az_tenant_time = 0.0 + assert auth._get_azure_cli_tenant(force_refresh=True) is None + + def test_az_not_installed_returns_none(self, monkeypatch, temp_dir_fixture): + """Should return None when az CLI is not installed.""" + monkeypatch.setattr("shutil.which", lambda cmd: None) + auth = FabAuth() + auth._cached_az_tenant = None + auth._cached_az_tenant_time = 0.0 + assert auth._get_azure_cli_tenant(force_refresh=True) is None + + @patch("subprocess.run") + def test_cache_hit_before_ttl_expiry(self, mock_run, temp_dir_fixture): + """Should return cached tenant without calling subprocess.""" + auth = FabAuth() + auth._cached_az_tenant = "cached-tenant" + auth._cached_az_tenant_time = time.monotonic() # Just cached now + result = auth._get_azure_cli_tenant() + assert result == "cached-tenant" + mock_run.assert_not_called() + + @patch("subprocess.run") + def test_cache_miss_after_ttl_expiry(self, mock_run, temp_dir_fixture): + """Should call subprocess after TTL expires.""" + mock_run.return_value = MagicMock(returncode=0, stdout="new-tenant\n") + auth = FabAuth() + auth._cached_az_tenant = "old-tenant" + auth._cached_az_tenant_time = time.monotonic() - 30 + result = auth._get_azure_cli_tenant() + assert result == "new-tenant" + mock_run.assert_called_once() diff --git a/tests/test_core/test_fab_msal_bridge_azure_cli.py b/tests/test_core/test_fab_msal_bridge_azure_cli.py new file mode 100644 index 000000000..adc676619 --- /dev/null +++ b/tests/test_core/test_fab_msal_bridge_azure_cli.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for the MSAL bridge with Azure CLI identity type.""" + +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_msal_bridge import MsalTokenCredential + + +@pytest.fixture(autouse=True) +def temp_dir_fixture(monkeypatch, tmp_path): + """Isolate FabAuth singleton for bridge tests.""" + monkeypatch.setattr( + "fabric_cli.core.fab_state_config.config_location", lambda: str(tmp_path) + ) + monkeypatch.delenv("FAB_TOKEN", raising=False) + monkeypatch.delenv("FAB_TOKEN_ONELAKE", raising=False) + monkeypatch.delenv("FAB_TOKEN_AZURE", raising=False) + auth = FabAuth() + auth._azure_cli_token_cache.clear() + auth._cached_az_tenant = None + auth._cached_az_tenant_time = 0.0 + auth._auth_info = {} + + +class TestMsalBridgeAzureCli: + """Verify MsalTokenCredential works when identity_type is azure_cli.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_bridge_returns_access_token_for_azure_cli( + self, mock_credential_class + ): + """MsalTokenCredential.get_token should return an AccessToken via Azure CLI.""" + mock_token = MagicMock() + mock_token.token = "bridge-azure-cli-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") + + credential = MsalTokenCredential(auth) + result = credential.get_token(con.SCOPE_FABRIC_DEFAULT[0]) + + assert result.token == "bridge-azure-cli-token" + assert result.expires_on == mock_token.expires_on + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_bridge_rejects_invalid_scope(self, mock_credential_class): + """MsalTokenCredential should reject scopes not in the allowlist.""" + from azure.core.exceptions import ClientAuthenticationError + + auth = FabAuth() + auth.set_access_mode("azure_cli") + + credential = MsalTokenCredential(auth) + with pytest.raises(ClientAuthenticationError): + credential.get_token("https://evil.example.com/.default") diff --git a/tests/test_parsers/test_fab_auth_parser.py b/tests/test_parsers/test_fab_auth_parser.py new file mode 100644 index 000000000..3f6a4e9de --- /dev/null +++ b/tests/test_parsers/test_fab_auth_parser.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for the auth parser module — verifies argparse flag mapping.""" + +import argparse + +from fabric_cli.core.fab_parser_setup import CustomArgumentParser +from fabric_cli.parsers import fab_auth_parser + + +def _build_auth_parser(): + """Build a parser with auth subcommands registered.""" + parser = CustomArgumentParser() + subparsers = parser.add_subparsers(dest="command") + fab_auth_parser.register_parser(subparsers) + return parser + + +class TestAuthParserAzureCli: + """Verify --azure-cli flag is parsed correctly.""" + + def test_azure_cli_flag_sets_attribute(self): + """--azure-cli should map to args.azure_cli=True.""" + parser = _build_auth_parser() + args = parser.parse_args(["auth", "login", "--azure-cli"]) + assert args.azure_cli is True + + def test_azure_cli_flag_with_tenant(self): + """--azure-cli --tenant should set both attributes.""" + parser = _build_auth_parser() + args = parser.parse_args( + ["auth", "login", "--azure-cli", "--tenant", "my-tenant-id"] + ) + assert args.azure_cli is True + assert args.tenant == "my-tenant-id" + + def test_azure_cli_flag_absent_defaults_false(self): + """Without --azure-cli, azure_cli should be falsy.""" + parser = _build_auth_parser() + args = parser.parse_args(["auth", "login"]) + assert not args.azure_cli + + def test_tenant_flag_without_azure_cli(self): + """--tenant alone should work (used by other auth modes).""" + parser = _build_auth_parser() + args = parser.parse_args(["auth", "login", "--tenant", "some-tenant"]) + assert args.tenant == "some-tenant" + assert not args.azure_cli