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..325df41cb 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.25.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..17aff5ce0 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(tenant_id=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(tenant_id=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: @@ -218,51 +226,65 @@ def logout(args: Namespace) -> None: def status(args: Namespace) -> None: auth = FabAuth() tenant_id = auth.get_tenant_id() + identity_type = auth.get_identity_type() or "N/A" + + # Suppress noisy Azure SDK stderr logging during status checks + # (AzureCliCredential logs "Please run 'az login'" before raising) + import logging + + azure_logger = logging.getLogger("azure.identity") + original_level = azure_logger.level + azure_logger.setLevel(logging.CRITICAL) + + try: + + def __get_token_info(scope): + try: + token = auth.get_access_token(scope, interactive_renew=False) + except FabricCLIError as e: + if e.status_code in [ + fab_constant.ERROR_UNAUTHORIZED, + fab_constant.ERROR_AUTHENTICATION_FAILED, + ]: + return {} + else: + raise e + if isinstance(token, str): + token = token.encode() # Ensure bytes type + return _get_token_info_from_bearer_token(token) if token else {} + + token_info = __get_token_info(fab_constant.SCOPE_FABRIC_DEFAULT) + + upn = token_info.get("upn") or "N/A" + oid = token_info.get("oid") or "N/A" + tid = token_info.get("tid", tenant_id) or "N/A" + appid = token_info.get("appid") or "N/A" + + def __mask_token(scope): + try: + token = auth.get_access_token(scope, interactive_renew=False) + except FabricCLIError as e: + if e.status_code in [ + fab_constant.ERROR_UNAUTHORIZED, + fab_constant.ERROR_AUTHENTICATION_FAILED, + ]: + return "N/A" + else: + raise e + if isinstance(token, str): + token = token.encode() # Ensure bytes type + return ( + token[:4].decode() + "************************************" + if token + else "N/A" + ) - def __get_token_info(scope): - try: - token = auth.get_access_token(scope, interactive_renew=False) - except FabricCLIError as e: - if e.status_code in [ - fab_constant.ERROR_UNAUTHORIZED, - fab_constant.ERROR_AUTHENTICATION_FAILED, - ]: - return {} - else: - raise e - if isinstance(token, str): - token = token.encode() # Ensure bytes type - return _get_token_info_from_bearer_token(token) if token else {} - - token_info = __get_token_info(fab_constant.SCOPE_FABRIC_DEFAULT) - - upn = token_info.get("upn") or "N/A" - oid = token_info.get("oid") or "N/A" - tid = token_info.get("tid", tenant_id) or "N/A" - appid = token_info.get("appid") or "N/A" - - def __mask_token(scope): - try: - token = auth.get_access_token(scope, interactive_renew=False) - except FabricCLIError as e: - if e.status_code in [ - fab_constant.ERROR_UNAUTHORIZED, - fab_constant.ERROR_AUTHENTICATION_FAILED, - ]: - return "N/A" - else: - raise e - if isinstance(token, str): - token = token.encode() # Ensure bytes type - return ( - token[:4].decode() + "************************************" - if token - else "N/A" - ) + fabric_secret = __mask_token(fab_constant.SCOPE_FABRIC_DEFAULT) + storage_secret = __mask_token(fab_constant.SCOPE_ONELAKE_DEFAULT) + azure_secret = __mask_token(fab_constant.SCOPE_AZURE_DEFAULT) - fabric_secret = __mask_token(fab_constant.SCOPE_FABRIC_DEFAULT) - storage_secret = __mask_token(fab_constant.SCOPE_ONELAKE_DEFAULT) - azure_secret = __mask_token(fab_constant.SCOPE_AZURE_DEFAULT) + finally: + azure_logger.setLevel(original_level) # Check login status is_logged_in = fabric_secret != "N/A" @@ -272,9 +294,17 @@ def __mask_token(scope): 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})") + elif identity_type == "azure_cli" and not is_logged_in: + fab_ui.print_grey( + " Azure CLI session expired or logged out. " + "Run 'az login' then 'fab auth login --azure-cli' to re-authenticate." + ) auth_data = { "logged_in": is_logged_in, + "auth_source": identity_type, "account": upn, "principal_id": oid, "tenant_id": tid, @@ -291,3 +321,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..6d34a285f 100644 --- a/src/fabric_cli/core/fab_auth.py +++ b/src/fabric_cli/core/fab_auth.py @@ -3,6 +3,8 @@ import json import os +import base64 +import binascii import uuid from binascii import hexlify from typing import Any, NamedTuple, Optional @@ -10,6 +12,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 @@ -50,6 +53,8 @@ def __init__(self): # Reset the auth info self.app: msal.ClientApplication = None self._auth_info = {} + # Singleton AzureCliCredential instance (like self.app for MSAL) + self._azure_cli_credential: Optional[AzureCliCredential] = None # Load the auth info and environment variables self._load_auth() @@ -418,6 +423,171 @@ def set_managed_identity(self, client_id=None): } ) + def set_azure_cli(self, tenant_id=None): + """Configure Azure CLI as the authentication source. + + Acquires a probe token from Azure CLI to discover and store + the tenant ID and principal OID from the actual JWT claims. + Fabric CLI strictly inherits the Azure CLI auth context. + If tenant_id is provided, it is validated against the Azure CLI + context — a mismatch raises an error directing the user to + switch tenants via 'az login --tenant'. + """ + # Clear credential to force recreation + self._azure_cli_credential = None + + # Acquire a probe token to discover identity from JWT claims + try: + probe_credential = AzureCliCredential() + probe_token = probe_credential.get_token(con.SCOPE_FABRIC_DEFAULT[0]) + claims = self._decode_jwt_claims(probe_token.token) + except CredentialUnavailableError: + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_not_available(), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + # Fail-closed: refuse to persist if identity claims are missing + if ( + not claims.get("iss") + or not claims.get("tid") + or not claims.get("oid") + ): + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_token_missing_claims(), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + # Validate explicit tenant against Azure CLI context + if tenant_id and tenant_id != claims["tid"]: + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_tenant_override_mismatch( + tenant_id, claims["tid"] + ), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + # Tenant is always inherited from Azure CLI — no override + self.set_tenant(claims["tid"]) + + # Set identity_type after tenant to survive any logout triggered by tenant change + auth_props: dict = {con.IDENTITY_TYPE: "azure_cli"} + # Store OID and issuer host for drift detection (immutable, no PII) + auth_props[con.FAB_AZURE_CLI_PRINCIPAL_ID] = claims["oid"] + from urllib.parse import urlparse + + auth_props[con.FAB_AZURE_CLI_ISSUER] = urlparse(claims["iss"]).hostname + self._set_auth_properties(auth_props) + + @staticmethod + def _decode_jwt_claims(token: str) -> dict: + """Decode JWT payload claims without signature validation. + + Used to extract identity claims (iss, tid, oid) from tokens + returned by AzureCliCredential. Signature validation is + unnecessary here — the token was just returned by the + Azure CLI SDK over a local subprocess call. + """ + try: + parts = token.split(".") + if len(parts) < 2: + return {} + # Add padding for base64url decoding (avoid adding 4 when already aligned) + payload = parts[1] + payload += "=" * ((-len(payload)) % 4) + decoded = base64.urlsafe_b64decode(payload) + return json.loads(decoded) + except (ValueError, json.JSONDecodeError, UnicodeDecodeError, binascii.Error): + return {} + + def _acquire_token_from_azure_cli(self, scope: list[str]) -> dict: + """Acquire a token using Azure CLI's AzureCliCredential. + + After acquiring the token, decodes JWT claims and verifies + that iss, tid, and oid match the stored values from login to detect + identity or environment drift. + """ + stored_tenant = self.get_tenant_id() + + try: + # Create singleton credential — no tenant pinning, inherit Azure CLI context + if self._azure_cli_credential is None: + self._azure_cli_credential = AzureCliCredential() + # AzureCliCredential.get_token expects scopes as positional args + azure_token = self._azure_cli_credential.get_token(scope[0]) + + # Post-acquisition drift detection from actual token claims + claims = self._decode_jwt_claims(azure_token.token) + + # Fail-closed: reject tokens with missing identity claims + if ( + not claims.get("iss") + or not claims.get("tid") + or not claims.get("oid") + ): + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_token_missing_claims(), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + # Tenant drift check (most common drift scenario) + if stored_tenant and claims["tid"] != stored_tenant: + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_tenant_mismatch( + stored_tenant, claims["tid"] + ), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + # Environment drift check (issuer host encodes cloud: public vs sovereign) + # Stored value is already a hostname; extract host from current token's iss + stored_issuer_host = self._auth_info.get(con.FAB_AZURE_CLI_ISSUER) + if stored_issuer_host: + from urllib.parse import urlparse + + current_host = urlparse(claims["iss"]).hostname + if stored_issuer_host != current_host: + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_environment_mismatch(), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + # Principal drift check (OID-based) + stored_principal = self._auth_info.get(con.FAB_AZURE_CLI_PRINCIPAL_ID) + if stored_principal and claims["oid"] != stored_principal: + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_principal_mismatch(), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + + token_result = { + "access_token": azure_token.token, + "expires_on": azure_token.expires_on, + } + return token_result + except CredentialUnavailableError: + raise FabricCLIError( + ErrorMessages.Auth.azure_cli_not_available(), + status_code=con.ERROR_AUTHENTICATION_FAILED, + ) + except FabricCLIError: + raise + 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", + "ServiceRequestError", + "ServiceResponseError", + ): + 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 print_auth_info(self): utils_ui.print_grey(json.dumps(self._get_auth_info(), indent=2)) @@ -480,6 +650,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 +718,9 @@ def logout(self): self.app = None + # Clear Azure CLI state + self._azure_cli_credential = None + 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..466f571d0 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -56,13 +56,15 @@ FAB_TENANT_ID = "fab_tenant_id" FAB_REFRESH_TOKEN = "fab_refresh_token" +FAB_AZURE_CLI_PRINCIPAL_ID = "fab_azure_cli_principal_id" +FAB_AZURE_CLI_ISSUER = "fab_azure_cli_issuer" IDENTITY_TYPE = "identity_type" FAB_AUTH_MODE = "fab_auth_mode" # Kept for backward compatibility FAB_AUTHORITY = "fab_authority" 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..13cb9ec31 100644 --- a/src/fabric_cli/errors/auth.py +++ b/src/fabric_cli/errors/auth.py @@ -119,3 +119,62 @@ 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_tenant_override_mismatch( + requested_tenant: str, cli_tenant: str + ) -> str: + return ( + f"Requested tenant '{requested_tenant}' does not match the Azure CLI " + f"session tenant '{cli_tenant}'. In Azure CLI auth mode, Fabric CLI " + "inherits the Azure CLI context. To switch tenants, run " + f"'az login --tenant {requested_tenant}' first, then retry." + ) + + @staticmethod + def azure_cli_environment_mismatch() -> str: + return ( + "Azure CLI cloud environment has changed since 'fab auth login --azure-cli' was run. " + "Run 'fab auth login --azure-cli' to re-authenticate in the current environment." + ) + + @staticmethod + def azure_cli_principal_mismatch() -> str: + return ( + "Azure CLI identity has changed since 'fab auth login --azure-cli' was run. " + "Run 'fab auth login --azure-cli' to re-authenticate with the current identity." + ) + + @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_missing_claims() -> str: + return ( + "Azure CLI returned a token with missing identity claims (iss, tid, or oid). " + "Run 'az account get-access-token --resource https://api.fabric.microsoft.com' " + "manually to diagnose." + ) + + @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..30be51710 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(tenant_id=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(tenant_id="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(tenant_id=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..e9295073c --- /dev/null +++ b/tests/test_core/test_fab_auth_azure_cli.py @@ -0,0 +1,746 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import base64 +import json as _json +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 + + +def _make_jwt(tid: str = "test-tenant", oid: str = "test-oid", + iss: str = "https://sts.windows.net/test-tenant/", **extra_claims) -> str: + """Create a fake JWT with specified claims (no signature validation needed).""" + header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + claims = {"tid": tid, "oid": oid, "iss": iss, **extra_claims} + payload = base64.urlsafe_b64encode(_json.dumps(claims).encode()).rstrip(b"=").decode() + return f"{header}.{payload}.fakesig" + + +def _mock_credential_with_jwt(mock_class, tid="test-tenant", oid="test-oid", + iss="https://sts.windows.net/test-tenant/", **extra): + """Set up a mock AzureCliCredential that returns a JWT with given claims.""" + token_str = _make_jwt(tid=tid, oid=oid, iss=iss, **extra) + mock_token = MagicMock() + mock_token.token = token_str + mock_token.expires_on = int(time.time()) + 3600 + mock_credential = MagicMock() + mock_credential.get_token.return_value = mock_token + mock_class.return_value = mock_credential + return mock_credential, mock_token + + +@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 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 state between tests + auth = FabAuth() + auth._azure_cli_credential = None + 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.""" + with patch("fabric_cli.core.fab_auth.AzureCliCredential") as mock_class: + _mock_credential_with_jwt(mock_class, tid="test-tenant") + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_identity_type() == "azure_cli" + + def test_set_azure_cli_stores_jwt_tenant(self, temp_dir_fixture): + """set_azure_cli should store the tenant from the JWT tid claim.""" + with patch("fabric_cli.core.fab_auth.AzureCliCredential") as mock_class: + _mock_credential_with_jwt(mock_class, tid="test-tenant-id") + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_tenant_id() == "test-tenant-id" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_set_azure_cli_auto_captures_tenant( + self, mock_credential_class, temp_dir_fixture + ): + """set_azure_cli without tenant_id should auto-capture from JWT claims.""" + _mock_credential_with_jwt(mock_credential_class, tid="auto-captured-tenant-id") + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_tenant_id() == "auto-captured-tenant-id" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_set_azure_cli_tenant_always_from_jwt( + self, mock_credential_class, temp_dir_fixture + ): + """Tenant is always inherited from the Azure CLI JWT, never overridden.""" + _mock_credential_with_jwt(mock_credential_class, tid="jwt-tenant") + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_tenant_id() == "jwt-tenant" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_set_azure_cli_matching_tenant_param_accepted( + self, mock_credential_class, temp_dir_fixture + ): + """Explicit tenant that matches Azure CLI context should succeed.""" + _mock_credential_with_jwt(mock_credential_class, tid="my-tenant") + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli(tenant_id="my-tenant") + assert auth.get_tenant_id() == "my-tenant" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_set_azure_cli_mismatched_tenant_param_rejected( + self, mock_credential_class, temp_dir_fixture + ): + """Explicit tenant that differs from Azure CLI context should error.""" + _mock_credential_with_jwt(mock_credential_class, tid="cli-tenant") + auth = FabAuth() + auth.set_access_mode("azure_cli") + with pytest.raises(FabricCLIError, match="does not match"): + auth.set_azure_cli(tenant_id="other-tenant") + + +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_credential, _ = _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth._azure_cli_credential = None + + result = auth.acquire_token(con.SCOPE_FABRIC_DEFAULT) + + assert "access_token" in result + mock_credential.get_token.assert_called_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_credential, mock_token = _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth._azure_cli_credential = None + + result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + assert result["access_token"] == mock_token.token + 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_credential_inherits_azure_cli_context( + self, mock_credential_class, temp_dir_fixture + ): + """Credential should be created without tenant_id — inherits Azure CLI context.""" + _mock_credential_with_jwt(mock_credential_class, tid="my-tenant-id") + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + auth._azure_cli_credential = None + + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + # All credential creations should be without tenant_id + for call in mock_credential_class.call_args_list: + assert call == ((), {}), f"Expected no tenant_id, got {call}" + + @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_credential = None + + 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_credential = None + + 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_credential = None + + 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 via JWT claims.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_tenant_drift_blocks_token_acquisition( + self, mock_credential_class, temp_dir_fixture + ): + """Should block when token tid differs from stored tenant.""" + # Login with original-tenant + _mock_credential_with_jwt(mock_credential_class, tid="original-tenant", oid="user1") + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + auth._azure_cli_credential = None + + # Now credential returns token for different-tenant + _mock_credential_with_jwt(mock_credential_class, tid="different-tenant", oid="user1") + + 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) + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_tenant_match_allows_token_acquisition( + self, mock_credential_class, temp_dir_fixture + ): + """Should allow when token tid matches stored tenant.""" + _mock_credential_with_jwt(mock_credential_class, tid="same-tenant", oid="user1") + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + auth._azure_cli_credential = None + + result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert "access_token" in result + + +class TestAzureCliEnvironmentDrift: + """Test cloud environment drift detection via JWT iss claim.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_environment_drift_blocks_token_acquisition( + self, mock_credential_class, temp_dir_fixture + ): + """Should block when token issuer differs from stored environment.""" + # Login in Azure Public + _mock_credential_with_jwt( + mock_credential_class, tid="t1", oid="u1", + iss="https://sts.windows.net/t1/" + ) + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + auth._azure_cli_credential = None + + # Now credential returns token from Azure Government + _mock_credential_with_jwt( + mock_credential_class, tid="t1", oid="u1", + iss="https://sts.microsoftonline.us/t1/" + ) + + with pytest.raises(FabricCLIError) as exc_info: + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + assert "environment has changed" in str(exc_info.value) + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_same_environment_allows_token_acquisition( + self, mock_credential_class, temp_dir_fixture + ): + """Should allow when token issuer matches stored environment.""" + _mock_credential_with_jwt( + mock_credential_class, tid="t1", oid="u1", + iss="https://sts.windows.net/t1/" + ) + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + auth._azure_cli_credential = None + + result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert "access_token" in result + + +class TestAzureCliPrincipalDrift: + """Test principal (identity) drift detection via JWT OID claims.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_principal_drift_blocks_token_acquisition( + self, mock_credential_class, temp_dir_fixture + ): + """Should block when token oid differs from stored principal.""" + # Login as alice (oid=alice-oid) + _mock_credential_with_jwt(mock_credential_class, tid="same-tenant", oid="alice-oid") + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + auth._azure_cli_credential = None + + # Now credential returns token for bob (oid=bob-oid, same tenant) + _mock_credential_with_jwt(mock_credential_class, tid="same-tenant", oid="bob-oid") + + with pytest.raises(FabricCLIError) as exc_info: + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + # Error message must NOT contain PII (no OIDs exposed) + assert "alice" not in str(exc_info.value) + assert "bob" not in str(exc_info.value) + assert "identity has changed" in str(exc_info.value) + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_principal_match_allows_token_acquisition( + self, mock_credential_class, temp_dir_fixture + ): + """Should allow when token oid matches stored principal.""" + _mock_credential_with_jwt(mock_credential_class, tid="same-tenant", oid="alice-oid") + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + auth._azure_cli_credential = None + + result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert "access_token" in result + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_no_stored_principal_skips_drift_check( + self, mock_credential_class, temp_dir_fixture + ): + """If no principal was stored at login, drift check is skipped.""" + _mock_credential_with_jwt(mock_credential_class, tid="same-tenant", oid="anyone-oid") + + auth = FabAuth() + auth.set_access_mode("azure_cli") + # Manually set identity without principal (simulate old auth file) + auth._set_auth_properties({con.IDENTITY_TYPE: "azure_cli"}) + auth.set_tenant("same-tenant") + auth._azure_cli_credential = None + + result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert "access_token" in result + + +class TestAzureCliSingletonCredential: + """Test singleton AzureCliCredential lifecycle.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_singleton_credential_reused_across_calls( + self, mock_credential_class, temp_dir_fixture + ): + """Repeated calls should reuse the same AzureCliCredential instance.""" + _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth._azure_cli_credential = None + + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + + # AzureCliCredential constructor called only once (singleton) + mock_credential_class.assert_called_once() + # get_token called twice (no in-memory cache) + assert mock_credential_class.return_value.get_token.call_count == 2 + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_different_scopes_use_same_credential( + self, mock_credential_class, temp_dir_fixture + ): + """Different scopes should use the same singleton credential instance.""" + _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth._azure_cli_credential = None + + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + auth._acquire_token_from_azure_cli(con.SCOPE_ONELAKE_DEFAULT) + + # Same credential instance for both scopes + mock_credential_class.assert_called_once() + assert mock_credential_class.return_value.get_token.call_count == 2 + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_login_clears_credential( + self, mock_credential_class, temp_dir_fixture + ): + """set_azure_cli should clear and recreate the credential instance.""" + _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + # Acquire a token — creates singleton credential + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert auth._azure_cli_credential is not None + + # Login with explicit tenant — credential must be cleared + auth.set_azure_cli() + assert auth._azure_cli_credential is None + + +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_credential, _ = _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth._azure_cli_credential = None + + 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_credential, _ = _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth._azure_cli_credential = None + + auth._acquire_token_from_azure_cli(con.SCOPE_AZURE_DEFAULT) + + mock_credential.get_token.assert_called_once_with( + "https://management.azure.com/.default" + ) + + +class TestAzureCliLoginLogoutLifecycle: + """Test login/logout lifecycle and credential management.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_logout_clears_credential(self, mock_credential_class, temp_dir_fixture): + """logout() should clear the credential instance.""" + _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth._azure_cli_credential is None # cleared after login probe + + # Acquire token to set credential + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert auth._azure_cli_credential is not None + + auth.logout() + assert auth._azure_cli_credential is None + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_login_discovers_tenant_from_jwt(self, mock_credential_class, temp_dir_fixture): + """set_azure_cli should discover tenant from probe token JWT claims.""" + _mock_credential_with_jwt(mock_credential_class, tid="discovered-tenant") + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_tenant_id() == "discovered-tenant" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_login_stores_oid_and_issuer_for_drift_detection(self, mock_credential_class, temp_dir_fixture): + """set_azure_cli should store OID and issuer from JWT for drift detection.""" + _mock_credential_with_jwt(mock_credential_class, tid="t1", oid="user-oid-123", + iss="https://sts.windows.net/t1/") + + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth._auth_info.get(con.FAB_AZURE_CLI_PRINCIPAL_ID) == "user-oid-123" + assert auth._auth_info.get(con.FAB_AZURE_CLI_ISSUER) == "sts.windows.net" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_re_login_updates_tenant_and_oid(self, mock_credential_class, temp_dir_fixture): + """Re-login should update tenant and OID from new probe token.""" + _mock_credential_with_jwt(mock_credential_class, tid="tenant-A", oid="oid-A") + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_tenant_id() == "tenant-A" + + # Re-login with different identity + _mock_credential_with_jwt(mock_credential_class, tid="tenant-B", oid="oid-B") + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_tenant_id() == "tenant-B" + assert auth._auth_info.get(con.FAB_AZURE_CLI_PRINCIPAL_ID) == "oid-B" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_identity_type_preserved_after_tenant_change( + self, mock_credential_class, temp_dir_fixture + ): + """identity_type should remain azure_cli after tenant changes.""" + _mock_credential_with_jwt(mock_credential_class, tid="tenant-A") + auth = FabAuth() + auth.set_access_mode("azure_cli") + auth.set_azure_cli() + assert auth.get_identity_type() == "azure_cli" + + _mock_credential_with_jwt(mock_credential_class, tid="tenant-B") + 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_credential_on_tenant_change( + self, mock_credential_class, temp_dir_fixture + ): + """set_azure_cli should clear credential when transitioning tenants.""" + _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + # Acquire a token to set credential + auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT) + assert auth._azure_cli_credential is not None + + # Login with explicit tenant — credential must be cleared for recreation + auth.set_azure_cli() + assert auth._azure_cli_credential is None + + +class TestJwtClaimsDecoding: + """Test the _decode_jwt_claims helper.""" + + def test_valid_jwt_extracts_claims(self, temp_dir_fixture): + """Should decode tid and oid from a valid JWT.""" + token = _make_jwt(tid="my-tenant", oid="my-oid") + auth = FabAuth() + claims = auth._decode_jwt_claims(token) + assert claims["tid"] == "my-tenant" + assert claims["oid"] == "my-oid" + + def test_invalid_jwt_returns_empty(self, temp_dir_fixture): + """Should return empty dict for malformed tokens.""" + auth = FabAuth() + assert auth._decode_jwt_claims("not-a-jwt") == {} + assert auth._decode_jwt_claims("") == {} + assert auth._decode_jwt_claims("a.!!!.c") == {} + + def test_jwt_with_extra_claims(self, temp_dir_fixture): + """Should extract additional claims.""" + token = _make_jwt(tid="t1", oid="o1", upn="user@contoso.com") + auth = FabAuth() + claims = auth._decode_jwt_claims(token) + assert claims["upn"] == "user@contoso.com" + + +class TestFailClosedOnMissingClaims: + """Verify tokens with missing identity claims are rejected, not silently used.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_login_rejects_token_missing_oid(self, mock_class, temp_dir_fixture): + """set_azure_cli should fail if probe token lacks oid.""" + header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode( + _json.dumps({"tid": "t1", "iss": "https://sts.windows.net/t1/"}).encode() + ).rstrip(b"=").decode() + token_str = f"{header}.{payload}.fakesig" + mock_token = MagicMock() + mock_token.token = token_str + mock_token.expires_on = int(time.time()) + 3600 + mock_class.return_value.get_token.return_value = mock_token + auth = FabAuth() + with pytest.raises(FabricCLIError, match="missing identity claims"): + auth.set_azure_cli() + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_login_rejects_token_missing_tid(self, mock_class, temp_dir_fixture): + """set_azure_cli should fail if probe token lacks tid.""" + header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode( + _json.dumps({"oid": "o1", "iss": "https://sts.windows.net/t1/"}).encode() + ).rstrip(b"=").decode() + token_str = f"{header}.{payload}.fakesig" + mock_token = MagicMock() + mock_token.token = token_str + mock_token.expires_on = int(time.time()) + 3600 + mock_class.return_value.get_token.return_value = mock_token + auth = FabAuth() + with pytest.raises(FabricCLIError, match="missing identity claims"): + auth.set_azure_cli() + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_login_rejects_malformed_token(self, mock_class, temp_dir_fixture): + """set_azure_cli should fail if probe token is not a valid JWT.""" + mock_token = MagicMock() + mock_token.token = "not-a-jwt" + mock_token.expires_on = int(time.time()) + 3600 + mock_class.return_value.get_token.return_value = mock_token + auth = FabAuth() + with pytest.raises(FabricCLIError, match="missing identity claims"): + auth.set_azure_cli() + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_acquisition_rejects_token_missing_claims(self, mock_class, temp_dir_fixture): + """Token acquisition should fail if returned token lacks identity claims.""" + # Login with good token + _mock_credential_with_jwt(mock_class) + auth = FabAuth() + auth.set_azure_cli() + + # Now return a bad token on next call + bad_token = MagicMock() + bad_token.token = "not-a-jwt" + bad_token.expires_on = int(time.time()) + 3600 + mock_class.return_value.get_token.return_value = bad_token + with pytest.raises(FabricCLIError, match="missing identity claims"): + auth.acquire_token(con.SCOPE_FABRIC_DEFAULT) + + +class TestNonAzureCliIsolation: + """Verify each auth method uses only its own credential path — no overlap.""" + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_user_identity_does_not_invoke_azure_cli( + self, mock_credential_class, temp_dir_fixture + ): + """When identity_type is 'user', AzureCliCredential must not be instantiated.""" + auth = FabAuth() + auth.set_access_mode("user") + + mock_app = MagicMock() + mock_app.get_accounts.return_value = [{"username": "test@contoso.com"}] + mock_app.acquire_token_silent.return_value = { + "access_token": "msal-user-token", + "expires_on": str(int(time.time()) + 3600), + } + auth.app = mock_app + + result = auth.acquire_token(con.SCOPE_FABRIC_DEFAULT) + assert result["access_token"] == "msal-user-token" + mock_credential_class.assert_not_called() + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_service_principal_does_not_invoke_azure_cli( + self, mock_credential_class, temp_dir_fixture + ): + """When identity_type is 'service_principal', AzureCliCredential must not be instantiated.""" + auth = FabAuth() + auth.set_access_mode("service_principal") + + mock_app = MagicMock() + mock_app.acquire_token_for_client.return_value = { + "access_token": "spn-token", + "expires_on": str(int(time.time()) + 3600), + } + auth.app = mock_app + + result = auth.acquire_token(con.SCOPE_FABRIC_DEFAULT) + assert result["access_token"] == "spn-token" + mock_credential_class.assert_not_called() + + @patch("fabric_cli.core.fab_auth.AzureCliCredential") + def test_azure_cli_does_not_invoke_msal_app( + self, mock_credential_class, temp_dir_fixture + ): + """When identity_type is 'azure_cli', MSAL app methods must not be called.""" + _mock_credential_with_jwt(mock_credential_class) + + auth = FabAuth() + auth.set_access_mode("azure_cli") + + mock_app = MagicMock() + auth.app = mock_app + + result = auth.acquire_token(con.SCOPE_FABRIC_DEFAULT) + assert "access_token" in result + mock_app.acquire_token_silent.assert_not_called() + mock_app.acquire_token_interactive.assert_not_called() + mock_app.acquire_token_for_client.assert_not_called() 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..7f815a5de --- /dev/null +++ b/tests/test_core/test_fab_msal_bridge_azure_cli.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for the MSAL bridge with Azure CLI identity type.""" + +import base64 +import json as _json +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 + + +def _make_jwt(tid: str = "test-tenant", oid: str = "test-oid") -> str: + """Create a fake JWT with specified claims.""" + header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + claims = {"tid": tid, "oid": oid, "iss": f"https://sts.windows.net/{tid}/"} + payload = base64.urlsafe_b64encode(_json.dumps(claims).encode()).rstrip(b"=").decode() + return f"{header}.{payload}.fakesig" + + +@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_credential = None + 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.""" + token_str = _make_jwt() + mock_token = MagicMock() + mock_token.token = token_str + 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 == token_str + 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