From 6228b6d6d70dd6098707d2e449879f80b48b3635 Mon Sep 17 00:00:00 2001 From: Laurent Mazuel Date: Tue, 26 Nov 2019 17:06:16 -0800 Subject: [PATCH 01/17] PoC CliCredentials --- .../identity/_credentials/cli_credentials.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py b/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py new file mode 100644 index 000000000000..08a9945d8fad --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py @@ -0,0 +1,27 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from azure.common.credentials import get_azure_cli_credentials +from azure.core.credentials import AccessToken +import time + + +class CliCredentials(object): + + _DEFAULT_PREFIX = "/.default()" + + def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument + if len(scopes) != 1: + raise ValueError("Cannot deal with multiple scope: {}".format(scopes)) + scope = scopes[0] + if scope.endswith(self._DEFAULT_PREFIX): + resource = scope[:-len(self._DEFAULT_PREFIX)] + else: + resource = scope + + credentials, subscription_id, tenant_id = get_azure_cli_credentials(resource=resource, + with_tenant=True) + scheme, token, fulltoken = credentials._token_retriever() + + return AccessToken(token, int(fulltoken['expiresIn'] + time.time())) From 8e5e2bb1eeabad48aef78c367f235e6c29970f75 Mon Sep 17 00:00:00 2001 From: Laurent Mazuel Date: Wed, 27 Nov 2019 10:03:32 -0800 Subject: [PATCH 02/17] Fix MSAL scope poststuff --- .../azure/identity/_credentials/cli_credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py b/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py index 08a9945d8fad..db0c1d7b39b7 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py @@ -9,7 +9,7 @@ class CliCredentials(object): - _DEFAULT_PREFIX = "/.default()" + _DEFAULT_PREFIX = "/.default" def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument if len(scopes) != 1: From 38be4ef0b5b3a0f8d571813aebde276bcbd53ea1 Mon Sep 17 00:00:00 2001 From: "RuiJun Hu (MSFT)" <47708215+cxznmhdcxz@users.noreply.github.com> Date: Wed, 26 Feb 2020 22:54:30 +0000 Subject: [PATCH 03/17] Cli credentials (#9162) --- .../azure-identity/azure/identity/__init__.py | 2 + .../azure/identity/_credentials/__init__.py | 2 + .../identity/_credentials/cli_credential.py | 58 +++++++++++ .../identity/_credentials/cli_credentials.py | 27 ----- .../azure/identity/_credentials/default.py | 6 ++ .../azure/identity/aio/__init__.py | 2 + .../identity/aio/_credentials/__init__.py | 2 + .../aio/_credentials/cli_credential.py | 62 ++++++++++++ .../tests/test_cli_credential.py | 74 ++++++++++++++ .../tests/test_cli_credential_async.py | 98 +++++++++++++++++++ 10 files changed, 306 insertions(+), 27 deletions(-) create mode 100644 sdk/identity/azure-identity/azure/identity/_credentials/cli_credential.py delete mode 100644 sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py create mode 100644 sdk/identity/azure-identity/azure/identity/aio/_credentials/cli_credential.py create mode 100644 sdk/identity/azure-identity/tests/test_cli_credential.py create mode 100644 sdk/identity/azure-identity/tests/test_cli_credential_async.py diff --git a/sdk/identity/azure-identity/azure/identity/__init__.py b/sdk/identity/azure-identity/azure/identity/__init__.py index 7648a37ffa19..6d1c88e72f4d 100644 --- a/sdk/identity/azure-identity/azure/identity/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/__init__.py @@ -8,6 +8,7 @@ from ._constants import KnownAuthorities from ._credentials import ( AuthorizationCodeCredential, + AzureCliCredential, CertificateCredential, ChainedTokenCredential, ClientSecretCredential, @@ -23,6 +24,7 @@ __all__ = [ "AuthorizationCodeCredential", + "AzureCliCredential", "CertificateCredential", "ChainedTokenCredential", "ClientSecretCredential", diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py b/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py index 62c41086c96c..6128ba80eb44 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py @@ -10,6 +10,7 @@ from .environment import EnvironmentCredential from .managed_identity import ManagedIdentityCredential from .shared_cache import SharedTokenCacheCredential +from .cli_credential import AzureCliCredential from .user import DeviceCodeCredential, UsernamePasswordCredential @@ -24,5 +25,6 @@ "InteractiveBrowserCredential", "ManagedIdentityCredential", "SharedTokenCacheCredential", + "AzureCliCredential", "UsernamePasswordCredential", ] diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/cli_credential.py b/sdk/identity/azure-identity/azure/identity/_credentials/cli_credential.py new file mode 100644 index 000000000000..7bf1b941f11d --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_credentials/cli_credential.py @@ -0,0 +1,58 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import json +import time +from datetime import datetime + +from subprocess import run, PIPE +import six + +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError + + +class AzureCliCredential(object): + + _DEFAULT_PREFIX = "/.default" + _CLI_NOT_INSTALLED_ERR = "Azure CLI not installed" + _CLI_LOGIN_ERR = "ERROR: Please run 'az login' to setup account.\r\n" + + def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument + command = 'az account get-access-token' + + if scopes: + resource = scopes[0] + if resource.endswith(self._DEFAULT_PREFIX): + resource = resource[:-len(self._DEFAULT_PREFIX)] + + command = ' '.join([command, '--resource', resource]) + + try: + get_access_token_stdout = self._get_cli_access_token(command) + get_access_token_object = json.loads(get_access_token_stdout) + access_token = get_access_token_object['accessToken'] + except ClientAuthenticationError: + raise + except Exception as e: + raise ClientAuthenticationError(repr(e)) + + expires_on = int(( + datetime.strptime(get_access_token_object['expiresOn'], '%Y-%m-%d %H:%M:%S.%f') + - datetime.now() + ).total_seconds() + time.time()) + + return AccessToken(access_token, expires_on) + + def _get_cli_access_token(self, command): + _proc = run(command, shell=True, stderr=PIPE, stdout=PIPE, timeout=10) + return_code = _proc.returncode + stdout = six.ensure_str(_proc.stdout) + stderr = six.ensure_str(_proc.stderr) + if return_code == 127 or (return_code == 1 and 'not recognized as' in stderr): + raise ClientAuthenticationError(self._CLI_NOT_INSTALLED_ERR) + elif return_code == 1: + raise ClientAuthenticationError(self._CLI_LOGIN_ERR) + + return stdout diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py b/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py deleted file mode 100644 index db0c1d7b39b7..000000000000 --- a/sdk/identity/azure-identity/azure/identity/_credentials/cli_credentials.py +++ /dev/null @@ -1,27 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -from azure.common.credentials import get_azure_cli_credentials -from azure.core.credentials import AccessToken -import time - - -class CliCredentials(object): - - _DEFAULT_PREFIX = "/.default" - - def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument - if len(scopes) != 1: - raise ValueError("Cannot deal with multiple scope: {}".format(scopes)) - scope = scopes[0] - if scope.endswith(self._DEFAULT_PREFIX): - resource = scope[:-len(self._DEFAULT_PREFIX)] - else: - resource = scope - - credentials, subscription_id, tenant_id = get_azure_cli_credentials(resource=resource, - with_tenant=True) - scheme, token, fulltoken = credentials._token_retriever() - - return AccessToken(token, int(fulltoken['expiresIn'] + time.time())) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/default.py b/sdk/identity/azure-identity/azure/identity/_credentials/default.py index 7ac2676f4df1..42f1c6746964 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/default.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/default.py @@ -13,6 +13,8 @@ from .environment import EnvironmentCredential from .managed_identity import ManagedIdentityCredential from .shared_cache import SharedTokenCacheCredential +from .cli_credential import AzureCliCredential + try: from typing import TYPE_CHECKING @@ -38,6 +40,7 @@ class DefaultAzureCredential(ChainedTokenCredential): 3. On Windows only: a user who has signed in with a Microsoft application, such as Visual Studio. If multiple identities are in the cache, then the value of the environment variable ``AZURE_USERNAME`` is used to select which identity to use. See :class:`~azure.identity.SharedTokenCacheCredential` for more details. + 4. An Azure CLI access token. See :class:`~azure.identity.AzureCliCredential` for more details. This default behavior is configurable with keyword arguments. @@ -69,6 +72,7 @@ def __init__(self, **kwargs): exclude_environment_credential = kwargs.pop("exclude_environment_credential", False) exclude_managed_identity_credential = kwargs.pop("exclude_managed_identity_credential", False) exclude_shared_token_cache_credential = kwargs.pop("exclude_shared_token_cache_credential", False) + exclude_cli_credential = kwargs.pop("exclude_cli_credential", False) exclude_interactive_browser_credential = kwargs.pop("exclude_interactive_browser_credential", True) credentials = [] @@ -86,6 +90,8 @@ def __init__(self, **kwargs): except Exception as ex: # pylint:disable=broad-except # transitive dependency pywin32 doesn't support 3.8 (https://github.com/mhammond/pywin32/issues/1431) _LOGGER.info("Shared token cache is unavailable: '%s'", ex) + if not exclude_cli_credential: + credentials.append(AzureCliCredential()) if not exclude_interactive_browser_credential: credentials.append(InteractiveBrowserCredential()) diff --git a/sdk/identity/azure-identity/azure/identity/aio/__init__.py b/sdk/identity/azure-identity/azure/identity/aio/__init__.py index 775ee06abd85..ee30973a07fb 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/aio/__init__.py @@ -6,6 +6,7 @@ from ._credentials import ( AuthorizationCodeCredential, + AzureCliCredential, CertificateCredential, ChainedTokenCredential, ClientSecretCredential, @@ -18,6 +19,7 @@ __all__ = [ "AuthorizationCodeCredential", + "AzureCliCredential", "CertificateCredential", "ClientSecretCredential", "DefaultAzureCredential", diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py index 976a90eb18e3..fc4e554b0de2 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py @@ -9,10 +9,12 @@ from .managed_identity import ManagedIdentityCredential from .client_credential import CertificateCredential, ClientSecretCredential from .shared_cache import SharedTokenCacheCredential +from .cli_credential import AzureCliCredential __all__ = [ "AuthorizationCodeCredential", + "AzureCliCredential", "CertificateCredential", "ChainedTokenCredential", "ClientSecretCredential", diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/cli_credential.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/cli_credential.py new file mode 100644 index 000000000000..a1055bc887da --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/cli_credential.py @@ -0,0 +1,62 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import json +import time +from datetime import datetime + +import asyncio +from subprocess import Popen, PIPE + +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError + + +class AzureCliCredential(object): + + _DEFAULT_PREFIX = "/.default" + _CLI_NOT_INSTALLED_ERR = "Azure CLI not installed" + _CLI_LOGIN_ERR = "ERROR: Please run 'az login' to setup account.\r\n" + + async def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument + command = 'az account get-access-token' + + if scopes: + resource = scopes[0] + if resource.endswith(self._DEFAULT_PREFIX): + resource = resource[:-len(self._DEFAULT_PREFIX)] + + command = ' '.join([command, '--resource', resource]) + + try: + get_access_token_stdout = await self._get_cli_access_token(command) + get_access_token_object = json.loads(get_access_token_stdout) + access_token = get_access_token_object['accessToken'] + except ClientAuthenticationError: + raise + except Exception as e: + raise ClientAuthenticationError("Azure CLI didn't provide an access token") + + expires_on = int(( + datetime.strptime(get_access_token_object['expiresOn'], '%Y-%m-%d %H:%M:%S.%f') + - datetime.now() + ).total_seconds() + time.time()) + + return AccessToken(access_token, expires_on) + + async def _get_cli_access_token(self, command): + _proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + timeout=10) + + stdout, stderr = await _proc.communicate() + + if _proc.returncode == 127 or (_proc.returncode == 1 and 'not recognized as' in stderr): + raise ClientAuthenticationError(self._CLI_NOT_INSTALLED_ERR) + elif _proc.returncode == 1: + raise ClientAuthenticationError(self._CLI_LOGIN_ERR) + + return stdout diff --git a/sdk/identity/azure-identity/tests/test_cli_credential.py b/sdk/identity/azure-identity/tests/test_cli_credential.py new file mode 100644 index 000000000000..68de99cff1b6 --- /dev/null +++ b/sdk/identity/azure-identity/tests/test_cli_credential.py @@ -0,0 +1,74 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import json + +from azure.identity import AzureCliCredential +from azure.core.exceptions import ClientAuthenticationError + +from subprocess import CompletedProcess +import pytest +try: + from unittest.mock import Mock, patch +except ImportError: + from mock import Mock, patch + + +def test_cli_credential(): + access_token = '***' + expires_on = '9999-1-1 00:00:00.1' + mock_token = json.dumps({"accessToken" : access_token, "expiresOn" : expires_on}) + mock_proc = CompletedProcess(args=None, stdout=mock_token, returncode=0, stderr='') + + with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): + cred = AzureCliCredential() + token = cred.get_token() + + assert token.token == access_token + +def test_cli_installation(): + mock_proc = CompletedProcess(args=None, stdout='', returncode=127, stderr='') + + with pytest.raises(ClientAuthenticationError) as excinfo: + with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): + cred = AzureCliCredential() + token = cred.get_token() + + assert ClientAuthenticationError == excinfo.type + assert AzureCliCredential._CLI_NOT_INSTALLED_ERR in str(excinfo.value) + +def test_cli_login(): + mock_proc = CompletedProcess(args=None, stdout='', returncode=1, stderr='') + + with pytest.raises(ClientAuthenticationError) as excinfo: + with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): + cred = AzureCliCredential() + token = cred.get_token() + + assert ClientAuthenticationError == excinfo.type + assert AzureCliCredential._CLI_LOGIN_ERR in str(excinfo.value) + +def test_no_json(): + mock_token = 'not a json' + mock_proc = CompletedProcess(args=None, stdout=mock_token, returncode=0, stderr='') + + with pytest.raises(ClientAuthenticationError) as excinfo: + with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): + cred = AzureCliCredential() + token = cred.get_token() + + assert ClientAuthenticationError == excinfo.type + assert 'JSONDecodeError' in str(excinfo.value) + +def test_bad_token(): + mock_token = json.dumps({"foo" : "bar"}) + mock_proc = CompletedProcess(args=None, stdout=mock_token, returncode=0, stderr='') + + with pytest.raises(ClientAuthenticationError) as excinfo: + with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): + cred = AzureCliCredential() + token = cred.get_token() + + assert ClientAuthenticationError == excinfo.type + assert 'KeyError' in str(excinfo.value) diff --git a/sdk/identity/azure-identity/tests/test_cli_credential_async.py b/sdk/identity/azure-identity/tests/test_cli_credential_async.py new file mode 100644 index 000000000000..655d9cf81ad0 --- /dev/null +++ b/sdk/identity/azure-identity/tests/test_cli_credential_async.py @@ -0,0 +1,98 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import json + +from azure.identity.aio import AzureCliCredential +from azure.core.exceptions import ClientAuthenticationError + +from subprocess import CompletedProcess +import pytest +from unittest.mock import Mock, patch + +from helpers_async import get_completed_future + + +@pytest.mark.asyncio +async def test_cli_credential(): + access_token = '***' + expires_on = '9999-1-1 00:00:00.1' + mock_token = json.dumps({"accessToken" : access_token, "expiresOn" : expires_on}) + + mock_proc = Mock() + attrs = { + 'returncode': 0, + 'communicate.return_value': get_completed_future((mock_token, ''))} + mock_proc.configure_mock(**attrs) + + with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): + cred = AzureCliCredential() + token = await cred.get_token() + + assert token.token == access_token + +@pytest.mark.asyncio +async def test_cli_installation(): + mock_proc = Mock() + attrs = { + 'returncode': 127, + 'communicate.return_value': get_completed_future(('', ''))} + mock_proc.configure_mock(**attrs) + + with pytest.raises(ClientAuthenticationError) as excinfo: + with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): + cred = AzureCliCredential() + token = await cred.get_token() + + assert ClientAuthenticationError == excinfo.type + assert AzureCliCredential._CLI_NOT_INSTALLED_ERR in str(excinfo.value) + +@pytest.mark.asyncio +async def test_cli_login(): + mock_proc = Mock() + attrs = { + 'returncode': 1, + 'communicate.return_value': get_completed_future(('', ''))} + mock_proc.configure_mock(**attrs) + + with pytest.raises(ClientAuthenticationError) as excinfo: + with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): + cred = AzureCliCredential() + token = await cred.get_token() + + assert ClientAuthenticationError == excinfo.type + assert AzureCliCredential._CLI_LOGIN_ERR in str(excinfo.value) + +@pytest.mark.asyncio +async def test_no_json(): + mock_proc = Mock() + attrs = { + 'returncode': 0, + 'communicate.return_value': get_completed_future(('Bad*Token',''))} + mock_proc.configure_mock(**attrs) + + with pytest.raises(ClientAuthenticationError) as excinfo: + with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): + cred = AzureCliCredential() + token = await cred.get_token() + + assert ClientAuthenticationError == excinfo.type + assert "Azure CLI didn't provide an access token" in str(excinfo.value) + +@pytest.mark.asyncio +async def test_bad_token(): + bad_token = json.dumps({'foo': 'bar'}) + mock_proc = Mock() + attrs = { + 'returncode': 0, + 'communicate.return_value': get_completed_future((bad_token, ''))} + mock_proc.configure_mock(**attrs) + + with pytest.raises(ClientAuthenticationError) as excinfo: + with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): + cred = AzureCliCredential() + token = await cred.get_token() + + assert ClientAuthenticationError == excinfo.type + assert "Azure CLI didn't provide an access token" in str(excinfo.value) From 8cd95d031097a7e2933cbc5236c412aef54c8686 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 28 Feb 2020 13:12:15 -0800 Subject: [PATCH 04/17] revise credential --- .../azure/identity/_credentials/__init__.py | 2 +- .../azure/identity/_credentials/azure_cli.py | 129 ++++++++++++++++++ .../identity/_credentials/cli_credential.py | 58 -------- .../azure/identity/_credentials/default.py | 3 +- .../azure/identity/_internal/__init__.py | 14 ++ .../identity/aio/_credentials/__init__.py | 2 +- .../{cli_credential.py => azure_cli.py} | 0 7 files changed, 147 insertions(+), 61 deletions(-) create mode 100644 sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py delete mode 100644 sdk/identity/azure-identity/azure/identity/_credentials/cli_credential.py rename sdk/identity/azure-identity/azure/identity/aio/_credentials/{cli_credential.py => azure_cli.py} (100%) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py b/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py index 6128ba80eb44..1afc6a6ee9e8 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py @@ -10,7 +10,7 @@ from .environment import EnvironmentCredential from .managed_identity import ManagedIdentityCredential from .shared_cache import SharedTokenCacheCredential -from .cli_credential import AzureCliCredential +from .azure_cli import AzureCliCredential from .user import DeviceCodeCredential, UsernamePasswordCredential diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py b/sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py new file mode 100644 index 000000000000..0fb23ef4525e --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py @@ -0,0 +1,129 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from datetime import datetime +import json +import os +import platform +import re +import sys +from typing import TYPE_CHECKING + +import subprocess + +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError + +from .. import CredentialUnavailableError +from .._internal import _scopes_to_resource + +if TYPE_CHECKING: + # pylint:disable=ungrouped-imports + from typing import Any + +CLI_NOT_FOUND = "Azure CLI not found on path" +COMMAND_LINE = "az account get-access-token --output json --resource {}" + +# CLI's "expiresOn" is naive, so we use this naive datetime for the epoch to calculate expires_on in UTC +EPOCH = datetime.fromtimestamp(0) + + +class AzureCliCredential(object): + """Authenticates by requesting a token from the Azure CLI. + + This requires previously logging in to Azure via "az login", and will use the CLI's currently logged in identity. + """ + + def get_token(self, *scopes, **kwargs): # pylint:disable=no-self-use,unused-argument + # type: (*str, **Any) -> AccessToken + """Request an access token for `scopes`. + + .. note:: This method is called by Azure SDK clients. It isn't intended for use in application code. + + Only one scope is supported per request. This credential won't cache tokens. Every call invokes the Azure CLI. + + :param str scopes: desired scopes for the token. Only **one** scope is supported per call. + :rtype: :class:`azure.core.credentials.AccessToken` + + :raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI. + :raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't + receive an access token. + """ + + resource = _scopes_to_resource(*scopes) + output, error = _run_command(COMMAND_LINE.format(resource)) + if error: + raise error + + token = parse_token(output) + if not token: + sanitized_output = sanitize_output(output) + raise ClientAuthenticationError(message="Unexpected output from Azure CLI: '{}'".format(sanitized_output)) + + return token + + +def parse_token(output): + """Parse output of 'az account get-access-token' to an AccessToken. + + In particular, convert the CLI's "expiresOn" value, the string representation of a naive datetime, to epoch seconds. + """ + try: + token = json.loads(output) + parsed_expires_on = datetime.strptime(token["expiresOn"], "%Y-%m-%d %H:%M:%S.%f") + + # calculate seconds since the epoch; parsed_expires_on and EPOCH are naive + expires_on = (parsed_expires_on - EPOCH).total_seconds() + + return AccessToken(token["accessToken"], int(expires_on)) + except (KeyError, ValueError): + return None + + +def get_safe_working_dir(): + """Invoke 'az' from a directory on $PATH to get 'az' from the path, not the executing program's directory""" + + path = os.environ["PATH"] + if sys.platform.startswith("win"): + return path.split(";")[0] + return path.split(":")[0] + + +def sanitize_output(output): + """Redact access tokens from CLI output to prevent error messages revealing them""" + return re.sub(r"\"accessToken\": \"(.*?)(\"|$)", "****", output) + + +def _run_command(command): + if sys.platform.startswith("win"): + args = ["cmd", "/c", command] + else: + args = ["/bin/sh", "-c", command] + try: + working_directory = get_safe_working_dir() + + kwargs = {"stderr": subprocess.STDOUT, "cwd": working_directory, "universal_newlines": True} + if platform.python_version() >= "3.3": + kwargs["timeout"] = 10 + + output = subprocess.check_output(args, **kwargs) + return output, None + except subprocess.CalledProcessError as ex: + # non-zero return from shell + if ex.returncode == 127 or ex.output.startswith("'az' is not recognized"): + error = CredentialUnavailableError(message=CLI_NOT_FOUND) + else: + # return code is from the CLI -> propagate its output + if ex.output: + message = sanitize_output(ex.output) + else: + message = "Failed to invoke Azure CLI" + error = ClientAuthenticationError(message=message) + except OSError as ex: + # failed to execute 'cmd' or '/bin/sh'; CLI may or may not be installed + error = CredentialUnavailableError(message="Failed to execute '{}'".format(args[0])) + except Exception as ex: # pylint:disable=broad-except + error = ex + + return None, error diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/cli_credential.py b/sdk/identity/azure-identity/azure/identity/_credentials/cli_credential.py deleted file mode 100644 index 7bf1b941f11d..000000000000 --- a/sdk/identity/azure-identity/azure/identity/_credentials/cli_credential.py +++ /dev/null @@ -1,58 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -import json -import time -from datetime import datetime - -from subprocess import run, PIPE -import six - -from azure.core.credentials import AccessToken -from azure.core.exceptions import ClientAuthenticationError - - -class AzureCliCredential(object): - - _DEFAULT_PREFIX = "/.default" - _CLI_NOT_INSTALLED_ERR = "Azure CLI not installed" - _CLI_LOGIN_ERR = "ERROR: Please run 'az login' to setup account.\r\n" - - def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument - command = 'az account get-access-token' - - if scopes: - resource = scopes[0] - if resource.endswith(self._DEFAULT_PREFIX): - resource = resource[:-len(self._DEFAULT_PREFIX)] - - command = ' '.join([command, '--resource', resource]) - - try: - get_access_token_stdout = self._get_cli_access_token(command) - get_access_token_object = json.loads(get_access_token_stdout) - access_token = get_access_token_object['accessToken'] - except ClientAuthenticationError: - raise - except Exception as e: - raise ClientAuthenticationError(repr(e)) - - expires_on = int(( - datetime.strptime(get_access_token_object['expiresOn'], '%Y-%m-%d %H:%M:%S.%f') - - datetime.now() - ).total_seconds() + time.time()) - - return AccessToken(access_token, expires_on) - - def _get_cli_access_token(self, command): - _proc = run(command, shell=True, stderr=PIPE, stdout=PIPE, timeout=10) - return_code = _proc.returncode - stdout = six.ensure_str(_proc.stdout) - stderr = six.ensure_str(_proc.stderr) - if return_code == 127 or (return_code == 1 and 'not recognized as' in stderr): - raise ClientAuthenticationError(self._CLI_NOT_INSTALLED_ERR) - elif return_code == 1: - raise ClientAuthenticationError(self._CLI_LOGIN_ERR) - - return stdout diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/default.py b/sdk/identity/azure-identity/azure/identity/_credentials/default.py index 42f1c6746964..7c0119cb0ffb 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/default.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/default.py @@ -13,7 +13,7 @@ from .environment import EnvironmentCredential from .managed_identity import ManagedIdentityCredential from .shared_cache import SharedTokenCacheCredential -from .cli_credential import AzureCliCredential +from .azure_cli import AzureCliCredential try: @@ -47,6 +47,7 @@ class DefaultAzureCredential(ChainedTokenCredential): :keyword str authority: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com', the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities` defines authorities for other clouds. Managed identities ignore this because they reside in a single cloud. + :keyword bool exclude_cli_credential: Whether to exclude the Azure CLI from the credential. Defaults to **False**. :keyword bool exclude_environment_credential: Whether to exclude a service principal configured by environment variables from the credential. Defaults to **False**. :keyword bool exclude_managed_identity_credential: Whether to exclude managed identity from the credential. diff --git a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py index b6234d1eee67..25de23f23ca4 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py @@ -9,6 +9,20 @@ from .msal_credentials import ConfidentialClientCredential, PublicClientCredential from .msal_transport_adapter import MsalTransportAdapter, MsalTransportResponse + +def _scopes_to_resource(*scopes): + """Convert an AADv2 scope to an AADv1 resource""" + + if len(scopes) != 1: + raise ValueError("This credential supports only one scope per token request") + + resource = scopes[0] + if resource.endswith("/.default"): + resource = resource[: -len("/.default")] + + return resource + + __all__ = [ "AadClient", "AadClientBase", diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py index fc4e554b0de2..5b06446c4775 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py @@ -9,7 +9,7 @@ from .managed_identity import ManagedIdentityCredential from .client_credential import CertificateCredential, ClientSecretCredential from .shared_cache import SharedTokenCacheCredential -from .cli_credential import AzureCliCredential +from .azure_cli import AzureCliCredential __all__ = [ diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/cli_credential.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py similarity index 100% rename from sdk/identity/azure-identity/azure/identity/aio/_credentials/cli_credential.py rename to sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py From 990761ab3b0dc01acc0bbb242c646e160094fe89 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 2 Mar 2020 12:45:57 -0800 Subject: [PATCH 05/17] revise tests --- .../tests/test_cli_credential.py | 142 ++++++++++++------ 1 file changed, 93 insertions(+), 49 deletions(-) diff --git a/sdk/identity/azure-identity/tests/test_cli_credential.py b/sdk/identity/azure-identity/tests/test_cli_credential.py index 68de99cff1b6..35299385869c 100644 --- a/sdk/identity/azure-identity/tests/test_cli_credential.py +++ b/sdk/identity/azure-identity/tests/test_cli_credential.py @@ -2,73 +2,117 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ +from datetime import datetime import json -from azure.identity import AzureCliCredential +from azure.identity import AzureCliCredential, CredentialUnavailableError +from azure.identity._credentials.azure_cli import CLI_NOT_FOUND from azure.core.exceptions import ClientAuthenticationError -from subprocess import CompletedProcess +import subprocess import pytest -try: - from unittest.mock import Mock, patch -except ImportError: - from mock import Mock, patch +from helpers import mock -def test_cli_credential(): - access_token = '***' - expires_on = '9999-1-1 00:00:00.1' - mock_token = json.dumps({"accessToken" : access_token, "expiresOn" : expires_on}) - mock_proc = CompletedProcess(args=None, stdout=mock_token, returncode=0, stderr='') +CHECK_OUTPUT = AzureCliCredential.__module__ + ".subprocess.check_output" - with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): - cred = AzureCliCredential() - token = cred.get_token() +TEST_ERROR_OUTPUTS = ( + '{"accessToken": "secret value', + '{"accessToken": "secret value"', + '{"accessToken": "secret value and some other nonsense"', + '{"accessToken": "secret value", some invalid json, "accessToken": "secret value"}', + '{"accessToken": "secret value"}', + '{"accessToken": "secret value", "subscription": "some-guid", "tenant": "some-guid", "tokenType": "Bearer"}', + "no secrets or json here", + "{}", +) - assert token.token == access_token -def test_cli_installation(): - mock_proc = CompletedProcess(args=None, stdout='', returncode=127, stderr='') +def raise_called_process_error(return_code, output, cmd="..."): + error = subprocess.CalledProcessError(return_code, cmd=cmd, output=output) + return mock.Mock(side_effect=error) - with pytest.raises(ClientAuthenticationError) as excinfo: - with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): - cred = AzureCliCredential() - token = cred.get_token() - assert ClientAuthenticationError == excinfo.type - assert AzureCliCredential._CLI_NOT_INSTALLED_ERR in str(excinfo.value) +def test_get_token(): + """The credential should parse the CLI's output to an AccessToken""" -def test_cli_login(): - mock_proc = CompletedProcess(args=None, stdout='', returncode=1, stderr='') + access_token = "access token" + valid_seconds = 42 + successful_output = json.dumps( + { + # expiresOn is a naive datetime representing valid_seconds from the epoch + "expiresOn": datetime.fromtimestamp(valid_seconds).strftime("%Y-%m-%d %H:%M:%S.%f"), + "accessToken": access_token, + "subscription": "some-guid", + "tenant": "some-guid", + "tokenType": "Bearer", + } + ) - with pytest.raises(ClientAuthenticationError) as excinfo: - with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): - cred = AzureCliCredential() - token = cred.get_token() + with mock.patch(CHECK_OUTPUT, mock.Mock(return_value=successful_output)): + token = AzureCliCredential().get_token("scope") - assert ClientAuthenticationError == excinfo.type - assert AzureCliCredential._CLI_LOGIN_ERR in str(excinfo.value) + assert token.token == access_token + assert type(token.expires_on) == int + assert token.expires_on == valid_seconds -def test_no_json(): - mock_token = 'not a json' - mock_proc = CompletedProcess(args=None, stdout=mock_token, returncode=0, stderr='') - with pytest.raises(ClientAuthenticationError) as excinfo: - with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): - cred = AzureCliCredential() - token = cred.get_token() +def test_cli_not_installed_linux(): + """The credential should raise CredentialUnavailableError when the CLI isn't installed""" - assert ClientAuthenticationError == excinfo.type - assert 'JSONDecodeError' in str(excinfo.value) + output = "/bin/sh: 1: az: not found" + with mock.patch(CHECK_OUTPUT, raise_called_process_error(127, output)): + with pytest.raises(CredentialUnavailableError, match=CLI_NOT_FOUND): + AzureCliCredential().get_token("scope") -def test_bad_token(): - mock_token = json.dumps({"foo" : "bar"}) - mock_proc = CompletedProcess(args=None, stdout=mock_token, returncode=0, stderr='') - with pytest.raises(ClientAuthenticationError) as excinfo: - with patch('azure.identity._credentials.cli_credential.run', return_value=mock_proc): - cred = AzureCliCredential() - token = cred.get_token() +def test_cli_not_installed_windows(): + """The credential should raise CredentialUnavailableError when the CLI isn't installed""" - assert ClientAuthenticationError == excinfo.type - assert 'KeyError' in str(excinfo.value) + output = "'az' is not recognized as an internal or external command, operable program or batch file." + with mock.patch(CHECK_OUTPUT, raise_called_process_error(1, output)): + with pytest.raises(CredentialUnavailableError, match=CLI_NOT_FOUND): + AzureCliCredential().get_token("scope") + + +@pytest.mark.parametrize("platform", ("darwin", "linux2", "win32")) +def test_cannot_execute_shell(platform): + """The credential should raise CredentialUnavailableError when the subprocess doesn't start""" + + with mock.patch(AzureCliCredential.__module__ + ".sys.platform", platform): + with mock.patch(CHECK_OUTPUT, mock.Mock(side_effect=OSError())): + with pytest.raises(CredentialUnavailableError): + AzureCliCredential().get_token("scope") + + +def test_not_logged_in(): + """When the CLI isn't logged in, the credential should raise an error containing the CLI's output""" + + output = "ERROR: Please run 'az login' to setup account." + with mock.patch(CHECK_OUTPUT, raise_called_process_error(1, output)): + with pytest.raises(ClientAuthenticationError, match=output): + AzureCliCredential().get_token("scope") + + +@pytest.mark.parametrize("output", TEST_ERROR_OUTPUTS) +def test_parsing_error_does_not_expose_token(output): + """Errors during CLI output parsing shouldn't expose access tokens in that output""" + + with mock.patch(CHECK_OUTPUT, mock.Mock(return_value=output)): + with pytest.raises(ClientAuthenticationError) as ex: + AzureCliCredential().get_token("scope") + + assert "secret value" not in str(ex.value) + assert "secret value" not in repr(ex.value) + + +@pytest.mark.parametrize("output", TEST_ERROR_OUTPUTS) +def test_subprocess_error_does_not_expose_token(output): + """Errors from the subprocess shouldn't expose access tokens in CLI output""" + + with mock.patch(CHECK_OUTPUT, raise_called_process_error(1, output=output)): + with pytest.raises(ClientAuthenticationError) as ex: + AzureCliCredential().get_token("scope") + + assert "secret value" not in str(ex.value) + assert "secret value" not in repr(ex.value) From 3dc868bcf39ea14b21e6fd95743633c5c3f822ca Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Mar 2020 12:59:18 -0800 Subject: [PATCH 06/17] async credential wraps the sync one --- .../identity/aio/_credentials/azure_cli.py | 86 ++++++------------- 1 file changed, 28 insertions(+), 58 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py index a1055bc887da..a1bfd1ba879a 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py @@ -2,61 +2,31 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -import json -import time -from datetime import datetime - -import asyncio -from subprocess import Popen, PIPE - -from azure.core.credentials import AccessToken -from azure.core.exceptions import ClientAuthenticationError - - -class AzureCliCredential(object): - - _DEFAULT_PREFIX = "/.default" - _CLI_NOT_INSTALLED_ERR = "Azure CLI not installed" - _CLI_LOGIN_ERR = "ERROR: Please run 'az login' to setup account.\r\n" - - async def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument - command = 'az account get-access-token' - - if scopes: - resource = scopes[0] - if resource.endswith(self._DEFAULT_PREFIX): - resource = resource[:-len(self._DEFAULT_PREFIX)] - - command = ' '.join([command, '--resource', resource]) - - try: - get_access_token_stdout = await self._get_cli_access_token(command) - get_access_token_object = json.loads(get_access_token_stdout) - access_token = get_access_token_object['accessToken'] - except ClientAuthenticationError: - raise - except Exception as e: - raise ClientAuthenticationError("Azure CLI didn't provide an access token") - - expires_on = int(( - datetime.strptime(get_access_token_object['expiresOn'], '%Y-%m-%d %H:%M:%S.%f') - - datetime.now() - ).total_seconds() + time.time()) - - return AccessToken(access_token, expires_on) - - async def _get_cli_access_token(self, command): - _proc = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - timeout=10) - - stdout, stderr = await _proc.communicate() - - if _proc.returncode == 127 or (_proc.returncode == 1 and 'not recognized as' in stderr): - raise ClientAuthenticationError(self._CLI_NOT_INSTALLED_ERR) - elif _proc.returncode == 1: - raise ClientAuthenticationError(self._CLI_LOGIN_ERR) - - return stdout +from .._credentials.base import AsyncCredentialBase +from ..._credentials import AzureCliCredential as _SyncAzureCliCredential + + +class AzureCliCredential(AsyncCredentialBase): + """Authenticates by requesting a token from the Azure CLI. + + This requires previously logging in to Azure via "az login", and will use the CLI's currently logged in identity. + """ + + async def get_token(self, *scopes, **kwargs): + """Request an access token for `scopes`. + + .. note:: This method is called by Azure SDK clients. It isn't intended for use in application code. + + Only one scope is supported per request. This credential won't cache tokens. Every call invokes the Azure CLI. + + :param str scopes: desired scopes for the token. Only **one** scope is supported per call. + :rtype: :class:`azure.core.credentials.AccessToken` + + :raises ~azure.identity.CredentialUnavailableError: the credential was unable to invoke the Azure CLI. + :raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't + receive an access token. + """ + return _SyncAzureCliCredential().get_token(*scopes, **kwargs) + + async def close(self): + return From 1abfba15ccf8ae7e7be6de539af0b8282e4652ad Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Mar 2020 12:59:27 -0800 Subject: [PATCH 07/17] revise async tests --- .../tests/test_cli_credential_async.py | 169 +++++++++++------- 1 file changed, 101 insertions(+), 68 deletions(-) diff --git a/sdk/identity/azure-identity/tests/test_cli_credential_async.py b/sdk/identity/azure-identity/tests/test_cli_credential_async.py index 655d9cf81ad0..5b4db1f01664 100644 --- a/sdk/identity/azure-identity/tests/test_cli_credential_async.py +++ b/sdk/identity/azure-identity/tests/test_cli_credential_async.py @@ -2,97 +2,130 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ +from datetime import datetime import json +from unittest import mock +from azure.identity import CredentialUnavailableError, AzureCliCredential as _SyncCredential from azure.identity.aio import AzureCliCredential +from azure.identity._credentials.azure_cli import CLI_NOT_FOUND from azure.core.exceptions import ClientAuthenticationError - -from subprocess import CompletedProcess import pytest -from unittest.mock import Mock, patch -from helpers_async import get_completed_future +from test_cli_credential import raise_called_process_error, TEST_ERROR_OUTPUTS + +CHECK_OUTPUT = _SyncCredential.__module__ + ".subprocess.check_output" + + +@pytest.mark.asyncio +async def test_close(): + """the credential must define close, although it's a no-op because the credential has no transport""" + + await AzureCliCredential().close() @pytest.mark.asyncio -async def test_cli_credential(): - access_token = '***' - expires_on = '9999-1-1 00:00:00.1' - mock_token = json.dumps({"accessToken" : access_token, "expiresOn" : expires_on}) +async def test_context_manager(): + """the credential must be a context manager, although it does nothing as one because it has no transport""" + + async with AzureCliCredential(): + pass - mock_proc = Mock() - attrs = { - 'returncode': 0, - 'communicate.return_value': get_completed_future((mock_token, ''))} - mock_proc.configure_mock(**attrs) - with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): - cred = AzureCliCredential() - token = await cred.get_token() +@pytest.mark.asyncio +async def test_get_token(): + """The credential should parse the CLI's output to an AccessToken""" + + access_token = "access token" + valid_seconds = 42 + successful_output = json.dumps( + { + # expiresOn is a naive datetime representing valid_seconds from the epoch + "expiresOn": datetime.fromtimestamp(valid_seconds).strftime("%Y-%m-%d %H:%M:%S.%f"), + "accessToken": access_token, + "subscription": "some-guid", + "tenant": "some-guid", + "tokenType": "Bearer", + } + ) + + with mock.patch(CHECK_OUTPUT, mock.Mock(return_value=successful_output)): + credential = AzureCliCredential() + token = await credential.get_token("scope") + + assert token.token == access_token + assert type(token.expires_on) == int + assert token.expires_on == valid_seconds - assert token.token == access_token @pytest.mark.asyncio -async def test_cli_installation(): - mock_proc = Mock() - attrs = { - 'returncode': 127, - 'communicate.return_value': get_completed_future(('', ''))} - mock_proc.configure_mock(**attrs) +async def test_cli_not_installed_linux(): + """The credential should raise CredentialUnavailableError when the CLI isn't installed""" - with pytest.raises(ClientAuthenticationError) as excinfo: - with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): - cred = AzureCliCredential() - token = await cred.get_token() + output = "/bin/sh: 1: az: not found" + with mock.patch(CHECK_OUTPUT, raise_called_process_error(127, output)): + with pytest.raises(CredentialUnavailableError, match=CLI_NOT_FOUND): + credential = AzureCliCredential() + await credential.get_token("scope") - assert ClientAuthenticationError == excinfo.type - assert AzureCliCredential._CLI_NOT_INSTALLED_ERR in str(excinfo.value) @pytest.mark.asyncio -async def test_cli_login(): - mock_proc = Mock() - attrs = { - 'returncode': 1, - 'communicate.return_value': get_completed_future(('', ''))} - mock_proc.configure_mock(**attrs) +async def test_cli_not_installed_windows(): + """The credential should raise CredentialUnavailableError when the CLI isn't installed""" - with pytest.raises(ClientAuthenticationError) as excinfo: - with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): - cred = AzureCliCredential() - token = await cred.get_token() + output = "'az' is not recognized as an internal or external command, operable program or batch file." + with mock.patch(CHECK_OUTPUT, raise_called_process_error(1, output)): + with pytest.raises(CredentialUnavailableError, match=CLI_NOT_FOUND): + credential = AzureCliCredential() + await credential.get_token("scope") - assert ClientAuthenticationError == excinfo.type - assert AzureCliCredential._CLI_LOGIN_ERR in str(excinfo.value) +@pytest.mark.parametrize("platform", ("darwin", "linux2", "win32")) @pytest.mark.asyncio -async def test_no_json(): - mock_proc = Mock() - attrs = { - 'returncode': 0, - 'communicate.return_value': get_completed_future(('Bad*Token',''))} - mock_proc.configure_mock(**attrs) +async def test_cannot_execute_shell(platform): + """The credential should raise CredentialUnavailableError when the subprocess doesn't start""" - with pytest.raises(ClientAuthenticationError) as excinfo: - with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): - cred = AzureCliCredential() - token = await cred.get_token() + with mock.patch(_SyncCredential.__module__ + ".sys.platform", platform): + with mock.patch(CHECK_OUTPUT, mock.Mock(side_effect=OSError())): + with pytest.raises(CredentialUnavailableError): + credential = AzureCliCredential() + await credential.get_token("scope") - assert ClientAuthenticationError == excinfo.type - assert "Azure CLI didn't provide an access token" in str(excinfo.value) @pytest.mark.asyncio -async def test_bad_token(): - bad_token = json.dumps({'foo': 'bar'}) - mock_proc = Mock() - attrs = { - 'returncode': 0, - 'communicate.return_value': get_completed_future((bad_token, ''))} - mock_proc.configure_mock(**attrs) - - with pytest.raises(ClientAuthenticationError) as excinfo: - with patch('asyncio.create_subprocess_shell', return_value=get_completed_future(mock_proc)): - cred = AzureCliCredential() - token = await cred.get_token() - - assert ClientAuthenticationError == excinfo.type - assert "Azure CLI didn't provide an access token" in str(excinfo.value) +async def test_not_logged_in(): + """When the CLI isn't logged in, the credential should raise an error containing the CLI's output""" + + output = "ERROR: Please run 'az login' to setup account." + with mock.patch(CHECK_OUTPUT, raise_called_process_error(1, output)): + with pytest.raises(ClientAuthenticationError, match=output): + credential = AzureCliCredential() + await credential.get_token("scope") + + +@pytest.mark.parametrize("output", TEST_ERROR_OUTPUTS) +@pytest.mark.asyncio +async def test_parsing_error_does_not_expose_token(output): + """Errors during CLI output parsing shouldn't expose access tokens in that output""" + + with mock.patch(CHECK_OUTPUT, mock.Mock(return_value=output)): + with pytest.raises(ClientAuthenticationError) as ex: + credential = AzureCliCredential() + await credential.get_token("scope") + + assert "secret value" not in str(ex.value) + assert "secret value" not in repr(ex.value) + + +@pytest.mark.parametrize("output", TEST_ERROR_OUTPUTS) +@pytest.mark.asyncio +async def test_subprocess_error_does_not_expose_token(output): + """Errors from the subprocess shouldn't expose access tokens in CLI output""" + + with mock.patch(CHECK_OUTPUT, raise_called_process_error(1, output=output)): + with pytest.raises(ClientAuthenticationError) as ex: + credential = AzureCliCredential() + await credential.get_token("scope") + + assert "secret value" not in str(ex.value) + assert "secret value" not in repr(ex.value) From a24c282a824cb93751ef4009292bbc4ba3dd9811 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Mar 2020 14:55:06 -0800 Subject: [PATCH 08/17] add async credential to default --- .../azure/identity/aio/_credentials/default.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/default.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/default.py index bcf81c4cc338..204bedf9f66f 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/default.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/default.py @@ -9,6 +9,7 @@ from azure.core.exceptions import ClientAuthenticationError from ..._constants import EnvironmentVariables, KnownAuthorities +from .azure_cli import AzureCliCredential from .chained import ChainedTokenCredential from .environment import EnvironmentCredential from .managed_identity import ManagedIdentityCredential @@ -32,12 +33,14 @@ class DefaultAzureCredential(ChainedTokenCredential): 3. On Windows only: a user who has signed in with a Microsoft application, such as Visual Studio. If multiple identities are in the cache, then the value of the environment variable ``AZURE_USERNAME`` is used to select which identity to use. See :class:`~azure.identity.aio.SharedTokenCacheCredential` for more details. + 4. An Azure CLI access token. See :class:`~azure.identity.aio.AzureCliCredential` for more details. This default behavior is configurable with keyword arguments. :keyword str authority: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com', the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities` defines authorities for other clouds. Managed identities ignore this because they reside in a single cloud. + :keyword bool exclude_cli_credential: Whether to exclude the Azure CLI from the credential. Defaults to **False**. :keyword bool exclude_environment_credential: Whether to exclude a service principal configured by environment variables from the credential. Defaults to **False**. :keyword bool exclude_managed_identity_credential: Whether to exclude managed identity from the credential. @@ -58,6 +61,7 @@ def __init__(self, **kwargs): "shared_cache_tenant_id", os.environ.get(EnvironmentVariables.AZURE_TENANT_ID) ) + exclude_cli_credential = kwargs.pop("exclude_cli_credential", False) exclude_environment_credential = kwargs.pop("exclude_environment_credential", False) exclude_managed_identity_credential = kwargs.pop("exclude_managed_identity_credential", False) exclude_shared_token_cache_credential = kwargs.pop("exclude_shared_token_cache_credential", False) @@ -77,6 +81,8 @@ def __init__(self, **kwargs): except Exception as ex: # pylint:disable=broad-except # transitive dependency pywin32 doesn't support 3.8 (https://github.com/mhammond/pywin32/issues/1431) _LOGGER.info("Shared token cache is unavailable: '%s'", ex) + if not exclude_cli_credential: + credentials.append(AzureCliCredential()) super().__init__(*credentials) From 0b975bb29410580015b9a76afad7325c6e3cc4fd Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Mar 2020 14:55:30 -0800 Subject: [PATCH 09/17] tests for default credential exclusion option --- sdk/identity/azure-identity/tests/test_default.py | 4 ++++ sdk/identity/azure-identity/tests/test_default_async.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/tests/test_default.py b/sdk/identity/azure-identity/tests/test_default.py index 78e399b52f5f..2dac09513119 100644 --- a/sdk/identity/azure-identity/tests/test_default.py +++ b/sdk/identity/azure-identity/tests/test_default.py @@ -5,6 +5,7 @@ import os from azure.identity import ( + AzureCliCredential, DefaultAzureCredential, InteractiveBrowserCredential, KnownAuthorities, @@ -101,6 +102,9 @@ def assert_credentials_not_present(chain, *excluded_credential_classes): credential = DefaultAzureCredential(exclude_shared_token_cache_credential=True) assert_credentials_not_present(credential, SharedTokenCacheCredential) + credential = DefaultAzureCredential(exclude_cli_credential=True) + assert_credentials_not_present(credential, AzureCliCredential) + # interactive auth is excluded by default credential = DefaultAzureCredential(exclude_interactive_browser_credential=False) actual = {c.__class__ for c in credential.credentials} diff --git a/sdk/identity/azure-identity/tests/test_default_async.py b/sdk/identity/azure-identity/tests/test_default_async.py index 6bbcc16667c0..9e6d5b0fd79f 100644 --- a/sdk/identity/azure-identity/tests/test_default_async.py +++ b/sdk/identity/azure-identity/tests/test_default_async.py @@ -8,7 +8,7 @@ from urllib.parse import urlparse from azure.identity import KnownAuthorities -from azure.identity.aio import DefaultAzureCredential, SharedTokenCacheCredential +from azure.identity.aio import AzureCliCredential, DefaultAzureCredential, SharedTokenCacheCredential from azure.identity.aio._credentials.managed_identity import ManagedIdentityCredential from azure.identity._constants import EnvironmentVariables import pytest @@ -106,6 +106,9 @@ def assert_credentials_not_present(chain, *credential_classes): credential = DefaultAzureCredential(exclude_shared_token_cache_credential=True) assert_credentials_not_present(credential, SharedTokenCacheCredential) + credential = DefaultAzureCredential(exclude_cli_credential=True) + assert_credentials_not_present(credential, AzureCliCredential) + @pytest.mark.asyncio async def test_shared_cache_tenant_id(): From 8a5f0a718ba0402df1b466947bf40f30e927b872 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Mar 2020 15:12:17 -0800 Subject: [PATCH 10/17] update changelog --- sdk/identity/azure-identity/CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index 39907d8ce5cc..cdd1815dd7ee 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -1,6 +1,10 @@ # Release History -## 1.3.1 (Unreleased) +## 1.4.0b1 (Unreleased) +- Added `AzureCliCredential`, which authenticates with the identity logged in +to the Azure CLI. This credential is part of `DefaultAzureCredential` by +default, but can be excluded with a keyword argument: +`DefaultAzureCredential(exclude_cli_credential=True)` ## 1.3.0 (2020-02-11) From 765c01ea4023c133fb4c6a4aa31da5f3da489132 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Mar 2020 15:12:21 -0800 Subject: [PATCH 11/17] update version --- sdk/identity/azure-identity/azure/identity/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/azure/identity/_version.py b/sdk/identity/azure-identity/azure/identity/_version.py index 895f65061040..52c6cf35cf0b 100644 --- a/sdk/identity/azure-identity/azure/identity/_version.py +++ b/sdk/identity/azure-identity/azure/identity/_version.py @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -VERSION = "1.3.1" +VERSION = "1.4.0b1" From 07fa8c42a6d3693cb71ace220161cf4d04f106b8 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 5 Mar 2020 14:07:07 -0800 Subject: [PATCH 12/17] asyncio implementation --- .../identity/aio/_credentials/azure_cli.py | 61 ++++++++++++++++++- .../tests/test_cli_credential_async.py | 48 +++++++++++---- 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py index a1bfd1ba879a..bd5705dd6f33 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_cli.py @@ -2,8 +2,21 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ +import asyncio +import sys + +from azure.core.exceptions import ClientAuthenticationError from .._credentials.base import AsyncCredentialBase -from ..._credentials import AzureCliCredential as _SyncAzureCliCredential +from ... import CredentialUnavailableError +from ..._credentials.azure_cli import ( + AzureCliCredential as _SyncAzureCliCredential, + CLI_NOT_FOUND, + COMMAND_LINE, + get_safe_working_dir, + parse_token, + sanitize_output, +) +from ..._internal import _scopes_to_resource class AzureCliCredential(AsyncCredentialBase): @@ -26,7 +39,49 @@ async def get_token(self, *scopes, **kwargs): :raises ~azure.core.exceptions.ClientAuthenticationError: the credential invoked the Azure CLI but didn't receive an access token. """ - return _SyncAzureCliCredential().get_token(*scopes, **kwargs) + # only ProactorEventLoop supports subprocesses on Windows (and it isn't the default loop on Python < 3.8) + if sys.platform.startswith("win") and not isinstance(asyncio.get_event_loop(), asyncio.ProactorEventLoop): + return _SyncAzureCliCredential().get_token(scopes, **kwargs) + + resource = _scopes_to_resource(*scopes) + output = await _run_command(COMMAND_LINE.format(resource)) + + token = parse_token(output) + if not token: + sanitized_output = sanitize_output(output) + raise ClientAuthenticationError(message="Unexpected output from Azure CLI: '{}'".format(sanitized_output)) + + return token async def close(self): - return + """Calling this method is unnecessary""" + + +async def _run_command(command): + if sys.platform.startswith("win"): + args = ("cmd", "/c " + command) + else: + args = ("/bin/sh", "-c " + command) + + working_directory = get_safe_working_dir() + + try: + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=working_directory + ) + except OSError as ex: + # failed to execute 'cmd' or '/bin/sh'; CLI may or may not be installed + error = CredentialUnavailableError(message="Failed to execute '{}'".format(args[0])) + raise error from ex + + stdout, _ = await asyncio.wait_for(proc.communicate(), 10) + output = stdout.decode() + + if proc.returncode == 0: + return output + + if proc.returncode == 127 or output.startswith("'az' is not recognized"): + raise CredentialUnavailableError(CLI_NOT_FOUND) + + message = sanitize_output(output) if output else "Failed to invoke Azure CLI" + raise ClientAuthenticationError(message=message) diff --git a/sdk/identity/azure-identity/tests/test_cli_credential_async.py b/sdk/identity/azure-identity/tests/test_cli_credential_async.py index 5b4db1f01664..4bf5793a8831 100644 --- a/sdk/identity/azure-identity/tests/test_cli_credential_async.py +++ b/sdk/identity/azure-identity/tests/test_cli_credential_async.py @@ -6,32 +6,54 @@ import json from unittest import mock -from azure.identity import CredentialUnavailableError, AzureCliCredential as _SyncCredential +from azure.identity import CredentialUnavailableError from azure.identity.aio import AzureCliCredential from azure.identity._credentials.azure_cli import CLI_NOT_FOUND from azure.core.exceptions import ClientAuthenticationError import pytest -from test_cli_credential import raise_called_process_error, TEST_ERROR_OUTPUTS +from helpers_async import get_completed_future +from test_cli_credential import TEST_ERROR_OUTPUTS -CHECK_OUTPUT = _SyncCredential.__module__ + ".subprocess.check_output" +SUBPROCESS_EXEC = AzureCliCredential.__module__ + ".asyncio.create_subprocess_exec" + + +def mock_exec(stdout, stderr="", return_code=0): + async def communicate(): + return (stdout.encode(), stderr.encode()) + + process = mock.Mock(communicate=communicate, returncode=return_code) + return mock.Mock(return_value=get_completed_future(process)) @pytest.mark.asyncio async def test_close(): - """the credential must define close, although it's a no-op because the credential has no transport""" + """The credential must define close, although it's a no-op because the credential has no transport""" await AzureCliCredential().close() @pytest.mark.asyncio async def test_context_manager(): - """the credential must be a context manager, although it does nothing as one because it has no transport""" + """The credential must be a context manager, although it does nothing as one because it has no transport""" async with AzureCliCredential(): pass +@pytest.mark.asyncio +async def test_windows_fallback(): + """The credential should fall back to the sync implementation when not using ProactorEventLoop on Windows""" + + with mock.patch("azure.identity.AzureCliCredential.get_token") as fallback: + with mock.patch(AzureCliCredential.__module__ + ".sys.platform", "win32"): + with mock.patch(AzureCliCredential.__module__ + ".asyncio.get_event_loop"): + credential = AzureCliCredential() + await credential.get_token("scope") + + assert fallback.call_count == 1 + + @pytest.mark.asyncio async def test_get_token(): """The credential should parse the CLI's output to an AccessToken""" @@ -49,7 +71,7 @@ async def test_get_token(): } ) - with mock.patch(CHECK_OUTPUT, mock.Mock(return_value=successful_output)): + with mock.patch(SUBPROCESS_EXEC, mock_exec(successful_output)): credential = AzureCliCredential() token = await credential.get_token("scope") @@ -63,7 +85,7 @@ async def test_cli_not_installed_linux(): """The credential should raise CredentialUnavailableError when the CLI isn't installed""" output = "/bin/sh: 1: az: not found" - with mock.patch(CHECK_OUTPUT, raise_called_process_error(127, output)): + with mock.patch(SUBPROCESS_EXEC, mock_exec(output, return_code=127)): with pytest.raises(CredentialUnavailableError, match=CLI_NOT_FOUND): credential = AzureCliCredential() await credential.get_token("scope") @@ -74,7 +96,7 @@ async def test_cli_not_installed_windows(): """The credential should raise CredentialUnavailableError when the CLI isn't installed""" output = "'az' is not recognized as an internal or external command, operable program or batch file." - with mock.patch(CHECK_OUTPUT, raise_called_process_error(1, output)): + with mock.patch(SUBPROCESS_EXEC, mock_exec(output, return_code=1)): with pytest.raises(CredentialUnavailableError, match=CLI_NOT_FOUND): credential = AzureCliCredential() await credential.get_token("scope") @@ -85,8 +107,8 @@ async def test_cli_not_installed_windows(): async def test_cannot_execute_shell(platform): """The credential should raise CredentialUnavailableError when the subprocess doesn't start""" - with mock.patch(_SyncCredential.__module__ + ".sys.platform", platform): - with mock.patch(CHECK_OUTPUT, mock.Mock(side_effect=OSError())): + with mock.patch(AzureCliCredential.__module__ + ".sys.platform", platform): + with mock.patch(SUBPROCESS_EXEC, mock.Mock(side_effect=OSError())): with pytest.raises(CredentialUnavailableError): credential = AzureCliCredential() await credential.get_token("scope") @@ -97,7 +119,7 @@ async def test_not_logged_in(): """When the CLI isn't logged in, the credential should raise an error containing the CLI's output""" output = "ERROR: Please run 'az login' to setup account." - with mock.patch(CHECK_OUTPUT, raise_called_process_error(1, output)): + with mock.patch(SUBPROCESS_EXEC, mock_exec(output, return_code=1)): with pytest.raises(ClientAuthenticationError, match=output): credential = AzureCliCredential() await credential.get_token("scope") @@ -108,7 +130,7 @@ async def test_not_logged_in(): async def test_parsing_error_does_not_expose_token(output): """Errors during CLI output parsing shouldn't expose access tokens in that output""" - with mock.patch(CHECK_OUTPUT, mock.Mock(return_value=output)): + with mock.patch(SUBPROCESS_EXEC, mock_exec(output)): with pytest.raises(ClientAuthenticationError) as ex: credential = AzureCliCredential() await credential.get_token("scope") @@ -122,7 +144,7 @@ async def test_parsing_error_does_not_expose_token(output): async def test_subprocess_error_does_not_expose_token(output): """Errors from the subprocess shouldn't expose access tokens in CLI output""" - with mock.patch(CHECK_OUTPUT, raise_called_process_error(1, output=output)): + with mock.patch(SUBPROCESS_EXEC, mock_exec(output, return_code=1)): with pytest.raises(ClientAuthenticationError) as ex: credential = AzureCliCredential() await credential.get_token("scope") From 0bd291653def4f8d01b0888e35b11b2c1c0a2fd0 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 5 Mar 2020 14:25:46 -0800 Subject: [PATCH 13/17] fix platform restrictions on test cases --- .../tests/test_cli_credential.py | 10 ++++----- .../tests/test_cli_credential_async.py | 21 +++++++++---------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/sdk/identity/azure-identity/tests/test_cli_credential.py b/sdk/identity/azure-identity/tests/test_cli_credential.py index 35299385869c..0f8870a4c85f 100644 --- a/sdk/identity/azure-identity/tests/test_cli_credential.py +++ b/sdk/identity/azure-identity/tests/test_cli_credential.py @@ -75,14 +75,12 @@ def test_cli_not_installed_windows(): AzureCliCredential().get_token("scope") -@pytest.mark.parametrize("platform", ("darwin", "linux2", "win32")) -def test_cannot_execute_shell(platform): +def test_cannot_execute_shell(): """The credential should raise CredentialUnavailableError when the subprocess doesn't start""" - with mock.patch(AzureCliCredential.__module__ + ".sys.platform", platform): - with mock.patch(CHECK_OUTPUT, mock.Mock(side_effect=OSError())): - with pytest.raises(CredentialUnavailableError): - AzureCliCredential().get_token("scope") + with mock.patch(CHECK_OUTPUT, mock.Mock(side_effect=OSError())): + with pytest.raises(CredentialUnavailableError): + AzureCliCredential().get_token("scope") def test_not_logged_in(): diff --git a/sdk/identity/azure-identity/tests/test_cli_credential_async.py b/sdk/identity/azure-identity/tests/test_cli_credential_async.py index 4bf5793a8831..7c97ac756c1c 100644 --- a/sdk/identity/azure-identity/tests/test_cli_credential_async.py +++ b/sdk/identity/azure-identity/tests/test_cli_credential_async.py @@ -4,6 +4,7 @@ # ------------------------------------ from datetime import datetime import json +import sys from unittest import mock from azure.identity import CredentialUnavailableError @@ -41,15 +42,15 @@ async def test_context_manager(): pass +@pytest.mark.skipif(not sys.platform.startswith("win"), reason="tests Windows-specific behavior") @pytest.mark.asyncio async def test_windows_fallback(): """The credential should fall back to the sync implementation when not using ProactorEventLoop on Windows""" with mock.patch("azure.identity.AzureCliCredential.get_token") as fallback: - with mock.patch(AzureCliCredential.__module__ + ".sys.platform", "win32"): - with mock.patch(AzureCliCredential.__module__ + ".asyncio.get_event_loop"): - credential = AzureCliCredential() - await credential.get_token("scope") + with mock.patch(AzureCliCredential.__module__ + ".asyncio.get_event_loop"): + credential = AzureCliCredential() + await credential.get_token("scope") assert fallback.call_count == 1 @@ -102,16 +103,14 @@ async def test_cli_not_installed_windows(): await credential.get_token("scope") -@pytest.mark.parametrize("platform", ("darwin", "linux2", "win32")) @pytest.mark.asyncio -async def test_cannot_execute_shell(platform): +async def test_cannot_execute_shell(): """The credential should raise CredentialUnavailableError when the subprocess doesn't start""" - with mock.patch(AzureCliCredential.__module__ + ".sys.platform", platform): - with mock.patch(SUBPROCESS_EXEC, mock.Mock(side_effect=OSError())): - with pytest.raises(CredentialUnavailableError): - credential = AzureCliCredential() - await credential.get_token("scope") + with mock.patch(SUBPROCESS_EXEC, mock.Mock(side_effect=OSError())): + with pytest.raises(CredentialUnavailableError): + credential = AzureCliCredential() + await credential.get_token("scope") @pytest.mark.asyncio From 5163bddb15a45d8d2702d041dd9d856b64239c93 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 6 Mar 2020 12:52:38 -0800 Subject: [PATCH 14/17] fall back to known install locations when PATH isn't set --- .../azure/identity/_credentials/azure_cli.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py b/sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py index 0fb23ef4525e..487ded23d9bc 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/azure_cli.py @@ -82,12 +82,34 @@ def parse_token(output): def get_safe_working_dir(): - """Invoke 'az' from a directory on $PATH to get 'az' from the path, not the executing program's directory""" + """Invoke 'az' from a directory on $PATH or a well-known install location, not the executing program's directory""" + + path = os.environ.get("PATH") - path = os.environ["PATH"] if sys.platform.startswith("win"): - return path.split(";")[0] - return path.split(":")[0] + if path: + return path.split(";")[0] + + # no system path; check well-known install locations + cli_path = "Microsoft SDKs\\Azure\\CLI2\\wbin" + for directory in (os.environ.get("PROGRAMFILES(X86)"), os.environ.get("PROGRAMFILES")): + if directory: + path = os.path.join(directory, cli_path) + if os.path.exists(os.path.join(path, "az.cmd")): + return path + + raise CredentialUnavailableError(message=CLI_NOT_FOUND) + + # linux or mac + if path: + return path.split(":")[0] + + # no system path; check well-known install locations + for path in ("/usr/bin", "/usr/local/bin"): + if os.path.exists(path + "/az"): + return path + + raise CredentialUnavailableError(message=CLI_NOT_FOUND) def sanitize_output(output): From 5a56278dd2dd9a24d31725d4887c8e0b956741da Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 6 Mar 2020 15:38:34 -0800 Subject: [PATCH 15/17] AzureCliCredential is internal --- sdk/identity/azure-identity/azure/identity/__init__.py | 2 -- sdk/identity/azure-identity/azure/identity/aio/__init__.py | 2 -- sdk/identity/azure-identity/tests/test_cli_credential.py | 4 ++-- .../azure-identity/tests/test_cli_credential_async.py | 4 ++-- sdk/identity/azure-identity/tests/test_default.py | 2 +- sdk/identity/azure-identity/tests/test_default_async.py | 3 ++- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/__init__.py b/sdk/identity/azure-identity/azure/identity/__init__.py index 6d1c88e72f4d..7648a37ffa19 100644 --- a/sdk/identity/azure-identity/azure/identity/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/__init__.py @@ -8,7 +8,6 @@ from ._constants import KnownAuthorities from ._credentials import ( AuthorizationCodeCredential, - AzureCliCredential, CertificateCredential, ChainedTokenCredential, ClientSecretCredential, @@ -24,7 +23,6 @@ __all__ = [ "AuthorizationCodeCredential", - "AzureCliCredential", "CertificateCredential", "ChainedTokenCredential", "ClientSecretCredential", diff --git a/sdk/identity/azure-identity/azure/identity/aio/__init__.py b/sdk/identity/azure-identity/azure/identity/aio/__init__.py index ee30973a07fb..775ee06abd85 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/aio/__init__.py @@ -6,7 +6,6 @@ from ._credentials import ( AuthorizationCodeCredential, - AzureCliCredential, CertificateCredential, ChainedTokenCredential, ClientSecretCredential, @@ -19,7 +18,6 @@ __all__ = [ "AuthorizationCodeCredential", - "AzureCliCredential", "CertificateCredential", "ClientSecretCredential", "DefaultAzureCredential", diff --git a/sdk/identity/azure-identity/tests/test_cli_credential.py b/sdk/identity/azure-identity/tests/test_cli_credential.py index 0f8870a4c85f..e432e6906fb0 100644 --- a/sdk/identity/azure-identity/tests/test_cli_credential.py +++ b/sdk/identity/azure-identity/tests/test_cli_credential.py @@ -5,8 +5,8 @@ from datetime import datetime import json -from azure.identity import AzureCliCredential, CredentialUnavailableError -from azure.identity._credentials.azure_cli import CLI_NOT_FOUND +from azure.identity import CredentialUnavailableError +from azure.identity._credentials.azure_cli import AzureCliCredential, CLI_NOT_FOUND from azure.core.exceptions import ClientAuthenticationError import subprocess diff --git a/sdk/identity/azure-identity/tests/test_cli_credential_async.py b/sdk/identity/azure-identity/tests/test_cli_credential_async.py index 7c97ac756c1c..c4fb64492971 100644 --- a/sdk/identity/azure-identity/tests/test_cli_credential_async.py +++ b/sdk/identity/azure-identity/tests/test_cli_credential_async.py @@ -8,7 +8,7 @@ from unittest import mock from azure.identity import CredentialUnavailableError -from azure.identity.aio import AzureCliCredential +from azure.identity.aio._credentials.azure_cli import AzureCliCredential from azure.identity._credentials.azure_cli import CLI_NOT_FOUND from azure.core.exceptions import ClientAuthenticationError import pytest @@ -47,7 +47,7 @@ async def test_context_manager(): async def test_windows_fallback(): """The credential should fall back to the sync implementation when not using ProactorEventLoop on Windows""" - with mock.patch("azure.identity.AzureCliCredential.get_token") as fallback: + with mock.patch("azure.identity._credentials.azure_cli.AzureCliCredential.get_token") as fallback: with mock.patch(AzureCliCredential.__module__ + ".asyncio.get_event_loop"): credential = AzureCliCredential() await credential.get_token("scope") diff --git a/sdk/identity/azure-identity/tests/test_default.py b/sdk/identity/azure-identity/tests/test_default.py index 2dac09513119..e7b1b98353a0 100644 --- a/sdk/identity/azure-identity/tests/test_default.py +++ b/sdk/identity/azure-identity/tests/test_default.py @@ -5,13 +5,13 @@ import os from azure.identity import ( - AzureCliCredential, DefaultAzureCredential, InteractiveBrowserCredential, KnownAuthorities, SharedTokenCacheCredential, ) from azure.identity._constants import EnvironmentVariables +from azure.identity._credentials.azure_cli import AzureCliCredential from azure.identity._credentials.managed_identity import ManagedIdentityCredential from six.moves.urllib_parse import urlparse diff --git a/sdk/identity/azure-identity/tests/test_default_async.py b/sdk/identity/azure-identity/tests/test_default_async.py index 9e6d5b0fd79f..6443e08c372a 100644 --- a/sdk/identity/azure-identity/tests/test_default_async.py +++ b/sdk/identity/azure-identity/tests/test_default_async.py @@ -8,7 +8,8 @@ from urllib.parse import urlparse from azure.identity import KnownAuthorities -from azure.identity.aio import AzureCliCredential, DefaultAzureCredential, SharedTokenCacheCredential +from azure.identity.aio import DefaultAzureCredential, SharedTokenCacheCredential +from azure.identity.aio._credentials.azure_cli import AzureCliCredential from azure.identity.aio._credentials.managed_identity import ManagedIdentityCredential from azure.identity._constants import EnvironmentVariables import pytest From 17e090ccd77d23b3c36fc4af5f6f17b2284b2871 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 6 Mar 2020 15:43:14 -0800 Subject: [PATCH 16/17] update changelog --- sdk/identity/azure-identity/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index cdd1815dd7ee..f5840efd0a4e 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -1,10 +1,10 @@ # Release History -## 1.4.0b1 (Unreleased) -- Added `AzureCliCredential`, which authenticates with the identity logged in -to the Azure CLI. This credential is part of `DefaultAzureCredential` by -default, but can be excluded with a keyword argument: +## 1.4.0b1 (2020-03-10) +- `DefaultAzureCredential` can now authenticate using the identity logged in to +the Azure CLI, unless explicitly disabled with a keyword argument: `DefaultAzureCredential(exclude_cli_credential=True)` +([#10092](https://github.com/Azure/azure-sdk-for-python/pull/10092)) ## 1.3.0 (2020-02-11) From 6b678cf526a25e7af47b67ea7d114cd631bf3890 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 6 Mar 2020 16:40:19 -0800 Subject: [PATCH 17/17] this is a beta package --- sdk/identity/azure-identity/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/setup.py b/sdk/identity/azure-identity/setup.py index e81a75eb44e8..669b99a6d80c 100644 --- a/sdk/identity/azure-identity/setup.py +++ b/sdk/identity/azure-identity/setup.py @@ -51,7 +51,7 @@ author_email="azpysdkhelp@microsoft.com", url="https://github.com/Azure/azure-sdk-for-python/tree/master/sdk/identity/azure-identity", classifiers=[ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7",