From a828156c967a56e5c5a1cb90ddbb8dfd0da87eab Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Thu, 31 Dec 2020 13:27:59 +0800 Subject: [PATCH 01/69] {Identity} Identity new features (#14690) --- azure-cli.pyproj | 2 +- azure-cli2017.pyproj | 2 +- scripts/ci/dependency_check.sh | 4 + scripts/install_full.sh | 5 +- scripts/release/debian/Dockerfile | 4 +- scripts/release/debian/build.sh | 12 +- scripts/release/debian/prepare.sh | 7 +- scripts/release/rpm/Dockerfile.centos | 4 +- scripts/release/rpm/Dockerfile.fedora | 4 +- scripts/release/rpm/azure-cli.spec | 17 +- scripts/release/rpm/build.sh | 2 +- src/azure-cli-core/azure/cli/core/_debug.py | 5 +- .../azure/cli/core/_identity.py | 778 +++++++ src/azure-cli-core/azure/cli/core/_msal.py | 2 + src/azure-cli-core/azure/cli/core/_profile.py | 1290 ++++------- .../azure/cli/core/adal_authentication.py | 248 --- .../azure/cli/core/commands/client_factory.py | 29 +- .../azure/cli/core/credential.py | 127 ++ .../azure/cli/core/profiles/_shared.py | 3 +- .../core/tests/test_adal_authentication.py | 87 - .../azure/cli/core/tests/test_identity.py | 131 ++ .../azure/cli/core/tests/test_profile.py | 1931 ++++++++--------- .../core/tests/test_profile_v2016_06_01.py | 1757 --------------- src/azure-cli-core/setup.py | 4 +- .../azure/cli/testsdk/patches.py | 42 +- ..._service_environment_commands_thru_mock.py | 4 +- .../test_functionapp_commands_thru_mock.py | 4 +- .../latest/test_webapp_commands_thru_mock.py | 4 +- .../cli/command_modules/configure/_consts.py | 2 + .../cli/command_modules/configure/custom.py | 8 +- .../command_modules/keyvault/_completers.py | 2 +- .../cli/command_modules/profile/__init__.py | 36 +- .../cli/command_modules/profile/_help.py | 67 +- .../cli/command_modules/profile/custom.py | 45 +- .../tests/latest/test_profile_custom.py | 33 +- .../azure/cli/command_modules/role/custom.py | 2 +- .../command_modules/servicefabric/custom.py | 2 +- .../azure/cli/command_modules/vm/_vm_utils.py | 2 +- .../azure/cli/command_modules/vm/custom.py | 4 +- src/azure-cli/requirements.opt.py3.Linux.txt | 2 + src/azure-cli/requirements.opt.py3.Trusty.txt | 2 + src/azure-cli/requirements.py3.Darwin.txt | 3 +- src/azure-cli/requirements.py3.Linux.txt | 3 +- src/azure-cli/requirements.py3.windows.txt | 3 +- 44 files changed, 2716 insertions(+), 4009 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/_identity.py delete mode 100644 src/azure-cli-core/azure/cli/core/adal_authentication.py create mode 100644 src/azure-cli-core/azure/cli/core/credential.py delete mode 100644 src/azure-cli-core/azure/cli/core/tests/test_adal_authentication.py create mode 100644 src/azure-cli-core/azure/cli/core/tests/test_identity.py delete mode 100644 src/azure-cli-core/azure/cli/core/tests/test_profile_v2016_06_01.py create mode 100644 src/azure-cli/requirements.opt.py3.Linux.txt create mode 100644 src/azure-cli/requirements.opt.py3.Trusty.txt diff --git a/azure-cli.pyproj b/azure-cli.pyproj index cae065a1daf..e5d60a885c5 100644 --- a/azure-cli.pyproj +++ b/azure-cli.pyproj @@ -23,7 +23,7 @@ 10.0 - + diff --git a/azure-cli2017.pyproj b/azure-cli2017.pyproj index 8144e45d8a3..e8e0a8f2c7d 100644 --- a/azure-cli2017.pyproj +++ b/azure-cli2017.pyproj @@ -23,7 +23,7 @@ 10.0 - + diff --git a/scripts/ci/dependency_check.sh b/scripts/ci/dependency_check.sh index be374175aab..947877ed06e 100755 --- a/scripts/ci/dependency_check.sh +++ b/scripts/ci/dependency_check.sh @@ -2,6 +2,10 @@ set -ev +if [ "$(uname)" != "Darwin" ]; then + sudo apt-get -y install libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 +fi + REPO_ROOT="$(dirname ${BASH_SOURCE[0]})/../.." # Uninstall any cruft that can poison the rest of the checks in this script. diff --git a/scripts/install_full.sh b/scripts/install_full.sh index bdda1836ef8..736c7ad1bd0 100755 --- a/scripts/install_full.sh +++ b/scripts/install_full.sh @@ -18,5 +18,8 @@ pushd ${REPO_ROOT} > /dev/null find src/ -name setup.py -type f | xargs -I {} dirname {} | grep -v azure-cli-testsdk | xargs pip install --no-deps pip install -r ./src/azure-cli/requirements.$(python ./scripts/get-python-version.py).$(uname).txt - +if [ -f "./src/azure-cli/requirements.opt.$(python ./scripts/get-python-version.py).$(uname).txt" ]; then + echo "./src/azure-cli/requirements.opt.$(python ./scripts/get-python-version.py).$(uname).txt exists." + pip install -r ./src/azure-cli/requirements.opt.$(python ./scripts/get-python-version.py).$(uname).txt +fi popd > /dev/null diff --git a/scripts/release/debian/Dockerfile b/scripts/release/debian/Dockerfile index bc408223447..0e6f0bd559e 100644 --- a/scripts/release/debian/Dockerfile +++ b/scripts/release/debian/Dockerfile @@ -3,8 +3,8 @@ FROM ${base_image} AS build-env # Update APT packages RUN apt-get update -RUN apt-get install -y libssl-dev libffi-dev python3-dev debhelper zlib1g-dev wget - +RUN apt-get install -y libssl-dev libffi-dev python3-dev debhelper zlib1g-dev wget libgirepository1.0-dev \ + libcairo2-dev gir1.2-secret-1 gnome-keyring # Download Python source code ARG python_version="3.6.10" ENV PYTHON_SRC_DIR=/usr/src/python diff --git a/scripts/release/debian/build.sh b/scripts/release/debian/build.sh index 6b007198344..332d9ca8e0f 100755 --- a/scripts/release/debian/build.sh +++ b/scripts/release/debian/build.sh @@ -22,6 +22,8 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" apt-get update apt-get install -y libssl-dev libffi-dev python3-dev debhelper zlib1g-dev apt-get install -y wget +apt-get install -y libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 pkg-config gnome-keyring libgtk2.0-dev +apt-get install -y glib-2.0 gir1.2-gtk-3.0 # Download Python source code PYTHON_SRC_DIR=$(mktemp -d) @@ -37,10 +39,18 @@ export PATH=$PATH:$WORKDIR/python_env/bin find ${WORKDIR}/src/ -name setup.py -type f | xargs -I {} dirname {} | grep -v azure-cli-testsdk | xargs pip3 install --no-deps pip3 install -r ${WORKDIR}/src/azure-cli/requirements.py3.$(uname).txt +if [[ -f "${WORKDIR}/src/azure-cli/requirements.opt.py3.$(uname).txt" && "${CLI_VERSION_REVISION:=1}" != *"trusty" && "${CLI_VERSION_REVISION:=1}" != *"jessie" ]]; then + pip3 install -r ${WORKDIR}/src/azure-cli/requirements.opt.py3.$(uname).txt +fi # Create create directory for debian build mkdir -p $WORKDIR/debian -$SCRIPT_DIR/prepare.sh $WORKDIR/debian $WORKDIR/az.completion $WORKDIR +if [[ "${CLI_VERSION_REVISION:=1}" == *"trusty" || "${CLI_VERSION_REVISION:=1}" == *"jessie" ]]; then + $SCRIPT_DIR/prepare.sh $WORKDIR/debian $WORKDIR/az.completion $WORKDIR +else + PYOBJECT_DEPENDENCY="gir1.2-secret-1, gnome-keyring" + $SCRIPT_DIR/prepare.sh $WORKDIR/debian $WORKDIR/az.completion $WORKDIR $PYOBJECT_DEPENDENCY +fi cd $WORKDIR dpkg-buildpackage -us -uc diff --git a/scripts/release/debian/prepare.sh b/scripts/release/debian/prepare.sh index ab3ec5f9181..325509176fe 100755 --- a/scripts/release/debian/prepare.sh +++ b/scripts/release/debian/prepare.sh @@ -33,6 +33,11 @@ TAB=$'\t' debian_dir=$1 completion_script=$2 source_dir=$3 +setup_depends="" +if [ ! -z "$4" ]; then + setup_depends=$4 +fi + mkdir $debian_dir/source echo '1.0' > $debian_dir/source/format @@ -58,7 +63,7 @@ Homepage: https://github.com/azure/azure-cli Package: azure-cli Architecture: all -Depends: \${shlibs:Depends}, \${misc:Depends} +Depends: \${shlibs:Depends}, \${misc:Depends}, $setup_depends Description: Azure CLI A great cloud needs great tools; we're excited to introduce Azure CLI, our next generation multi-platform command line experience for Azure. diff --git a/scripts/release/rpm/Dockerfile.centos b/scripts/release/rpm/Dockerfile.centos index a3ca29f1324..f02d34f4921 100644 --- a/scripts/release/rpm/Dockerfile.centos +++ b/scripts/release/rpm/Dockerfile.centos @@ -4,7 +4,7 @@ FROM centos:${tag} AS build-env ARG cli_version=dev RUN yum update -y -RUN yum install -y wget rpm-build gcc libffi-devel python3-devel openssl-devel make bash coreutils diffutils patch dos2unix python3-virtualenv +RUN yum install -y wget rpm-build gcc libffi-devel python3-devel openssl-devel make bash coreutils diffutils patch dos2unix python3-virtualenv gobject-introspection-devel cairo-devel pkgconfig cairo-gobject-devel WORKDIR /azure-cli @@ -17,7 +17,7 @@ RUN dos2unix ./scripts/release/rpm/azure-cli.spec && \ FROM centos:${tag} AS execution-env RUN yum update -y -RUN yum install -y python3 python3-virtualenv +RUN yum install -y python3 python3-virtualenv cairo cairo-gobject COPY --from=build-env /azure-cli-dev.rpm ./ RUN rpm -i ./azure-cli-dev.rpm && \ diff --git a/scripts/release/rpm/Dockerfile.fedora b/scripts/release/rpm/Dockerfile.fedora index c95d7b31f39..9a926a3a06c 100644 --- a/scripts/release/rpm/Dockerfile.fedora +++ b/scripts/release/rpm/Dockerfile.fedora @@ -4,7 +4,7 @@ FROM fedora:${tag} AS build-env ARG cli_version=dev RUN dnf update -y -RUN dnf install -y wget rpm-build gcc libffi-devel python3-devel python3-virtualenv openssl-devel make bash coreutils diffutils patch dos2unix perl +RUN dnf install -y wget rpm-build gcc libffi-devel python3-devel python3-virtualenv openssl-devel make bash coreutils diffutils patch dos2unix perl gobject-introspection-devel cairo-devel pkgconfig cairo-gobject-devel WORKDIR /azure-cli @@ -16,7 +16,7 @@ RUN dos2unix ./scripts/release/rpm/azure-cli.spec && \ FROM fedora:${tag} AS execution-env -RUN dnf install -y python3 python3-virtualenv +RUN dnf install -y python3 python3-virtualenv cairo cairo-gobject COPY --from=build-env /azure-cli-dev.rpm ./ RUN rpm -i ./azure-cli-dev.rpm diff --git a/scripts/release/rpm/azure-cli.spec b/scripts/release/rpm/azure-cli.spec index d172dc91092..1f601ee69ec 100644 --- a/scripts/release/rpm/azure-cli.spec +++ b/scripts/release/rpm/azure-cli.spec @@ -25,10 +25,10 @@ Version: %{version} Release: %{release} Url: https://docs.microsoft.com/cli/azure/install-azure-cli BuildArch: x86_64 -Requires: %{python_cmd} +Requires: %{python_cmd}, cairo, cairo-gobject -BuildRequires: gcc, libffi-devel, openssl-devel, perl -BuildRequires: %{python_cmd}-devel +BuildRequires: gcc, libffi-devel, openssl-devel, perl, binutils +BuildRequires: %{python_cmd}-devel, gobject-introspection-devel, cairo-devel, pkgconfig, cairo-gobject-devel %global _python_bytecompile_errors_terminate_build 0 @@ -48,13 +48,22 @@ deactivate # Fix up %{buildroot} appearing in some files... for d in %{buildroot}%{cli_lib_dir}/bin/*; do perl -p -i -e "s#%{buildroot}##g" $d; done; +for d in %{buildroot}%{cli_lib_dir}/lib/pkgconfig/*; do perl -p -i -e "s#%{buildroot}##g" $d; done; # Create executable mkdir -p %{buildroot}%{_bindir} -python_version=$(ls %{buildroot}%{cli_lib_dir}/lib/ | head -n 1) +python_version=$(ls %{buildroot}%{cli_lib_dir}/lib/ | grep "^python" | head -n 1) printf "#!/usr/bin/env bash\nAZ_INSTALLER=RPM PYTHONPATH=%{cli_lib_dir}/lib/${python_version}/site-packages /usr/bin/%{python_cmd} -sm azure.cli \"\$@\"" > %{buildroot}%{_bindir}/az rm %{buildroot}%{cli_lib_dir}/bin/python* %{buildroot}%{cli_lib_dir}/bin/pip* +# strip debug info which contains build root info +set +e +find "%{buildroot}%{cli_lib_dir}/lib/${python_version}/site-packages/gi" -type f -name "*.so" | while read so_file +do + strip --strip-debug "$so_file" +done +set -e + # Remove unused Network SDK API versions pushd %{buildroot}%{cli_lib_dir}/lib/${python_version}/site-packages/azure/mgmt/network/ > /dev/null rm -rf v2016_09_01 v2016_12_01 v2017_03_01 v2017_06_01 v2017_08_01 v2017_09_01 v2017_11_01 v2018_02_01 v2018_04_01 v2018_06_01 v2018_10_01 v2018_12_01 v2019_04_01 v2019_08_01 v2019_09_01 v2019_11_01 v2019_12_01 v2020_03_01 diff --git a/scripts/release/rpm/build.sh b/scripts/release/rpm/build.sh index d59e9664b9c..269d63542bc 100755 --- a/scripts/release/rpm/build.sh +++ b/scripts/release/rpm/build.sh @@ -5,7 +5,7 @@ yum check-update yum install -y gcc rpm-build rpm-level rpmlint make bash corutils diffutils \ path rpmdevtools python libffi-devel python3-devel openssl-devel \ - wget + wget gobject-introspection-devel cairo-devel pkg-config cairo-gobject-devel set -ev diff --git a/src/azure-cli-core/azure/cli/core/_debug.py b/src/azure-cli-core/azure/cli/core/_debug.py index e66b5b1c386..b873b5694dc 100644 --- a/src/azure-cli-core/azure/cli/core/_debug.py +++ b/src/azure-cli-core/azure/cli/core/_debug.py @@ -47,6 +47,5 @@ def change_ssl_cert_verification_track2(): return client_kwargs -def allow_debug_adal_connection(): - if should_disable_connection_verify(): - os.environ[ADAL_PYTHON_SSL_NO_VERIFY] = '1' +def msal_connection_verify(): + return not should_disable_connection_verify() diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/_identity.py new file mode 100644 index 00000000000..6813234c625 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/_identity.py @@ -0,0 +1,778 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import json + +from knack.util import CLIError +from knack.log import get_logger + +from azure.identity import ( + AuthenticationRecord, + InteractiveBrowserCredential, + DeviceCodeCredential, + UsernamePasswordCredential, + ClientSecretCredential, + CertificateCredential, + ManagedIdentityCredential, + EnvironmentCredential +) + +from ._environment import get_config_dir +from .util import get_file_json, resource_to_scopes, scopes_to_resource + +AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' + +logger = get_logger(__name__) + +_SERVICE_PRINCIPAL_ID = 'servicePrincipalId' +_SERVICE_PRINCIPAL_TENANT = 'servicePrincipalTenant' +_ACCESS_TOKEN = 'accessToken' +_SERVICE_PRINCIPAL_SECRET = 'secret' +_SERVICE_PRINCIPAL_CERT_FILE = 'certificateFile' +_SERVICE_PRINCIPAL_CERT_THUMBPRINT = 'thumbprint' + + +class Identity: # pylint: disable=too-many-instance-attributes + """Class to interact with Azure Identity. + """ + MANAGED_IDENTITY_TENANT_ID = "tenant_id" + MANAGED_IDENTITY_CLIENT_ID = "client_id" + MANAGED_IDENTITY_OBJECT_ID = "object_id" + MANAGED_IDENTITY_RESOURCE_ID = "resource_id" + MANAGED_IDENTITY_SYSTEM_ASSIGNED = 'systemAssignedIdentity' + MANAGED_IDENTITY_USER_ASSIGNED = 'userAssignedIdentity' + MANAGED_IDENTITY_TYPE = 'type' + MANAGED_IDENTITY_ID_TYPE = "id_type" + + CLOUD_SHELL_IDENTITY_UNIQUE_NAME = "unique_name" + + def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): + """ + + :param authority: + :param tenant_id: + :param client_id::param kwargs: + """ + self.authority = authority + self.tenant_id = tenant_id or "organizations" + self.client_id = client_id or AZURE_CLI_CLIENT_ID + # self._cred_cache = AdalCredentialCache() + self._cred_cache = None + self.allow_unencrypted = kwargs.pop('allow_unencrypted', True) + self._msal_app_instance = None + # Store for Service principal credential persistence + self._msal_secret_store = MsalSecretStore(fallback_to_plaintext=self.allow_unencrypted) + + # TODO: Allow disabling SSL verification + # The underlying requests lib of MSAL has been patched with Azure Core by MsalTransportAdapter + # connection_verify will be received by azure.core.configuration.ConnectionConfiguration + # However, MSAL defaults verify to True, thus overriding ConnectionConfiguration + # Still not work yet + from azure.cli.core._debug import change_ssl_cert_verification_track2 + self._credential_kwargs = {} + self._credential_kwargs.update(change_ssl_cert_verification_track2()) + # Turn on NetworkTraceLoggingPolicy to show DEBUG logs + self._credential_kwargs['logging_enable'] = True + + def _load_msal_cache(self): + # sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py:95 + from azure.identity._internal.persistent_cache import load_user_cache + # Store for user token persistence + cache = load_user_cache(self.allow_unencrypted) + return cache + + def _build_persistent_msal_app(self, authority): + # Initialize _msal_app for logout, token migration which Azure Identity doesn't support + from msal import PublicClientApplication + msal_app = PublicClientApplication(authority=authority, client_id=self.client_id, + token_cache=self._load_msal_cache(), + verify=self._credential_kwargs.get('connection_verify', True)) + return msal_app + + @property + def _msal_app(self): + if not self._msal_app_instance: + # Build the authority in MSAL style, like https://login.microsoftonline.com/your_tenant + msal_authority = "https://{}/{}".format(self.authority, self.tenant_id) + self._msal_app_instance = self._build_persistent_msal_app(msal_authority) + return self._msal_app_instance + + def login_with_interactive_browser(self, scopes=None): + """ + :param scopes: Scopes for the `authenticate` method call (initial /authorize API) + :return: + """ + # Use InteractiveBrowserCredential + credential = InteractiveBrowserCredential(authority=self.authority, + tenant_id=self.tenant_id, + client_id=self.client_id, + enable_persistent_cache=True, + allow_unencrypted_cache=self.allow_unencrypted, + **self._credential_kwargs) + auth_record = credential.authenticate(scopes=scopes) + # todo: remove after ADAL token deprecation + if self._cred_cache: + self._cred_cache.add_credential(credential) + return credential, auth_record + + def login_with_device_code(self, scopes=None): + # Use DeviceCodeCredential + def prompt_callback(verification_uri, user_code, _): + # expires_on is discarded + logger.warning("To sign in, use a web browser to open the page %s and enter the code %s to authenticate.", + verification_uri, user_code) + try: + credential = DeviceCodeCredential(authority=self.authority, + tenant_id=self.tenant_id, + client_id=self.client_id, + enable_persistent_cache=True, + prompt_callback=prompt_callback, + allow_unencrypted_cache=self.allow_unencrypted, + **self._credential_kwargs) + + auth_record = credential.authenticate(scopes=scopes) + # todo: remove after ADAL token deprecation + if self._cred_cache: + self._cred_cache.add_credential(credential) + return credential, auth_record + except ValueError as ex: + logger.debug('Device code authentication failed: %s', str(ex)) + if 'PyGObject' in str(ex): + raise CLIError("PyGObject is required to encrypt the persistent cache. Please install that lib or " + "allow fallback to plaintext if encrypt credential fail via 'az configure'.") + raise + + def login_with_username_password(self, username, password, scopes=None): + # Use UsernamePasswordCredential + credential = UsernamePasswordCredential(authority=self.authority, + tenant_id=self.tenant_id, + client_id=self.client_id, + username=username, + password=password, + enable_persistent_cache=True, + allow_unencrypted_cache=self.allow_unencrypted, + **self._credential_kwargs) + auth_record = credential.authenticate(scopes=scopes) + + # todo: remove after ADAL token deprecation + if self._cred_cache: + self._cred_cache.add_credential(credential, scopes, self.authority) + return credential, auth_record + + def login_with_service_principal_secret(self, client_id, client_secret): + # Use ClientSecretCredential + # TODO: Persist to encrypted cache + # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/pull/44 + sp_auth = ServicePrincipalAuth(client_id, self.tenant_id, secret=client_secret) + entry = sp_auth.get_entry_to_persist() + self._msal_secret_store.save_service_principal_cred(entry) + # backward compatible with ADAL, to be deprecated + if self._cred_cache: + self._cred_cache.save_service_principal_cred(entry) + + credential = ClientSecretCredential(self.tenant_id, client_id, client_secret, authority=self.authority) + return credential + + def login_with_service_principal_certificate(self, client_id, certificate_path): + # Use CertificateCredential + # TODO: support use_cert_sn_issuer in CertificateCredential + credential = CertificateCredential(self.tenant_id, client_id, certificate_path, authority=self.authority) + + # CertificateCredential.__init__ will verify the certificate + # Persist to encrypted cache + # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/pull/44 + sp_auth = ServicePrincipalAuth(client_id, self.tenant_id, certificate_file=certificate_path) + entry = sp_auth.get_entry_to_persist() + self._msal_secret_store.save_service_principal_cred(entry) + + # backward compatible with ADAL, to be deprecated + if self._cred_cache: + entry = sp_auth.get_entry_to_persist_legacy() + self._cred_cache.save_service_principal_cred(entry) + return credential + + def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disable=too-many-statements + from msrestazure.tools import is_valid_resource_id + from requests import HTTPError + from azure.core.exceptions import ClientAuthenticationError + + credential = None + id_type = None + token = None + + # https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http + if identity_id: + # Try resource ID + if is_valid_resource_id(identity_id): + credential = ManagedIdentityCredential(identity_config={"mi_res_id": identity_id}) + token = credential.get_token(*scopes) + id_type = self.MANAGED_IDENTITY_RESOURCE_ID + else: + authenticated = False + try: + # Try client ID + credential = ManagedIdentityCredential(client_id=identity_id) + token = credential.get_token(*scopes) + id_type = self.MANAGED_IDENTITY_CLIENT_ID + authenticated = True + except ClientAuthenticationError as e: + logger.debug('Managed Identity authentication error: %s', e.message) + logger.info('Username is not an MSI client id') + except HTTPError as ex: + if ex.response.reason == 'Bad Request' and ex.response.status == 400: + logger.info('Username is not an MSI client id') + else: + raise + + if not authenticated: + try: + # Try object ID + credential = ManagedIdentityCredential(identity_config={"object_id": identity_id}) + token = credential.get_token(*scopes) + id_type = self.MANAGED_IDENTITY_OBJECT_ID + authenticated = True + except ClientAuthenticationError as e: + logger.debug('Managed Identity authentication error: %s', e.message) + logger.info('Username is not an MSI object id') + except HTTPError as ex: + if ex.response.reason == 'Bad Request' and ex.response.status == 400: + logger.info('Username is not an MSI object id') + else: + raise + + if not authenticated: + raise CLIError('Failed to connect to MSI, check your managed service identity id.') + + else: + # Use the default managed identity. It can be either system assigned or user assigned. + credential = ManagedIdentityCredential() + token = credential.get_token(*scopes) + + decoded = _decode_access_token(token) + resource_id = decoded.get('xms_mirid') + # User-assigned identity has resourceID as + # /subscriptions/xxx/resourcegroups/xxx/providers/Microsoft.ManagedIdentity/userAssignedIdentities/xxx + if resource_id and 'Microsoft.ManagedIdentity' in resource_id: + mi_type = self.MANAGED_IDENTITY_USER_ASSIGNED + else: + mi_type = self.MANAGED_IDENTITY_SYSTEM_ASSIGNED + + managed_identity_info = { + self.MANAGED_IDENTITY_TYPE: mi_type, + # The type of the ID provided with --username, only valid for a user-assigned managed identity + self.MANAGED_IDENTITY_ID_TYPE: id_type, + self.MANAGED_IDENTITY_TENANT_ID: decoded['tid'], + self.MANAGED_IDENTITY_CLIENT_ID: decoded['appid'], + self.MANAGED_IDENTITY_OBJECT_ID: decoded['oid'], + self.MANAGED_IDENTITY_RESOURCE_ID: resource_id, + } + logger.debug('Using Managed Identity: %s', json.dumps(managed_identity_info)) + + return credential, managed_identity_info + + def login_in_cloud_shell(self, scopes): + credential = ManagedIdentityCredential() + # As Managed Identity doesn't have ID token, we need to get an initial access token and extract info from it + # The scopes is only used for acquiring the initial access token + token = credential.get_token(*scopes) + decoded = _decode_access_token(token) + + cloud_shell_identity_info = { + self.MANAGED_IDENTITY_TENANT_ID: decoded['tid'], + # For getting the user email in Cloud Shell, maybe 'email' can also be used + self.CLOUD_SHELL_IDENTITY_UNIQUE_NAME: decoded.get('unique_name', 'N/A') + } + logger.warning('Using Cloud Shell Managed Identity: %s', json.dumps(cloud_shell_identity_info)) + return credential, cloud_shell_identity_info + + def logout_user(self, user): + accounts = self._msal_app.get_accounts(user) + logger.info('Before account removal:') + logger.info(json.dumps(accounts)) + + # `accounts` are the same user in all tenants, log out all of them + for account in accounts: + self._msal_app.remove_account(account) + + accounts = self._msal_app.get_accounts(user) + logger.info('After account removal:') + logger.info(json.dumps(accounts)) + + def logout_sp(self, sp): + # remove service principal secrets + self._msal_secret_store.remove_cached_creds(sp) + + def logout_all(self): + # TODO: Support multi-authority logout + accounts = self._msal_app.get_accounts() + logger.info('Before account removal:') + logger.info(json.dumps(accounts)) + + for account in accounts: + self._msal_app.remove_account(account) + + accounts = self._msal_app.get_accounts() + logger.info('After account removal:') + logger.info(json.dumps(accounts)) + # remove service principal secrets + self._msal_secret_store.remove_all_cached_creds() + + def get_user(self, user=None): + accounts = self._msal_app.get_accounts(user) if user else self._msal_app.get_accounts() + return accounts + + def get_user_credential(self, username): + accounts = self._msal_app.get_accounts(username) + + # TODO: Confirm with MSAL team that username can uniquely identify the account + if not accounts: + raise CLIError("User {} doesn't exist in the credential cache. The user could have been logged out by " + "another application that uses Single Sign-On. " + "Please run `az login` to re-login.".format(username)) + account = accounts[0] + auth_record = AuthenticationRecord(self.tenant_id, self.client_id, self.authority, + account['home_account_id'], username) + return InteractiveBrowserCredential(authentication_record=auth_record, disable_automatic_authentication=True, + enable_persistent_cache=True, + allow_unencrypted_cache=self.allow_unencrypted, + **self._credential_kwargs) + + def get_service_principal_credential(self, client_id, use_cert_sn_issuer): + client_secret, certificate_path = \ + self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) + # TODO: support use_cert_sn_issuer in CertificateCredential + if client_secret: + return ClientSecretCredential(self.tenant_id, client_id, client_secret) + if certificate_path: + return CertificateCredential(self.tenant_id, client_id, certificate_path) + raise CLIError("Secret of service principle {} not found. Please run 'az login'".format(client_id)) + + def get_environment_credential(self): + username = os.environ.get('AZURE_USERNAME') + client_id = os.environ.get('AZURE_CLIENT_ID') + + # If the user doesn't provide AZURE_CLIENT_ID, fill it will Azure CLI's client ID + if username and not client_id: + logger.info("set AZURE_CLIENT_ID=%s", AZURE_CLI_CLIENT_ID) + os.environ['AZURE_CLIENT_ID'] = AZURE_CLI_CLIENT_ID + + return EnvironmentCredential(**self._credential_kwargs) + + @staticmethod + def get_managed_identity_credential(client_id=None): + return ManagedIdentityCredential(client_id=client_id) + + def migrate_tokens(self): + """Migrate ADAL token cache to MSAL.""" + logger.warning("Migrating token cache from ADAL to MSAL.") + + entries = AdalCredentialCache()._load_tokens_from_file() # pylint: disable=protected-access + if not entries: + logger.debug("No ADAL token cache found.") + return + + for entry in entries: + try: + # TODO: refine the filter logic + if 'userId' in entry: + # User account + username = entry['userId'] + authority = entry['_authority'] + scopes = resource_to_scopes(entry['resource']) + refresh_token = entry['refreshToken'] + + msal_app = self._build_persistent_msal_app(authority) + # TODO: Not work in ADFS: + # {'error': 'invalid_grant', 'error_description': "MSIS9614: The refresh token received in + # 'refresh_token' parameter is invalid."} + logger.warning("Migrating refresh token: username: %s, authority: %s, scopes: %s", + username, authority, scopes) + token_dict = msal_app.acquire_token_by_refresh_token(refresh_token, scopes) + if 'error' in token_dict: + raise CLIError("Failed to migrate token from ADAL cache to MSAL cache. {}".format(token_dict)) + else: + # Service principal account + logger.warning("Migrating service principal secret: servicePrincipalId: %s, " + "servicePrincipalTenant: %s", + entry['servicePrincipalId'], entry['servicePrincipalTenant']) + self._msal_secret_store.save_service_principal_cred(entry) + except CLIError: + # Ignore failed tokens + continue + + # TODO: Delete accessToken.json after migration (accessToken.json deprecation) + + def serialize_token_cache(self, path=None): + path = path or os.path.join(get_config_dir(), "msal.cache.snapshot.json") + path = os.path.expanduser(path) + logger.warning("Token cache is exported to '%s'. The exported cache is unencrypted. " + "It contains login information of all logged-in users. Make sure you protect it safely.", path) + + cache = self._load_msal_cache() + cache._reload_if_necessary() # pylint: disable=protected-access + with open(path, "w") as fd: + fd.write(cache.serialize()) + + +TOKEN_FIELDS_EXCLUDED_FROM_PERSISTENCE = ['familyName', + 'givenName', + 'isUserIdDisplayable', + 'tenantId'] +_TOKEN_ENTRY_USER_ID = 'userId' + + +class AdalCredentialCache: + """Caches secrets in ADAL format, will be deprecated + """ + + # TODO: Persist SP to encrypted cache + def __init__(self, async_persist=False): + + # AZURE_ACCESS_TOKEN_FILE is used by Cloud Console and not meant to be user configured + self._token_file = (os.environ.get('AZURE_ACCESS_TOKEN_FILE', None) or + os.path.join(get_config_dir(), 'accessTokens.json')) + self._service_principal_creds = [] + self._adal_token_cache_attr = None + self._should_flush_to_disk = False + self._async_persist = async_persist + if async_persist: + import atexit + atexit.register(self.flush_to_disk) + + def _load_tokens_from_file(self): + if os.path.isfile(self._token_file): + try: + return get_file_json(self._token_file, throw_on_empty=False) or [] + except (CLIError, ValueError) as ex: + raise CLIError("Failed to load token files. If you have a repro, please log an issue at " + "https://github.com/Azure/azure-cli/issues. At the same time, you can clean " + "up by running 'az account clear' and then 'az login'. (Inner Error: {})".format(ex)) + return [] + + def _delete_token_file(self): + try: + os.remove(self._token_file) + except FileNotFoundError: + pass + + def persist_cached_creds(self): + self._should_flush_to_disk = True + if not self._async_persist: + self.flush_to_disk() + + def flush_to_disk(self): + if self._should_flush_to_disk: + with os.fdopen(os.open(self._token_file, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), + 'w+') as cred_file: + items = self.adal_token_cache.read_items() + all_creds = [entry for _, entry in items] + + # trim away useless fields (needed for cred sharing with xplat) + for i in all_creds: + for key in TOKEN_FIELDS_EXCLUDED_FROM_PERSISTENCE: + i.pop(key, None) + + all_creds.extend(self._service_principal_creds) + cred_file.write(json.dumps(all_creds)) + + def retrieve_secret_of_service_principal(self, sp_id, tenant): + self.load_service_principal_creds() + matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] + if not matched: + raise CLIError("Could not retrieve credential from local cache for service principal {}. " + "Please run 'az login' for this service principal." + .format(sp_id)) + matched_with_tenant = [x for x in matched if tenant == x[_SERVICE_PRINCIPAL_TENANT]] + if matched_with_tenant: + cred = matched_with_tenant[0] + else: + logger.warning("Could not retrieve credential from local cache for service principal %s under tenant %s. " + "Trying credential under tenant %s, assuming that is an app credential.", + sp_id, tenant, matched[0][_SERVICE_PRINCIPAL_TENANT]) + cred = matched[0] + return cred.get(_ACCESS_TOKEN, None), cred.get(_SERVICE_PRINCIPAL_CERT_FILE, None) + + def save_service_principal_cred(self, sp_entry): + self.load_adal_token_cache() + matched = [x for x in self._service_principal_creds + if sp_entry[_SERVICE_PRINCIPAL_ID] == x[_SERVICE_PRINCIPAL_ID] and + sp_entry[_SERVICE_PRINCIPAL_TENANT] == x[_SERVICE_PRINCIPAL_TENANT]] + state_changed = False + if matched: + # pylint: disable=line-too-long + if (sp_entry.get(_ACCESS_TOKEN, None) != matched[0].get(_ACCESS_TOKEN, None) or + sp_entry.get(_SERVICE_PRINCIPAL_CERT_FILE, None) != matched[0].get(_SERVICE_PRINCIPAL_CERT_FILE, + None)): + self._service_principal_creds.remove(matched[0]) + self._service_principal_creds.append(sp_entry) + state_changed = True + else: + self._service_principal_creds.append(sp_entry) + state_changed = True + + if state_changed: + self.persist_cached_creds() + + # noinspection PyBroadException + # pylint: disable=protected-access + def add_credential(self, credential, scopes, authority): + try: + query = { + "client_id": AZURE_CLI_CLIENT_ID, + "environment": credential._auth_record.authority, + "home_account_id": credential._auth_record.home_account_id + } + refresh_token = credential._cache.find( + credential._cache.CredentialType.REFRESH_TOKEN, + # target=scopes, # AAD RTs are scope-independent + query=query) + access_token = credential.get_token(*scopes) + import datetime + entry = { + "tokenType": "Bearer", + "expiresOn": datetime.datetime.fromtimestamp(access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f"), + "resource": scopes_to_resource(scopes), + "userId": credential._auth_record.username, + "accessToken": access_token.token, + "refreshToken": refresh_token[0]['secret'], + "_clientId": AZURE_CLI_CLIENT_ID, + "_authority": '{}/{}'.format(authority, credential._auth_record.tenant_id), + "isMRRT": True + } + self.adal_token_cache.add([entry]) + self.persist_cached_creds() + except Exception as e: # pylint: disable=broad-except + logger.debug("Failed to store ADAL token: %s", e) + # swallow all errors since it does not impact az + + @property + def adal_token_cache(self): + return self.load_adal_token_cache() + + def load_adal_token_cache(self): + if self._adal_token_cache_attr is None: + import adal + all_entries = self._load_tokens_from_file() + self.load_service_principal_creds(all_entries=all_entries) + real_token = [x for x in all_entries if x not in self._service_principal_creds] + self._adal_token_cache_attr = adal.TokenCache(json.dumps(real_token)) + return self._adal_token_cache_attr + + def load_service_principal_creds(self, **kwargs): + creds = kwargs.pop("all_entries", None) + if not creds: + creds = self._load_tokens_from_file() + for c in creds: + if c.get(_SERVICE_PRINCIPAL_ID): + self._service_principal_creds.append(c) + return self._service_principal_creds + + def remove_cached_creds(self, user_or_sp): + state_changed = False + # clear AAD tokens + tokens = self.adal_token_cache.find({_TOKEN_ENTRY_USER_ID: user_or_sp}) + if tokens: + state_changed = True + self.adal_token_cache.remove(tokens) + + # clear service principal creds + matched = [x for x in self._service_principal_creds + if x[_SERVICE_PRINCIPAL_ID] == user_or_sp] + if matched: + state_changed = True + self._service_principal_creds = [x for x in self._service_principal_creds + if x not in matched] + + if state_changed: + self.persist_cached_creds() + + def remove_all_cached_creds(self): + # we can clear file contents, but deleting it is simpler + self._delete_token_file() + + +class ServicePrincipalAuth: # pylint: disable=too-few-public-methods + + def __init__(self, client_id, tenant_id, secret=None, certificate_file=None, use_cert_sn_issuer=None): + if not (secret or certificate_file): + raise CLIError('Missing secret or certificate in order to ' + 'authenticate through a service principal') + self.client_id = client_id + self.tenant_id = tenant_id + if certificate_file: + from OpenSSL.crypto import load_certificate, FILETYPE_PEM + self.certificate_file = certificate_file + self.public_certificate = None + with open(certificate_file, 'r') as file_reader: + self.cert_file_string = file_reader.read() + cert = load_certificate(FILETYPE_PEM, self.cert_file_string) + self.thumbprint = cert.digest("sha1").decode() + if use_cert_sn_issuer: + import re + # low-tech but safe parsing based on + # https://github.com/libressl-portable/openbsd/blob/master/src/lib/libcrypto/pem/pem.h + match = re.search(r'\-+BEGIN CERTIFICATE.+\-+(?P[^-]+)\-+END CERTIFICATE.+\-+', + self.cert_file_string, re.I) + self.public_certificate = match.group('public').strip() + else: + self.secret = secret + + def get_entry_to_persist_legacy(self): + entry = { + _SERVICE_PRINCIPAL_ID: self.client_id, + _SERVICE_PRINCIPAL_TENANT: self.tenant_id, + } + if hasattr(self, 'secret'): + entry[_ACCESS_TOKEN] = self.secret + else: + entry[_SERVICE_PRINCIPAL_CERT_FILE] = self.certificate_file + entry[_SERVICE_PRINCIPAL_CERT_THUMBPRINT] = self.thumbprint + + return entry + + def get_entry_to_persist(self): + entry = { + _SERVICE_PRINCIPAL_ID: self.client_id, + _SERVICE_PRINCIPAL_TENANT: self.tenant_id, + } + if hasattr(self, 'secret'): + entry[_SERVICE_PRINCIPAL_SECRET] = self.secret + else: + entry[_SERVICE_PRINCIPAL_CERT_FILE] = self.certificate_file + return entry + + +class MsalSecretStore: + """Caches secrets in MSAL custom secret store for Service Principal authentication. + """ + + def __init__(self, fallback_to_plaintext=True): + self._token_file = os.path.join(get_config_dir(), 'msalSecrets.cache') + self._lock_file = self._token_file + '.lock' + self._service_principal_creds = [] + self._fallback_to_plaintext = fallback_to_plaintext + + def retrieve_secret_of_service_principal(self, sp_id, tenant): + self._load_cached_creds() + matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] + if not matched: + raise CLIError("Could not retrieve credential from local cache for service principal {}. " + "Please run 'az login' for this service principal." + .format(sp_id)) + matched_with_tenant = [x for x in matched if tenant == x[_SERVICE_PRINCIPAL_TENANT]] + if matched_with_tenant: + cred = matched_with_tenant[0] + else: + logger.warning("Could not retrieve credential from local cache for service principal %s under tenant %s. " + "Trying credential under tenant %s, assuming that is an app credential.", + sp_id, tenant, matched[0][_SERVICE_PRINCIPAL_TENANT]) + cred = matched[0] + return cred.get(_SERVICE_PRINCIPAL_SECRET, None), cred.get(_SERVICE_PRINCIPAL_CERT_FILE, None) + + def save_service_principal_cred(self, sp_entry): + self._load_cached_creds() + matched = [x for x in self._service_principal_creds + if sp_entry[_SERVICE_PRINCIPAL_ID] == x[_SERVICE_PRINCIPAL_ID] and + sp_entry[_SERVICE_PRINCIPAL_TENANT] == x[_SERVICE_PRINCIPAL_TENANT]] + state_changed = False + if matched: + # pylint: disable=line-too-long + if (sp_entry.get(_ACCESS_TOKEN, None) != matched[0].get(_ACCESS_TOKEN, None) or + sp_entry.get(_SERVICE_PRINCIPAL_CERT_FILE, None) != matched[0].get(_SERVICE_PRINCIPAL_CERT_FILE, + None)): + self._service_principal_creds.remove(matched[0]) + self._service_principal_creds.append(sp_entry) + state_changed = True + else: + self._service_principal_creds.append(sp_entry) + state_changed = True + + if state_changed: + self._persist_cached_creds() + + def remove_cached_creds(self, user_or_sp): + self._load_cached_creds() + state_changed = False + + # clear service principal creds + matched = [x for x in self._service_principal_creds + if x[_SERVICE_PRINCIPAL_ID] == user_or_sp] + if matched: + state_changed = True + self._service_principal_creds = [x for x in self._service_principal_creds + if x not in matched] + + if state_changed: + self._persist_cached_creds() + + def remove_all_cached_creds(self): + try: + os.remove(self._token_file) + except FileNotFoundError: + pass + + def _persist_cached_creds(self): + persistence = self._build_persistence() + from msal_extensions import CrossPlatLock + with CrossPlatLock(self._lock_file): + persistence.save(json.dumps(self._service_principal_creds)) + + def _load_cached_creds(self): + persistence = self._build_persistence() + from msal_extensions import CrossPlatLock + from msal_extensions.persistence import PersistenceNotFound + with CrossPlatLock(self._lock_file): + try: + self._service_principal_creds = json.loads(persistence.load()) + except PersistenceNotFound: + pass + except Exception as ex: + raise CLIError("Failed to load token files. If you have a repro, please log an issue at " + "https://github.com/Azure/azure-cli/issues. At the same time, you can clean " + "up by running 'az account clear' and then 'az login'. (Inner Error: {})".format(ex)) + + def _build_persistence(self): + # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/0.2.2/sample/persistence_sample.py + from msal_extensions import FilePersistenceWithDataProtection, \ + KeychainPersistence, \ + LibsecretPersistence, \ + FilePersistence + + import sys + if sys.platform.startswith('win'): + return FilePersistenceWithDataProtection(self._token_file) + if sys.platform.startswith('darwin'): + # todo: support darwin + return KeychainPersistence(self._token_file, "Microsoft.Developer.IdentityService", "MSALCustomCache") + if sys.platform.startswith('linux'): + try: + return LibsecretPersistence( + self._token_file, + schema_name="MSALCustomToken", + attributes={"MsalClientID": "Microsoft.Developer.IdentityService"} + ) + except: # pylint: disable=bare-except + if not self._fallback_to_plaintext: + raise + # todo: add missing lib in message + logger.warning("Encryption unavailable. Opting in to plain text.") + return FilePersistence(self._token_file) + + def _serialize_secrets(self): + # ONLY FOR DEBUGGING PURPOSE. DO NOT USE IN PRODUCTION CODE. + logger.warning("Secrets are serialized as plain text and saved to `msalSecrets.cache.json`.") + with open(self._token_file + ".json", "w") as fd: + fd.write(json.dumps(self._service_principal_creds)) + + +def _decode_access_token(token): + # Decode the access token. We can do the same with https://jwt.ms + from msal.oauth2cli.oidc import decode_part + access_token = token.token + + # Access token consists of headers.claims.signature. Decode the claim part + decoded_str = decode_part(access_token.split('.')[1]) + return json.loads(decoded_str) diff --git a/src/azure-cli-core/azure/cli/core/_msal.py b/src/azure-cli-core/azure/cli/core/_msal.py index 6c9960a68db..8ca3fb10634 100644 --- a/src/azure-cli-core/azure/cli/core/_msal.py +++ b/src/azure-cli-core/azure/cli/core/_msal.py @@ -10,6 +10,7 @@ class AdalRefreshTokenBasedClientApplication(ClientApplication): """ This is added only for vmssh feature. It is a temporary solution and will deprecate after MSAL adopted completely. + todo: msal """ def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( self, authority, scopes, account, **kwargs): @@ -17,6 +18,7 @@ def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family( return self._acquire_token_silent_by_finding_specific_refresh_token( authority, scopes, None, **kwargs) + # pylint:disable=arguments-differ def _acquire_token_silent_by_finding_specific_refresh_token( self, authority, scopes, query, rt_remover=None, break_condition=lambda response: False, **kwargs): diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 9a11f577ab8..42340581b6d 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -6,23 +6,19 @@ from __future__ import print_function import collections -import errno -import json + import os import os.path import re -import string from copy import deepcopy from enum import Enum from knack.log import get_logger from knack.util import CLIError - -from azure.cli.core._environment import get_config_dir from azure.cli.core._session import ACCOUNT -from azure.cli.core.util import get_file_json, in_cloud_console, open_page_in_browser, can_launch_browser,\ - is_windows, is_wsl +from azure.cli.core.util import in_cloud_console, can_launch_browser, resource_to_scopes from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription +from azure.cli.core._identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID logger = get_logger(__name__) @@ -41,33 +37,20 @@ _MANAGED_BY_TENANTS = 'managedByTenants' _USER_ENTITY = 'user' _USER_NAME = 'name' +_CLIENT_ID = 'clientId' _CLOUD_SHELL_ID = 'cloudShellID' _SUBSCRIPTIONS = 'subscriptions' _INSTALLATION_ID = 'installationId' +_USE_MSAL_TOKEN_CACHE = 'useMsalTokenCache' _ENVIRONMENT_NAME = 'environmentName' _STATE = 'state' _USER_TYPE = 'type' _USER = 'user' _SERVICE_PRINCIPAL = 'servicePrincipal' -_SERVICE_PRINCIPAL_ID = 'servicePrincipalId' -_SERVICE_PRINCIPAL_TENANT = 'servicePrincipalTenant' -_SERVICE_PRINCIPAL_CERT_FILE = 'certificateFile' -_SERVICE_PRINCIPAL_CERT_THUMBPRINT = 'thumbprint' +_IS_ENVIRONMENT_CREDENTIAL = 'isEnvironmentCredential' _SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH = 'useCertSNIssuerAuth' _TOKEN_ENTRY_USER_ID = 'userId' _TOKEN_ENTRY_TOKEN_TYPE = 'tokenType' -# This could mean either real access token, or client secret of a service principal -# This naming is no good, but can't change because xplat-cli does so. -_ACCESS_TOKEN = 'accessToken' -_REFRESH_TOKEN = 'refreshToken' - -TOKEN_FIELDS_EXCLUDED_FROM_PERSISTENCE = ['familyName', - 'givenName', - 'isUserIdDisplayable', - 'tenantId'] - -_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' -_COMMON_TENANT = 'common' _TENANT_LEVEL_ACCOUNT_NAME = 'N/A(tenant level account)' @@ -88,46 +71,23 @@ def load_subscriptions(cli_ctx, all_clouds=False, refresh=False): return subscriptions -def _get_authority_url(cli_ctx, tenant): - authority_url = cli_ctx.cloud.endpoints.active_directory - is_adfs = bool(re.match('.+(/adfs|/adfs/)$', authority_url, re.I)) - if is_adfs: - authority_url = authority_url.rstrip('/') # workaround: ADAL is known to reject auth urls with trailing / - else: - authority_url = authority_url.rstrip('/') + '/' + (tenant or _COMMON_TENANT) - return authority_url, is_adfs - - -def _authentication_context_factory(cli_ctx, tenant, cache): - import adal - authority_url, is_adfs = _get_authority_url(cli_ctx, tenant) - return adal.AuthenticationContext(authority_url, cache=cache, api_version=None, validate_authority=(not is_adfs)) +def _detect_adfs_authority(authority_url, tenant): + """Prepare authority and tenant for Azure Identity with ADFS support. + If `authority_url` ends with '/adfs', `tenant` will be set to 'adfs'. For example: + 'https://adfs.redmond.azurestack.corp.microsoft.com/adfs' + -> ('https://adfs.redmond.azurestack.corp.microsoft.com/', 'adfs') + """ + authority_url = authority_url.rstrip('/') + if authority_url.endswith('/adfs'): + authority_url = authority_url[:-len('/adfs')] + # The custom tenant is discarded in ADFS environment + tenant = 'adfs' -_AUTH_CTX_FACTORY = _authentication_context_factory - - -def _load_tokens_from_file(file_path): - if os.path.isfile(file_path): - try: - return get_file_json(file_path, throw_on_empty=False) or [] - except (CLIError, ValueError) as ex: - raise CLIError("Failed to load token files. If you have a repro, please log an issue at " - "https://github.com/Azure/azure-cli/issues. At the same time, you can clean " - "up by running 'az account clear' and then 'az login'. (Inner Error: {})".format(ex)) - return [] - - -def _delete_file(file_path): - try: - os.remove(file_path) - except OSError as e: - if e.errno != errno.ENOENT: - raise + return authority_url, tenant def get_credential_types(cli_ctx): - class CredentialType(Enum): # pylint: disable=too-few-public-methods cloud = get_active_cloud(cli_ctx) management = cli_ctx.cloud.endpoints.management @@ -140,112 +100,266 @@ def _get_cloud_console_token_endpoint(): return os.environ.get('MSI_ENDPOINT') -# pylint: disable=too-many-lines,too-many-instance-attributes -class Profile: +def _attach_token_tenant(subscription, tenant): + """Attach the token tenant ID to the subscription as tenant_id, so that CLI knows which token should be used + to access the subscription. + + This function supports multiple APIs: + - v2016_06_01's Subscription doesn't have tenant_id + - v2019_11_01's Subscription has tenant_id representing the home tenant ID. It will mapped to home_tenant_id + """ + if hasattr(subscription, "tenant_id"): + setattr(subscription, 'home_tenant_id', subscription.tenant_id) + setattr(subscription, 'tenant_id', tenant) + - _global_creds_cache = None +# pylint: disable=too-many-lines,too-many-instance-attributes,unused-argument +class Profile: - def __init__(self, storage=None, auth_ctx_factory=None, use_global_creds_cache=True, - async_persist=True, cli_ctx=None): + def __init__(self, cli_ctx=None, storage=None, auth_ctx_factory=None, use_global_creds_cache=True, + async_persist=True, store_adal_cache=False): + """Class to manage CLI's accounts (profiles) and identities (credentials). + + :param cli_ctx: + :param storage: + :param auth_ctx_factory: + :param use_global_creds_cache: + :param async_persist: + :param client_id: The AAD client ID for the CLI application. Default to Azure CLI's client ID. + :param scopes: The initial scopes for authentication (/authorize), it must include all scopes + for following get_token calls. Default to Azure Resource Manager of the current cloud. + :param store_adal_cache: Save tokens to the old ~/.azure/accessToken.json for backward compatibility. + This option will be deprecated very soon. + """ from azure.cli.core import get_default_cli self.cli_ctx = cli_ctx or get_default_cli() self._storage = storage or ACCOUNT - self.auth_ctx_factory = auth_ctx_factory or _AUTH_CTX_FACTORY - - if use_global_creds_cache: - # for perf, use global cache - if not Profile._global_creds_cache: - Profile._global_creds_cache = CredsCache(self.cli_ctx, self.auth_ctx_factory, - async_persist=async_persist) - self._creds_cache = Profile._global_creds_cache - else: - self._creds_cache = CredsCache(self.cli_ctx, self.auth_ctx_factory, async_persist=async_persist) self._management_resource_uri = self.cli_ctx.cloud.endpoints.management self._ad_resource_uri = self.cli_ctx.cloud.endpoints.active_directory_resource_id + self._authority = self.cli_ctx.cloud.endpoints.active_directory.replace('https://', '') self._ad = self.cli_ctx.cloud.endpoints.active_directory - self._msi_creds = None - - def find_subscriptions_on_login(self, - interactive, - username, - password, - is_service_principal, - tenant, - use_device_code=False, - allow_no_subscriptions=False, - subscription_finder=None, - use_cert_sn_issuer=None): - from azure.cli.core._debug import allow_debug_adal_connection - allow_debug_adal_connection() - subscriptions = [] + self._adal_cache = None + if store_adal_cache: + self._adal_cache = AdalCredentialCache() + + # pylint: disable=too-many-branches,too-many-statements,too-many-locals + def login(self, + interactive, + username, + password, + is_service_principal, + tenant, + scopes=None, + client_id=AZURE_CLI_CLIENT_ID, + use_device_code=False, + allow_no_subscriptions=False, + subscription_finder=None, + use_cert_sn_issuer=None, + find_subscriptions=True): + + scopes = self._prepare_authenticate_scopes(scopes) + + credential = None + auth_record = None + # For ADFS, auth_tenant is 'adfs' + # https://github.com/Azure/azure-sdk-for-python/blob/661cd524e88f480c14220ed1f86de06aaff9a977/sdk/identity/azure-identity/CHANGELOG.md#L19 + authority, auth_tenant = _detect_adfs_authority(self.cli_ctx.cloud.endpoints.active_directory, tenant) + identity = Identity(authority=authority, tenant_id=auth_tenant, + client_id=client_id, + allow_unencrypted=self.cli_ctx.config + .getboolean('core', 'allow_fallback_to_plaintext', fallback=True), + cred_cache=self._adal_cache) if not subscription_finder: - subscription_finder = SubscriptionFinder(self.cli_ctx, - self.auth_ctx_factory, - self._creds_cache.adal_token_cache) + subscription_finder = SubscriptionFinder(self.cli_ctx, adal_cache=self._adal_cache) if interactive: if not use_device_code and (in_cloud_console() or not can_launch_browser()): logger.info('Detect no GUI is available, so fall back to device code') use_device_code = True if not use_device_code: + from azure.identity import CredentialUnavailableError try: - authority_url, _ = _get_authority_url(self.cli_ctx, tenant) - subscriptions = subscription_finder.find_through_authorization_code_flow( - tenant, self._ad_resource_uri, authority_url) - except RuntimeError: + credential, auth_record = identity.login_with_interactive_browser(scopes=scopes) + except CredentialUnavailableError: use_device_code = True logger.warning('Not able to launch a browser to log you in, falling back to device code...') if use_device_code: - subscriptions = subscription_finder.find_through_interactive_flow( - tenant, self._ad_resource_uri) + credential, auth_record = identity.login_with_device_code(scopes=scopes) else: if is_service_principal: if not tenant: raise CLIError('Please supply tenant using "--tenant"') - sp_auth = ServicePrincipalAuth(password, use_cert_sn_issuer) - subscriptions = subscription_finder.find_from_service_principal_id( - username, sp_auth, tenant, self._ad_resource_uri) - + if os.path.isfile(password): + credential = identity.login_with_service_principal_certificate(username, password) + else: + credential = identity.login_with_service_principal_secret(username, password) else: - subscriptions = subscription_finder.find_from_user_account( - username, password, tenant, self._ad_resource_uri) + credential, auth_record = identity.login_with_username_password(username, password, scopes=scopes) - if not allow_no_subscriptions and not subscriptions: - if username: - msg = "No subscriptions found for {}.".format(username) + # List tenants and find subscriptions by calling ARM + if find_subscriptions: + if tenant: + subscriptions = subscription_finder.find_using_specific_tenant(tenant, credential) else: - # Don't show username if bare 'az login' is used - msg = "No subscriptions found." - raise CLIError(msg) - - if is_service_principal: - self._creds_cache.save_service_principal_cred(sp_auth.get_entry_to_persist(username, - tenant)) - if self._creds_cache.adal_token_cache.has_state_changed: - self._creds_cache.persist_cached_creds() - - if allow_no_subscriptions: - t_list = [s.tenant_id for s in subscriptions] - bare_tenants = [t for t in subscription_finder.tenants if t not in t_list] - profile = Profile(cli_ctx=self.cli_ctx) - tenant_accounts = profile._build_tenant_level_accounts(bare_tenants) # pylint: disable=protected-access - subscriptions.extend(tenant_accounts) - if not subscriptions: - return [] + subscriptions = subscription_finder.find_using_common_tenant(auth_record.username, credential) + + if not subscriptions and not allow_no_subscriptions: + if username: + msg = "No subscriptions found for {}.".format(username) + else: + # Don't show username if bare 'az login' is used + msg = "No subscriptions found." + raise CLIError(msg) - consolidated = self._normalize_properties(subscription_finder.user_id, subscriptions, + if allow_no_subscriptions: + t_list = [s.tenant_id for s in subscriptions] + bare_tenants = [t for t in subscription_finder.tenants if t not in t_list] + profile = Profile(cli_ctx=self.cli_ctx) + tenant_accounts = profile._build_tenant_level_accounts(bare_tenants) # pylint: disable=protected-access + subscriptions.extend(tenant_accounts) + if not subscriptions: + return [] + else: + # Build a tenant account + bare_tenant = tenant or auth_record.tenant_id + subscriptions = self._build_tenant_level_accounts([bare_tenant]) + + if auth_record: + username = auth_record.username + + consolidated = self._normalize_properties(username, subscriptions, is_service_principal, bool(use_cert_sn_issuer)) self._set_subscriptions(consolidated) + # todo: remove after ADAL token deprecation + if self._adal_cache: + self._adal_cache.persist_cached_creds() # use deepcopy as we don't want to persist these changes to file. return deepcopy(consolidated) + def login_with_managed_identity(self, identity_id=None, allow_no_subscriptions=None, find_subscriptions=True, + scopes=None): + # pylint: disable=too-many-statements + + # https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview + # Managed identities for Azure resources is the new name for the service formerly known as + # Managed Service Identity (MSI). + + scopes = self._prepare_authenticate_scopes(scopes) + identity = Identity() + credential, mi_info = identity.login_with_managed_identity(scopes=scopes, identity_id=identity_id) + + tenant = mi_info[Identity.MANAGED_IDENTITY_TENANT_ID] + if find_subscriptions: + logger.info('Finding subscriptions...') + subscription_finder = SubscriptionFinder(self.cli_ctx) + subscriptions = subscription_finder.find_using_specific_tenant(tenant, credential) + if not subscriptions: + if allow_no_subscriptions: + subscriptions = self._build_tenant_level_accounts([tenant]) + else: + raise CLIError('No access was configured for the VM, hence no subscriptions were found. ' + "If this is expected, use '--allow-no-subscriptions' to have tenant level access.") + else: + subscriptions = self._build_tenant_level_accounts([tenant]) + + # Get info for persistence + user_name = mi_info[Identity.MANAGED_IDENTITY_TYPE] + id_type_to_identity_type = { + Identity.MANAGED_IDENTITY_CLIENT_ID: MsiAccountTypes.user_assigned_client_id, + Identity.MANAGED_IDENTITY_OBJECT_ID: MsiAccountTypes.user_assigned_object_id, + Identity.MANAGED_IDENTITY_RESOURCE_ID: MsiAccountTypes.user_assigned_resource_id, + None: MsiAccountTypes.system_assigned + } + + # Previously we persist user's input in assignedIdentityInfo: + # "assignedIdentityInfo": "MSI", + # "assignedIdentityInfo": "MSIClient-eecb2419-a29d-4580-a92a-f6a7b7b71300", + # "assignedIdentityInfo": "MSIObject-27c363a5-7016-4ae0-8540-818ec05673f1", + # "assignedIdentityInfo": "MSIResource-/subscriptions/.../providers/Microsoft.ManagedIdentity/ + # userAssignedIdentities/id", + # Now we persist the output - info extracted from the access token. + # All client_id, object_id, and resource_id are preserved. + # Also, the name "MSI" is deprecated. So will be assignedIdentityInfo. + legacy_identity_type = id_type_to_identity_type[mi_info[Identity.MANAGED_IDENTITY_ID_TYPE]] + legacy_base_name = ('{}-{}'.format(legacy_identity_type, identity_id) if identity_id else legacy_identity_type) + + consolidated = self._normalize_properties(user_name, subscriptions, is_service_principal=True, + user_assigned_identity_id=legacy_base_name, + managed_identity_info=mi_info) + self._set_subscriptions(consolidated) + return deepcopy(consolidated) + + def login_in_cloud_shell(self, allow_no_subscriptions=None, find_subscriptions=True, scopes=None): + # TODO: deprecate allow_no_subscriptions + scopes = self._prepare_authenticate_scopes(scopes) + identity = Identity() + credential, identity_info = identity.login_in_cloud_shell(scopes) + + tenant = identity_info[Identity.MANAGED_IDENTITY_TENANT_ID] + if find_subscriptions: + logger.info('Finding subscriptions...') + subscription_finder = SubscriptionFinder(self.cli_ctx) + subscriptions = subscription_finder.find_using_specific_tenant(tenant, credential) + if not subscriptions: + if allow_no_subscriptions: + subscriptions = self._build_tenant_level_accounts([tenant]) + else: + raise CLIError('No access was configured for the VM, hence no subscriptions were found. ' + "If this is expected, use '--allow-no-subscriptions' to have tenant level access.") + else: + subscriptions = self._build_tenant_level_accounts([tenant]) + + consolidated = self._normalize_properties(identity_info[Identity.CLOUD_SHELL_IDENTITY_UNIQUE_NAME], + subscriptions, is_service_principal=False) + for s in consolidated: + s[_USER_ENTITY][_CLOUD_SHELL_ID] = True + self._set_subscriptions(consolidated) + return deepcopy(consolidated) + + def login_with_environment_credential(self, find_subscriptions=True): + # pylint: disable=protected-access + identity = Identity() + + tenant_id = os.environ.get('AZURE_TENANT_ID') + username = os.environ.get('AZURE_USERNAME') + client_id = os.environ.get('AZURE_CLIENT_ID') + + credential = identity.get_environment_credential() + + authentication_record = None + if credential._credential.__class__.__name__ == 'UsernamePasswordCredential': + user_type = _USER + # For user account, credential._credential is a UsernamePasswordCredential. + # Login the user so that MSAL has it in cache. + authentication_record = credential._credential.authenticate() + else: + user_type = _SERVICE_PRINCIPAL + + if find_subscriptions: + subscription_finder = SubscriptionFinder(self.cli_ctx) + if tenant_id: + logger.info('Finding subscriptions under tenant %s.', tenant_id) + subscriptions = subscription_finder.find_using_specific_tenant(tenant_id, credential) + else: + logger.info('Finding subscriptions under all available tenants.') + subscriptions = subscription_finder.find_using_common_tenant(username, credential) + else: + # Use home tenant ID if tenant_id is not given + subscriptions = self._build_tenant_level_accounts([tenant_id or authentication_record.tenant_id]) + + consolidated = self._normalize_properties(username or client_id, subscriptions, + is_service_principal=(user_type == _SERVICE_PRINCIPAL), + is_environment=True) + self._set_subscriptions(consolidated) + return deepcopy(consolidated) + def _normalize_properties(self, user, subscriptions, is_service_principal, cert_sn_issuer_auth=None, - user_assigned_identity_id=None): + user_assigned_identity_id=None, managed_identity_info=None, is_environment=False): import sys consolidated = [] for s in subscriptions: @@ -269,6 +383,11 @@ def _normalize_properties(self, user, subscriptions, is_service_principal, cert_ _TENANT_ID: s.tenant_id, _ENVIRONMENT_NAME: self.cli_ctx.cloud.name } + + # Add _IS_ENVIRONMENT_CREDENTIAL for environment credential accounts, but not for normal accounts. + if is_environment: + subscription_dict[_USER_ENTITY][_IS_ENVIRONMENT_CREDENTIAL] = True + # For subscription account from Subscriptions - List 2019-06-01 and later. if subscription_dict[_SUBSCRIPTION_NAME] != _TENANT_LEVEL_ACCOUNT_NAME: if hasattr(s, 'home_tenant_id'): @@ -285,12 +404,23 @@ def _normalize_properties(self, user, subscriptions, is_service_principal, cert_ .format(cloud_name=self.cli_ctx.cloud.name)) subscription_dict[_MANAGED_BY_TENANTS] = [{_TENANT_ID: t.tenant_id} for t in s.managed_by_tenants] - consolidated.append(subscription_dict) - if cert_sn_issuer_auth: - consolidated[-1][_USER_ENTITY][_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH] = True + subscription_dict[_USER_ENTITY][_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH] = True + if managed_identity_info: + subscription_dict[_USER_ENTITY]['clientId'] = \ + managed_identity_info[Identity.MANAGED_IDENTITY_CLIENT_ID] + subscription_dict[_USER_ENTITY]['objectId'] = \ + managed_identity_info[Identity.MANAGED_IDENTITY_OBJECT_ID] + subscription_dict[_USER_ENTITY]['resourceId'] = \ + managed_identity_info[Identity.MANAGED_IDENTITY_RESOURCE_ID] + + # This will be deprecated and client_id will be the only persisted ID if user_assigned_identity_id: - consolidated[-1][_USER_ENTITY][_ASSIGNED_IDENTITY_INFO] = user_assigned_identity_id + logger.warning("assignedIdentityInfo will be deprecated in the future. All IDs of the identity " + "are now preserved.") + subscription_dict[_USER_ENTITY][_ASSIGNED_IDENTITY_INFO] = user_assigned_identity_id + + consolidated.append(subscription_dict) return consolidated def _build_tenant_level_accounts(self, tenants): @@ -318,98 +448,6 @@ def _new_account(self): s.state = 'Enabled' return s - def find_subscriptions_in_vm_with_msi(self, identity_id=None, allow_no_subscriptions=None): - # pylint: disable=too-many-statements - - import jwt - from msrestazure.tools import is_valid_resource_id - from azure.cli.core.adal_authentication import MSIAuthenticationWrapper - resource = self.cli_ctx.cloud.endpoints.active_directory_resource_id - - if identity_id: - if is_valid_resource_id(identity_id): - msi_creds = MSIAuthenticationWrapper(resource=resource, msi_res_id=identity_id) - identity_type = MsiAccountTypes.user_assigned_resource_id - else: - authenticated = False - from azure.cli.core.azclierror import AzureResponseError - try: - msi_creds = MSIAuthenticationWrapper(resource=resource, client_id=identity_id) - identity_type = MsiAccountTypes.user_assigned_client_id - authenticated = True - except AzureResponseError as ex: - if 'http error: 400, reason: Bad Request' in ex.error_msg: - logger.info('Sniff: not an MSI client id') - else: - raise - - if not authenticated: - try: - identity_type = MsiAccountTypes.user_assigned_object_id - msi_creds = MSIAuthenticationWrapper(resource=resource, object_id=identity_id) - authenticated = True - except AzureResponseError as ex: - if 'http error: 400, reason: Bad Request' in ex.error_msg: - logger.info('Sniff: not an MSI object id') - else: - raise - - if not authenticated: - raise CLIError('Failed to connect to MSI, check your managed service identity id.') - - else: - identity_type = MsiAccountTypes.system_assigned - msi_creds = MSIAuthenticationWrapper(resource=resource) - - token_entry = msi_creds.token - token = token_entry['access_token'] - logger.info('MSI: token was retrieved. Now trying to initialize local accounts...') - decode = jwt.decode(token, verify=False, algorithms=['RS256']) - tenant = decode['tid'] - - subscription_finder = SubscriptionFinder(self.cli_ctx, self.auth_ctx_factory, None) - subscriptions = subscription_finder.find_from_raw_token(tenant, token) - base_name = ('{}-{}'.format(identity_type, identity_id) if identity_id else identity_type) - user = _USER_ASSIGNED_IDENTITY if identity_id else _SYSTEM_ASSIGNED_IDENTITY - if not subscriptions: - if allow_no_subscriptions: - subscriptions = self._build_tenant_level_accounts([tenant]) - else: - raise CLIError('No access was configured for the VM, hence no subscriptions were found. ' - "If this is expected, use '--allow-no-subscriptions' to have tenant level access.") - - consolidated = self._normalize_properties(user, subscriptions, is_service_principal=True, - user_assigned_identity_id=base_name) - self._set_subscriptions(consolidated) - return deepcopy(consolidated) - - def find_subscriptions_in_cloud_console(self): - import jwt - - _, token, _ = self._get_token_from_cloud_shell(self.cli_ctx.cloud.endpoints.active_directory_resource_id) - logger.info('MSI: token was retrieved. Now trying to initialize local accounts...') - decode = jwt.decode(token, verify=False, algorithms=['RS256']) - tenant = decode['tid'] - - subscription_finder = SubscriptionFinder(self.cli_ctx, self.auth_ctx_factory, None) - subscriptions = subscription_finder.find_from_raw_token(tenant, token) - if not subscriptions: - raise CLIError('No subscriptions were found in the cloud shell') - user = decode.get('unique_name', 'N/A') - - consolidated = self._normalize_properties(user, subscriptions, is_service_principal=False) - for s in consolidated: - s[_USER_ENTITY][_CLOUD_SHELL_ID] = True - self._set_subscriptions(consolidated) - return deepcopy(consolidated) - - def _get_token_from_cloud_shell(self, resource): # pylint: disable=no-self-use - from azure.cli.core.adal_authentication import MSIAuthenticationWrapper - auth = MSIAuthenticationWrapper(resource=resource) - auth.set_token() - token_entry = auth.token - return (token_entry['token_type'], token_entry['access_token'], token_entry) - def _set_subscriptions(self, new_subscriptions, merge=True, secondary_key_name=None): def _get_key_name(account, secondary_key_name): @@ -454,6 +492,7 @@ def _match_account(account, subscription_id, secondary_key_name, secondary_key_v set_cloud_subscription(self.cli_ctx, active_cloud.name, default_sub_id) self._storage[_SUBSCRIPTIONS] = subscriptions + self._storage[_USE_MSAL_TOKEN_CACHE] = True @staticmethod def _pick_working_subscription(subscriptions): @@ -483,18 +522,69 @@ def set_active_subscription(self, subscription): # take id or name set_cloud_subscription(self.cli_ctx, active_cloud.name, result[0][_SUBSCRIPTION_ID]) self._storage[_SUBSCRIPTIONS] = subscriptions - def logout(self, user_or_sp): + def logout(self, user_or_sp, clear_credential): subscriptions = self.load_cached_subscriptions(all_clouds=True) result = [x for x in subscriptions if user_or_sp.lower() == x[_USER_ENTITY][_USER_NAME].lower()] - subscriptions = [x for x in subscriptions if x not in result] - self._storage[_SUBSCRIPTIONS] = subscriptions - self._creds_cache.remove_cached_creds(user_or_sp) + if result: + # Remove the account from the profile + subscriptions = [x for x in subscriptions if x not in result] + self._storage[_SUBSCRIPTIONS] = subscriptions + + # Always remove credential from the legacy cred cache, regardless of MSAL cache, to be deprecated + adal_cache = AdalCredentialCache() + adal_cache.remove_cached_creds(user_or_sp) + + logger.warning('Account %s has been logged out from Azure CLI.', user_or_sp) + else: + # https://english.stackexchange.com/questions/5302/log-in-to-or-log-into-or-login-to + logger.warning("Account %s was not logged in to Azure CLI.", user_or_sp) + + # Log out from MSAL cache + identity = Identity(self._authority) + accounts = identity.get_user(user_or_sp) + if accounts: + logger.info("The credential of %s were found from MSAL encrypted cache.", user_or_sp) + if clear_credential: + identity.logout_user(user_or_sp) + logger.warning("The credential of %s were cleared from MSAL encrypted cache. This account is " + "also logged out from other SDK tools which use Azure CLI's credential " + "via Single Sign-On.", user_or_sp) + else: + logger.warning('The credential of %s is still stored in MSAL encrypted cached. Other SDK tools may use ' + 'Azure CLI\'s credential via Single Sign-On. ' + 'To clear the credential, run `az logout --username %s --clear-credential`.', + user_or_sp, user_or_sp) + else: + # remove service principle secret + identity.logout_sp(user_or_sp) - def logout_all(self): + def logout_all(self, clear_credential): self._storage[_SUBSCRIPTIONS] = [] - self._creds_cache.remove_all_cached_creds() + + # Always remove credentials from the legacy cred cache, regardless of MSAL cache + adal_cache = AdalCredentialCache() + adal_cache.remove_all_cached_creds() + logger.warning('All accounts were logged out.') + + # Deal with MSAL cache + identity = Identity(self._authority) + accounts = identity.get_user() + if accounts: + logger.info("These credentials were found from MSAL encrypted cache: %s", accounts) + if clear_credential: + identity.logout_all() + logger.warning('All credentials store in MSAL encrypted cache were cleared.') + else: + logger.warning('These credentials are still stored in MSAL encrypted cached:') + for account in identity.get_user(): + logger.warning(account['username']) + logger.warning('Other SDK tools may use Azure CLI\'s credential via Single Sign-On. ' + 'To clear all credentials, run `az account clear --clear-credential`. ' + 'To clear one of them, run `az logout --username USERNAME` --clear-credential.') + else: + logger.warning('No credential was not found from MSAL encrypted cache.') def load_cached_subscriptions(self, all_clouds=False): subscriptions = self._storage.get(_SUBSCRIPTIONS) or [] @@ -534,35 +624,88 @@ def get_subscription(self, subscription=None): # take id or name def get_subscription_id(self, subscription=None): # take id or name return self.get_subscription(subscription)[_SUBSCRIPTION_ID] - def get_access_token_for_resource(self, username, tenant, resource): + def get_access_token_for_scopes(self, username, tenant, scopes): tenant = tenant or 'common' - _, access_token, _ = self._creds_cache.retrieve_token_for_user( - username, tenant, resource) - return access_token + authority = self.cli_ctx.cloud.endpoints.active_directory.replace('https://', '') + identity = Identity(authority, tenant, cred_cache=self._adal_cache) + identity_credential = identity.get_user_credential(username) + from azure.cli.core.credential import CredentialAdaptor + auth = CredentialAdaptor(identity_credential) + token = auth.get_token(*scopes) + return token.token + + def get_access_token_for_resource(self, username, tenant, resource): + """get access token for current user account, used by vsts and iot module""" + return self.get_access_token_for_scopes(username, tenant, resource_to_scopes(resource)) @staticmethod def _try_parse_msi_account_name(account): - msi_info, user = account[_USER_ENTITY].get(_ASSIGNED_IDENTITY_INFO), account[_USER_ENTITY].get(_USER_NAME) - - if user in [_SYSTEM_ASSIGNED_IDENTITY, _USER_ASSIGNED_IDENTITY]: - if not msi_info: - msi_info = account[_SUBSCRIPTION_NAME] # fall back to old persisting way - parts = msi_info.split('-', 1) - if parts[0] in MsiAccountTypes.valid_msi_account_types(): - return parts[0], (None if len(parts) <= 1 else parts[1]) + user_name = account[_USER_ENTITY].get(_USER_NAME) + + if user_name in [_SYSTEM_ASSIGNED_IDENTITY, _USER_ASSIGNED_IDENTITY]: + return user_name, account[_USER_ENTITY].get(_CLIENT_ID) return None, None - def get_login_credentials(self, resource=None, subscription_id=None, aux_subscriptions=None, aux_tenants=None): + def _create_identity_credential(self, account, aux_tenant_id=None, client_id=None): + user_type = account[_USER_ENTITY][_USER_TYPE] + username_or_sp_id = account[_USER_ENTITY][_USER_NAME] + identity_type, identity_id = Profile._try_parse_msi_account_name(account) + tenant_id = aux_tenant_id if aux_tenant_id else account[_TENANT_ID] + # _IS_ENVIRONMENT_CREDENTIAL doesn't exist for normal account + is_environment = account[_USER_ENTITY].get(_IS_ENVIRONMENT_CREDENTIAL) + + identity = Identity(client_id=client_id, authority=self._authority, tenant_id=tenant_id, + cred_cache=self._adal_cache) + + if identity_type is None: + if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): + if aux_tenant_id: + raise CLIError("Tenant shouldn't be specified for Cloud Shell account") + return Identity.get_managed_identity_credential() + + # EnvironmentCredential. Ignore user_type + if is_environment: + return identity.get_environment_credential() + + # User + if user_type == _USER: + # if not home_account_id: + # raise CLIError("CLI authentication is migrated to AADv2.0, please run 'az login' to re-login") + return identity.get_user_credential(username_or_sp_id) + + # Service Principal + use_cert_sn_issuer = account[_USER_ENTITY].get(_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH) + return identity.get_service_principal_credential(username_or_sp_id, use_cert_sn_issuer) + + # MSI + if aux_tenant_id: + raise CLIError("Tenant shouldn't be specified for MSI account") + return Identity.get_managed_identity_credential(identity_id) + + def get_login_credentials(self, resource=None, client_id=None, subscription_id=None, aux_subscriptions=None, + aux_tenants=None): + """Get a CredentialAdaptor instance to be used with both Track 1 and Track 2 SDKs. + + :param resource: The resource ID to acquire an access token. Only provide it for Track 1 SDKs. + :param client_id: + :param subscription_id: + :param aux_subscriptions: + :param aux_tenants: + """ + # Check if the token has been migrated to MSAL by checking "useMsalTokenCache": true + # If not yet, do it now. + use_msal = self._storage.get(_USE_MSAL_TOKEN_CACHE) + if not use_msal: + identity = Identity() + identity.migrate_tokens() + self._storage[_USE_MSAL_TOKEN_CACHE] = True + if aux_tenants and aux_subscriptions: raise CLIError("Please specify only one of aux_subscriptions and aux_tenants, not both") account = self.get_subscription(subscription_id) - user_type = account[_USER_ENTITY][_USER_TYPE] - username_or_sp_id = account[_USER_ENTITY][_USER_NAME] - resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - - identity_type, identity_id = Profile._try_parse_msi_account_name(account) + resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id external_tenants_info = [] if aux_tenants: external_tenants_info = [tenant for tenant in aux_tenants if tenant != account[_TENANT_ID]] @@ -572,116 +715,39 @@ def get_login_credentials(self, resource=None, subscription_id=None, aux_subscri sub = self.get_subscription(ext_sub) if sub[_TENANT_ID] != account[_TENANT_ID]: external_tenants_info.append(sub[_TENANT_ID]) - - if identity_type is None: - def _retrieve_token(sdk_resource=None): - # When called by - # - Track 1 SDK, use `resource` specified by CLI - # - Track 2 SDK, use `sdk_resource` specified by SDK and ignore `resource` specified by CLI - token_resource = sdk_resource or resource - logger.debug("Retrieving token from ADAL for resource %r", token_resource) - - if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): - return self._get_token_from_cloud_shell(token_resource) - if user_type == _USER: - return self._creds_cache.retrieve_token_for_user(username_or_sp_id, - account[_TENANT_ID], token_resource) - use_cert_sn_issuer = account[_USER_ENTITY].get(_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH) - return self._creds_cache.retrieve_token_for_service_principal(username_or_sp_id, token_resource, - account[_TENANT_ID], - use_cert_sn_issuer) - - def _retrieve_tokens_from_external_tenants(sdk_resource=None): - token_resource = sdk_resource or resource - logger.debug("Retrieving token from ADAL for external tenants and resource %r", token_resource) - - external_tokens = [] - for sub_tenant_id in external_tenants_info: - if user_type == _USER: - external_tokens.append(self._creds_cache.retrieve_token_for_user( - username_or_sp_id, sub_tenant_id, token_resource)) - else: - external_tokens.append(self._creds_cache.retrieve_token_for_service_principal( - username_or_sp_id, token_resource, sub_tenant_id, token_resource)) - return external_tokens - - from azure.cli.core.adal_authentication import AdalAuthentication - auth_object = AdalAuthentication(_retrieve_token, - _retrieve_tokens_from_external_tenants if external_tenants_info else None) - else: - if self._msi_creds is None: - self._msi_creds = MsiAccountTypes.msi_auth_factory(identity_type, identity_id, resource) - auth_object = self._msi_creds - + identity_credential = self._create_identity_credential(account, client_id=client_id) + external_credentials = [] + for sub_tenant_id in external_tenants_info: + external_credentials.append(self._create_identity_credential(account, sub_tenant_id, client_id=client_id)) + from azure.cli.core.credential import CredentialAdaptor + auth_object = CredentialAdaptor(identity_credential, + external_credentials=external_credentials if external_credentials else None, + resource=resource) return (auth_object, str(account[_SUBSCRIPTION_ID]), str(account[_TENANT_ID])) - def get_msal_token(self, scopes, data): - """ - This is added only for vmssh feature. - It is a temporary solution and will deprecate after MSAL adopted completely. - """ - account = self.get_subscription() - username = account[_USER_ENTITY][_USER_NAME] - tenant = account[_TENANT_ID] or 'common' - _, refresh_token, _, _ = self.get_refresh_token() - certificate = self._creds_cache.retrieve_msal_token(tenant, scopes, data, refresh_token) - return username, certificate - - def get_refresh_token(self, resource=None, - subscription=None): - account = self.get_subscription(subscription) - user_type = account[_USER_ENTITY][_USER_TYPE] - username_or_sp_id = account[_USER_ENTITY][_USER_NAME] - resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - - if user_type == _USER: - _, _, token_entry = self._creds_cache.retrieve_token_for_user( - username_or_sp_id, account[_TENANT_ID], resource) - return None, token_entry.get(_REFRESH_TOKEN), token_entry[_ACCESS_TOKEN], str(account[_TENANT_ID]) + def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=None): + # Convert resource to scopes + if resource and not scopes: + scopes = resource_to_scopes(resource) - sp_secret = self._creds_cache.retrieve_cred_for_service_principal(username_or_sp_id) - return username_or_sp_id, sp_secret, None, str(account[_TENANT_ID]) + # Use ARM as the default scopes + if not scopes: + scopes = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) - def get_raw_token(self, resource=None, subscription=None, tenant=None): - logger.debug("Profile.get_raw_token invoked with resource=%r, subscription=%r, tenant=%r", - resource, subscription, tenant) if subscription and tenant: raise CLIError("Please specify only one of subscription and tenant, not both") - account = self.get_subscription(subscription) - user_type = account[_USER_ENTITY][_USER_TYPE] - username_or_sp_id = account[_USER_ENTITY][_USER_NAME] - resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - identity_type, identity_id = Profile._try_parse_msi_account_name(account) - if identity_type: - # MSI - if tenant: - raise CLIError("Tenant shouldn't be specified for MSI account") - msi_creds = MsiAccountTypes.msi_auth_factory(identity_type, identity_id, resource) - msi_creds.set_token() - token_entry = msi_creds.token - creds = (token_entry['token_type'], token_entry['access_token'], token_entry) - elif in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): - # Cloud Shell - if tenant: - raise CLIError("Tenant shouldn't be specified for Cloud Shell account") - creds = self._get_token_from_cloud_shell(resource) - else: - tenant_dest = tenant if tenant else account[_TENANT_ID] - if user_type == _USER: - # User - creds = self._creds_cache.retrieve_token_for_user(username_or_sp_id, - tenant_dest, resource) - else: - # Service Principal - use_cert_sn_issuer = bool(account[_USER_ENTITY].get(_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH)) - creds = self._creds_cache.retrieve_token_for_service_principal(username_or_sp_id, - resource, - tenant_dest, - use_cert_sn_issuer) - return (creds, + account = self.get_subscription(subscription) + identity_credential = self._create_identity_credential(account, tenant) + + from azure.cli.core.credential import CredentialAdaptor, _convert_token_entry + auth = CredentialAdaptor(identity_credential) + token = auth.get_token(*scopes) + # (tokenType, accessToken, tokenEntry) + cred = 'Bearer', token.token, _convert_token_entry(token) + return (cred, None if tenant else str(account[_SUBSCRIPTION_ID]), str(tenant if tenant else account[_TENANT_ID])) @@ -689,11 +755,7 @@ def refresh_accounts(self, subscription_finder=None): subscriptions = self.load_cached_subscriptions() to_refresh = subscriptions - from azure.cli.core._debug import allow_debug_adal_connection - allow_debug_adal_connection() - subscription_finder = subscription_finder or SubscriptionFinder(self.cli_ctx, - self.auth_ctx_factory, - self._creds_cache.adal_token_cache) + subscription_finder = subscription_finder or SubscriptionFinder(self.cli_ctx, adal_cache=self._adal_cache) refreshed_list = set() result = [] for s in to_refresh: @@ -705,13 +767,13 @@ def refresh_accounts(self, subscription_finder=None): tenant = s[_TENANT_ID] subscriptions = [] try: + identity_credential = self._create_identity_credential(s, tenant) if is_service_principal: - sp_auth = ServicePrincipalAuth(self._creds_cache.retrieve_cred_for_service_principal(user_name)) - subscriptions = subscription_finder.find_from_service_principal_id(user_name, sp_auth, tenant, - self._ad_resource_uri) + subscriptions = subscription_finder.find_using_specific_tenant(tenant, identity_credential) else: - subscriptions = subscription_finder.find_from_user_account(user_name, None, None, - self._ad_resource_uri) + # pylint: disable=protected-access + subscriptions = subscription_finder. \ + find_using_common_tenant(user_name, identity_credential) # pylint: disable=protected-access except Exception as ex: # pylint: disable=broad-except logger.warning("Refreshing for '%s' failed with an error '%s'. The existing accounts were not " "modified. You can run 'az login' later to explicitly refresh them", user_name, ex) @@ -730,9 +792,6 @@ def refresh_accounts(self, subscription_finder=None): is_service_principal) result += consolidated - if self._creds_cache.adal_token_cache.has_state_changed: - self._creds_cache.persist_cached_creds() - self._set_subscriptions(result, merge=False) def get_sp_auth_info(self, subscription_id=None, name=None, password=None, cert_file=None): @@ -752,14 +811,14 @@ def get_sp_auth_info(self, subscription_id=None, name=None, password=None, cert_ user_type = account[_USER_ENTITY].get(_USER_TYPE) if user_type == _SERVICE_PRINCIPAL: result['clientId'] = account[_USER_ENTITY][_USER_NAME] - sp_auth = ServicePrincipalAuth(self._creds_cache.retrieve_cred_for_service_principal( - account[_USER_ENTITY][_USER_NAME])) - secret = getattr(sp_auth, 'secret', None) + msal_cache = MsalSecretStore(True) + secret, certificate_file = msal_cache.retrieve_secret_of_service_principal( + account[_USER_ENTITY][_USER_NAME], account[_TENANT_ID]) if secret: result['clientSecret'] = secret else: # we can output 'clientCertificateThumbprint' if asked - result['clientCertificate'] = sp_auth.certificate_file + result['clientCertificate'] = certificate_file result['subscriptionId'] = account[_SUBSCRIPTION_ID] else: raise CLIError('SDK Auth file is only applicable when authenticated using a service principal') @@ -788,42 +847,37 @@ def get_installation_id(self): self._storage[_INSTALLATION_ID] = installation_id return installation_id + def _prepare_authenticate_scopes(self, scopes): + """Prepare the scopes to be sent to MSAL. If `scopes` is not a list, it will be put into a list.""" + if scopes: + if not isinstance(scopes, (list, tuple)): + # Put scopes into a list + scopes = [scopes] + else: + # If scope is not provided, use the ARM resource ID + scopes = resource_to_scopes(self._ad_resource_uri) + return scopes + +# pylint: disable=no-method-argument,no-self-argument,too-few-public-methods class MsiAccountTypes: - # pylint: disable=no-method-argument,no-self-argument system_assigned = 'MSI' user_assigned_client_id = 'MSIClient' user_assigned_object_id = 'MSIObject' user_assigned_resource_id = 'MSIResource' - @staticmethod - def valid_msi_account_types(): - return [MsiAccountTypes.system_assigned, MsiAccountTypes.user_assigned_client_id, - MsiAccountTypes.user_assigned_object_id, MsiAccountTypes.user_assigned_resource_id] - - @staticmethod - def msi_auth_factory(cli_account_name, identity, resource): - from azure.cli.core.adal_authentication import MSIAuthenticationWrapper - if cli_account_name == MsiAccountTypes.system_assigned: - return MSIAuthenticationWrapper(resource=resource) - if cli_account_name == MsiAccountTypes.user_assigned_client_id: - return MSIAuthenticationWrapper(resource=resource, client_id=identity) - if cli_account_name == MsiAccountTypes.user_assigned_object_id: - return MSIAuthenticationWrapper(resource=resource, object_id=identity) - if cli_account_name == MsiAccountTypes.user_assigned_resource_id: - return MSIAuthenticationWrapper(resource=resource, msi_res_id=identity) - raise ValueError("unrecognized msi account name '{}'".format(cli_account_name)) - class SubscriptionFinder: - '''finds all subscriptions for a user or service principal''' + # An ARM client. It finds subscriptions for a user or service principal. It shouldn't do any + # authentication work, but only find subscriptions + def __init__(self, cli_ctx, arm_client_factory=None, **kwargs): - def __init__(self, cli_ctx, auth_context_factory, adal_token_cache, arm_client_factory=None): - - self._adal_token_cache = adal_token_cache - self._auth_context_factory = auth_context_factory self.user_id = None # will figure out after log user in self.cli_ctx = cli_ctx + self.secret = None + self._arm_resource_id = cli_ctx.cloud.endpoints.active_directory_resource_id + self.authority = self.cli_ctx.cloud.endpoints.active_directory.replace('https://', '') + self.adal_cache = kwargs.pop("adal_cache", None) def create_arm_client_factory(credentials): if arm_client_factory: @@ -840,89 +894,34 @@ def create_arm_client_factory(credentials): client_kwargs = _prepare_client_kwargs_track2(cli_ctx) # We don't need to change credential_scopes as 'scopes' is ignored by BasicTokenCredential anyway client = client_type(credentials, api_version=api_version, - base_url=self.cli_ctx.cloud.endpoints.resource_manager, **client_kwargs) + base_url=self.cli_ctx.cloud.endpoints.resource_manager, + credential_scopes=resource_to_scopes(self._arm_resource_id), + **client_kwargs) return client self._arm_client_factory = create_arm_client_factory self.tenants = [] - def find_from_user_account(self, username, password, tenant, resource): - context = self._create_auth_context(tenant) - if password: - token_entry = context.acquire_token_with_username_password(resource, username, password, _CLIENT_ID) - else: # when refresh account, we will leverage local cached tokens - token_entry = context.acquire_token(resource, username, _CLIENT_ID) - - if not token_entry: - return [] - self.user_id = token_entry[_TOKEN_ENTRY_USER_ID] - - if tenant is None: - result = self._find_using_common_tenant(token_entry[_ACCESS_TOKEN], resource) - else: - result = self._find_using_specific_tenant(tenant, token_entry[_ACCESS_TOKEN]) - return result - - def find_through_authorization_code_flow(self, tenant, resource, authority_url): - # launch browser and get the code - results = _get_authorization_code(resource, authority_url) - - if not results.get('code'): - raise CLIError('Login failed') # error detail is already displayed through previous steps - - # exchange the code for the token - context = self._create_auth_context(tenant) - token_entry = context.acquire_token_with_authorization_code(results['code'], results['reply_url'], - resource, _CLIENT_ID, None) - self.user_id = token_entry[_TOKEN_ENTRY_USER_ID] - logger.warning("You have logged in. Now let us find all the subscriptions to which you have access...") - if tenant is None: - result = self._find_using_common_tenant(token_entry[_ACCESS_TOKEN], resource) - else: - result = self._find_using_specific_tenant(tenant, token_entry[_ACCESS_TOKEN]) - return result - - def find_through_interactive_flow(self, tenant, resource): - context = self._create_auth_context(tenant) - code = context.acquire_user_code(resource, _CLIENT_ID) - logger.warning(code['message']) - token_entry = context.acquire_token_with_device_code(resource, code, _CLIENT_ID) - self.user_id = token_entry[_TOKEN_ENTRY_USER_ID] - if tenant is None: - result = self._find_using_common_tenant(token_entry[_ACCESS_TOKEN], resource) - else: - result = self._find_using_specific_tenant(tenant, token_entry[_ACCESS_TOKEN]) - return result - - def find_from_service_principal_id(self, client_id, sp_auth, tenant, resource): - context = self._create_auth_context(tenant, False) - token_entry = sp_auth.acquire_token(context, resource, client_id) - self.user_id = client_id - result = self._find_using_specific_tenant(tenant, token_entry[_ACCESS_TOKEN]) - self.tenants = [tenant] - return result - # only occur inside cloud console or VM with identity def find_from_raw_token(self, tenant, token): # decode the token, so we know the tenant - result = self._find_using_specific_tenant(tenant, token) + # msal : todo + result = self.find_using_specific_tenant(tenant, token) self.tenants = [tenant] return result - def _create_auth_context(self, tenant, use_token_cache=True): - token_cache = self._adal_token_cache if use_token_cache else None - return self._auth_context_factory(self.cli_ctx, tenant, token_cache) - - def _find_using_common_tenant(self, access_token, resource): + def find_using_common_tenant(self, username, credential=None): + # pylint: disable=too-many-statements import adal - from azure.cli.core.adal_authentication import BasicTokenCredential - all_subscriptions = [] empty_tenants = [] mfa_tenants = [] - token_credential = BasicTokenCredential(access_token) - client = self._arm_client_factory(token_credential) + + from azure.cli.core.credential import CredentialAdaptor + credential = CredentialAdaptor(credential) + client = self._arm_client_factory(credential) tenants = client.tenants.list() + for t in tenants: tenant_id = t.tenant_id logger.debug("Finding subscriptions under tenant %s", tenant_id) @@ -930,10 +929,18 @@ def _find_using_common_tenant(self, access_token, resource): # not available in /tenants?api-version=2016-06-01 if not hasattr(t, 'display_name'): t.display_name = None - temp_context = self._create_auth_context(tenant_id) + + identity = Identity(self.authority, tenant_id, + allow_unencrypted=self.cli_ctx.config + .getboolean('core', 'allow_fallback_to_plaintext', fallback=True)) try: - logger.debug("Acquiring a token with tenant=%s, resource=%s", tenant_id, resource) - temp_credentials = temp_context.acquire_token(resource, self.user_id, _CLIENT_ID) + specific_tenant_credential = identity.get_user_credential(username) + # todo: remove after ADAL deprecation + if self.adal_cache: + self.adal_cache.add_credential(specific_tenant_credential, + self.cli_ctx.cloud.endpoints.active_directory_resource_id, + self.authority) + # TODO: handle MSAL exceptions except adal.AdalError as ex: # because user creds went through the 'common' tenant, the error here must be # tenant specific, like the account was disabled. For such errors, we will continue @@ -945,9 +952,16 @@ def _find_using_common_tenant(self, access_token, resource): else: logger.warning("Failed to authenticate '%s' due to error '%s'", t, ex) continue - subscriptions = self._find_using_specific_tenant( + + tenant_id_name = tenant_id + if t.display_name: + # e.g. '72f988bf-86f1-41af-91ab-2d7cd011db47 Microsoft' + tenant_id_name = "{} '{}'".format(tenant_id, t.display_name) + logger.info("Finding subscriptions under tenant %s", tenant_id_name) + + subscriptions = self.find_using_specific_tenant( tenant_id, - temp_credentials[_ACCESS_TOKEN]) + specific_tenant_credential) if not subscriptions: empty_tenants.append(t) @@ -988,18 +1002,14 @@ def _find_using_common_tenant(self, access_token, resource): logger.warning("%s", t.tenant_id) return all_subscriptions - def _find_using_specific_tenant(self, tenant, access_token): - from azure.cli.core.adal_authentication import BasicTokenCredential - - token_credential = BasicTokenCredential(access_token) - client = self._arm_client_factory(token_credential) + def find_using_specific_tenant(self, tenant, credential): + from azure.cli.core.credential import CredentialAdaptor + track1_credential = CredentialAdaptor(credential) + client = self._arm_client_factory(track1_credential) subscriptions = client.subscriptions.list() all_subscriptions = [] for s in subscriptions: - # map tenantId from REST API to homeTenantId - if hasattr(s, "tenant_id"): - setattr(s, 'home_tenant_id', s.tenant_id) - setattr(s, 'tenant_id', tenant) + _attach_token_tenant(s, tenant) all_subscriptions.append(s) self.tenants.append(tenant) return all_subscriptions @@ -1009,7 +1019,7 @@ def _get_subscription_client_class(self): # pylint: disable=no-self-use on the design of architecture. """ if _USE_VENDORED_SUBSCRIPTION_SDK: - # Use vendered subscription SDK to decouple from `resource` command module + # Use vendored subscription SDK to decouple from `resource` command module from azure.cli.core.vendored_sdks.subscriptions import SubscriptionClient client_type = SubscriptionClient else: @@ -1018,343 +1028,3 @@ def _get_subscription_client_class(self): # pylint: disable=no-self-use from azure.cli.core.profiles._shared import get_client_class client_type = get_client_class(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS) return client_type - - -class CredsCache: - '''Caches AAD tokena and service principal secrets, and persistence will - also be handled - ''' - - def __init__(self, cli_ctx, auth_ctx_factory=None, async_persist=True): - # AZURE_ACCESS_TOKEN_FILE is used by Cloud Console and not meant to be user configured - self._token_file = (os.environ.get('AZURE_ACCESS_TOKEN_FILE', None) or - os.path.join(get_config_dir(), 'accessTokens.json')) - self._service_principal_creds = [] - self._auth_ctx_factory = auth_ctx_factory - self._adal_token_cache_attr = None - self._should_flush_to_disk = False - self._async_persist = async_persist - self._ctx = cli_ctx - if async_persist: - import atexit - atexit.register(self.flush_to_disk) - - def persist_cached_creds(self): - self._should_flush_to_disk = True - if not self._async_persist: - self.flush_to_disk() - self.adal_token_cache.has_state_changed = False - - def flush_to_disk(self): - if self._should_flush_to_disk: - with os.fdopen(os.open(self._token_file, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), - 'w+') as cred_file: - items = self.adal_token_cache.read_items() - all_creds = [entry for _, entry in items] - - # trim away useless fields (needed for cred sharing with xplat) - for i in all_creds: - for key in TOKEN_FIELDS_EXCLUDED_FROM_PERSISTENCE: - i.pop(key, None) - - all_creds.extend(self._service_principal_creds) - cred_file.write(json.dumps(all_creds)) - - def retrieve_token_for_user(self, username, tenant, resource): - context = self._auth_ctx_factory(self._ctx, tenant, cache=self.adal_token_cache) - token_entry = context.acquire_token(resource, username, _CLIENT_ID) - if not token_entry: - raise CLIError("Could not retrieve token from local cache.{}".format( - " Please run 'az login'." if not in_cloud_console() else '')) - - if self.adal_token_cache.has_state_changed: - self.persist_cached_creds() - return (token_entry[_TOKEN_ENTRY_TOKEN_TYPE], token_entry[_ACCESS_TOKEN], token_entry) - - def retrieve_msal_token(self, tenant, scopes, data, refresh_token): - """ - This is added only for vmssh feature. - It is a temporary solution and will deprecate after MSAL adopted completely. - """ - from azure.cli.core._msal import AdalRefreshTokenBasedClientApplication - tenant = tenant or 'organizations' - authority = self._ctx.cloud.endpoints.active_directory + '/' + tenant - app = AdalRefreshTokenBasedClientApplication(_CLIENT_ID, authority=authority) - result = app.acquire_token_silent(scopes, None, data=data, refresh_token=refresh_token) - - return result["access_token"] - - def retrieve_token_for_service_principal(self, sp_id, resource, tenant, use_cert_sn_issuer=False): - self.load_adal_token_cache() - matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] - if not matched: - raise CLIError("Could not retrieve credential from local cache for service principal {}. " - "Please run 'az login' for this service principal." - .format(sp_id)) - matched_with_tenant = [x for x in matched if tenant == x[_SERVICE_PRINCIPAL_TENANT]] - if matched_with_tenant: - cred = matched_with_tenant[0] - else: - logger.warning("Could not retrieve credential from local cache for service principal %s under tenant %s. " - "Trying credential under tenant %s, assuming that is an app credential.", - sp_id, tenant, matched[0][_SERVICE_PRINCIPAL_TENANT]) - cred = matched[0] - - context = self._auth_ctx_factory(self._ctx, tenant, None) - sp_auth = ServicePrincipalAuth(cred.get(_ACCESS_TOKEN, None) or - cred.get(_SERVICE_PRINCIPAL_CERT_FILE, None), - use_cert_sn_issuer) - token_entry = sp_auth.acquire_token(context, resource, sp_id) - return (token_entry[_TOKEN_ENTRY_TOKEN_TYPE], token_entry[_ACCESS_TOKEN], token_entry) - - def retrieve_cred_for_service_principal(self, sp_id): - """Returns the secret or certificate of the specified service principal.""" - self.load_adal_token_cache() - matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] - if not matched: - raise CLIError("No matched service principal found") - cred = matched[0] - return cred.get(_ACCESS_TOKEN) or cred.get(_SERVICE_PRINCIPAL_CERT_FILE) - - @property - def adal_token_cache(self): - return self.load_adal_token_cache() - - def load_adal_token_cache(self): - if self._adal_token_cache_attr is None: - import adal - all_entries = _load_tokens_from_file(self._token_file) - self._load_service_principal_creds(all_entries) - real_token = [x for x in all_entries if x not in self._service_principal_creds] - self._adal_token_cache_attr = adal.TokenCache(json.dumps(real_token)) - return self._adal_token_cache_attr - - def save_service_principal_cred(self, sp_entry): - self.load_adal_token_cache() - matched = [x for x in self._service_principal_creds - if sp_entry[_SERVICE_PRINCIPAL_ID] == x[_SERVICE_PRINCIPAL_ID] and - sp_entry[_SERVICE_PRINCIPAL_TENANT] == x[_SERVICE_PRINCIPAL_TENANT]] - state_changed = False - if matched: - # pylint: disable=line-too-long - if (sp_entry.get(_ACCESS_TOKEN, None) != matched[0].get(_ACCESS_TOKEN, None) or - sp_entry.get(_SERVICE_PRINCIPAL_CERT_FILE, None) != matched[0].get(_SERVICE_PRINCIPAL_CERT_FILE, None)): - self._service_principal_creds.remove(matched[0]) - self._service_principal_creds.append(sp_entry) - state_changed = True - else: - self._service_principal_creds.append(sp_entry) - state_changed = True - - if state_changed: - self.persist_cached_creds() - - def _load_service_principal_creds(self, creds): - for c in creds: - if c.get(_SERVICE_PRINCIPAL_ID): - self._service_principal_creds.append(c) - return self._service_principal_creds - - def remove_cached_creds(self, user_or_sp): - state_changed = False - # clear AAD tokens - tokens = self.adal_token_cache.find({_TOKEN_ENTRY_USER_ID: user_or_sp}) - if tokens: - state_changed = True - self.adal_token_cache.remove(tokens) - - # clear service principal creds - matched = [x for x in self._service_principal_creds - if x[_SERVICE_PRINCIPAL_ID] == user_or_sp] - if matched: - state_changed = True - self._service_principal_creds = [x for x in self._service_principal_creds - if x not in matched] - - if state_changed: - self.persist_cached_creds() - - def remove_all_cached_creds(self): - # we can clear file contents, but deleting it is simpler - _delete_file(self._token_file) - - -class ServicePrincipalAuth: - - def __init__(self, password_arg_value, use_cert_sn_issuer=None): - if not password_arg_value: - raise CLIError('missing secret or certificate in order to ' - 'authenticate through a service principal') - if os.path.isfile(password_arg_value): - certificate_file = password_arg_value - from OpenSSL.crypto import load_certificate, FILETYPE_PEM, Error - self.certificate_file = certificate_file - self.public_certificate = None - try: - with open(certificate_file, 'r') as file_reader: - self.cert_file_string = file_reader.read() - cert = load_certificate(FILETYPE_PEM, self.cert_file_string) - self.thumbprint = cert.digest("sha1").decode() - if use_cert_sn_issuer: - # low-tech but safe parsing based on - # https://github.com/libressl-portable/openbsd/blob/master/src/lib/libcrypto/pem/pem.h - match = re.search(r'\-+BEGIN CERTIFICATE.+\-+(?P[^-]+)\-+END CERTIFICATE.+\-+', - self.cert_file_string, re.I) - self.public_certificate = match.group('public').strip() - except (UnicodeDecodeError, Error): - raise CLIError('Invalid certificate, please use a valid PEM file.') - else: - self.secret = password_arg_value - - def acquire_token(self, authentication_context, resource, client_id): - if hasattr(self, 'secret'): - return authentication_context.acquire_token_with_client_credentials(resource, client_id, self.secret) - return authentication_context.acquire_token_with_client_certificate(resource, client_id, self.cert_file_string, - self.thumbprint, self.public_certificate) - - def get_entry_to_persist(self, sp_id, tenant): - entry = { - _SERVICE_PRINCIPAL_ID: sp_id, - _SERVICE_PRINCIPAL_TENANT: tenant, - } - if hasattr(self, 'secret'): - entry[_ACCESS_TOKEN] = self.secret - else: - entry[_SERVICE_PRINCIPAL_CERT_FILE] = self.certificate_file - entry[_SERVICE_PRINCIPAL_CERT_THUMBPRINT] = self.thumbprint - - return entry - - -def _get_authorization_code_worker(authority_url, resource, results): - # pylint: disable=too-many-statements - import socket - import random - import http.server - - class ClientRedirectServer(http.server.HTTPServer): # pylint: disable=too-few-public-methods - query_params = {} - - class ClientRedirectHandler(http.server.BaseHTTPRequestHandler): - # pylint: disable=line-too-long - - def do_GET(self): - try: - from urllib.parse import parse_qs - except ImportError: - from urlparse import parse_qs # pylint: disable=import-error - - if self.path.endswith('/favicon.ico'): # deal with legacy IE - self.send_response(204) - return - - query = self.path.split('?', 1)[-1] - query = parse_qs(query, keep_blank_values=True) - self.server.query_params = query - - self.send_response(200) - self.send_header('Content-type', 'text/html') - self.end_headers() - - landing_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'auth_landing_pages', - 'ok.html' if 'code' in query else 'fail.html') - with open(landing_file, 'rb') as html_file: - self.wfile.write(html_file.read()) - - def log_message(self, format, *args): # pylint: disable=redefined-builtin,unused-argument,no-self-use - pass # this prevent http server from dumping messages to stdout - - reply_url = None - - # On Windows, HTTPServer by default doesn't throw error if the port is in-use - # https://github.com/Azure/azure-cli/issues/10578 - if is_windows(): - logger.debug('Windows is detected. Set HTTPServer.allow_reuse_address to False') - ClientRedirectServer.allow_reuse_address = False - elif is_wsl(): - logger.debug('WSL is detected. Set HTTPServer.allow_reuse_address to False') - ClientRedirectServer.allow_reuse_address = False - - for port in range(8400, 9000): - try: - web_server = ClientRedirectServer(('localhost', port), ClientRedirectHandler) - reply_url = "http://localhost:{}".format(port) - break - except socket.error as ex: - logger.warning("Port '%s' is taken with error '%s'. Trying with the next one", port, ex) - except UnicodeDecodeError: - logger.warning("Please make sure there is no international (Unicode) character in the computer name " - r"or C:\Windows\System32\drivers\etc\hosts file's 127.0.0.1 entries. " - "For more details, please see https://github.com/Azure/azure-cli/issues/12957") - break - - if reply_url is None: - logger.warning("Error: can't reserve a port for authentication reply url") - return - - try: - request_state = ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(20)) - except NotImplementedError: - request_state = 'code' - - # launch browser: - url = ('{0}/oauth2/authorize?response_type=code&client_id={1}' - '&redirect_uri={2}&state={3}&resource={4}&prompt=select_account') - url = url.format(authority_url, _CLIENT_ID, reply_url, request_state, resource) - logger.info('Open browser with url: %s', url) - succ = open_page_in_browser(url) - if succ is False: - web_server.server_close() - results['no_browser'] = True - return - - # Emit a warning to inform that a browser is opened. - # Only show the path part of the URL and hide the query string. - logger.warning("The default web browser has been opened at %s. Please continue the login in the web browser. " - "If no web browser is available or if the web browser fails to open, use device code flow " - "with `az login --use-device-code`.", url.split('?')[0]) - - # wait for callback from browser. - while True: - web_server.handle_request() - if 'error' in web_server.query_params or 'code' in web_server.query_params: - break - - if 'error' in web_server.query_params: - logger.warning('Authentication Error: "%s". Description: "%s" ', web_server.query_params['error'], - web_server.query_params.get('error_description')) - return - - if 'code' in web_server.query_params: - code = web_server.query_params['code'] - else: - logger.warning('Authentication Error: Authorization code was not captured in query strings "%s"', - web_server.query_params) - return - - if 'state' in web_server.query_params: - response_state = web_server.query_params['state'][0] - if response_state != request_state: - raise RuntimeError("mismatched OAuth state") - else: - raise RuntimeError("missing OAuth state") - - results['code'] = code[0] - results['reply_url'] = reply_url - - -def _get_authorization_code(resource, authority_url): - import threading - import time - results = {} - t = threading.Thread(target=_get_authorization_code_worker, - args=(authority_url, resource, results)) - t.daemon = True - t.start() - while True: - time.sleep(2) # so that ctrl+c can stop the command - if not t.is_alive(): - break # done - if results.get('no_browser'): - raise RuntimeError() - return results diff --git a/src/azure-cli-core/azure/cli/core/adal_authentication.py b/src/azure-cli-core/azure/cli/core/adal_authentication.py deleted file mode 100644 index 1ba6d504928..00000000000 --- a/src/azure-cli-core/azure/cli/core/adal_authentication.py +++ /dev/null @@ -1,248 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import requests -import adal - -from msrest.authentication import Authentication -from msrestazure.azure_active_directory import MSIAuthentication -from azure.core.credentials import AccessToken -from azure.cli.core.util import in_cloud_console, scopes_to_resource - -from knack.util import CLIError -from knack.log import get_logger - -logger = get_logger(__name__) - - -class AdalAuthentication(Authentication): # pylint: disable=too-few-public-methods - - def __init__(self, token_retriever, external_tenant_token_retriever=None): - # DO NOT call _token_retriever from outside azure-cli-core. It is only available for user or - # Service Principal credential (AdalAuthentication), but not for Managed Identity credential - # (MSIAuthenticationWrapper). - # To retrieve a raw token, either call - # - Profile.get_raw_token, which is more direct - # - AdalAuthentication.get_token, which is designed for Track 2 SDKs - self._token_retriever = token_retriever - self._external_tenant_token_retriever = external_tenant_token_retriever - - def _get_token(self, sdk_resource=None): - """ - :param sdk_resource: `resource` converted from Track 2 SDK's `scopes` - """ - external_tenant_tokens = None - try: - scheme, token, full_token = self._token_retriever(sdk_resource) - if self._external_tenant_token_retriever: - external_tenant_tokens = self._external_tenant_token_retriever(sdk_resource) - except CLIError as err: - if in_cloud_console(): - AdalAuthentication._log_hostname() - raise err - except adal.AdalError as err: - # pylint: disable=no-member - if in_cloud_console(): - AdalAuthentication._log_hostname() - - err = (getattr(err, 'error_response', None) or {}).get('error_description') or str(err) - if 'AADSTS70008' in err: # all errors starting with 70008 should be creds expiration related - raise CLIError("Credentials have expired due to inactivity. {}".format( - "Please run 'az login'" if not in_cloud_console() else '')) - if 'AADSTS50079' in err: - raise CLIError("Configuration of your account was changed. {}".format( - "Please run 'az login'" if not in_cloud_console() else '')) - if 'AADSTS50173' in err: - raise CLIError("The credential data used by CLI has been expired because you might have changed or " - "reset the password. {}".format( - "Please clear browser's cookies and run 'az login'" - if not in_cloud_console() else '')) - - raise CLIError(err) - except requests.exceptions.SSLError as err: - from .util import SSLERROR_TEMPLATE - raise CLIError(SSLERROR_TEMPLATE.format(str(err))) - except requests.exceptions.ConnectionError as err: - raise CLIError('Please ensure you have network connection. Error detail: ' + str(err)) - - return scheme, token, full_token, external_tenant_tokens - - def get_all_tokens(self, *scopes): - scheme, token, full_token, external_tenant_tokens = self._get_token(_try_scopes_to_resource(scopes)) - return scheme, token, full_token, external_tenant_tokens - - # This method is exposed for Azure Core. - def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument - logger.debug("AdalAuthentication.get_token invoked by Track 2 SDK with scopes=%s", scopes) - - _, token, full_token, _ = self._get_token(_try_scopes_to_resource(scopes)) - - # NEVER use expiresIn (expires_in) as the token is cached and expiresIn will be already out-of date - # when being retrieved. - - # User token entry sample: - # { - # "tokenType": "Bearer", - # "expiresOn": "2020-11-13 14:44:42.492318", - # "resource": "https://management.core.windows.net/", - # "userId": "test@azuresdkteam.onmicrosoft.com", - # "accessToken": "eyJ0eXAiOiJKV...", - # "refreshToken": "0.ATcAImuCVN...", - # "_clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", - # "_authority": "https://login.microsoftonline.com/54826b22-38d6-4fb2-bad9-b7b93a3e9c5a", - # "isMRRT": True, - # "expiresIn": 3599 - # } - - # Service Principal token entry sample: - # { - # "tokenType": "Bearer", - # "expiresIn": 3599, - # "expiresOn": "2020-11-12 13:50:47.114324", - # "resource": "https://management.core.windows.net/", - # "accessToken": "eyJ0eXAiOiJKV...", - # "isMRRT": True, - # "_clientId": "22800c35-46c2-4210-b8a7-d8c3ec3b526f", - # "_authority": "https://login.microsoftonline.com/54826b22-38d6-4fb2-bad9-b7b93a3e9c5a" - # } - if 'expiresOn' in full_token: - import datetime - expires_on_timestamp = int(_timestamp( - datetime.datetime.strptime(full_token['expiresOn'], '%Y-%m-%d %H:%M:%S.%f'))) - return AccessToken(token, expires_on_timestamp) - - # Cloud Shell (Managed Identity) token entry sample: - # { - # "access_token": "eyJ0eXAiOiJKV...", - # "refresh_token": "", - # "expires_in": "2106", - # "expires_on": "1605686811", - # "not_before": "1605682911", - # "resource": "https://management.core.windows.net/", - # "token_type": "Bearer" - # } - if 'expires_on' in full_token: - return AccessToken(token, int(full_token['expires_on'])) - - from azure.cli.core.azclierror import CLIInternalError - raise CLIInternalError("No expiresOn or expires_on is available in the token entry.") - - # This method is exposed for msrest. - def signed_session(self, session=None): # pylint: disable=arguments-differ - logger.debug("AdalAuthentication.signed_session invoked by Track 1 SDK") - session = session or super(AdalAuthentication, self).signed_session() - - scheme, token, _, external_tenant_tokens = self._get_token() - - header = "{} {}".format(scheme, token) - session.headers['Authorization'] = header - if external_tenant_tokens: - aux_tokens = ';'.join(['{} {}'.format(scheme2, tokens2) for scheme2, tokens2, _ in external_tenant_tokens]) - session.headers['x-ms-authorization-auxiliary'] = aux_tokens - return session - - @staticmethod - def _log_hostname(): - import socket - logger.warning("A Cloud Shell credential problem occurred. When you report the issue with the error " - "below, please mention the hostname '%s'", socket.gethostname()) - - -class MSIAuthenticationWrapper(MSIAuthentication): - # This method is exposed for Azure Core. Add *scopes, **kwargs to fit azure.core requirement - def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument - logger.debug("MSIAuthenticationWrapper.get_token invoked by Track 2 SDK with scopes=%s", scopes) - resource = _try_scopes_to_resource(scopes) - if resource: - # If available, use resource provided by SDK - self.resource = resource - self.set_token() - # Managed Identity token entry sample: - # { - # "access_token": "eyJ0eXAiOiJKV...", - # "client_id": "da95e381-d7ab-4fdc-8047-2457909c723b", - # "expires_in": "86386", - # "expires_on": "1605238724", - # "ext_expires_in": "86399", - # "not_before": "1605152024", - # "resource": "https://management.azure.com/", - # "token_type": "Bearer" - # } - return AccessToken(self.token['access_token'], int(self.token['expires_on'])) - - def set_token(self): - import traceback - from azure.cli.core.azclierror import AzureConnectionError, AzureResponseError - try: - super(MSIAuthenticationWrapper, self).set_token() - except requests.exceptions.ConnectionError as err: - logger.debug('throw requests.exceptions.ConnectionError when doing MSIAuthentication: \n%s', - traceback.format_exc()) - raise AzureConnectionError('Failed to connect to MSI. Please make sure MSI is configured correctly ' - 'and check the network connection.\nError detail: {}'.format(str(err))) - except requests.exceptions.HTTPError as err: - logger.debug('throw requests.exceptions.HTTPError when doing MSIAuthentication: \n%s', - traceback.format_exc()) - try: - raise AzureResponseError('Failed to connect to MSI. Please make sure MSI is configured correctly.\n' - 'Get Token request returned http error: {}, reason: {}' - .format(err.response.status, err.response.reason)) - except AttributeError: - raise AzureResponseError('Failed to connect to MSI. Please make sure MSI is configured correctly.\n' - 'Get Token request returned: {}'.format(err.response)) - except TimeoutError as err: - logger.debug('throw TimeoutError when doing MSIAuthentication: \n%s', - traceback.format_exc()) - raise AzureConnectionError('MSI endpoint is not responding. Please make sure MSI is configured correctly.\n' - 'Error detail: {}'.format(str(err))) - - def signed_session(self, session=None): - logger.debug("MSIAuthenticationWrapper.signed_session invoked by Track 1 SDK") - super().signed_session(session) - - -def _try_scopes_to_resource(scopes): - """Wrap scopes_to_resource to workaround some SDK issues.""" - - # Track 2 SDKs generated before https://github.com/Azure/autorest.python/pull/239 don't maintain - # credential_scopes and call `get_token` with empty scopes. - # As a workaround, return None so that the CLI-managed resource is used. - if not scopes: - logger.debug("No scope is provided by the SDK, use the CLI-managed resource.") - return None - - # Track 2 SDKs generated before https://github.com/Azure/autorest.python/pull/745 extend default - # credential_scopes with custom credential_scopes. Instead, credential_scopes should be replaced by - # custom credential_scopes. https://github.com/Azure/azure-sdk-for-python/issues/12947 - # As a workaround, remove the first one if there are multiple scopes provided. - if len(scopes) > 1: - logger.debug("Multiple scopes are provided by the SDK, discarding the first one: %s", scopes[0]) - return scopes_to_resource(scopes[1:]) - - # Exactly only one scope is provided - return scopes_to_resource(scopes) - - -class BasicTokenCredential: - # pylint:disable=too-few-public-methods - """A Track 2 implementation of msrest.authentication.BasicTokenAuthentication. - This credential shouldn't be used by any command module, expect azure-cli-core. - """ - def __init__(self, access_token): - self.access_token = access_token - - def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument - # Because get_token can't refresh the access token, always mark the token as unexpired - import time - return AccessToken(self.access_token, int(time.time() + 3600)) - - -def _timestamp(dt): - # datetime.datetime can't be patched: - # TypeError: can't set attributes of built-in/extension type 'datetime.datetime' - # So we wrap datetime.datetime.timestamp with this function. - # https://docs.python.org/3/library/unittest.mock-examples.html#partial-mocking - # https://williambert.online/2011/07/how-to-unit-testing-in-django-with-mocking-and-patching/ - return dt.timestamp() diff --git a/src/azure-cli-core/azure/cli/core/commands/client_factory.py b/src/azure-cli-core/azure/cli/core/commands/client_factory.py index ec9bd349743..51ecdbd6ce1 100644 --- a/src/azure-cli-core/azure/cli/core/commands/client_factory.py +++ b/src/azure-cli-core/azure/cli/core/commands/client_factory.py @@ -7,7 +7,7 @@ from azure.cli.core.extension import EXTENSIONS_MOD_PREFIX from azure.cli.core.profiles._shared import get_client_class, SDKProfile from azure.cli.core.profiles import ResourceType, CustomResourceType, get_api_version, get_sdk -from azure.cli.core.util import get_az_user_agent, is_track2 +from azure.cli.core.util import get_az_user_agent, is_track2, resource_to_scopes from knack.log import get_logger from knack.util import CLIError @@ -167,13 +167,33 @@ def _get_mgmt_service_client(cli_ctx, api_version=None, base_url_bound=True, resource=None, + credential_scopes=None, sdk_profile=None, aux_subscriptions=None, aux_tenants=None, **kwargs): + """ + + :param cli_ctx: + :param client_type: + :param subscription_bound: + :param subscription_id: + :param api_version: + :param base_url_bound: + :param resource: For track 1 SDK which uses msrest and ADAL. It will be passed to get_login_credentials. + :param credential_scopes: For track 2 SDK which uses Azure Identity and MSAL. It will be passed to the client's + __init__ method. + :param sdk_profile: + :param aux_subscriptions: + :param aux_tenants: + :param kwargs: + :return: + """ from azure.cli.core._profile import Profile - from azure.cli.core.util import resource_to_scopes logger.debug('Getting management service client client_type=%s', client_type.__name__) + + # Track 1 SDK doesn't maintain the `resource`. The `resource` of the token is the one passed to + # get_login_credentials. resource = resource or cli_ctx.cloud.endpoints.active_directory_resource_id profile = Profile(cli_ctx=cli_ctx) cred, subscription_id, _ = profile.get_login_credentials(subscription_id=subscription_id, resource=resource, @@ -192,7 +212,10 @@ def _get_mgmt_service_client(cli_ctx, if is_track2(client_type): client_kwargs.update(_prepare_client_kwargs_track2(cli_ctx)) - client_kwargs['credential_scopes'] = resource_to_scopes(resource) + # Track 2 SDK maintains `scopes` and passes `scopes` to get_token. Specify `scopes` via `credential_scopes` + # in client's __init__ method. + client_kwargs['credential_scopes'] = credential_scopes or \ + resource_to_scopes(cli_ctx.cloud.endpoints.active_directory_resource_id) if subscription_bound: client = client_type(cred, subscription_id, **client_kwargs) diff --git a/src/azure-cli-core/azure/cli/core/credential.py b/src/azure-cli-core/azure/cli/core/credential.py new file mode 100644 index 00000000000..77ad74d224c --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/credential.py @@ -0,0 +1,127 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from typing import Tuple, List + +import requests +from azure.cli.core._identity import resource_to_scopes +from azure.cli.core.util import in_cloud_console +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError + +from knack.log import get_logger +from knack.util import CLIError + +logger = get_logger(__name__) + + +def _convert_token_entry(token): + import datetime + return {'accessToken': token.token, + 'expiresOn': datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")} + + +class CredentialAdaptor: + """Adaptor to both + - Track 1: msrest.authentication.Authentication, which exposes signed_session + - Track 2: azure.core.credentials.TokenCredential, which exposes get_token + """ + + def __init__(self, credential, resource=None, external_credentials=None): + self._credential = credential + # _external_credentials and _resource are only needed in Track1 SDK + self._external_credentials = external_credentials + self._resource = resource + + def _get_token(self, scopes=None): + external_tenant_tokens = [] + # If scopes is not provided, use CLI-managed resource + scopes = scopes or resource_to_scopes(self._resource) + logger.debug("Retrieving token from MSAL for scopes %r", scopes) + try: + token = self._credential.get_token(*scopes) + if self._external_credentials: + external_tenant_tokens = [cred.get_token(*scopes) for cred in self._external_credentials] + except CLIError as err: + if in_cloud_console(): + CredentialAdaptor._log_hostname() + raise err + except ClientAuthenticationError as err: + # pylint: disable=no-member + if in_cloud_console(): + CredentialAdaptor._log_hostname() + + err = getattr(err, 'message', None) or '' + if 'authentication is required' in err: + raise CLIError("Authentication is migrated to Microsoft identity platform (v2.0). {}".format( + "Please run 'az login' to login." if not in_cloud_console() else '')) + if 'AADSTS70008' in err: # all errors starting with 70008 should be creds expiration related + raise CLIError("Credentials have expired due to inactivity. {}".format( + "Please run 'az login'" if not in_cloud_console() else '')) + if 'AADSTS50079' in err: + raise CLIError("Configuration of your account was changed. {}".format( + "Please run 'az login'" if not in_cloud_console() else '')) + if 'AADSTS50173' in err: + raise CLIError("The credential data used by CLI has been expired because you might have changed or " + "reset the password. {}".format( + "Please clear browser's cookies and run 'az login'" + if not in_cloud_console() else '')) + raise CLIError(err) + except requests.exceptions.SSLError as err: + from .util import SSLERROR_TEMPLATE + raise CLIError(SSLERROR_TEMPLATE.format(str(err))) + except requests.exceptions.ConnectionError as err: + raise CLIError('Please ensure you have network connection. Error detail: ' + str(err)) + return token, external_tenant_tokens + + def signed_session(self, session=None): + logger.debug("CredentialAdaptor.signed_session invoked by Track 1 SDK") + session = session or requests.Session() + token, external_tenant_tokens = self._get_token() + header = "{} {}".format('Bearer', token.token) + session.headers['Authorization'] = header + if external_tenant_tokens: + aux_tokens = ';'.join(['{} {}'.format('Bearer', tokens2.token) for tokens2 in external_tenant_tokens]) + session.headers['x-ms-authorization-auxiliary'] = aux_tokens + return session + + def get_token(self, *scopes): + # type: (*str) -> AccessToken + logger.debug("CredentialAdaptor.get_token invoked by Track 2 SDK with scopes=%r", scopes) + scopes = _normalize_scopes(scopes) + token, _ = self._get_token(scopes) + return token + + def get_all_tokens(self, *scopes): + # type: (*str) -> Tuple[AccessToken, List[AccessToken]] + # TODO: Track 2 SDK should support external credentials. + return self._get_token(scopes) + + @staticmethod + def _log_hostname(): + import socket + logger.warning("A Cloud Shell credential problem occurred. When you report the issue with the error " + "below, please mention the hostname '%s'", socket.gethostname()) + + +def _normalize_scopes(scopes): + """Normalize scopes to workaround some SDK issues.""" + + # Track 2 SDKs generated before https://github.com/Azure/autorest.python/pull/239 don't maintain + # credential_scopes and call `get_token` with empty scopes. + # As a workaround, return None so that the CLI-managed resource is used. + if not scopes: + logger.debug("No scope is provided by the SDK, use the CLI-managed resource.") + return None + + # Track 2 SDKs generated before https://github.com/Azure/autorest.python/pull/745 extend default + # credential_scopes with custom credential_scopes. Instead, credential_scopes should be replaced by + # custom credential_scopes. https://github.com/Azure/azure-sdk-for-python/issues/12947 + # As a workaround, remove the first one if there are multiple scopes provided. + if len(scopes) > 1: + logger.debug("Multiple scopes are provided by the SDK, discarding the first one: %s", scopes[0]) + return scopes[1:] + + return scopes diff --git a/src/azure-cli-core/azure/cli/core/profiles/_shared.py b/src/azure-cli-core/azure/cli/core/profiles/_shared.py index cd1ca775cd5..765d7d2f310 100644 --- a/src/azure-cli-core/azure/cli/core/profiles/_shared.py +++ b/src/azure-cli-core/azure/cli/core/profiles/_shared.py @@ -531,7 +531,8 @@ def supported_resource_type(api_profile, resource_type): return False -def _get_attr(sdk_path, mod_attr_path, checked=True): +def _get_attr(sdk_path, mod_attr_path, checked=False): + """If `checked` is True, None is returned in case of import failure.""" try: attr_mod, attr_path = mod_attr_path.split('#') \ if '#' in mod_attr_path else (mod_attr_path, '') diff --git a/src/azure-cli-core/azure/cli/core/tests/test_adal_authentication.py b/src/azure-cli-core/azure/cli/core/tests/test_adal_authentication.py deleted file mode 100644 index 2d9bbf84e13..00000000000 --- a/src/azure-cli-core/azure/cli/core/tests/test_adal_authentication.py +++ /dev/null @@ -1,87 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -# pylint: disable=line-too-long -import datetime -import unittest -import unittest.mock as mock -from unittest.mock import MagicMock - -from azure.cli.core.adal_authentication import AdalAuthentication, _try_scopes_to_resource - - -class TestUtils(unittest.TestCase): - - def test_try_scopes_to_resource(self): - # Test no scopes - self.assertIsNone(_try_scopes_to_resource(())) - self.assertIsNone(_try_scopes_to_resource([])) - self.assertIsNone(_try_scopes_to_resource(None)) - - # Test multiple scopes, with the first one discarded - resource = _try_scopes_to_resource(("https://management.core.windows.net//.default", - "https://management.core.chinacloudapi.cn//.default")) - self.assertEqual(resource, "https://management.core.chinacloudapi.cn/") - - # Test single scopes (the correct usage) - resource = _try_scopes_to_resource(("https://management.core.chinacloudapi.cn//.default",)) - self.assertEqual(resource, "https://management.core.chinacloudapi.cn/") - - -class TestAdalAuthentication(unittest.TestCase): - - def test_get_token(self): - user_full_token = ( - 'Bearer', - 'access_token_user_mock', - { - 'tokenType': 'Bearer', - 'expiresIn': 3599, - 'expiresOn': '2020-11-18 15:35:17.512862', # Local time - 'resource': 'https://management.core.windows.net/', - 'accessToken': 'access_token_user_mock', - 'refreshToken': 'refresh_token_user_mock', - 'oid': '6d97229a-391f-473a-893f-f0608b592d7b', 'userId': 'rolelivetest@azuresdkteam.onmicrosoft.com', - 'isMRRT': True, '_clientId': '04b07795-8ddb-461a-bbee-02f9e1bf7b46', - '_authority': 'https://login.microsoftonline.com/54826b22-38d6-4fb2-bad9-b7b93a3e9c5a' - }) - cloud_shell_full_token = ( - 'Bearer', - 'access_token_cloud_shell_mock', - { - 'access_token': 'access_token_cloud_shell_mock', - 'refresh_token': '', - 'expires_in': '2732', - 'expires_on': '1605683384', - 'not_before': '1605679484', - 'resource': 'https://management.core.windows.net/', - 'token_type': 'Bearer' - }) - token_retriever = MagicMock() - cred = AdalAuthentication(token_retriever) - - def utc_to_timestamp(dt): - # Obtain the POSIX timestamp from a naive datetime instance representing UTC time - # https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp - return dt.replace(tzinfo=datetime.timezone.utc).timestamp() - - # Test expiresOn is used and converted to epoch time - # Force expiresOn to be treated as UTC to make the test pass on both local machine (such as UTC+8) - # and CI (UTC). - with mock.patch("azure.cli.core.adal_authentication._timestamp", utc_to_timestamp): - token_retriever.return_value = user_full_token - access_token = cred.get_token("https://management.core.windows.net//.default") - self.assertEqual(access_token.token, "access_token_user_mock") - self.assertEqual(access_token.expires_on, 1605713717) - - # Test expires_on is used as epoch directly - token_retriever.return_value = cloud_shell_full_token - access_token = cred.get_token("https://management.core.windows.net//.default") - self.assertEqual(access_token.token, "access_token_cloud_shell_mock") - self.assertEqual(access_token.expires_on, 1605683384) - - -if __name__ == '__main__': - unittest.main() diff --git a/src/azure-cli-core/azure/cli/core/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/tests/test_identity.py new file mode 100644 index 00000000000..a82de7f3c9f --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/tests/test_identity.py @@ -0,0 +1,131 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=protected-access +import os +import json +import unittest +from unittest import mock + +from azure.cli.core._identity import Identity, ServicePrincipalAuth, MsalSecretStore + + +class TestIdentity(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pass + + @mock.patch('azure.cli.core._identity.MsalSecretStore.save_service_principal_cred', autospec=True) + @mock.patch('azure.cli.core._identity.Identity._build_persistent_msal_app', autospec=True) + @mock.patch('azure.cli.core._identity.AdalCredentialCache._load_tokens_from_file', autospec=True) + def test_migrate_tokens(self, load_tokens_from_file_mock, build_persistent_msal_app_mock, + save_service_principal_cred_mock): + adal_tokens = [ + { + "tokenType": "Bearer", + "expiresOn": "2020-08-03 19:00:36.784501", + "resource": "https://management.core.windows.net/", + "userId": "test_user@microsoft.com", + "accessToken": "test_access_token", + "refreshToken": "test_refresh_token", + "_clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", + "_authority": "https://login.microsoftonline.com/00000001-0000-0000-0000-000000000000", + "isMRRT": True, + "expiresIn": 3599 + }, + { + "servicePrincipalId": "00000002-0000-0000-0000-000000000000", + "servicePrincipalTenant": "00000001-0000-0000-0000-000000000000", + "accessToken": "test_sp_secret" + } + ] + load_tokens_from_file_mock.return_value = adal_tokens + + identity = Identity() + identity.migrate_tokens() + msal_app_mock = build_persistent_msal_app_mock.return_value + msal_app_mock.acquire_token_by_refresh_token.assert_called_with( + 'test_refresh_token', ['https://management.core.windows.net//.default']) + save_service_principal_cred_mock.assert_called_with(mock.ANY, adal_tokens[1]) + + def test_login_with_service_principal_certificate_cert_err(self): + import os + identity = Identity() + current_dir = os.path.dirname(os.path.realpath(__file__)) + test_cert_file = os.path.join(current_dir, 'err_sp_cert.pem') + # TODO: wrap exception + with self.assertRaisesRegex(ValueError, "Unable to load certificate."): + identity.login_with_service_principal_certificate("00000000-0000-0000-0000-000000000000", test_cert_file) + + +class TestServicePrincipalAuth(unittest.TestCase): + + def test_service_principal_auth_client_secret(self): + sp_auth = ServicePrincipalAuth('sp_id1', 'tenant1', 'verySecret!') + result = sp_auth.get_entry_to_persist() + self.assertEqual(result, { + 'servicePrincipalId': 'sp_id1', + 'servicePrincipalTenant': 'tenant1', + 'secret': 'verySecret!' + }) + + def test_service_principal_auth_client_cert(self): + curr_dir = os.path.dirname(os.path.realpath(__file__)) + test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') + sp_auth = ServicePrincipalAuth('sp_id1', 'tenant1', None, test_cert_file) + + result = sp_auth.get_entry_to_persist() + self.assertEqual(result, { + 'servicePrincipalId': 'sp_id1', + 'servicePrincipalTenant': 'tenant1', + 'certificateFile': test_cert_file, + }) + + +class TestMsalSecretStore(unittest.TestCase): + + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + def test_retrieve_secret_of_service_principal_with_secret(self, mock_read_file, mock_read_file2, mock_read_file3): + test_sp = [{ + 'servicePrincipalId': 'myapp', + 'servicePrincipalTenant': 'mytenant', + 'secret': 'Secret' + }] + mock_read_file.return_value = json.dumps(test_sp) + mock_read_file2.return_value = json.dumps(test_sp) + mock_read_file3.return_value = json.dumps(test_sp) + from azure.cli.core._identity import MsalSecretStore + # action + secret_store = MsalSecretStore() + token, file = secret_store.retrieve_secret_of_service_principal("myapp", "mytenant") + + self.assertEqual(token, "Secret") + + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + def test_retrieve_secret_of_service_principal_with_cert(self, mock_read_file, mock_read_file2, mock_read_file3): + test_sp = [{ + "servicePrincipalId": "myapp", + "servicePrincipalTenant": "mytenant", + "certificateFile": 'junkcert.pem' + }] + mock_read_file.return_value = json.dumps(test_sp) + mock_read_file2.return_value = json.dumps(test_sp) + mock_read_file3.return_value = json.dumps(test_sp) + from azure.cli.core._identity import MsalSecretStore + # action + creds_cache = MsalSecretStore() + token, file = creds_cache.retrieve_secret_of_service_principal("myapp", "mytenant") + + # assert + self.assertEqual(file, 'junkcert.pem') + + +if __name__ == '__main__': + unittest.main() diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index f879fafd3fa..c83e59a917d 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -10,25 +10,33 @@ import unittest import mock import re +import datetime from copy import deepcopy from adal import AdalError -from azure.cli.core._profile import (Profile, CredsCache, SubscriptionFinder, - ServicePrincipalAuth, _AUTH_CTX_FACTORY, _USE_VENDORED_SUBSCRIPTION_SDK) +from azure.cli.core._profile import (Profile, SubscriptionFinder, _USE_VENDORED_SUBSCRIPTION_SDK, + _detect_adfs_authority, _attach_token_tenant) if _USE_VENDORED_SUBSCRIPTION_SDK: from azure.cli.core.vendored_sdks.subscriptions.models import \ - (SubscriptionState, Subscription, SubscriptionPolicies, SpendingLimit, ManagedByTenant) + (Subscription, SubscriptionPolicies, SpendingLimit, ManagedByTenant) else: from azure.mgmt.resource.subscriptions.models import \ - (SubscriptionState, Subscription, SubscriptionPolicies, SpendingLimit, ManagedByTenant) + (Subscription, SubscriptionPolicies, SpendingLimit, ManagedByTenant) from azure.cli.core.mock import DummyCli +from azure.identity import AuthenticationRecord from knack.util import CLIError +class PublicClientApplicationMock(mock.MagicMock): + + def get_accounts(self, username): + return [account for account in TestProfile.msal_accounts if account['username'] == username] + + class TestProfile(unittest.TestCase): @classmethod @@ -37,7 +45,12 @@ def setUpClass(cls): cls.user1 = 'foo@foo.com' cls.id1 = 'subscriptions/1' cls.display_name1 = 'foo account' - cls.state1 = SubscriptionState.enabled + cls.home_account_id = "00000003-0000-0000-0000-000000000000.00000003-0000-0000-0000-000000000000" + cls.client_id = "00000003-0000-0000-0000-000000000000" + cls.authentication_record = AuthenticationRecord(cls.tenant_id, cls.client_id, + "https://login.microsoftonline.com", cls.home_account_id, + cls.user1) + cls.state1 = 'Enabled' cls.managed_by_tenants = [ManagedByTenantStub('00000003-0000-0000-0000-000000000000'), ManagedByTenantStub('00000004-0000-0000-0000-000000000000')] # Dummy Subscription from SDK azure.mgmt.resource.subscriptions.v2019_06_01.operations._subscriptions_operations.SubscriptionsOperations.list @@ -48,9 +61,23 @@ def setUpClass(cls): cls.state1, tenant_id=cls.tenant_id, managed_by_tenants=cls.managed_by_tenants) + + cls.subscription1_output = [{'environmentName': 'AzureCloud', + 'homeTenantId': 'microsoft.com', + 'id': '1', + 'isDefault': True, + 'managedByTenants': [{'tenantId': '00000003-0000-0000-0000-000000000000'}, + {'tenantId': '00000004-0000-0000-0000-000000000000'}], + 'name': 'foo account', + 'state': 'Enabled', + 'tenantId': 'microsoft.com', + 'user': { + 'name': 'foo@foo.com', + 'type': 'user' + }}] + # Dummy result of azure.cli.core._profile.SubscriptionFinder._find_using_specific_tenant - # home_tenant_id is mapped from tenant_id - # tenant_id denotes token tenant + # It has home_tenant_id which is mapped from tenant_id. tenant_id now denotes token tenant. cls.subscription1 = SubscriptionStub(cls.id1, cls.display_name1, cls.state1, @@ -62,7 +89,7 @@ def setUpClass(cls): 'environmentName': 'AzureCloud', 'id': '1', 'name': cls.display_name1, - 'state': cls.state1.value, + 'state': cls.state1, 'user': { 'name': cls.user1, 'type': 'user' @@ -94,11 +121,13 @@ def setUpClass(cls): "accessToken": cls.raw_token1, "userId": cls.user1 } - + from azure.core.credentials import AccessToken + import time + cls.access_token = AccessToken(cls.raw_token1, int(cls.token_entry1['expiresIn'] + time.time())) cls.user2 = 'bar@bar.com' cls.id2 = 'subscriptions/2' cls.display_name2 = 'bar account' - cls.state2 = SubscriptionState.past_due + cls.state2 = 'PastDue' cls.subscription2_raw = SubscriptionStub(cls.id2, cls.display_name2, cls.state2, @@ -112,7 +141,7 @@ def setUpClass(cls): 'environmentName': 'AzureCloud', 'id': '2', 'name': cls.display_name2, - 'state': cls.state2.value, + 'state': cls.state2, 'user': { 'name': cls.user2, 'type': 'user' @@ -145,19 +174,329 @@ def setUpClass(cls): 'e-lOym1sH5iOcxfIjXF0Tp2y0f3zM7qCq8Cp1ZxEwz6xYIgByoxjErNXrOME5Ld1WizcsaWxTXpwxJn_' 'Q8U2g9kXHrbYFeY2gJxF_hnfLvNKxUKUBnftmyYxZwKi0GDS0BvdJnJnsqSRSpxUx__Ra9QJkG1IaDzj' 'ZcSZPHK45T6ohK9Hk9ktZo0crVl7Tmw') + cls.test_user_msi_access_token = ('eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IlNzWnNCTmhaY0YzUTlTNHRycFFCVE' + 'J5TlJSSSIsImtpZCI6IlNzWnNCTmhaY0YzUTlTNHRycFFCVEJ5TlJSSSJ9.eyJhdWQiOiJodHR' + 'wczovL21hbmFnZW1lbnQuY29yZS53aW5kb3dzLm5ldCIsImlzcyI6Imh0dHBzOi8vc3RzLndpbm' + 'Rvd3MubmV0LzU0ODI2YjIyLTM4ZDYtNGZiMi1iYWQ5LWI3YjkzYTNlOWM1YS8iLCJpYXQiOjE1O' + 'TE3ODM5MDQsIm5iZiI6MTU5MTc4MzkwNCwiZXhwIjoxNTkxODcwNjA0LCJhaW8iOiI0MmRnWUZE' + 'd2JsZmR0WmYxck8zeGlMcVdtOU5MQVE9PSIsImFwcGlkIjoiNjJhYzQ5ZTYtMDQzOC00MTJjLWJ' + 'kZjUtNDg0ZTdkNDUyOTM2IiwiYXBwaWRhY3IiOiIyIiwiaWRwIjoiaHR0cHM6Ly9zdHMud2luZG' + '93cy5uZXQvNTQ4MjZiMjItMzhkNi00ZmIyLWJhZDktYjdiOTNhM2U5YzVhLyIsIm9pZCI6ImQ4M' + 'zRjNjZmLTNhZjgtNDBiNy1iNDYzLWViZGNlN2YzYTgyNyIsInN1YiI6ImQ4MzRjNjZmLTNhZjgt' + 'NDBiNy1iNDYzLWViZGNlN2YzYTgyNyIsInRpZCI6IjU0ODI2YjIyLTM4ZDYtNGZiMi1iYWQ5LWI' + '3YjkzYTNlOWM1YSIsInV0aSI6Ild2YjFyVlBQT1V5VjJDYmNyeHpBQUEiLCJ2ZXIiOiIxLjAiLC' + 'J4bXNfbWlyaWQiOiIvc3Vic2NyaXB0aW9ucy8wYjFmNjQ3MS0xYmYwLTRkZGEtYWVjMy1jYjkyNz' + 'JmMDk1OTAvcmVzb3VyY2Vncm91cHMvcWlhbndlbnMvcHJvdmlkZXJzL01pY3Jvc29mdC5NYW5hZ2' + 'VkSWRlbnRpdHkvdXNlckFzc2lnbmVkSWRlbnRpdGllcy9xaWFud2VuaWRlbnRpdHkifQ.nAxWA5_' + 'qTs_uwGoziKtDFAqxlmYSlyPGqAKZ8YFqFfm68r5Ouo2x2PztAv2D71L-j8B3GykNgW-2yhbB-z2' + 'h53dgjG2TVoeZjhV9DOpSJ06kLAeH-nskGxpBFf7se1qohlU7uyctsUMQWjXVUQbTEanJzj_IH-Y' + '47O3lvM4Yrliz5QUApm63VF4EhqNpNvb5w0HkuB72SJ0MKJt5VdQqNcG077NQNoiTJ34XVXkyNDp' + 'I15y0Cj504P_xw-Dpvg-hmEbykjFMIaB8RoSrp3BzYjNtJh2CHIuWhXF0ngza2SwN2CXK0Vpn5Za' + 'EvZdD57j3h8iGE0Tw5IzG86uNS2AQ0A') + + cls.msal_accounts = [ + { + 'home_account_id': '182c0000-0000-0000-0000-000000000000.54820000-0000-0000-0000-000000000000', + 'environment': 'login.microsoftonline.com', + 'realm': 'organizations', + 'local_account_id': '182c0000-0000-0000-0000-000000000000', + 'username': cls.user1, + 'authority_type': 'MSSTS' + }, { + 'home_account_id': '182c0000-0000-0000-0000-000000000000.54820000-0000-0000-0000-000000000000', + 'environment': 'login.microsoftonline.com', + 'realm': '54820000-0000-0000-0000-000000000000', + 'local_account_id': '182c0000-0000-0000-0000-000000000000', + 'username': cls.user1, + 'authority_type': 'MSSTS' + }, { + 'home_account_id': 'c7970000-0000-0000-0000-000000000000.54820000-0000-0000-0000-000000000000', + 'environment': 'login.microsoftonline.com', + 'realm': 'organizations', + 'local_account_id': 'c7970000-0000-0000-0000-000000000000', + 'username': cls.user2, + 'authority_type': 'MSSTS' + }, { + 'home_account_id': 'c7970000-0000-0000-0000-000000000000.54820000-0000-0000-0000-000000000000', + 'environment': 'login.microsoftonline.com', + 'realm': '54820000-0000-0000-0000-000000000000', + 'local_account_id': 'c7970000-0000-0000-0000-000000000000', + 'username': cls.user2, + 'authority_type': 'MSSTS' + }] + + cls.msal_scopes = ['https://foo/.default'] + + cls.service_principal_id = "00000001-0000-0000-0000-000000000000" + cls.service_principal_secret = "test_secret" + cls.service_principal_tenant_id = "00000001-0000-0000-0000-000000000000" + + @mock.patch('azure.identity.InteractiveBrowserCredential.authenticate', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + @mock.patch('azure.cli.core._profile.can_launch_browser', autospec=True, return_value=True) + def test_login_with_interactive_browser(self, can_launch_browser_mock, app_mock, authenticate_mock): + authenticate_mock.return_value = self.authentication_record + + cli = DummyCli() + mock_arm_client = mock.MagicMock() + mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + subs = profile.login(True, None, None, False, None, use_device_code=False, + allow_no_subscriptions=False, subscription_finder=finder) + + # assert + self.assertEqual(self.subscription1_output, subs) + + @mock.patch('azure.identity.UsernamePasswordCredential.authenticate', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_login_with_username_password_for_tenant(self, app_mock, authenticate_mock): + authenticate_mock.return_value = self.authentication_record + cli = DummyCli() + mock_arm_client = mock.MagicMock() + mock_arm_client.tenants.list.side_effect = ValueError("'tenants.list' should not occur") + mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + subs = profile.login(False, '1234', 'my-secret', False, self.tenant_id, use_device_code=False, + allow_no_subscriptions=False, subscription_finder=finder) + + # assert + self.assertEqual(self.subscription1_output, subs) + + @mock.patch('azure.identity.DeviceCodeCredential.authenticate', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_login_with_device_code(self, app_mock, authenticate_mock): + authenticate_mock.return_value = self.authentication_record + cli = DummyCli() + mock_arm_client = mock.MagicMock() + mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + subs = profile.login(True, None, None, False, None, use_device_code=True, + allow_no_subscriptions=False, subscription_finder=finder) + + # assert + self.assertEqual(self.subscription1_output, subs) + + @mock.patch('azure.identity.DeviceCodeCredential.authenticate', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_login_with_device_code_for_tenant(self, app_mock, authenticate_mock): + authenticate_mock.return_value = self.authentication_record + cli = DummyCli() + mock_arm_client = mock.MagicMock() + mock_arm_client.tenants.list.side_effect = ValueError("'tenants.list' should not occur") + mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + subs = profile.login(True, None, None, False, self.tenant_id, use_device_code=True, + allow_no_subscriptions=False, subscription_finder=finder) + + # assert + self.assertEqual(self.subscription1_output, subs) + + def test_login_with_service_principal_secret(self): + cli = DummyCli() + mock_arm_client = mock.MagicMock() + mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + subs = profile.login(False, 'my app', 'my secret', True, self.tenant_id, use_device_code=True, + allow_no_subscriptions=False, subscription_finder=finder) + output = [{'environmentName': 'AzureCloud', + 'homeTenantId': 'microsoft.com', + 'id': '1', + 'isDefault': True, + 'managedByTenants': [{'tenantId': '00000003-0000-0000-0000-000000000000'}, + {'tenantId': '00000004-0000-0000-0000-000000000000'}], + 'name': 'foo account', + 'state': 'Enabled', + 'tenantId': 'microsoft.com', + 'user': { + 'name': 'my app', + 'type': 'servicePrincipal'}}] + # assert + self.assertEqual(output, subs) + + def test_login_with_service_principal_cert(self): + cli = DummyCli() + mock_arm_client = mock.MagicMock() + mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + curr_dir = os.path.dirname(os.path.realpath(__file__)) + test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + subs = profile.login(False, 'my app', test_cert_file, True, self.tenant_id, use_device_code=True, + allow_no_subscriptions=False, subscription_finder=finder) + output = [{'environmentName': 'AzureCloud', + 'homeTenantId': 'microsoft.com', + 'id': '1', + 'isDefault': True, + 'managedByTenants': [{'tenantId': '00000003-0000-0000-0000-000000000000'}, + {'tenantId': '00000004-0000-0000-0000-000000000000'}], + 'name': 'foo account', + 'state': 'Enabled', + 'tenantId': 'microsoft.com', + 'user': { + 'name': 'my app', + 'type': 'servicePrincipal'}}] + # assert + self.assertEqual(output, subs) + + @unittest.skip("Not supported by Azure Identity.") + def test_login_with_service_principal_cert_sn_issuer(self, get_token_mock): + cli = DummyCli() + mock_arm_client = mock.MagicMock() + mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + curr_dir = os.path.dirname(os.path.realpath(__file__)) + test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + subs = profile.login(False, 'my app', test_cert_file, True, self.tenant_id, use_device_code=True, + allow_no_subscriptions=False, subscription_finder=finder, use_cert_sn_issuer=True) + output = [{'environmentName': 'AzureCloud', + 'homeTenantId': 'microsoft.com', + 'id': '1', + 'isDefault': True, + 'managedByTenants': [{'tenantId': '00000003-0000-0000-0000-000000000000'}, + {'tenantId': '00000004-0000-0000-0000-000000000000'}], + 'name': 'foo account', + 'state': 'Enabled', + 'tenantId': 'microsoft.com', + 'user': { + 'name': 'my app', + 'type': 'servicePrincipal', + 'useCertSNIssuerAuth': True}}] + # assert + self.assertEqual(output, subs) + + @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) + @mock.patch.dict('os.environ') + def test_login_with_environment_credential_service_principal(self, get_client_class_mock): + os.environ['AZURE_TENANT_ID'] = self.service_principal_tenant_id + os.environ['AZURE_CLIENT_ID'] = self.service_principal_id + os.environ['AZURE_CLIENT_SECRET'] = self.service_principal_secret + + client_mock = mock.MagicMock() + get_client_class_mock.return_value = mock.MagicMock(return_value=client_mock) + client_mock.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + + cli = DummyCli() + mock_arm_client = mock.MagicMock() + mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + subs = profile.login_with_environment_credential() + output = [{'environmentName': 'AzureCloud', + 'homeTenantId': 'microsoft.com', + 'id': '1', + 'isDefault': True, + 'managedByTenants': [{'tenantId': '00000003-0000-0000-0000-000000000000'}, + {'tenantId': '00000004-0000-0000-0000-000000000000'}], + 'name': 'foo account', + 'state': 'Enabled', + 'tenantId': self.service_principal_tenant_id, + 'user': { + 'isEnvironmentCredential': True, + 'name': self.service_principal_id, + 'type': 'servicePrincipal'}}] + # assert + self.assertEqual(output, subs) + + @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) + @mock.patch('azure.identity.UsernamePasswordCredential.authenticate', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + @mock.patch.dict('os.environ') + def test_login_with_environment_credential_username_password(self, app_mock, authenticate_mock, get_client_class_mock): + os.environ['AZURE_USERNAME'] = self.user1 + os.environ['AZURE_PASSWORD'] = "test_user_password" + + authenticate_mock.return_value = self.authentication_record + + arm_client_mock = mock.MagicMock() + get_client_class_mock.return_value = mock.MagicMock(return_value=arm_client_mock) + arm_client_mock.tenants.list.return_value = [TenantStub(self.tenant_id)] + arm_client_mock.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + + cli = DummyCli() + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + subs = profile.login_with_environment_credential() + output = [{'environmentName': 'AzureCloud', + 'homeTenantId': 'microsoft.com', + 'id': '1', + 'isDefault': True, + 'managedByTenants': [{'tenantId': '00000003-0000-0000-0000-000000000000'}, + {'tenantId': '00000004-0000-0000-0000-000000000000'}], + 'name': 'foo account', + 'state': 'Enabled', + 'tenantId': self.tenant_id, + 'user': { + 'isEnvironmentCredential': True, + 'name': self.user1, + 'type': 'user'}}] + # assert + self.assertEqual(output, subs) def test_normalize(self): cli = DummyCli() storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) + consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) expected = self.subscription1_normalized self.assertEqual(expected, consolidated[0]) # verify serialization works self.assertIsNotNone(json.dumps(consolidated[0])) + # Test is_environment is mapped to user.isEnvironmentCredential + consolidated = profile._normalize_properties(self.user1, [self.subscription1], False, is_environment=True) + self.assertEqual(consolidated[0]['user']['isEnvironmentCredential'], True) + + def test_normalize_v2016_06_01(self): + cli = DummyCli() + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + from azure.cli.core.vendored_sdks.subscriptions.v2016_06_01.models import Subscription \ + as Subscription_v2016_06_01 + subscription = Subscription_v2016_06_01() + subscription.id = self.id1 + subscription.display_name = self.display_name1 + subscription.state = self.state1 + subscription.tenant_id = self.tenant_id + # The subscription shouldn't have managed_by_tenants and home_tenant_id + + consolidated = profile._normalize_properties(self.user1, [subscription], False) + expected = { + 'id': '1', + 'name': self.display_name1, + 'state': 'Enabled', + 'user': { + 'name': 'foo@foo.com', + 'type': 'user' + }, + 'isDefault': False, + 'tenantId': self.tenant_id, + 'environmentName': 'AzureCloud' + } + self.assertEqual(expected, consolidated[0]) + # verify serialization works + self.assertIsNotNone(json.dumps(consolidated[0])) + def test_normalize_with_unicode_in_subscription_name(self): cli = DummyCli() storage_mock = {'subscriptions': None} @@ -165,7 +504,7 @@ def test_normalize_with_unicode_in_subscription_name(self): polished_display_name = 'sub?' test_subscription = SubscriptionStub('subscriptions/sub1', test_display_name, - SubscriptionState.enabled, + 'Enabled', 'tenant1') profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) consolidated = profile._normalize_properties(self.user1, @@ -180,7 +519,7 @@ def test_normalize_with_none_subscription_name(self): polished_display_name = '' test_subscription = SubscriptionStub('subscriptions/sub1', test_display_name, - SubscriptionState.enabled, + 'Enabled', 'tenant1') profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) consolidated = profile._normalize_properties(self.user1, @@ -317,32 +656,23 @@ def test_subscription_finder_constructor(self, get_api_mock): cli = DummyCli() get_api_mock.return_value = '2016-06-01' cli.cloud.endpoints.resource_manager = 'http://foo_arm' - finder = SubscriptionFinder(cli, None, None, arm_client_factory=None) + finder = SubscriptionFinder(cli) result = finder._arm_client_factory(mock.MagicMock()) self.assertEqual(result._client._base_url, 'http://foo_arm') - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - def test_subscription_finder_fail_on_arm_client_factory(self, get_client_class_mock): - cli = DummyCli() - get_client_class_mock.return_value = None - finder = SubscriptionFinder(cli, None, None, arm_client_factory=None) - from azure.cli.core.azclierror import CLIInternalError - with self.assertRaisesRegexp(CLIInternalError, 'Unable to get'): - finder._arm_client_factory(mock.MagicMock()) - @mock.patch('adal.AuthenticationContext', autospec=True) def test_get_auth_info_for_logged_in_service_principal(self, mock_auth_context): cli = DummyCli() mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 mock_arm_client = mock.MagicMock() mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) storage_mock = {'subscriptions': []} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) profile._management_resource_uri = 'https://management.core.windows.net/' - profile.find_subscriptions_on_login(False, '1234', 'my-secret', True, self.tenant_id, use_device_code=False, - allow_no_subscriptions=False, subscription_finder=finder) + profile.login(False, '1234', 'my-secret', True, self.tenant_id, use_device_code=False, + allow_no_subscriptions=False, subscription_finder=finder) # action extended_info = profile.get_sp_auth_info() # assert @@ -369,27 +699,25 @@ def test_get_auth_info_for_newly_created_service_principal(self): self.assertEqual('https://login.microsoftonline.com', extended_info['activeDirectoryEndpointUrl']) self.assertEqual('https://management.azure.com/', extended_info['resourceManagerEndpointUrl']) - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_create_account_without_subscriptions_thru_service_principal(self, mock_auth_context): - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 + def test_create_account_without_subscriptions_thru_service_principal(self): cli = DummyCli() mock_arm_client = mock.MagicMock() mock_arm_client.subscriptions.list.return_value = [] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) storage_mock = {'subscriptions': []} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) profile._management_resource_uri = 'https://management.core.windows.net/' # action - result = profile.find_subscriptions_on_login(False, - '1234', - 'my-secret', - True, - self.tenant_id, - use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) + result = profile.login(False, + '1234', + 'my-secret', + True, + self.tenant_id, + use_device_code=False, + allow_no_subscriptions=True, + subscription_finder=finder) # assert self.assertEqual(1, len(result)) self.assertEqual(result[0]['id'], self.tenant_id) @@ -398,28 +726,26 @@ def test_create_account_without_subscriptions_thru_service_principal(self, mock_ self.assertEqual(result[0]['name'], 'N/A(tenant level account)') self.assertTrue(profile.is_tenant_level_account()) - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_create_account_with_subscriptions_allow_no_subscriptions_thru_service_principal(self, mock_auth_context): + def test_create_account_with_subscriptions_allow_no_subscriptions_thru_service_principal(self): """test subscription is returned even with --allow-no-subscriptions. """ - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 cli = DummyCli() mock_arm_client = mock.MagicMock() mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) storage_mock = {'subscriptions': []} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) profile._management_resource_uri = 'https://management.core.windows.net/' # action - result = profile.find_subscriptions_on_login(False, - '1234', - 'my-secret', - True, - self.tenant_id, - use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) + result = profile.login(False, + '1234', + 'my-secret', + True, + self.tenant_id, + use_device_code=False, + allow_no_subscriptions=True, + subscription_finder=finder) # assert self.assertEqual(1, len(result)) self.assertEqual(result[0]['id'], self.id1.split('/')[-1]) @@ -428,10 +754,13 @@ def test_create_account_with_subscriptions_allow_no_subscriptions_thru_service_p self.assertEqual(result[0]['name'], self.display_name1) self.assertFalse(profile.is_tenant_level_account()) - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_create_account_without_subscriptions_thru_common_tenant(self, mock_auth_context): - mock_auth_context.acquire_token.return_value = self.token_entry1 - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 + @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) + @mock.patch('azure.identity.UsernamePasswordCredential.authenticate', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_create_account_without_subscriptions_thru_common_tenant(self, app_mock, authenticate_mock, get_token_mock): + get_token_mock.return_value = self.access_token + authenticate_mock.return_value = self.authentication_record + cli = DummyCli() tenant_object = mock.MagicMock() tenant_object.id = "foo-bar" @@ -440,21 +769,21 @@ def test_create_account_without_subscriptions_thru_common_tenant(self, mock_auth mock_arm_client.subscriptions.list.return_value = [] mock_arm_client.tenants.list.return_value = (x for x in [tenant_object]) - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) storage_mock = {'subscriptions': []} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) profile._management_resource_uri = 'https://management.core.windows.net/' # action - result = profile.find_subscriptions_on_login(False, - '1234', - 'my-secret', - False, - None, - use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) + result = profile.login(False, + '1234', + 'my-secret', + False, + None, + use_device_code=False, + allow_no_subscriptions=True, + subscription_finder=finder) # assert self.assertEqual(1, len(result)) @@ -463,32 +792,36 @@ def test_create_account_without_subscriptions_thru_common_tenant(self, mock_auth self.assertEqual(result[0]['tenantId'], self.tenant_id) self.assertEqual(result[0]['name'], 'N/A(tenant level account)') - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_create_account_without_subscriptions_without_tenant(self, mock_auth_context): + @mock.patch('azure.cli.core._identity.Identity.login_with_username_password', autospec=True) + def test_create_account_without_subscriptions_without_tenant(self, login_with_username_password): cli = DummyCli() - finder = mock.MagicMock() - finder.find_through_interactive_flow.return_value = [] + from azure.identity import UsernamePasswordCredential + auth_profile = self.authentication_record + credential = UsernamePasswordCredential(self.client_id, '1234', 'my-secret') + login_with_username_password.return_value = [credential, auth_profile] + + mock_arm_client = mock.MagicMock() + mock_arm_client.subscriptions.list.return_value = [] + mock_arm_client.tenants.list.return_value = [] + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) storage_mock = {'subscriptions': []} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) # action - result = profile.find_subscriptions_on_login(True, - '1234', - 'my-secret', - False, - None, - use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) + result = profile.login(False, + '1234', + 'my-secret', + False, + None, + use_device_code=False, + allow_no_subscriptions=True, + subscription_finder=finder) # assert self.assertTrue(0 == len(result)) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_get_current_account_user(self, mock_read_cred_file): + def test_get_current_account_user(self): cli = DummyCli() - # setup - mock_read_cred_file.return_value = [TestProfile.token_entry1] storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) @@ -502,46 +835,20 @@ def test_get_current_account_user(self, mock_read_cred_file): # verify self.assertEqual(user, self.user1) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', return_value=None) - def test_create_token_cache(self, mock_read_file): - cli = DummyCli() - mock_read_file.return_value = [] - profile = Profile(cli_ctx=cli, use_global_creds_cache=False, async_persist=False) - cache = profile._creds_cache.adal_token_cache - self.assertFalse(cache.read_items()) - self.assertTrue(mock_read_file.called) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_load_cached_tokens(self, mock_read_file): + @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_get_login_credentials(self, app_mock, get_token_mock): cli = DummyCli() - mock_read_file.return_value = [TestProfile.token_entry1] - profile = Profile(cli_ctx=cli, use_global_creds_cache=False, async_persist=False) - cache = profile._creds_cache.adal_token_cache - matched = cache.find({ - "_authority": "https://login.microsoftonline.com/common", - "_clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", - "userId": self.user1 - }) - self.assertEqual(len(matched), 1) - self.assertEqual(matched[0]['accessToken'], self.raw_token1) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_login_credentials(self, mock_get_token, mock_read_cred_file): - cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1) + get_token_mock.return_value = TestProfile.raw_token1 # setup storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' - test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' test_subscription = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id), 'MSI-DEV-INC', self.state1, '12345678-38d6-4fb2-bad9-b7b93a3e1234') consolidated = profile._normalize_properties(self.user1, [test_subscription], - False) + False, None, None) profile._set_subscriptions(consolidated) # action cred, subscription_id, _ = profile.get_login_credentials() @@ -549,28 +856,38 @@ def test_get_login_credentials(self, mock_get_token, mock_read_cred_file): # verify self.assertEqual(subscription_id, test_subscription_id) - # verify the cred._tokenRetriever is a working lambda - token_type, token = cred._token_retriever() + # verify the cred.get_token() + token = cred.get_token() self.assertEqual(token, self.raw_token1) - self.assertEqual(some_token_type, token_type) - mock_get_token.assert_called_once_with(mock.ANY, self.user1, test_tenant_id, - 'https://management.core.windows.net/') - self.assertEqual(mock_get_token.call_count, 1) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_login_credentials_aux_subscriptions(self, mock_get_token, mock_read_cred_file): - cli = DummyCli() - raw_token2 = 'some...secrets2' - token_entry2 = { - "resource": "https://management.core.windows.net/", - "tokenType": "Bearer", - "_authority": "https://login.microsoftonline.com/common", - "accessToken": raw_token2, + + @mock.patch('azure.cli.core._identity.Identity.migrate_tokens', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_get_login_credentials_with_token_migration(self, app_mock, migrate_tokens_mock): + # Mimic an old subscription storage without 'useMsalTokenCache' + adal_storage_mock = { + 'subscriptions': [{ + 'id': '12345678-1bf0-4dda-aec3-cb9272f09590', + 'name': 'MSI-DEV-INC', + 'state': 'Enabled', + 'user': {'name': 'foo@foo.com', 'type': 'user'}, + 'isDefault': True, + 'tenantId': '12345678-38d6-4fb2-bad9-b7b93a3e1234', + 'environmentName': 'AzureCloud', + 'managedByTenants': [] + }] } - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1, token_entry2] - mock_get_token.side_effect = [(some_token_type, TestProfile.raw_token1), (some_token_type, raw_token2)] + + cli = DummyCli() + profile = Profile(cli_ctx=cli, storage=adal_storage_mock) + cred, subscription_id, _ = profile.get_login_credentials() + # make sure migrate_tokens_mock is called + migrate_tokens_mock.assert_called() + + @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_get_login_credentials_aux_subscriptions(self, app_mock, get_token_mock): + cli = DummyCli() + get_token_mock.return_value = TestProfile.raw_token1 # setup storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) @@ -584,7 +901,7 @@ def test_get_login_credentials_aux_subscriptions(self, mock_get_token, mock_read 'MSI-DEV-INC2', self.state1, test_tenant_id2) consolidated = profile._normalize_properties(self.user1, [test_subscription, test_subscription2], - False) + False, None, None) profile._set_subscriptions(consolidated) # action cred, subscription_id, _ = profile.get_login_credentials(subscription_id=test_subscription_id, @@ -593,31 +910,16 @@ def test_get_login_credentials_aux_subscriptions(self, mock_get_token, mock_read # verify self.assertEqual(subscription_id, test_subscription_id) - # verify the cred._tokenRetriever is a working lambda - token_type, token = cred._token_retriever() + # verify the cred._get_token + token, external_tokens = cred._get_token() self.assertEqual(token, self.raw_token1) - self.assertEqual(some_token_type, token_type) + self.assertEqual(external_tokens[0], self.raw_token1) - token2 = cred._external_tenant_token_retriever() - self.assertEqual(len(token2), 1) - self.assertEqual(token2[0][1], raw_token2) - - self.assertEqual(mock_get_token.call_count, 2) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_login_credentials_aux_tenants(self, mock_get_token, mock_read_cred_file): + @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_get_login_credentials_aux_tenants(self, app_mock, get_token_mock): cli = DummyCli() - raw_token2 = 'some...secrets2' - token_entry2 = { - "resource": "https://management.core.windows.net/", - "tokenType": "Bearer", - "_authority": "https://login.microsoftonline.com/common", - "accessToken": raw_token2, - } - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1, token_entry2] - mock_get_token.side_effect = [(some_token_type, TestProfile.raw_token1), (some_token_type, raw_token2)] + get_token_mock.return_value = TestProfile.raw_token1 # setup storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) @@ -631,7 +933,7 @@ def test_get_login_credentials_aux_tenants(self, mock_get_token, mock_read_cred_ 'MSI-DEV-INC2', self.state1, test_tenant_id2) consolidated = profile._normalize_properties(self.user1, [test_subscription, test_subscription2], - False) + False, None, None) profile._set_subscriptions(consolidated) # test only input aux_tenants cred, subscription_id, _ = profile.get_login_credentials(subscription_id=test_subscription_id, @@ -640,16 +942,10 @@ def test_get_login_credentials_aux_tenants(self, mock_get_token, mock_read_cred_ # verify self.assertEqual(subscription_id, test_subscription_id) - # verify the cred._tokenRetriever is a working lambda - token_type, token = cred._token_retriever() + # verify the cred._get_token + token, external_tokens = cred._get_token() self.assertEqual(token, self.raw_token1) - self.assertEqual(some_token_type, token_type) - - token2 = cred._external_tenant_token_retriever() - self.assertEqual(len(token2), 1) - self.assertEqual(token2[0][1], raw_token2) - - self.assertEqual(mock_get_token.call_count, 2) + self.assertEqual(external_tokens[0], self.raw_token1) # test input aux_tenants and aux_subscriptions with self.assertRaisesRegexp(CLIError, @@ -658,10 +954,9 @@ def test_get_login_credentials_aux_tenants(self, mock_get_token, mock_read_cred_ aux_subscriptions=[test_subscription_id2], aux_tenants=[test_tenant_id2]) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_login_credentials_msi_system_assigned(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) + def test_get_login_credentials_msi_system_assigned(self, get_token_mock): + get_token_mock.return_value = TestProfile.raw_token1 # setup an existing msi subscription profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, @@ -669,30 +964,25 @@ def test_get_login_credentials_msi_system_assigned(self, mock_msi_auth, mock_rea test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' test_user = 'systemAssignedIdentity' - msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, 'MSI', self.state1, test_tenant_id) + msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, 'MSI', self.state1, + test_tenant_id) consolidated = profile._normalize_properties(test_user, [msi_subscription], True) profile._set_subscriptions(consolidated) - mock_msi_auth.side_effect = MSRestAzureAuthStub - # action cred, subscription_id, _ = profile.get_login_credentials() # assert self.assertEqual(subscription_id, test_subscription_id) - # sniff test the msi_auth object - cred.set_token() - cred.token - self.assertTrue(cred.set_token_invoked_count) - self.assertTrue(cred.token_read_count) + token = cred.get_token() + self.assertEqual(token, self.raw_token1) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_login_credentials_msi_user_assigned_with_client_id(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) + def test_get_login_credentials_msi_user_assigned_with_client_id(self, get_token_mock): + get_token_mock.return_value = TestProfile.raw_token1 # setup an existing msi subscription profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, @@ -701,29 +991,23 @@ def test_get_login_credentials_msi_user_assigned_with_client_id(self, mock_msi_a test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' test_user = 'userAssignedIdentity' test_client_id = '12345678-38d6-4fb2-bad9-b7b93a3e8888' - msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, 'MSIClient-{}'.format(test_client_id), self.state1, test_tenant_id) + msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, + 'MSIClient-{}'.format(test_client_id), self.state1, test_tenant_id) consolidated = profile._normalize_properties(test_user, [msi_subscription], True) profile._set_subscriptions(consolidated, secondary_key_name='name') - mock_msi_auth.side_effect = MSRestAzureAuthStub - # action cred, subscription_id, _ = profile.get_login_credentials() # assert self.assertEqual(subscription_id, test_subscription_id) - # sniff test the msi_auth object - cred.set_token() - cred.token - self.assertTrue(cred.set_token_invoked_count) - self.assertTrue(cred.token_read_count) - self.assertTrue(cred.client_id, test_client_id) + token = cred.get_token() + self.assertEqual(token, self.raw_token1) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_login_credentials_msi_user_assigned_with_object_id(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) + def test_get_login_credentials_msi_user_assigned_with_object_id(self, get_token_mock): + get_token_mock.return_value = TestProfile.raw_token1 # setup an existing msi subscription profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, @@ -736,25 +1020,18 @@ def test_get_login_credentials_msi_user_assigned_with_object_id(self, mock_msi_a consolidated = profile._normalize_properties('userAssignedIdentity', [msi_subscription], True) profile._set_subscriptions(consolidated, secondary_key_name='name') - mock_msi_auth.side_effect = MSRestAzureAuthStub - # action cred, subscription_id, _ = profile.get_login_credentials() # assert self.assertEqual(subscription_id, test_subscription_id) - # sniff test the msi_auth object - cred.set_token() - cred.token - self.assertTrue(cred.set_token_invoked_count) - self.assertTrue(cred.token_read_count) - self.assertTrue(cred.object_id, test_object_id) + token = cred.get_token() + self.assertEqual(token, self.raw_token1) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_login_credentials_msi_user_assigned_with_res_id(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) + def test_get_login_credentials_msi_user_assigned_with_res_id(self, get_token_mock): + get_token_mock.return_value = self.access_token # setup an existing msi subscription profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, @@ -768,75 +1045,68 @@ def test_get_login_credentials_msi_user_assigned_with_res_id(self, mock_msi_auth consolidated = profile._normalize_properties('userAssignedIdentity', [msi_subscription], True) profile._set_subscriptions(consolidated, secondary_key_name='name') - mock_msi_auth.side_effect = MSRestAzureAuthStub - # action cred, subscription_id, _ = profile.get_login_credentials() # assert self.assertEqual(subscription_id, test_subscription_id) - # sniff test the msi_auth object - cred.set_token() - cred.token - self.assertTrue(cred.set_token_invoked_count) - self.assertTrue(cred.token_read_count) - self.assertTrue(cred.msi_res_id, test_res_id) + token = cred.get_token() + self.assertEqual(token, self.access_token) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_raw_token(self, mock_get_token, mock_read_cred_file): + @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_get_raw_token(self, app_mock, get_token_mock): cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1, - TestProfile.token_entry1) + get_token_mock.return_value = self.access_token + # setup storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) consolidated = profile._normalize_properties(self.user1, [self.subscription1], - False) + False, None, None) profile._set_subscriptions(consolidated) + # action - creds, sub, tenant = profile.get_raw_token(resource='https://foo') + # Get token with ADAL-style resource + resource_result = profile.get_raw_token(resource='https://foo') + # Get token with MSAL-style scopes + scopes_result = profile.get_raw_token(scopes=self.msal_scopes) # verify + self.assertEqual(resource_result, scopes_result) + creds, sub, tenant = scopes_result + self.assertEqual(creds[0], self.token_entry1['tokenType']) self.assertEqual(creds[1], self.raw_token1) + import datetime # the last in the tuple is the whole token entry which has several fields - self.assertEqual(creds[2]['expiresOn'], self.token_entry1['expiresOn']) - mock_get_token.assert_called_once_with(mock.ANY, self.user1, self.tenant_id, - 'https://foo') - self.assertEqual(mock_get_token.call_count, 1) - self.assertEqual(sub, '1') - self.assertEqual(tenant, self.tenant_id) + self.assertEqual(creds[2]['expiresOn'], + datetime.datetime.fromtimestamp(self.access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")) # Test get_raw_token with tenant creds, sub, tenant = profile.get_raw_token(resource='https://foo', tenant=self.tenant_id) self.assertEqual(creds[0], self.token_entry1['tokenType']) self.assertEqual(creds[1], self.raw_token1) - self.assertEqual(creds[2]['expiresOn'], self.token_entry1['expiresOn']) - mock_get_token.assert_called_with(mock.ANY, self.user1, self.tenant_id, 'https://foo') - self.assertEqual(mock_get_token.call_count, 2) + self.assertEqual(creds[2]['expiresOn'], + datetime.datetime.fromtimestamp(self.access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")) self.assertIsNone(sub) self.assertEqual(tenant, self.tenant_id) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_service_principal', autospec=True) - def test_get_raw_token_for_sp(self, mock_get_token, mock_read_cred_file): + @mock.patch('azure.identity.ClientSecretCredential.get_token', autospec=True) + @mock.patch('azure.cli.core._identity.MsalSecretStore.retrieve_secret_of_service_principal', autospec=True) + def test_get_raw_token_for_sp(self, retrieve_secret_of_service_principal, get_token_mock): cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1, - TestProfile.token_entry1) + retrieve_secret_of_service_principal.return_value = 'fake', 'fake' + get_token_mock.return_value = self.access_token # setup storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) consolidated = profile._normalize_properties('sp1', [self.subscription1], - True) + True, None, None) profile._set_subscriptions(consolidated) # action creds, sub, tenant = profile.get_raw_token(resource='https://foo') @@ -845,9 +1115,8 @@ def test_get_raw_token_for_sp(self, mock_get_token, mock_read_cred_file): self.assertEqual(creds[0], self.token_entry1['tokenType']) self.assertEqual(creds[1], self.raw_token1) # the last in the tuple is the whole token entry which has several fields - self.assertEqual(creds[2]['expiresOn'], self.token_entry1['expiresOn']) - mock_get_token.assert_called_once_with(mock.ANY, 'sp1', 'https://foo', self.tenant_id, False) - self.assertEqual(mock_get_token.call_count, 1) + self.assertEqual(creds[2]['expiresOn'], + datetime.datetime.fromtimestamp(self.access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")) self.assertEqual(sub, '1') self.assertEqual(tenant, self.tenant_id) @@ -856,16 +1125,14 @@ def test_get_raw_token_for_sp(self, mock_get_token, mock_read_cred_file): self.assertEqual(creds[0], self.token_entry1['tokenType']) self.assertEqual(creds[1], self.raw_token1) - self.assertEqual(creds[2]['expiresOn'], self.token_entry1['expiresOn']) - mock_get_token.assert_called_with(mock.ANY, 'sp1', 'https://foo', self.tenant_id, False) - self.assertEqual(mock_get_token.call_count, 2) + self.assertEqual(creds[2]['expiresOn'], + datetime.datetime.fromtimestamp(self.access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")) self.assertIsNone(sub) self.assertEqual(tenant, self.tenant_id) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_raw_token_msi_system_assigned(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) + def test_get_raw_token_msi_system_assigned(self, get_token_mock): + get_token_mock.return_value = self.access_token # setup an existing msi subscription profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, @@ -880,15 +1147,13 @@ def test_get_raw_token_msi_system_assigned(self, mock_msi_auth, mock_read_cred_f True) profile._set_subscriptions(consolidated) - mock_msi_auth.side_effect = MSRestAzureAuthStub - # action cred, subscription_id, tenant_id = profile.get_raw_token(resource='http://test_resource') # assert self.assertEqual(subscription_id, test_subscription_id) self.assertEqual(cred[0], 'Bearer') - self.assertEqual(cred[1], TestProfile.test_msi_access_token) + self.assertEqual(cred[1], self.raw_token1) self.assertEqual(subscription_id, test_subscription_id) self.assertEqual(tenant_id, test_tenant_id) @@ -896,12 +1161,10 @@ def test_get_raw_token_msi_system_assigned(self, mock_msi_auth, mock_read_cred_f with self.assertRaisesRegexp(CLIError, "MSI"): cred, subscription_id, _ = profile.get_raw_token(resource='http://test_resource', tenant=self.tenant_id) + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True, return_value=True) @mock.patch('azure.cli.core._profile.in_cloud_console', autospec=True) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_raw_token_in_cloud_console(self, mock_msi_auth, mock_read_cred_file, mock_in_cloud_console): - mock_read_cred_file.return_value = [] - mock_in_cloud_console.return_value = True + def test_get_raw_token_in_cloud_console(self, mock_in_cloud_console, get_token_mock): + get_token_mock.return_value = self.access_token # setup an existing msi subscription profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, @@ -916,15 +1179,13 @@ def test_get_raw_token_in_cloud_console(self, mock_msi_auth, mock_read_cred_file consolidated[0]['user']['cloudShellID'] = True profile._set_subscriptions(consolidated) - mock_msi_auth.side_effect = MSRestAzureAuthStub - # action cred, subscription_id, tenant_id = profile.get_raw_token(resource='http://test_resource') # assert self.assertEqual(subscription_id, test_subscription_id) self.assertEqual(cred[0], 'Bearer') - self.assertEqual(cred[1], TestProfile.test_msi_access_token) + self.assertEqual(cred[1], self.raw_token1) self.assertEqual(subscription_id, test_subscription_id) self.assertEqual(tenant_id, test_tenant_id) @@ -932,77 +1193,113 @@ def test_get_raw_token_in_cloud_console(self, mock_msi_auth, mock_read_cred_file with self.assertRaisesRegexp(CLIError, 'Cloud Shell'): cred, subscription_id, _ = profile.get_raw_token(resource='http://test_resource', tenant=self.tenant_id) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_login_credentials_for_graph_client(self, mock_get_token, mock_read_cred_file): + @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_get_login_credentials_for_graph_client(self, app_mock, get_token_mock): cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1) + get_token_mock.return_value = self.access_token # setup storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) consolidated = profile._normalize_properties(self.user1, [self.subscription1], - False) + False, None, None) profile._set_subscriptions(consolidated) # action cred, _, tenant_id = profile.get_login_credentials( resource=cli.cloud.endpoints.active_directory_graph_resource_id) - _, _ = cred._token_retriever() + _, _ = cred.get_token() # verify - mock_get_token.assert_called_once_with(mock.ANY, self.user1, self.tenant_id, - 'https://graph.windows.net/') + get_token_mock.assert_called_once_with(mock.ANY, 'https://graph.windows.net//.default') self.assertEqual(tenant_id, self.tenant_id) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_login_credentials_for_data_lake_client(self, mock_get_token, mock_read_cred_file): + @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_get_login_credentials_for_data_lake_client(self, app_mock, get_token_mock): cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1) + get_token_mock.return_value = self.access_token # setup storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) consolidated = profile._normalize_properties(self.user1, [self.subscription1], - False) + False, None, None) profile._set_subscriptions(consolidated) # action cred, _, tenant_id = profile.get_login_credentials( resource=cli.cloud.endpoints.active_directory_data_lake_resource_id) - _, _ = cred._token_retriever() + _, _ = cred.get_token() # verify - mock_get_token.assert_called_once_with(mock.ANY, self.user1, self.tenant_id, - 'https://datalake.azure.net/') + get_token_mock.assert_called_once_with(mock.ANY, 'https://datalake.azure.net//.default') self.assertEqual(tenant_id, self.tenant_id) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.persist_cached_creds', autospec=True) - def test_logout(self, mock_persist_creds, mock_read_cred_file): + @mock.patch('msal.PublicClientApplication.remove_account', autospec=True) + @mock.patch('msal.PublicClientApplication.get_accounts', autospec=True) + def test_logout(self, mock_get_accounts, mock_remove_account): cli = DummyCli() - # setup - mock_read_cred_file.return_value = [TestProfile.token_entry1] - storage_mock = {'subscriptions': None} + storage_mock = {'subscriptions': []} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) + + # 1. Log out from CLI, but not from MSAL profile._set_subscriptions(consolidated) self.assertEqual(1, len(storage_mock['subscriptions'])) - # action - profile.logout(self.user1) - # verify + profile.logout(self.user1, clear_credential=False) + self.assertEqual(0, len(storage_mock['subscriptions'])) - self.assertEqual(mock_read_cred_file.call_count, 1) - self.assertEqual(mock_persist_creds.call_count, 1) + mock_get_accounts.assert_called_with(mock.ANY, self.user1) + mock_remove_account.assert_not_called() + + # 2. Log out from both CLI and MSAL + profile._set_subscriptions(consolidated) + mock_get_accounts.reset_mock() + mock_remove_account.reset_mock() + mock_get_accounts.return_value = self.msal_accounts + + profile.logout(self.user1, True) - @mock.patch('azure.cli.core._profile._delete_file', autospec=True) - def test_logout_all(self, mock_delete_cred_file): + self.assertEqual(0, len(storage_mock['subscriptions'])) + mock_get_accounts.assert_called_with(mock.ANY, self.user1) + mock_remove_account.assert_has_calls([mock.call(mock.ANY, self.msal_accounts[0]), + mock.call(mock.ANY, self.msal_accounts[1])]) + + # 3. When already logged out from CLI, log out from MSAL + profile._set_subscriptions([]) + mock_get_accounts.reset_mock() + mock_remove_account.reset_mock() + profile.logout(self.user1, True) + mock_get_accounts.assert_called_with(mock.ANY, self.user1) + mock_remove_account.assert_has_calls([mock.call(mock.ANY, self.msal_accounts[0]), + mock.call(mock.ANY, self.msal_accounts[1])]) + + # 4. Log out from CLI, when already logged out from MSAL + profile._set_subscriptions(consolidated) + mock_get_accounts.reset_mock() + mock_remove_account.reset_mock() + mock_get_accounts.return_value = [] + profile.logout(self.user1, True) + self.assertEqual(0, len(storage_mock['subscriptions'])) + mock_get_accounts.assert_called_with(mock.ANY, self.user1) + mock_remove_account.assert_not_called() + + # 5. Not logged in to CLI or MSAL + profile._set_subscriptions([]) + mock_get_accounts.reset_mock() + mock_remove_account.reset_mock() + mock_get_accounts.return_value = [] + profile.logout(self.user1, True) + self.assertEqual(0, len(storage_mock['subscriptions'])) + mock_get_accounts.assert_called_with(mock.ANY, self.user1) + mock_remove_account.assert_not_called() + + @mock.patch('msal.PublicClientApplication.remove_account', autospec=True) + @mock.patch('msal.PublicClientApplication.get_accounts', autospec=True) + def test_logout_all(self, mock_get_accounts, mock_remove_account): cli = DummyCli() # setup - storage_mock = {'subscriptions': None} + storage_mock = {'subscriptions': []} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) consolidated = profile._normalize_properties(self.user1, [self.subscription1], @@ -1010,71 +1307,76 @@ def test_logout_all(self, mock_delete_cred_file): consolidated2 = profile._normalize_properties(self.user2, [self.subscription2], False) + # 1. Log out from CLI, but not from MSAL profile._set_subscriptions(consolidated + consolidated2) - self.assertEqual(2, len(storage_mock['subscriptions'])) - # action - profile.logout_all() - # verify + profile.logout_all(clear_credential=False) self.assertEqual([], storage_mock['subscriptions']) - self.assertEqual(mock_delete_cred_file.call_count, 1) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_thru_username_password(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - # action - subs = finder.find_from_user_account(self.user1, 'bar', None, mgmt_resource) + mock_get_accounts.assert_called_with(mock.ANY) + mock_remove_account.assert_not_called() - # assert - self.assertEqual([self.subscription1], subs) - mock_auth_context.acquire_token_with_username_password.assert_called_once_with( - mgmt_resource, self.user1, 'bar', mock.ANY) - mock_auth_context.acquire_token.assert_called_once_with( - mgmt_resource, self.user1, mock.ANY) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_thru_username_non_password(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_username_password.return_value = None - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: None) - # action - subs = finder.find_from_user_account(self.user1, 'bar', None, 'http://goo-resource') + # 2. Log out from both CLI and MSAL + profile._set_subscriptions(consolidated + consolidated2) + mock_get_accounts.reset_mock() + mock_remove_account.reset_mock() + mock_get_accounts.return_value = self.msal_accounts + profile.logout_all(clear_credential=True) + self.assertEqual([], storage_mock['subscriptions']) + mock_get_accounts.assert_called_with(mock.ANY) + self.assertEqual(mock_remove_account.call_count, 4) + + # 3. When already logged out from CLI, log out from MSAL + profile._set_subscriptions([]) + mock_get_accounts.reset_mock() + mock_remove_account.reset_mock() + mock_get_accounts.return_value = self.msal_accounts + profile.logout_all(clear_credential=True) + self.assertEqual([], storage_mock['subscriptions']) + mock_get_accounts.assert_called_with(mock.ANY) + self.assertEqual(mock_remove_account.call_count, 4) - # assert - self.assertEqual([], subs) + # 4. Log out from CLI, when already logged out from MSAL + profile._set_subscriptions(consolidated + consolidated2) + mock_get_accounts.reset_mock() + mock_remove_account.reset_mock() + mock_get_accounts.return_value = [] + profile.logout_all(clear_credential=True) + self.assertEqual([], storage_mock['subscriptions']) + mock_get_accounts.assert_called_with(mock.ANY) + mock_remove_account.assert_not_called() + + # 5. Not logged in to CLI or MSAL + profile._set_subscriptions([]) + mock_get_accounts.reset_mock() + mock_remove_account.reset_mock() + mock_get_accounts.return_value = [] + profile.logout_all(clear_credential=True) + self.assertEqual([], storage_mock['subscriptions']) + mock_get_accounts.assert_called_with(mock.ANY) + mock_remove_account.assert_not_called() - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - @mock.patch('azure.cli.core.profiles._shared.get_client_class', autospec=True) - @mock.patch('azure.cli.core._profile._get_cloud_console_token_endpoint', autospec=True) + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_cloud_console(self, mock_subscription_finder, mock_get_token_endpoint, - mock_get_client_class, mock_msi_auth): - + def test_find_subscriptions_in_cloud_console(self, mock_subscription_finder, get_token_mock): class SubscriptionFinderStub: - def find_from_raw_token(self, tenant, token): + def find_using_specific_tenant(self, tenant, credential): # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if token != TestProfile.test_msi_access_token or tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_from_raw_token was not invoked with expected tenant or token') + if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': + raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') return [TestProfile.subscription1] mock_subscription_finder.return_value = SubscriptionFinderStub() - mock_get_token_endpoint.return_value = "http://great_endpoint" - mock_msi_auth.return_value = MSRestAzureAuthStub() - + from azure.core.credentials import AccessToken + import time + get_token_mock.return_value = AccessToken(TestProfile.test_msi_access_token, + int(self.token_entry1['expiresIn'] + time.time())) profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, async_persist=False) # action - subscriptions = profile.find_subscriptions_in_cloud_console() + subscriptions = profile.login_in_cloud_shell() # assert self.assertEqual(len(subscriptions), 1) @@ -1085,33 +1387,25 @@ def find_from_raw_token(self, tenant, token): self.assertEqual(s['name'], self.display_name1) self.assertEqual(s['id'], self.id1.split('/')[-1]) - @mock.patch('requests.get', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - def test_find_subscriptions_in_vm_with_msi_system_assigned(self, mock_get_client_class, mock_get): - - class ClientStub: - def __init__(self, *args, **kwargs): - self.subscriptions = mock.MagicMock() - self.subscriptions.list.return_value = [deepcopy(TestProfile.subscription1_raw)] - self.config = mock.MagicMock() - self._client = mock.MagicMock() + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) + def test_find_subscriptions_in_vm_with_msi_system_assigned(self, mock_subscription_finder, get_token_mock): + class SubscriptionFinderStub: + def find_using_specific_tenant(self, tenant, credential): + # make sure the tenant and token args match 'TestProfile.test_msi_access_token' + if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': + raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') + return [TestProfile.subscription1] - mock_get_client_class.return_value = ClientStub - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + mock_subscription_finder.return_value = SubscriptionFinderStub() - test_token_entry = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - encoded_test_token = json.dumps(test_token_entry).encode() - good_response = mock.MagicMock() - good_response.status_code = 200 - good_response.content = encoded_test_token - mock_get.return_value = good_response + from azure.core.credentials import AccessToken + import time + get_token_mock.return_value = AccessToken(TestProfile.test_msi_access_token, + int(self.token_entry1['expiresIn'] + time.time())) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, async_persist=False) - subscriptions = profile.find_subscriptions_in_vm_with_msi() + subscriptions = profile.login_with_managed_identity() # assert self.assertEqual(len(subscriptions), 1) @@ -1121,35 +1415,27 @@ def __init__(self, *args, **kwargs): self.assertEqual(s['user']['assignedIdentityInfo'], 'MSI') self.assertEqual(s['name'], self.display_name1) self.assertEqual(s['id'], self.id1.split('/')[-1]) - self.assertEqual(s['tenantId'], '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a') - - @mock.patch('requests.get', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - def test_find_subscriptions_in_vm_with_msi_no_subscriptions(self, mock_get_client_class, mock_get): + self.assertEqual(s['tenantId'], 'microsoft.com') - class ClientStub: - def __init__(self, *args, **kwargs): - self.subscriptions = mock.MagicMock() - self.subscriptions.list.return_value = [] - self.config = mock.MagicMock() - self._client = mock.MagicMock() + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) + def test_find_subscriptions_in_vm_with_msi_no_subscriptions(self, mock_subscription_finder, get_token_mock): + class SubscriptionFinderStub: + def find_using_specific_tenant(self, tenant, credential): + # make sure the tenant and token args match 'TestProfile.test_msi_access_token' + if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': + raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') + return [] - mock_get_client_class.return_value = ClientStub - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + mock_subscription_finder.return_value = SubscriptionFinderStub() - test_token_entry = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - encoded_test_token = json.dumps(test_token_entry).encode() - good_response = mock.MagicMock() - good_response.status_code = 200 - good_response.content = encoded_test_token - mock_get.return_value = good_response + from azure.core.credentials import AccessToken + import time + get_token_mock.return_value = AccessToken(TestProfile.test_msi_access_token, + int(self.token_entry1['expiresIn'] + time.time())) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, async_persist=False) - subscriptions = profile.find_subscriptions_in_vm_with_msi(allow_no_subscriptions=True) + subscriptions = profile.login_with_managed_identity(allow_no_subscriptions=True) # assert self.assertEqual(len(subscriptions), 1) @@ -1161,133 +1447,117 @@ def __init__(self, *args, **kwargs): self.assertEqual(s['id'], self.test_msi_tenant) self.assertEqual(s['tenantId'], self.test_msi_tenant) - @mock.patch('requests.get', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - def test_find_subscriptions_in_vm_with_msi_user_assigned_with_client_id(self, mock_get_client_class, mock_get): + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) + def test_find_subscriptions_in_vm_with_msi_user_assigned_with_client_id(self, mock_subscription_finder, get_token_mock): + class SubscriptionFinderStub: + def find_using_specific_tenant(self, tenant, credential): + # make sure the tenant and token args match 'TestProfile.test_msi_access_token' + if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': + raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') + return [TestProfile.subscription1] - class ClientStub: - def __init__(self, *args, **kwargs): - self.subscriptions = mock.MagicMock() - self.subscriptions.list.return_value = [deepcopy(TestProfile.subscription1_raw)] - self.config = mock.MagicMock() - self._client = mock.MagicMock() + mock_subscription_finder.return_value = SubscriptionFinderStub() - mock_get_client_class.return_value = ClientStub - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + from azure.core.credentials import AccessToken + import time - test_token_entry = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - test_client_id = '54826b22-38d6-4fb2-bad9-b7b93a3e9999' - encoded_test_token = json.dumps(test_token_entry).encode() - good_response = mock.MagicMock() - good_response.status_code = 200 - good_response.content = encoded_test_token - mock_get.return_value = good_response + get_token_mock.return_value = AccessToken(TestProfile.test_user_msi_access_token, + int(self.token_entry1['expiresIn'] + time.time())) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, + use_global_creds_cache=False, async_persist=False) + + test_client_id = '62ac49e6-0438-412c-bdf5-484e7d452936' - subscriptions = profile.find_subscriptions_in_vm_with_msi(identity_id=test_client_id) + subscriptions = profile.login_with_managed_identity(identity_id=test_client_id) # assert self.assertEqual(len(subscriptions), 1) s = subscriptions[0] self.assertEqual(s['user']['name'], 'userAssignedIdentity') self.assertEqual(s['user']['type'], 'servicePrincipal') + self.assertEqual(s['user']['clientId'], test_client_id) self.assertEqual(s['name'], self.display_name1) - self.assertEqual(s['user']['assignedIdentityInfo'], 'MSIClient-{}'.format(test_client_id)) self.assertEqual(s['id'], self.id1.split('/')[-1]) - self.assertEqual(s['tenantId'], '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a') + self.assertEqual(s['tenantId'], 'microsoft.com') - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - @mock.patch('azure.cli.core.profiles._shared.get_client_class', autospec=True) + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_vm_with_msi_user_assigned_with_object_id(self, mock_subscription_finder, mock_get_client_class, - mock_msi_auth): - from azure.cli.core.azclierror import AzureResponseError - + def test_find_subscriptions_in_vm_with_msi_user_assigned_with_object_id(self, mock_subscription_finder, get_token_mock): class SubscriptionFinderStub: - def find_from_raw_token(self, tenant, token): + def find_using_specific_tenant(self, tenant, credential): # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if token != TestProfile.test_msi_access_token or tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_from_raw_token was not invoked with expected tenant or token') + if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': + raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') return [TestProfile.subscription1] - class AuthStub: - def __init__(self, **kwargs): - self.token = None - self.client_id = kwargs.get('client_id') - self.object_id = kwargs.get('object_id') - # since msrestazure 0.4.34, set_token in init - self.set_token() - - def set_token(self): - # here we will reject the 1st sniffing of trying with client_id and then acccept the 2nd - if self.object_id: - self.token = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - else: - raise AzureResponseError('Failed to connect to MSI. Please make sure MSI is configured correctly.\n' - 'Get Token request returned http error: 400, reason: Bad Request') + mock_subscription_finder.return_value = SubscriptionFinderStub() - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) + from azure.core.credentials import AccessToken + import time - mock_subscription_finder.return_value = SubscriptionFinderStub() + get_token_mock.return_value = AccessToken(TestProfile.test_user_msi_access_token, + int(self.token_entry1['expiresIn'] + time.time())) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, + use_global_creds_cache=False, async_persist=False) - mock_msi_auth.side_effect = AuthStub - test_object_id = '54826b22-38d6-4fb2-bad9-b7b93a3e9999' + test_object_id = 'd834c66f-3af8-40b7-b463-ebdce7f3a827' - # action - subscriptions = profile.find_subscriptions_in_vm_with_msi(identity_id=test_object_id) + subscriptions = profile.login_with_managed_identity(identity_id=test_object_id) # assert - self.assertEqual(subscriptions[0]['user']['assignedIdentityInfo'], 'MSIObject-{}'.format(test_object_id)) + self.assertEqual(len(subscriptions), 1) + s = subscriptions[0] + self.assertEqual(s['user']['name'], 'userAssignedIdentity') + self.assertEqual(s['user']['type'], 'servicePrincipal') + self.assertEqual(s['user']['objectId'], test_object_id) + self.assertEqual(s['name'], self.display_name1) + self.assertEqual(s['id'], self.id1.split('/')[-1]) + self.assertEqual(s['tenantId'], 'microsoft.com') - @mock.patch('requests.get', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - def test_find_subscriptions_in_vm_with_msi_user_assigned_with_res_id(self, mock_get_client_class, mock_get): + @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) + def test_find_subscriptions_in_vm_with_msi_user_assigned_with_res_id(self, mock_subscription_finder, get_token_mock): + class SubscriptionFinderStub: + def find_using_specific_tenant(self, tenant, credential): + # make sure the tenant and token args match 'TestProfile.test_msi_access_token' + if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': + raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') + return [TestProfile.subscription1] - class ClientStub: - def __init__(self, *args, **kwargs): - self.subscriptions = mock.MagicMock() - self.subscriptions.list.return_value = [deepcopy(TestProfile.subscription1_raw)] - self.config = mock.MagicMock() - self._client = mock.MagicMock() + mock_subscription_finder.return_value = SubscriptionFinderStub() - mock_get_client_class.return_value = ClientStub - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + from azure.core.credentials import AccessToken + import time - test_token_entry = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - test_res_id = ('/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590/resourcegroups/g1/' - 'providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1') + get_token_mock.return_value = AccessToken(TestProfile.test_user_msi_access_token, + int(self.token_entry1['expiresIn'] + time.time())) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, + use_global_creds_cache=False, async_persist=False) - encoded_test_token = json.dumps(test_token_entry).encode() - good_response = mock.MagicMock() - good_response.status_code = 200 - good_response.content = encoded_test_token - mock_get.return_value = good_response + test_resource_id = ('/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590/resourcegroups/qianwens/providers/' + 'Microsoft.ManagedIdentity/userAssignedIdentities/qianwenidentity') - subscriptions = profile.find_subscriptions_in_vm_with_msi(identity_id=test_res_id) + subscriptions = profile.login_with_managed_identity(identity_id=test_resource_id) # assert - self.assertEqual(subscriptions[0]['user']['assignedIdentityInfo'], 'MSIResource-{}'.format(test_res_id)) + self.assertEqual(len(subscriptions), 1) + s = subscriptions[0] + self.assertEqual(s['user']['name'], 'userAssignedIdentity') + self.assertEqual(s['user']['type'], 'servicePrincipal') + self.assertEqual(s['user']['resourceId'], test_resource_id) + self.assertEqual(s['name'], self.display_name1) + self.assertEqual(s['id'], self.id1.split('/')[-1]) + self.assertEqual(s['tenantId'], 'microsoft.com') - @mock.patch('adal.AuthenticationContext.acquire_token_with_username_password', autospec=True) - @mock.patch('adal.AuthenticationContext.acquire_token', autospec=True) - def test_find_subscriptions_thru_username_password_adfs(self, mock_acquire_token, - mock_acquire_token_username_password): + @unittest.skip("todo: wait for identity support") + @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) + def test_find_subscriptions_thru_username_password_adfs(self, get_token_mock): cli = DummyCli() TEST_ADFS_AUTH_URL = 'https://adfs.local.azurestack.external/adfs' + get_token_mock.return_value = self.access_token + # todo: adfs test should be covered in azure.identity def test_acquire_token(self, resource, username, password, client_id): global acquire_token_invoked acquire_token_invoked = True @@ -1296,209 +1566,38 @@ def test_acquire_token(self, resource, username, password, client_id): else: raise ValueError('AuthContext was not initialized correctly for ADFS') - mock_acquire_token_username_password.side_effect = test_acquire_token - mock_acquire_token.return_value = self.token_entry1 + get_token_mock.return_value = self.access_token mock_arm_client = mock.MagicMock() mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] cli.cloud.endpoints.active_directory = TEST_ADFS_AUTH_URL - finder = SubscriptionFinder(cli, _AUTH_CTX_FACTORY, None, lambda _: mock_arm_client) + finder = SubscriptionFinder(cli) + finder._arm_client_factory = mock_arm_client mgmt_resource = 'https://management.core.windows.net/' - # action - subs = finder.find_from_user_account(self.user1, 'bar', None, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - self.assertTrue(acquire_token_invoked) + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile.login(False, '1234', 'my-secret', True, self.tenant_id, use_device_code=False, + allow_no_subscriptions=False, subscription_finder=finder) - @mock.patch('adal.AuthenticationContext', autospec=True) - @mock.patch('azure.cli.core._profile.logger', autospec=True) - def test_find_subscriptions_thru_username_password_with_account_disabled(self, mock_logger, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.side_effect = AdalError('Account is disabled') - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' # action subs = finder.find_from_user_account(self.user1, 'bar', None, mgmt_resource) - # assert - self.assertEqual([], subs) - mock_logger.warning.assert_called_once_with(mock.ANY, mock.ANY, mock.ANY) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_from_particular_tenent(self, mock_auth_context): - def just_raise(ex): - raise ex - - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.side_effect = lambda: just_raise( - ValueError("'tenants.list' should not occur")) - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - # action - subs = finder.find_from_user_account(self.user1, 'bar', self.tenant_id, 'http://someresource') - - # assert - self.assertEqual([self.subscription1], subs) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_through_device_code_flow(self, mock_auth_context): - cli = DummyCli() - test_nonsense_code = {'message': 'magic code for you'} - mock_auth_context.acquire_user_code.return_value = test_nonsense_code - mock_auth_context.acquire_token_with_device_code.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - # action - subs = finder.find_through_interactive_flow(None, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - mock_auth_context.acquire_user_code.assert_called_once_with( - mgmt_resource, mock.ANY) - mock_auth_context.acquire_token_with_device_code.assert_called_once_with( - mgmt_resource, test_nonsense_code, mock.ANY) - mock_auth_context.acquire_token.assert_called_once_with( - mgmt_resource, self.user1, mock.ANY) - - @mock.patch('adal.AuthenticationContext', autospec=True) - @mock.patch('azure.cli.core._profile._get_authorization_code', autospec=True) - def test_find_subscriptions_through_authorization_code_flow(self, _get_authorization_code_mock, mock_auth_context): - import adal - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - token_cache = adal.TokenCache() - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, token_cache, lambda _: mock_arm_client) - _get_authorization_code_mock.return_value = { - 'code': 'code1', - 'reply_url': 'http://localhost:8888' - } - mgmt_resource = 'https://management.core.windows.net/' - temp_token_cache = mock.MagicMock() - type(mock_auth_context).cache = temp_token_cache - temp_token_cache.read_items.return_value = [] - mock_auth_context.acquire_token_with_authorization_code.return_value = self.token_entry1 - - # action - subs = finder.find_through_authorization_code_flow(None, mgmt_resource, 'https:/some_aad_point/common') - - # assert - self.assertEqual([self.subscription1], subs) - mock_auth_context.acquire_token.assert_called_once_with(mgmt_resource, self.user1, mock.ANY) - mock_auth_context.acquire_token_with_authorization_code.assert_called_once_with('code1', - 'http://localhost:8888', - mgmt_resource, mock.ANY, - None) - _get_authorization_code_mock.assert_called_once_with(mgmt_resource, 'https:/some_aad_point/common') - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_interactive_from_particular_tenent(self, mock_auth_context): - def just_raise(ex): - raise ex - - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.side_effect = lambda: just_raise( - ValueError("'tenants.list' should not occur")) - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - # action - subs = finder.find_through_interactive_flow(self.tenant_id, 'http://someresource') - - # assert - self.assertEqual([self.subscription1], subs) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_from_service_principal_id(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - # action - subs = finder.find_from_service_principal_id('my app', ServicePrincipalAuth('my secret'), - self.tenant_id, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - mock_arm_client.tenants.list.assert_not_called() - mock_auth_context.acquire_token.assert_not_called() - mock_auth_context.acquire_token_with_client_credentials.assert_called_once_with( - mgmt_resource, 'my app', 'my secret') - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_from_service_principal_using_cert(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_client_certificate.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - - curr_dir = os.path.dirname(os.path.realpath(__file__)) - test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') - - # action - subs = finder.find_from_service_principal_id('my app', ServicePrincipalAuth(test_cert_file), - self.tenant_id, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - mock_arm_client.tenants.list.assert_not_called() - mock_auth_context.acquire_token.assert_not_called() - mock_auth_context.acquire_token_with_client_certificate.assert_called_once_with( - mgmt_resource, 'my app', mock.ANY, mock.ANY, None) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_from_service_principal_using_cert_sn_issuer(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_client_certificate.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - - curr_dir = os.path.dirname(os.path.realpath(__file__)) - test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') - with open(test_cert_file) as cert_file: - cert_file_string = cert_file.read() - match = re.search(r'\-+BEGIN CERTIFICATE.+\-+(?P[^-]+)\-+END CERTIFICATE.+\-+', - cert_file_string, re.I) - public_certificate = match.group('public').strip() - # action - subs = finder.find_from_service_principal_id('my app', ServicePrincipalAuth(test_cert_file, use_cert_sn_issuer=True), - self.tenant_id, mgmt_resource) - # assert self.assertEqual([self.subscription1], subs) - mock_arm_client.tenants.list.assert_not_called() - mock_auth_context.acquire_token.assert_not_called() - mock_auth_context.acquire_token_with_client_certificate.assert_called_once_with( - mgmt_resource, 'my app', mock.ANY, mock.ANY, public_certificate) - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_refresh_accounts_one_user_account(self, mock_auth_context): + @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_refresh_accounts_one_user_account(self, app_mock, get_token_mock): cli = DummyCli() storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False) + consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False, None, None) profile._set_subscriptions(consolidated) - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.return_value = self.token_entry1 + get_token_mock.return_value = self.access_token mock_arm_client = mock.MagicMock() mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] mock_arm_client.subscriptions.list.return_value = deepcopy([self.subscription1_raw, self.subscription2_raw]) - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) # action profile.refresh_accounts(finder) @@ -1509,24 +1608,28 @@ def test_refresh_accounts_one_user_account(self, mock_auth_context): self.assertEqual(self.id2.split('/')[-1], result[1]['id']) self.assertTrue(result[0]['isDefault']) - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_refresh_accounts_one_user_account_one_sp_account(self, mock_auth_context): + @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) + @mock.patch('azure.identity.ClientSecretCredential.get_token', autospec=True) + @mock.patch('azure.cli.core._identity.MsalSecretStore.retrieve_secret_of_service_principal', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_refresh_accounts_one_user_account_one_sp_account(self, app_mock, retrieve_secret_of_service_principal, + get_token1, get_token2): cli = DummyCli() storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) sp_subscription1 = SubscriptionStub('sp-sub/3', 'foo-subname', self.state1, 'foo_tenant.onmicrosoft.com') - consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False) + consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False, None, None) consolidated += profile._normalize_properties('http://foo', [sp_subscription1], True) profile._set_subscriptions(consolidated) - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.return_value = self.token_entry1 - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 + retrieve_secret_of_service_principal.return_value = 'fake', 'fake' + get_token1.return_value = self.access_token + get_token2.return_value = self.access_token mock_arm_client = mock.MagicMock() mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.side_effect = deepcopy([[self.subscription1], [self.subscription2, sp_subscription1]]) - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - profile._creds_cache.retrieve_cred_for_service_principal = lambda _: 'verySecret' - profile._creds_cache.flush_to_disk = lambda _: '' + mock_arm_client.subscriptions.list.side_effect = deepcopy( + [[self.subscription1], [self.subscription2, sp_subscription1]]) + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + # action profile.refresh_accounts(finder) @@ -1538,19 +1641,19 @@ def test_refresh_accounts_one_user_account_one_sp_account(self, mock_auth_contex self.assertEqual('3', result[2]['id']) self.assertTrue(result[0]['isDefault']) - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_refresh_accounts_with_nothing(self, mock_auth_context): + @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) + @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + def test_refresh_accounts_with_nothing(self, app_mock, get_token_mock): cli = DummyCli() + get_token_mock.return_value = self.access_token storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False) + consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False, None, None) profile._set_subscriptions(consolidated) - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.return_value = self.token_entry1 mock_arm_client = mock.MagicMock() mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] mock_arm_client.subscriptions.list.return_value = [] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) + finder = SubscriptionFinder(cli, lambda _: mock_arm_client) # action profile.refresh_accounts(finder) @@ -1558,71 +1661,14 @@ def test_refresh_accounts_with_nothing(self, mock_auth_context): result = storage_mock['subscriptions'] self.assertEqual(0, len(result)) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_credscache_load_tokens_and_sp_creds_with_secret(self, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_read_file.return_value = [self.token_entry1, test_sp] - - # action - creds_cache = CredsCache(cli, async_persist=False) - - # assert - token_entries = [entry for _, entry in creds_cache.load_adal_token_cache().read_items()] - self.assertEqual(token_entries, [self.token_entry1]) - self.assertEqual(creds_cache._service_principal_creds, [test_sp]) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_credscache_load_tokens_and_sp_creds_with_cert(self, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "certificateFile": 'junkcert.pem' - } - mock_read_file.return_value = [test_sp] - - # action - creds_cache = CredsCache(cli, async_persist=False) - creds_cache.load_adal_token_cache() - - # assert - self.assertEqual(creds_cache._service_principal_creds, [test_sp]) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_credscache_retrieve_sp_cred(self, mock_read_file): - cli = DummyCli() - test_cache = [ - { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - }, - { - "servicePrincipalId": "myapp2", - "servicePrincipalTenant": "mytenant", - "certificateFile": 'junkcert.pem' - } - ] - mock_read_file.return_value = test_cache - - # action - creds_cache = CredsCache(cli, async_persist=False) - creds_cache.load_adal_token_cache() - - # assert - self.assertEqual(creds_cache.retrieve_cred_for_service_principal('myapp'), 'Secret') - self.assertEqual(creds_cache.retrieve_cred_for_service_principal('myapp2'), 'junkcert.pem') - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_add_new_sp_creds(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) + @mock.patch('msal_extensions.FilePersistence.save', autospec=True) + def test_credscache_add_new_sp_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, + mock_read_file1, mock_read_file2, mock_read_file3): test_sp = { "servicePrincipalId": "myapp", "servicePrincipalTenant": "mytenant", @@ -1633,54 +1679,71 @@ def test_credscache_add_new_sp_creds(self, _, mock_open_for_write, mock_read_fil "servicePrincipalTenant": "mytenant2", "accessToken": "Secret2" } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [self.token_entry1, test_sp] - creds_cache = CredsCache(cli, async_persist=False) + mock_open_for_write1.return_value = None + mock_open_for_write2.return_value = None + mock_open_for_write3.return_value = None + mock_read_file1.return_value = json.dumps([test_sp]) + mock_read_file2.return_value = json.dumps([test_sp]) + mock_read_file3.return_value = json.dumps([test_sp]) + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() # action creds_cache.save_service_principal_cred(test_sp2) # assert - token_entries = [e for _, e in creds_cache.adal_token_cache.read_items()] # noqa: F812 - self.assertEqual(token_entries, [self.token_entry1]) self.assertEqual(creds_cache._service_principal_creds, [test_sp, test_sp2]) - mock_open_for_write.assert_called_with(mock.ANY, 'w+') - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_add_preexisting_sp_creds(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) + @mock.patch('msal_extensions.FilePersistence.save', autospec=True) + def test_credscache_add_preexisting_sp_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, + mock_read_file1, mock_read_file2, mock_read_file3): test_sp = { "servicePrincipalId": "myapp", "servicePrincipalTenant": "mytenant", "accessToken": "Secret" } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [test_sp] - creds_cache = CredsCache(cli, async_persist=False) + mock_open_for_write1.return_value = None + mock_open_for_write2.return_value = None + mock_open_for_write3.return_value = None + mock_read_file1.return_value = json.dumps([test_sp]) + mock_read_file2.return_value = json.dumps([test_sp]) + mock_read_file3.return_value = json.dumps([test_sp]) + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() # action creds_cache.save_service_principal_cred(test_sp) # assert self.assertEqual(creds_cache._service_principal_creds, [test_sp]) - self.assertFalse(mock_open_for_write.called) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_add_preexisting_sp_new_secret(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) + @mock.patch('msal_extensions.FilePersistence.save', autospec=True) + def test_credscache_add_preexisting_sp_new_secret(self, mock_open_for_write1, mock_open_for_write2, + mock_open_for_write3, mock_read_file1, + mock_read_file2, mock_read_file3): test_sp = { "servicePrincipalId": "myapp", "servicePrincipalTenant": "mytenant", "accessToken": "Secret" } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [test_sp] - creds_cache = CredsCache(cli, async_persist=False) - + mock_open_for_write1.return_value = None + mock_open_for_write2.return_value = None + mock_open_for_write3.return_value = None + mock_read_file1.return_value = json.dumps([test_sp]) + mock_read_file2.return_value = json.dumps([test_sp]) + mock_read_file3.return_value = json.dumps([test_sp]) + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() new_creds = test_sp.copy() new_creds['accessToken'] = 'Secret2' # action @@ -1688,196 +1751,58 @@ def test_credscache_add_preexisting_sp_new_secret(self, _, mock_open_for_write, # assert self.assertEqual(creds_cache._service_principal_creds, [new_creds]) - self.assertTrue(mock_open_for_write.called) - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_match_service_principal_correctly(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) + @mock.patch('msal_extensions.FilePersistence.save', autospec=True) + def test_credscache_remove_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, + mock_read_file1, mock_read_file2, mock_read_file3): test_sp = { "servicePrincipalId": "myapp", "servicePrincipalTenant": "mytenant", "accessToken": "Secret" } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [test_sp] - factory = mock.MagicMock() - factory.side_effect = ValueError('SP was found') - creds_cache = CredsCache(cli, factory, async_persist=False) - - # action and verify(we plant an exception to throw after the SP was found; so if the exception is thrown, - # we know the matching did go through) - self.assertRaises(ValueError, creds_cache.retrieve_token_for_service_principal, - 'myapp', 'resource1', 'mytenant', False) - - # tenant doesn't exactly match, but it still succeeds - # before fully migrating to pytest and utilizing capsys fixture, use `pytest -o log_cli=True` to manually - # verify the warning log - self.assertRaises(ValueError, creds_cache.retrieve_token_for_service_principal, - 'myapp', 'resource1', 'mytenant2', False) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_remove_creds(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [self.token_entry1, test_sp] - creds_cache = CredsCache(cli, async_persist=False) - - # action #1, logout a user - creds_cache.remove_cached_creds(self.user1) - - # assert #1 - token_entries = [e for _, e in creds_cache.adal_token_cache.read_items()] # noqa: F812 - self.assertEqual(token_entries, []) - - # action #2 logout a service principal + mock_open_for_write1.return_value = None + mock_open_for_write2.return_value = None + mock_open_for_write3.return_value = None + mock_read_file1.return_value = json.dumps([test_sp]) + mock_read_file2.return_value = json.dumps([test_sp]) + mock_read_file3.return_value = json.dumps([test_sp]) + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() + + # action logout a service principal creds_cache.remove_cached_creds('myapp') - # assert #2 - self.assertEqual(creds_cache._service_principal_creds, []) - - mock_open_for_write.assert_called_with(mock.ANY, 'w+') - self.assertEqual(mock_open_for_write.call_count, 2) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_credscache_new_token_added_by_adal(self, mock_adal_auth_context, _, mock_open_for_write, mock_read_file): # pylint: disable=line-too-long - cli = DummyCli() - token_entry2 = { - "accessToken": "new token", - "tokenType": "Bearer", - "userId": self.user1 - } - - def acquire_token_side_effect(*args): # pylint: disable=unused-argument - creds_cache.adal_token_cache.has_state_changed = True - return token_entry2 - - def get_auth_context(_, authority, **kwargs): # pylint: disable=unused-argument - mock_adal_auth_context.cache = kwargs['cache'] - return mock_adal_auth_context - - mock_adal_auth_context.acquire_token.side_effect = acquire_token_side_effect - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [self.token_entry1] - creds_cache = CredsCache(cli, auth_ctx_factory=get_auth_context, async_persist=False) - - # action - mgmt_resource = 'https://management.core.windows.net/' - token_type, token, _ = creds_cache.retrieve_token_for_user(self.user1, self.tenant_id, - mgmt_resource) - mock_adal_auth_context.acquire_token.assert_called_once_with( - 'https://management.core.windows.net/', - self.user1, - mock.ANY) - # assert - mock_open_for_write.assert_called_with(mock.ANY, 'w+') - self.assertEqual(token, 'new token') - self.assertEqual(token_type, token_entry2['tokenType']) + self.assertEqual(creds_cache._service_principal_creds, []) - @mock.patch('azure.cli.core._profile.get_file_json', autospec=True) - def test_credscache_good_error_on_file_corruption(self, mock_read_file): - mock_read_file.side_effect = ValueError('a bad error for you') - cli = DummyCli() + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + def test_credscache_good_error_on_file_corruption(self, mock_read_file1, mock_read_file2, mock_read_file3): + mock_read_file1.side_effect = ValueError('a bad error for you') + mock_read_file2.side_effect = ValueError('a bad error for you') + mock_read_file3.side_effect = ValueError('a bad error for you') - # action - creds_cache = CredsCache(cli, async_persist=False) + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() # assert with self.assertRaises(CLIError) as context: - creds_cache.load_adal_token_cache() + creds_cache._load_cached_creds() self.assertTrue(re.findall(r'bad error for you', str(context.exception))) - def test_service_principal_auth_client_secret(self): - sp_auth = ServicePrincipalAuth('verySecret!') - result = sp_auth.get_entry_to_persist('sp_id1', 'tenant1') - self.assertEqual(result, { - 'servicePrincipalId': 'sp_id1', - 'servicePrincipalTenant': 'tenant1', - 'accessToken': 'verySecret!' - }) - - def test_service_principal_auth_client_cert(self): - curr_dir = os.path.dirname(os.path.realpath(__file__)) - test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') - sp_auth = ServicePrincipalAuth(test_cert_file) - - result = sp_auth.get_entry_to_persist('sp_id1', 'tenant1') - self.assertEqual(result, { - 'servicePrincipalId': 'sp_id1', - 'servicePrincipalTenant': 'tenant1', - 'certificateFile': test_cert_file, - 'thumbprint': 'F0:6A:53:84:8B:BE:71:4A:42:90:D6:9D:33:52:79:C1:D0:10:73:FD' - }) - - def test_service_principal_auth_client_cert_err(self): - curr_dir = os.path.dirname(os.path.realpath(__file__)) - test_cert_file = os.path.join(curr_dir, 'err_sp_cert.pem') - with self.assertRaisesRegexp(CLIError, 'Invalid certificate'): - ServicePrincipalAuth(test_cert_file) - - def test_detect_adfs_authority_url(self): - cli = DummyCli() - adfs_url_1 = 'https://adfs.redmond.ext-u15f2402.masd.stbtest.microsoft.com/adfs/' - cli.cloud.endpoints.active_directory = adfs_url_1 - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - # test w/ trailing slash - r = profile.auth_ctx_factory(cli, 'common', None) - self.assertEqual(r.authority.url, adfs_url_1.rstrip('/')) - - # test w/o trailing slash - adfs_url_2 = 'https://adfs.redmond.ext-u15f2402.masd.stbtest.microsoft.com/adfs' - cli.cloud.endpoints.active_directory = adfs_url_2 - r = profile.auth_ctx_factory(cli, 'common', None) - self.assertEqual(r.authority.url, adfs_url_2) - - # test w/ regular aad - aad_url = 'https://login.microsoftonline.com' - cli.cloud.endpoints.active_directory = aad_url - r = profile.auth_ctx_factory(cli, 'common', None) - self.assertEqual(r.authority.url, aad_url + '/common') - - @mock.patch('adal.AuthenticationContext', autospec=True) - @mock.patch('azure.cli.core._profile._get_authorization_code', autospec=True) - def test_find_using_common_tenant(self, _get_authorization_code_mock, mock_auth_context): - """When a subscription can be listed by multiple tenants, only the first appearance is retained - """ - import adal - cli = DummyCli() - mock_arm_client = mock.MagicMock() - tenant2 = "00000002-0000-0000-0000-000000000000" - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id), TenantStub(tenant2)] - - # same subscription but listed from another tenant - subscription2_raw = SubscriptionStub(self.id1, self.display_name1, self.state1, self.tenant_id) - mock_arm_client.subscriptions.list.side_effect = [[deepcopy(self.subscription1_raw)], [subscription2_raw]] - - mgmt_resource = 'https://management.core.windows.net/' - token_cache = adal.TokenCache() - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, token_cache, lambda _: mock_arm_client) - all_subscriptions = finder._find_using_common_tenant(access_token="token1", resource=mgmt_resource) - - self.assertEqual(len(all_subscriptions), 1) - self.assertEqual(all_subscriptions[0].tenant_id, self.tenant_id) - + @unittest.skip("todo: wait for identity support") @mock.patch('adal.AuthenticationContext', autospec=True) @mock.patch('azure.cli.core._profile._get_authorization_code', autospec=True) def test_find_using_common_tenant_mfa_warning(self, _get_authorization_code_mock, mock_auth_context): # Assume 2 tenants. Home tenant tenant1 doesn't require MFA, but tenant2 does + # todo: @jiashuo import adal cli = DummyCli() mock_arm_client = mock.MagicMock() @@ -1906,8 +1831,8 @@ def test_find_using_common_tenant_mfa_warning(self, _get_authorization_code_mock mock_auth_context.acquire_token.side_effect = [self.token_entry1, adal_error_mfa] # action - all_subscriptions = finder._find_using_common_tenant(access_token="token1", - resource='https://management.core.windows.net/') + all_subscriptions = finder.find_using_common_tenant(access_token="token1", + resource='https://management.core.windows.net/') # assert # subscriptions are correctly returned @@ -1916,57 +1841,6 @@ def test_find_using_common_tenant_mfa_warning(self, _get_authorization_code_mock # With pytest, use -o log_cli=True to manually check the log - @mock.patch('adal.AuthenticationContext', autospec=True) - @mock.patch('azure.cli.core._profile._get_authorization_code', autospec=True) - def test_find_using_specific_tenant(self, _get_authorization_code_mock, mock_auth_context): - """ Test tenant_id -> home_tenant_id mapping and token tenant attachment - """ - import adal - cli = DummyCli() - mock_arm_client = mock.MagicMock() - token_tenant = "00000001-0000-0000-0000-000000000000" - home_tenant = "00000002-0000-0000-0000-000000000000" - - subscription_raw = SubscriptionStub(self.id1, self.display_name1, self.state1, tenant_id=home_tenant) - mock_arm_client.subscriptions.list.return_value = [subscription_raw] - - token_cache = adal.TokenCache() - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, token_cache, lambda _: mock_arm_client) - all_subscriptions = finder._find_using_specific_tenant(tenant=token_tenant, access_token="token1") - - self.assertEqual(len(all_subscriptions), 1) - self.assertEqual(all_subscriptions[0].tenant_id, token_tenant) - self.assertEqual(all_subscriptions[0].home_tenant_id, home_tenant) - - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - @mock.patch('azure.cli.core._msal.AdalRefreshTokenBasedClientApplication._acquire_token_silent_by_finding_specific_refresh_token', autospec=True) - def test_get_msal_token(self, mock_acquire_token, mock_retrieve_token_for_user): - """ - This is added only for vmssh feature. - It is a temporary solution and will deprecate after MSAL adopted completely. - """ - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) - profile._set_subscriptions(consolidated) - - some_token_type = 'Bearer' - mock_retrieve_token_for_user.return_value = (some_token_type, TestProfile.raw_token1, TestProfile.token_entry1) - mock_acquire_token.return_value = { - 'access_token': 'fake_access_token' - } - scopes = ["https://pas.windows.net/CheckMyAccess/Linux/user_impersonation"] - data = { - "token_type": "ssh-cert", - "req_cnf": "fake_jwk", - "key_id": "fake_id" - } - username, access_token = profile.get_msal_token(scopes, data) - self.assertEqual(username, self.user1) - self.assertEqual(access_token, 'fake_access_token') - class FileHandleStub(object): # pylint: disable=too-few-public-methods @@ -1986,7 +1860,8 @@ def __init__(self, id, display_name, state, tenant_id, managed_by_tenants=[], ho policies = SubscriptionPolicies() policies.spending_limit = SpendingLimit.current_period_off policies.quota_id = 'some quota' - super(SubscriptionStub, self).__init__(subscription_policies=policies, authorization_source='some_authorization_source') + super(SubscriptionStub, self).__init__(subscription_policies=policies, + authorization_source='some_authorization_source') self.id = id self.subscription_id = id.split('/')[1] self.display_name = display_name @@ -2039,5 +1914,71 @@ def token(self, value): self._token = value +class TestProfileUtils(unittest.TestCase): + def test_get_authority_and_tenant(self): + from azure.cli.core._profile import _detect_adfs_authority + + # Public cloud, without tenant + expected_authority = "https://login.microsoftonline.com" + self.assertEqual(_detect_adfs_authority("https://login.microsoftonline.com", None), + (expected_authority, None)) + # Public cloud, with tenant + self.assertEqual(_detect_adfs_authority("https://login.microsoftonline.com", '00000000-0000-0000-0000-000000000001'), + (expected_authority, '00000000-0000-0000-0000-000000000001')) + + # ADFS, without tenant + expected_authority = "https://adfs.redmond.azurestack.corp.microsoft.com" + self.assertEqual(_detect_adfs_authority("https://adfs.redmond.azurestack.corp.microsoft.com/adfs", None), + (expected_authority, 'adfs')) + # ADFS, without tenant (including a trailing /) + self.assertEqual(_detect_adfs_authority("https://adfs.redmond.azurestack.corp.microsoft.com/adfs/", None), + (expected_authority, 'adfs')) + # ADFS, with tenant + self.assertEqual(_detect_adfs_authority("https://adfs.redmond.azurestack.corp.microsoft.com/adfs", '00000000-0000-0000-0000-000000000001'), + (expected_authority, 'adfs')) + + +class TestUtils(unittest.TestCase): + def test_detect_adfs_authority(self): + # Public cloud + # Default tenant + self.assertEqual(_detect_adfs_authority('https://login.microsoftonline.com', None), + ('https://login.microsoftonline.com', None)) + # Trailing slash is stripped + self.assertEqual(_detect_adfs_authority('https://login.microsoftonline.com/', None), + ('https://login.microsoftonline.com', None)) + # Custom tenant + self.assertEqual(_detect_adfs_authority('https://login.microsoftonline.com', '601d729d-0000-0000-0000-000000000000'), + ('https://login.microsoftonline.com', '601d729d-0000-0000-0000-000000000000')) + + # ADFS + # Default tenant + self.assertEqual(_detect_adfs_authority('https://adfs.redmond.azurestack.corp.microsoft.com/adfs', None), + ('https://adfs.redmond.azurestack.corp.microsoft.com', 'adfs')) + # Trailing slash is stripped + self.assertEqual(_detect_adfs_authority('https://adfs.redmond.azurestack.corp.microsoft.com/adfs/', None), + ('https://adfs.redmond.azurestack.corp.microsoft.com', 'adfs')) + # Tenant ID is discarded + self.assertEqual(_detect_adfs_authority('https://adfs.redmond.azurestack.corp.microsoft.com/adfs', '601d729d-0000-0000-0000-000000000000'), + ('https://adfs.redmond.azurestack.corp.microsoft.com', 'adfs')) + + def test_attach_token_tenant(self): + from azure.cli.core.vendored_sdks.subscriptions.v2016_06_01.models import Subscription \ + as Subscription_v2016_06_01 + subscription = Subscription_v2016_06_01() + _attach_token_tenant(subscription, "token_tenant_1") + self.assertEqual(subscription.tenant_id, "token_tenant_1") + self.assertFalse(hasattr(subscription, "home_tenant_id")) + + def test_attach_token_tenant_v2016_06_01(self): + from azure.cli.core.vendored_sdks.subscriptions.v2019_11_01.models import Subscription \ + as Subscription_v2019_11_01 + subscription = Subscription_v2019_11_01() + subscription.tenant_id = "home_tenant_1" + _attach_token_tenant(subscription, "token_tenant_1") + self.assertEqual(subscription.tenant_id, "token_tenant_1") + self.assertEqual(subscription.home_tenant_id, "home_tenant_1") + + if __name__ == '__main__': unittest.main() diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile_v2016_06_01.py b/src/azure-cli-core/azure/cli/core/tests/test_profile_v2016_06_01.py deleted file mode 100644 index a913794252c..00000000000 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile_v2016_06_01.py +++ /dev/null @@ -1,1757 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -# pylint: disable=protected-access -import json -import os -import sys -import unittest -import mock -import re - -from copy import deepcopy - -from adal import AdalError - -from azure.cli.core._profile import (Profile, CredsCache, SubscriptionFinder, - ServicePrincipalAuth, _AUTH_CTX_FACTORY, _USE_VENDORED_SUBSCRIPTION_SDK) - -if _USE_VENDORED_SUBSCRIPTION_SDK: - from azure.cli.core.vendored_sdks.subscriptions.v2016_06_01.models import \ - (SubscriptionState, Subscription, SubscriptionPolicies, SpendingLimit) -else: - from azure.mgmt.resource.subscriptions.v2016_06_01.models import \ - (SubscriptionState, Subscription, SubscriptionPolicies, SpendingLimit) - -from azure.cli.core.mock import DummyCli - -from knack.util import CLIError - - -class TestProfile(unittest.TestCase): - - @classmethod - def setUpClass(cls): - cls.tenant_id = 'microsoft.com' - cls.user1 = 'foo@foo.com' - cls.id1 = 'subscriptions/1' - cls.display_name1 = 'foo account' - cls.state1 = SubscriptionState.enabled - # Dummy Subscription from SDK azure.mgmt.resource.subscriptions.v2016_06_01.operations._subscriptions_operations.SubscriptionsOperations.list - # tenant_id shouldn't be set as tenantId isn't returned by REST API - # Must be deepcopied before used as mock_arm_client.subscriptions.list.return_value - cls.subscription1_raw = SubscriptionStub(cls.id1, - cls.display_name1, - cls.state1) - # Dummy result of azure.cli.core._profile.SubscriptionFinder._find_using_specific_tenant - # tenant_id denotes token tenant - cls.subscription1 = SubscriptionStub(cls.id1, - cls.display_name1, - cls.state1, - cls.tenant_id) - # Dummy result of azure.cli.core._profile.Profile._normalize_properties - cls.subscription1_normalized = { - 'environmentName': 'AzureCloud', - 'id': '1', - 'name': cls.display_name1, - 'state': cls.state1.value, - 'user': { - 'name': cls.user1, - 'type': 'user' - }, - 'isDefault': False, - 'tenantId': cls.tenant_id - } - - cls.raw_token1 = 'some...secrets' - cls.token_entry1 = { - "_clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", - "resource": "https://management.core.windows.net/", - "tokenType": "Bearer", - "expiresOn": "2016-03-31T04:26:56.610Z", - "expiresIn": 3599, - "identityProvider": "live.com", - "_authority": "https://login.microsoftonline.com/common", - "isMRRT": True, - "refreshToken": "faked123", - "accessToken": cls.raw_token1, - "userId": cls.user1 - } - - cls.user2 = 'bar@bar.com' - cls.id2 = 'subscriptions/2' - cls.display_name2 = 'bar account' - cls.state2 = SubscriptionState.past_due - cls.subscription2_raw = SubscriptionStub(cls.id2, - cls.display_name2, - cls.state2) - cls.subscription2 = SubscriptionStub(cls.id2, - cls.display_name2, - cls.state2, - cls.tenant_id) - cls.subscription2_normalized = { - 'environmentName': 'AzureCloud', - 'id': '2', - 'name': cls.display_name2, - 'state': cls.state2.value, - 'user': { - 'name': cls.user2, - 'type': 'user' - }, - 'isDefault': False, - 'tenantId': cls.tenant_id - } - cls.test_msi_tenant = '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a' - cls.test_msi_access_token = ('eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IlZXVkljMVdEMVRrc2JiMzAxc2FzTTVrT3E1' - 'USIsImtpZCI6IlZXVkljMVdEMVRrc2JiMzAxc2FzTTVrT3E1USJ9.eyJhdWQiOiJodHRwczovL21hbmF' - 'nZW1lbnQuY29yZS53aW5kb3dzLm5ldC8iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC81NDg' - 'yNmIyMi0zOGQ2LTRmYjItYmFkOS1iN2I5M2EzZTljNWEvIiwiaWF0IjoxNTAzMzU0ODc2LCJuYmYiOjE' - '1MDMzNTQ4NzYsImV4cCI6MTUwMzM1ODc3NiwiYWNyIjoiMSIsImFpbyI6IkFTUUEyLzhFQUFBQTFGL1k' - '0VVR3bFI1Y091QXJxc1J0OU5UVVc2MGlsUHZna0daUC8xczVtdzg9IiwiYW1yIjpbInB3ZCJdLCJhcHB' - 'pZCI6IjA0YjA3Nzk1LThkZGItNDYxYS1iYmVlLTAyZjllMWJmN2I0NiIsImFwcGlkYWNyIjoiMCIsImV' - 'fZXhwIjoyNjI4MDAsImZhbWlseV9uYW1lIjoic2RrIiwiZ2l2ZW5fbmFtZSI6ImFkbWluMyIsImdyb3V' - 'wcyI6WyJlNGJiMGI1Ni0xMDE0LTQwZjgtODhhYi0zZDhhOGNiMGUwODYiLCI4YTliMTYxNy1mYzhkLTR' - 'hYTktYTQyZi05OTg2OGQzMTQ2OTkiLCI1NDgwMzkxNy00YzcxLTRkNmMtOGJkZi1iYmQ5MzEwMTBmOGM' - 'iXSwiaXBhZGRyIjoiMTY3LjIyMC4xLjIzNCIsIm5hbWUiOiJhZG1pbjMiLCJvaWQiOiJlN2UxNThkMy0' - '3Y2RjLTQ3Y2QtODgyNS01ODU5ZDdhYjJiNTUiLCJwdWlkIjoiMTAwMzNGRkY5NUQ0NEU4NCIsInNjcCI' - '6InVzZXJfaW1wZXJzb25hdGlvbiIsInN1YiI6ImhRenl3b3FTLUEtRzAySTl6ZE5TRmtGd3R2MGVwZ2l' - 'WY1Vsdm1PZEZHaFEiLCJ0aWQiOiI1NDgyNmIyMi0zOGQ2LTRmYjItYmFkOS1iN2I5M2EzZTljNWEiLCJ' - '1bmlxdWVfbmFtZSI6ImFkbWluM0BBenVyZVNES1RlYW0ub25taWNyb3NvZnQuY29tIiwidXBuIjoiYWR' - 'taW4zQEF6dXJlU0RLVGVhbS5vbm1pY3Jvc29mdC5jb20iLCJ1dGkiOiJuUEROYm04UFkwYUdELWhNeWx' - 'rVEFBIiwidmVyIjoiMS4wIiwid2lkcyI6WyI2MmU5MDM5NC02OWY1LTQyMzctOTE5MC0wMTIxNzcxNDV' - 'lMTAiXX0.Pg4cq0MuP1uGhY_h51ZZdyUYjGDUFgTW2EfIV4DaWT9RU7GIK_Fq9VGBTTbFZA0pZrrmP-z' - '7DlN9-U0A0nEYDoXzXvo-ACTkm9_TakfADd36YlYB5aLna-yO0B7rk5W9ANelkzUQgRfidSHtCmV6i4V' - 'e-lOym1sH5iOcxfIjXF0Tp2y0f3zM7qCq8Cp1ZxEwz6xYIgByoxjErNXrOME5Ld1WizcsaWxTXpwxJn_' - 'Q8U2g9kXHrbYFeY2gJxF_hnfLvNKxUKUBnftmyYxZwKi0GDS0BvdJnJnsqSRSpxUx__Ra9QJkG1IaDzj' - 'ZcSZPHK45T6ohK9Hk9ktZo0crVl7Tmw') - - def test_normalize(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - expected = self.subscription1_normalized - self.assertEqual(expected, consolidated[0]) - # verify serialization works - self.assertIsNotNone(json.dumps(consolidated[0])) - - def test_normalize_with_unicode_in_subscription_name(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - test_display_name = 'sub' + chr(255) - polished_display_name = 'sub?' - test_subscription = SubscriptionStub('subscriptions/sub1', - test_display_name, - SubscriptionState.enabled, - 'tenant1') - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [test_subscription], - False) - self.assertTrue(consolidated[0]['name'] in [polished_display_name, test_display_name]) - - def test_normalize_with_none_subscription_name(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - test_display_name = None - polished_display_name = '' - test_subscription = SubscriptionStub('subscriptions/sub1', - test_display_name, - SubscriptionState.enabled, - 'tenant1') - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [test_subscription], - False) - self.assertTrue(consolidated[0]['name'] == polished_display_name) - - def test_update_add_two_different_subscriptions(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - # add the first and verify - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - profile._set_subscriptions(consolidated) - - self.assertEqual(len(storage_mock['subscriptions']), 1) - subscription1 = storage_mock['subscriptions'][0] - subscription1_is_default = deepcopy(self.subscription1_normalized) - subscription1_is_default['isDefault'] = True - self.assertEqual(subscription1, subscription1_is_default) - - # add the second and verify - consolidated = profile._normalize_properties(self.user2, - [self.subscription2], - False) - profile._set_subscriptions(consolidated) - - self.assertEqual(len(storage_mock['subscriptions']), 2) - subscription2 = storage_mock['subscriptions'][1] - subscription2_is_default = deepcopy(self.subscription2_normalized) - subscription2_is_default['isDefault'] = True - self.assertEqual(subscription2, subscription2_is_default) - - # verify the old one stays, but no longer active - self.assertEqual(storage_mock['subscriptions'][0]['name'], - subscription1['name']) - self.assertFalse(storage_mock['subscriptions'][0]['isDefault']) - - def test_update_with_same_subscription_added_twice(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - # add one twice and verify we will have one but with new token - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - profile._set_subscriptions(consolidated) - - new_subscription1 = SubscriptionStub(self.id1, - self.display_name1, - self.state1, - self.tenant_id) - consolidated = profile._normalize_properties(self.user1, - [new_subscription1], - False) - profile._set_subscriptions(consolidated) - - self.assertEqual(len(storage_mock['subscriptions']), 1) - self.assertTrue(storage_mock['subscriptions'][0]['isDefault']) - - def test_set_active_subscription(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - profile._set_subscriptions(consolidated) - - consolidated = profile._normalize_properties(self.user2, - [self.subscription2], - False) - profile._set_subscriptions(consolidated) - - self.assertTrue(storage_mock['subscriptions'][1]['isDefault']) - - profile.set_active_subscription(storage_mock['subscriptions'][0]['id']) - self.assertFalse(storage_mock['subscriptions'][1]['isDefault']) - self.assertTrue(storage_mock['subscriptions'][0]['isDefault']) - - def test_default_active_subscription_to_non_disabled_one(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - subscriptions = profile._normalize_properties( - self.user2, [self.subscription2, self.subscription1], False) - - profile._set_subscriptions(subscriptions) - - # verify we skip the overdued subscription and default to the 2nd one in the list - self.assertEqual(storage_mock['subscriptions'][1]['name'], self.subscription1.display_name) - self.assertTrue(storage_mock['subscriptions'][1]['isDefault']) - - def test_get_subscription(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - profile._set_subscriptions(consolidated) - - self.assertEqual(self.display_name1, profile.get_subscription()['name']) - self.assertEqual(self.display_name1, - profile.get_subscription(subscription=self.display_name1)['name']) - - sub_id = self.id1.split('/')[-1] - self.assertEqual(sub_id, profile.get_subscription()['id']) - self.assertEqual(sub_id, profile.get_subscription(subscription=sub_id)['id']) - self.assertRaises(CLIError, profile.get_subscription, "random_id") - - def test_get_auth_info_fail_on_user_account(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - profile._set_subscriptions(consolidated) - - # testing dump of existing logged in account - self.assertRaises(CLIError, profile.get_sp_auth_info) - - @mock.patch('azure.cli.core.profiles.get_api_version', autospec=True) - def test_subscription_finder_constructor(self, get_api_mock): - cli = DummyCli() - get_api_mock.return_value = '2016-06-01' - cli.cloud.endpoints.resource_manager = 'http://foo_arm' - finder = SubscriptionFinder(cli, None, None, arm_client_factory=None) - result = finder._arm_client_factory(mock.MagicMock()) - self.assertEqual(result._client._base_url, 'http://foo_arm') - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_get_auth_info_for_logged_in_service_principal(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - profile._management_resource_uri = 'https://management.core.windows.net/' - profile.find_subscriptions_on_login(False, '1234', 'my-secret', True, self.tenant_id, use_device_code=False, - allow_no_subscriptions=False, subscription_finder=finder) - # action - extended_info = profile.get_sp_auth_info() - # assert - self.assertEqual(self.id1.split('/')[-1], extended_info['subscriptionId']) - self.assertEqual('1234', extended_info['clientId']) - self.assertEqual('my-secret', extended_info['clientSecret']) - self.assertEqual('https://login.microsoftonline.com', extended_info['activeDirectoryEndpointUrl']) - self.assertEqual('https://management.azure.com/', extended_info['resourceManagerEndpointUrl']) - - def test_get_auth_info_for_newly_created_service_principal(self): - cli = DummyCli() - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) - profile._set_subscriptions(consolidated) - # action - extended_info = profile.get_sp_auth_info(name='1234', cert_file='/tmp/123.pem') - # assert - self.assertEqual(self.id1.split('/')[-1], extended_info['subscriptionId']) - self.assertEqual(self.tenant_id, extended_info['tenantId']) - self.assertEqual('1234', extended_info['clientId']) - self.assertEqual('/tmp/123.pem', extended_info['clientCertificate']) - self.assertIsNone(extended_info.get('clientSecret', None)) - self.assertEqual('https://login.microsoftonline.com', extended_info['activeDirectoryEndpointUrl']) - self.assertEqual('https://management.azure.com/', extended_info['resourceManagerEndpointUrl']) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_create_account_without_subscriptions_thru_service_principal(self, mock_auth_context): - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - profile._management_resource_uri = 'https://management.core.windows.net/' - - # action - result = profile.find_subscriptions_on_login(False, - '1234', - 'my-secret', - True, - self.tenant_id, - use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) - # assert - self.assertEqual(1, len(result)) - self.assertEqual(result[0]['id'], self.tenant_id) - self.assertEqual(result[0]['state'], 'Enabled') - self.assertEqual(result[0]['tenantId'], self.tenant_id) - self.assertEqual(result[0]['name'], 'N/A(tenant level account)') - self.assertTrue(profile.is_tenant_level_account()) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_create_account_with_subscriptions_allow_no_subscriptions_thru_service_principal(self, mock_auth_context): - """test subscription is returned even with --allow-no-subscriptions. """ - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - profile._management_resource_uri = 'https://management.core.windows.net/' - - # action - result = profile.find_subscriptions_on_login(False, - '1234', - 'my-secret', - True, - self.tenant_id, - use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) - # assert - self.assertEqual(1, len(result)) - self.assertEqual(result[0]['id'], self.id1.split('/')[-1]) - self.assertEqual(result[0]['state'], 'Enabled') - self.assertEqual(result[0]['tenantId'], self.tenant_id) - self.assertEqual(result[0]['name'], self.display_name1) - self.assertFalse(profile.is_tenant_level_account()) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_create_account_without_subscriptions_thru_common_tenant(self, mock_auth_context): - mock_auth_context.acquire_token.return_value = self.token_entry1 - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - cli = DummyCli() - tenant_object = mock.MagicMock() - tenant_object.id = "foo-bar" - tenant_object.tenant_id = self.tenant_id - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [] - mock_arm_client.tenants.list.return_value = (x for x in [tenant_object]) - - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - profile._management_resource_uri = 'https://management.core.windows.net/' - - # action - result = profile.find_subscriptions_on_login(False, - '1234', - 'my-secret', - False, - None, - use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) - - # assert - self.assertEqual(1, len(result)) - self.assertEqual(result[0]['id'], self.tenant_id) - self.assertEqual(result[0]['state'], 'Enabled') - self.assertEqual(result[0]['tenantId'], self.tenant_id) - self.assertEqual(result[0]['name'], 'N/A(tenant level account)') - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_create_account_without_subscriptions_without_tenant(self, mock_auth_context): - cli = DummyCli() - finder = mock.MagicMock() - finder.find_through_interactive_flow.return_value = [] - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - # action - result = profile.find_subscriptions_on_login(True, - '1234', - 'my-secret', - False, - None, - use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) - - # assert - self.assertTrue(0 == len(result)) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_get_current_account_user(self, mock_read_cred_file): - cli = DummyCli() - # setup - mock_read_cred_file.return_value = [TestProfile.token_entry1] - - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - profile._set_subscriptions(consolidated) - # action - user = profile.get_current_account_user() - - # verify - self.assertEqual(user, self.user1) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', return_value=None) - def test_create_token_cache(self, mock_read_file): - cli = DummyCli() - mock_read_file.return_value = [] - profile = Profile(cli_ctx=cli, use_global_creds_cache=False, async_persist=False) - cache = profile._creds_cache.adal_token_cache - self.assertFalse(cache.read_items()) - self.assertTrue(mock_read_file.called) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_load_cached_tokens(self, mock_read_file): - cli = DummyCli() - mock_read_file.return_value = [TestProfile.token_entry1] - profile = Profile(cli_ctx=cli, use_global_creds_cache=False, async_persist=False) - cache = profile._creds_cache.adal_token_cache - matched = cache.find({ - "_authority": "https://login.microsoftonline.com/common", - "_clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", - "userId": self.user1 - }) - self.assertEqual(len(matched), 1) - self.assertEqual(matched[0]['accessToken'], self.raw_token1) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_login_credentials(self, mock_get_token, mock_read_cred_file): - cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1) - # setup - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' - test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' - test_subscription = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id), - 'MSI-DEV-INC', self.state1, '12345678-38d6-4fb2-bad9-b7b93a3e1234') - consolidated = profile._normalize_properties(self.user1, - [test_subscription], - False) - profile._set_subscriptions(consolidated) - # action - cred, subscription_id, _ = profile.get_login_credentials() - - # verify - self.assertEqual(subscription_id, test_subscription_id) - - # verify the cred._tokenRetriever is a working lambda - token_type, token = cred._token_retriever() - self.assertEqual(token, self.raw_token1) - self.assertEqual(some_token_type, token_type) - mock_get_token.assert_called_once_with(mock.ANY, self.user1, test_tenant_id, - 'https://management.core.windows.net/') - self.assertEqual(mock_get_token.call_count, 1) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_login_credentials_aux_subscriptions(self, mock_get_token, mock_read_cred_file): - cli = DummyCli() - raw_token2 = 'some...secrets2' - token_entry2 = { - "resource": "https://management.core.windows.net/", - "tokenType": "Bearer", - "_authority": "https://login.microsoftonline.com/common", - "accessToken": raw_token2, - } - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1, token_entry2] - mock_get_token.side_effect = [(some_token_type, TestProfile.raw_token1), (some_token_type, raw_token2)] - # setup - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' - test_subscription_id2 = '12345678-1bf0-4dda-aec3-cb9272f09591' - test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' - test_tenant_id2 = '12345678-38d6-4fb2-bad9-b7b93a3e4321' - test_subscription = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id), - 'MSI-DEV-INC', self.state1, test_tenant_id) - test_subscription2 = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id2), - 'MSI-DEV-INC2', self.state1, test_tenant_id2) - consolidated = profile._normalize_properties(self.user1, - [test_subscription, test_subscription2], - False) - profile._set_subscriptions(consolidated) - # action - cred, subscription_id, _ = profile.get_login_credentials(subscription_id=test_subscription_id, - aux_subscriptions=[test_subscription_id2]) - - # verify - self.assertEqual(subscription_id, test_subscription_id) - - # verify the cred._tokenRetriever is a working lambda - token_type, token = cred._token_retriever() - self.assertEqual(token, self.raw_token1) - self.assertEqual(some_token_type, token_type) - - token2 = cred._external_tenant_token_retriever() - self.assertEqual(len(token2), 1) - self.assertEqual(token2[0][1], raw_token2) - - self.assertEqual(mock_get_token.call_count, 2) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_login_credentials_msi_system_assigned(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] - - # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) - test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' - test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' - test_user = 'systemAssignedIdentity' - msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, 'MSI', self.state1, test_tenant_id) - consolidated = profile._normalize_properties(test_user, - [msi_subscription], - True) - profile._set_subscriptions(consolidated) - - mock_msi_auth.side_effect = MSRestAzureAuthStub - - # action - cred, subscription_id, _ = profile.get_login_credentials() - - # assert - self.assertEqual(subscription_id, test_subscription_id) - - # sniff test the msi_auth object - cred.set_token() - cred.token - self.assertTrue(cred.set_token_invoked_count) - self.assertTrue(cred.token_read_count) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_login_credentials_msi_user_assigned_with_client_id(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] - - # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) - test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' - test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' - test_user = 'userAssignedIdentity' - test_client_id = '12345678-38d6-4fb2-bad9-b7b93a3e8888' - msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, 'MSIClient-{}'.format(test_client_id), self.state1, test_tenant_id) - consolidated = profile._normalize_properties(test_user, [msi_subscription], True) - profile._set_subscriptions(consolidated, secondary_key_name='name') - - mock_msi_auth.side_effect = MSRestAzureAuthStub - - # action - cred, subscription_id, _ = profile.get_login_credentials() - - # assert - self.assertEqual(subscription_id, test_subscription_id) - - # sniff test the msi_auth object - cred.set_token() - cred.token - self.assertTrue(cred.set_token_invoked_count) - self.assertTrue(cred.token_read_count) - self.assertTrue(cred.client_id, test_client_id) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_login_credentials_msi_user_assigned_with_object_id(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] - - # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) - test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' - test_object_id = '12345678-38d6-4fb2-bad9-b7b93a3e9999' - msi_subscription = SubscriptionStub('/subscriptions/12345678-1bf0-4dda-aec3-cb9272f09590', - 'MSIObject-{}'.format(test_object_id), - self.state1, '12345678-38d6-4fb2-bad9-b7b93a3e1234') - consolidated = profile._normalize_properties('userAssignedIdentity', [msi_subscription], True) - profile._set_subscriptions(consolidated, secondary_key_name='name') - - mock_msi_auth.side_effect = MSRestAzureAuthStub - - # action - cred, subscription_id, _ = profile.get_login_credentials() - - # assert - self.assertEqual(subscription_id, test_subscription_id) - - # sniff test the msi_auth object - cred.set_token() - cred.token - self.assertTrue(cred.set_token_invoked_count) - self.assertTrue(cred.token_read_count) - self.assertTrue(cred.object_id, test_object_id) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_login_credentials_msi_user_assigned_with_res_id(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] - - # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) - test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' - test_res_id = ('/subscriptions/{}/resourceGroups/r1/providers/Microsoft.ManagedIdentity/' - 'userAssignedIdentities/id1').format(test_subscription_id) - msi_subscription = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id), - 'MSIResource-{}'.format(test_res_id), - self.state1, '12345678-38d6-4fb2-bad9-b7b93a3e1234') - consolidated = profile._normalize_properties('userAssignedIdentity', [msi_subscription], True) - profile._set_subscriptions(consolidated, secondary_key_name='name') - - mock_msi_auth.side_effect = MSRestAzureAuthStub - - # action - cred, subscription_id, _ = profile.get_login_credentials() - - # assert - self.assertEqual(subscription_id, test_subscription_id) - - # sniff test the msi_auth object - cred.set_token() - cred.token - self.assertTrue(cred.set_token_invoked_count) - self.assertTrue(cred.token_read_count) - self.assertTrue(cred.msi_res_id, test_res_id) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_raw_token(self, mock_get_token, mock_read_cred_file): - cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1, - TestProfile.token_entry1) - # setup - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - profile._set_subscriptions(consolidated) - # action - creds, sub, tenant = profile.get_raw_token(resource='https://foo') - - # verify - self.assertEqual(creds[0], self.token_entry1['tokenType']) - self.assertEqual(creds[1], self.raw_token1) - # the last in the tuple is the whole token entry which has several fields - self.assertEqual(creds[2]['expiresOn'], self.token_entry1['expiresOn']) - mock_get_token.assert_called_once_with(mock.ANY, self.user1, self.tenant_id, - 'https://foo') - self.assertEqual(mock_get_token.call_count, 1) - self.assertEqual(sub, '1') - self.assertEqual(tenant, self.tenant_id) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_service_principal', autospec=True) - def test_get_raw_token_for_sp(self, mock_get_token, mock_read_cred_file): - cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1, - TestProfile.token_entry1) - # setup - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties('sp1', - [self.subscription1], - True) - profile._set_subscriptions(consolidated) - # action - creds, sub, tenant = profile.get_raw_token(resource='https://foo') - - # verify - self.assertEqual(creds[0], self.token_entry1['tokenType']) - self.assertEqual(creds[1], self.raw_token1) - # the last in the tuple is the whole token entry which has several fields - self.assertEqual(creds[2]['expiresOn'], self.token_entry1['expiresOn']) - mock_get_token.assert_called_once_with(mock.ANY, 'sp1', 'https://foo', self.tenant_id, False) - self.assertEqual(mock_get_token.call_count, 1) - self.assertEqual(sub, '1') - self.assertEqual(tenant, self.tenant_id) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - def test_get_raw_token_msi_system_assigned(self, mock_msi_auth, mock_read_cred_file): - mock_read_cred_file.return_value = [] - - # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) - test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' - test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' - test_user = 'systemAssignedIdentity' - msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, - 'MSI', self.state1, test_tenant_id) - consolidated = profile._normalize_properties(test_user, - [msi_subscription], - True) - profile._set_subscriptions(consolidated) - - mock_msi_auth.side_effect = MSRestAzureAuthStub - - # action - cred, subscription_id, _ = profile.get_raw_token(resource='http://test_resource') - - # assert - self.assertEqual(subscription_id, test_subscription_id) - self.assertEqual(cred[0], 'Bearer') - self.assertEqual(cred[1], TestProfile.test_msi_access_token) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_login_credentials_for_graph_client(self, mock_get_token, mock_read_cred_file): - cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1) - # setup - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, [self.subscription1], - False) - profile._set_subscriptions(consolidated) - # action - cred, _, tenant_id = profile.get_login_credentials( - resource=cli.cloud.endpoints.active_directory_graph_resource_id) - _, _ = cred._token_retriever() - # verify - mock_get_token.assert_called_once_with(mock.ANY, self.user1, self.tenant_id, - 'https://graph.windows.net/') - self.assertEqual(tenant_id, self.tenant_id) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - def test_get_login_credentials_for_data_lake_client(self, mock_get_token, mock_read_cred_file): - cli = DummyCli() - some_token_type = 'Bearer' - mock_read_cred_file.return_value = [TestProfile.token_entry1] - mock_get_token.return_value = (some_token_type, TestProfile.raw_token1) - # setup - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, [self.subscription1], - False) - profile._set_subscriptions(consolidated) - # action - cred, _, tenant_id = profile.get_login_credentials( - resource=cli.cloud.endpoints.active_directory_data_lake_resource_id) - _, _ = cred._token_retriever() - # verify - mock_get_token.assert_called_once_with(mock.ANY, self.user1, self.tenant_id, - 'https://datalake.azure.net/') - self.assertEqual(tenant_id, self.tenant_id) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('azure.cli.core._profile.CredsCache.persist_cached_creds', autospec=True) - def test_logout(self, mock_persist_creds, mock_read_cred_file): - cli = DummyCli() - # setup - mock_read_cred_file.return_value = [TestProfile.token_entry1] - - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - profile._set_subscriptions(consolidated) - self.assertEqual(1, len(storage_mock['subscriptions'])) - # action - profile.logout(self.user1) - - # verify - self.assertEqual(0, len(storage_mock['subscriptions'])) - self.assertEqual(mock_read_cred_file.call_count, 1) - self.assertEqual(mock_persist_creds.call_count, 1) - - @mock.patch('azure.cli.core._profile._delete_file', autospec=True) - def test_logout_all(self, mock_delete_cred_file): - cli = DummyCli() - # setup - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - consolidated2 = profile._normalize_properties(self.user2, - [self.subscription2], - False) - profile._set_subscriptions(consolidated + consolidated2) - - self.assertEqual(2, len(storage_mock['subscriptions'])) - # action - profile.logout_all() - - # verify - self.assertEqual([], storage_mock['subscriptions']) - self.assertEqual(mock_delete_cred_file.call_count, 1) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_thru_username_password(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - # action - subs = finder.find_from_user_account(self.user1, 'bar', None, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - mock_auth_context.acquire_token_with_username_password.assert_called_once_with( - mgmt_resource, self.user1, 'bar', mock.ANY) - mock_auth_context.acquire_token.assert_called_once_with( - mgmt_resource, self.user1, mock.ANY) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_thru_username_non_password(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_username_password.return_value = None - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: None) - # action - subs = finder.find_from_user_account(self.user1, 'bar', None, 'http://goo-resource') - - # assert - self.assertEqual([], subs) - - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - @mock.patch('azure.cli.core.profiles._shared.get_client_class', autospec=True) - @mock.patch('azure.cli.core._profile._get_cloud_console_token_endpoint', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_cloud_console(self, mock_subscription_finder, mock_get_token_endpoint, - mock_get_client_class, mock_msi_auth): - - class SubscriptionFinderStub: - def find_from_raw_token(self, tenant, token): - # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if token != TestProfile.test_msi_access_token or tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_from_raw_token was not invoked with expected tenant or token') - return [TestProfile.subscription1] - - mock_subscription_finder.return_value = SubscriptionFinderStub() - - mock_get_token_endpoint.return_value = "http://great_endpoint" - mock_msi_auth.return_value = MSRestAzureAuthStub() - - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) - - # action - subscriptions = profile.find_subscriptions_in_cloud_console() - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'admin3@AzureSDKTeam.onmicrosoft.com') - self.assertEqual(s['user']['cloudShellID'], True) - self.assertEqual(s['user']['type'], 'user') - self.assertEqual(s['name'], self.display_name1) - self.assertEqual(s['id'], self.id1.split('/')[-1]) - - @mock.patch('requests.get', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - def test_find_subscriptions_in_vm_with_msi_system_assigned(self, mock_get_client_class, mock_get): - - class ClientStub: - def __init__(self, *args, **kwargs): - self.subscriptions = mock.MagicMock() - self.subscriptions.list.return_value = [deepcopy(TestProfile.subscription1_raw)] - self.config = mock.MagicMock() - self._client = mock.MagicMock() - - mock_get_client_class.return_value = ClientStub - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - test_token_entry = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - encoded_test_token = json.dumps(test_token_entry).encode() - good_response = mock.MagicMock() - good_response.status_code = 200 - good_response.content = encoded_test_token - mock_get.return_value = good_response - - subscriptions = profile.find_subscriptions_in_vm_with_msi() - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'systemAssignedIdentity') - self.assertEqual(s['user']['type'], 'servicePrincipal') - self.assertEqual(s['user']['assignedIdentityInfo'], 'MSI') - self.assertEqual(s['name'], self.display_name1) - self.assertEqual(s['id'], self.id1.split('/')[-1]) - self.assertEqual(s['tenantId'], '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a') - - @mock.patch('requests.get', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - def test_find_subscriptions_in_vm_with_msi_no_subscriptions(self, mock_get_client_class, mock_get): - - class ClientStub: - def __init__(self, *args, **kwargs): - self.subscriptions = mock.MagicMock() - self.subscriptions.list.return_value = [] - self.config = mock.MagicMock() - self._client = mock.MagicMock() - - mock_get_client_class.return_value = ClientStub - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - test_token_entry = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - encoded_test_token = json.dumps(test_token_entry).encode() - good_response = mock.MagicMock() - good_response.status_code = 200 - good_response.content = encoded_test_token - mock_get.return_value = good_response - - subscriptions = profile.find_subscriptions_in_vm_with_msi(allow_no_subscriptions=True) - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'systemAssignedIdentity') - self.assertEqual(s['user']['type'], 'servicePrincipal') - self.assertEqual(s['user']['assignedIdentityInfo'], 'MSI') - self.assertEqual(s['name'], 'N/A(tenant level account)') - self.assertEqual(s['id'], self.test_msi_tenant) - self.assertEqual(s['tenantId'], self.test_msi_tenant) - - @mock.patch('requests.get', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - def test_find_subscriptions_in_vm_with_msi_user_assigned_with_client_id(self, mock_get_client_class, mock_get): - - class ClientStub: - def __init__(self, *args, **kwargs): - self.subscriptions = mock.MagicMock() - self.subscriptions.list.return_value = [deepcopy(TestProfile.subscription1_raw)] - self.config = mock.MagicMock() - self._client = mock.MagicMock() - - mock_get_client_class.return_value = ClientStub - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - test_token_entry = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - test_client_id = '54826b22-38d6-4fb2-bad9-b7b93a3e9999' - encoded_test_token = json.dumps(test_token_entry).encode() - good_response = mock.MagicMock() - good_response.status_code = 200 - good_response.content = encoded_test_token - mock_get.return_value = good_response - - subscriptions = profile.find_subscriptions_in_vm_with_msi(identity_id=test_client_id) - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'userAssignedIdentity') - self.assertEqual(s['user']['type'], 'servicePrincipal') - self.assertEqual(s['name'], self.display_name1) - self.assertEqual(s['user']['assignedIdentityInfo'], 'MSIClient-{}'.format(test_client_id)) - self.assertEqual(s['id'], self.id1.split('/')[-1]) - self.assertEqual(s['tenantId'], '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a') - - @mock.patch('azure.cli.core.adal_authentication.MSIAuthenticationWrapper', autospec=True) - @mock.patch('azure.cli.core.profiles._shared.get_client_class', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_vm_with_msi_user_assigned_with_object_id(self, mock_subscription_finder, mock_get_client_class, - mock_msi_auth): - from azure.cli.core.azclierror import AzureResponseError - - class SubscriptionFinderStub: - def find_from_raw_token(self, tenant, token): - # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if token != TestProfile.test_msi_access_token or tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_from_raw_token was not invoked with expected tenant or token') - return [TestProfile.subscription1] - - class AuthStub: - def __init__(self, **kwargs): - self.token = None - self.client_id = kwargs.get('client_id') - self.object_id = kwargs.get('object_id') - # since msrestazure 0.4.34, set_token in init - self.set_token() - - def set_token(self): - # here we will reject the 1st sniffing of trying with client_id and then acccept the 2nd - if self.object_id: - self.token = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - else: - raise AzureResponseError('Failed to connect to MSI. Please make sure MSI is configured correctly.\n' - 'Get Token request returned http error: 400, reason: Bad Request') - - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) - - mock_subscription_finder.return_value = SubscriptionFinderStub() - - mock_msi_auth.side_effect = AuthStub - test_object_id = '54826b22-38d6-4fb2-bad9-b7b93a3e9999' - - # action - subscriptions = profile.find_subscriptions_in_vm_with_msi(identity_id=test_object_id) - - # assert - self.assertEqual(subscriptions[0]['user']['assignedIdentityInfo'], 'MSIObject-{}'.format(test_object_id)) - - @mock.patch('requests.get', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - def test_find_subscriptions_in_vm_with_msi_user_assigned_with_res_id(self, mock_get_client_class, mock_get): - - class ClientStub: - def __init__(self, *args, **kwargs): - self.subscriptions = mock.MagicMock() - self.subscriptions.list.return_value = [deepcopy(TestProfile.subscription1_raw)] - self.config = mock.MagicMock() - self._client = mock.MagicMock() - - mock_get_client_class.return_value = ClientStub - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - test_token_entry = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - test_res_id = ('/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590/resourcegroups/g1/' - 'providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1') - - encoded_test_token = json.dumps(test_token_entry).encode() - good_response = mock.MagicMock() - good_response.status_code = 200 - good_response.content = encoded_test_token - mock_get.return_value = good_response - - subscriptions = profile.find_subscriptions_in_vm_with_msi(identity_id=test_res_id) - - # assert - self.assertEqual(subscriptions[0]['user']['assignedIdentityInfo'], 'MSIResource-{}'.format(test_res_id)) - - @mock.patch('adal.AuthenticationContext.acquire_token_with_username_password', autospec=True) - @mock.patch('adal.AuthenticationContext.acquire_token', autospec=True) - def test_find_subscriptions_thru_username_password_adfs(self, mock_acquire_token, - mock_acquire_token_username_password): - cli = DummyCli() - TEST_ADFS_AUTH_URL = 'https://adfs.local.azurestack.external/adfs' - - def test_acquire_token(self, resource, username, password, client_id): - global acquire_token_invoked - acquire_token_invoked = True - if (self.authority.url == TEST_ADFS_AUTH_URL and self.authority.is_adfs_authority): - return TestProfile.token_entry1 - else: - raise ValueError('AuthContext was not initialized correctly for ADFS') - - mock_acquire_token_username_password.side_effect = test_acquire_token - mock_acquire_token.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - cli.cloud.endpoints.active_directory = TEST_ADFS_AUTH_URL - finder = SubscriptionFinder(cli, _AUTH_CTX_FACTORY, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - # action - subs = finder.find_from_user_account(self.user1, 'bar', None, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - self.assertTrue(acquire_token_invoked) - - @mock.patch('adal.AuthenticationContext', autospec=True) - @mock.patch('azure.cli.core._profile.logger', autospec=True) - def test_find_subscriptions_thru_username_password_with_account_disabled(self, mock_logger, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.side_effect = AdalError('Account is disabled') - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - # action - subs = finder.find_from_user_account(self.user1, 'bar', None, mgmt_resource) - - # assert - self.assertEqual([], subs) - mock_logger.warning.assert_called_once_with(mock.ANY, mock.ANY, mock.ANY) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_from_particular_tenent(self, mock_auth_context): - def just_raise(ex): - raise ex - - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.side_effect = lambda: just_raise( - ValueError("'tenants.list' should not occur")) - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - # action - subs = finder.find_from_user_account(self.user1, 'bar', self.tenant_id, 'http://someresource') - - # assert - self.assertEqual([self.subscription1], subs) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_through_device_code_flow(self, mock_auth_context): - cli = DummyCli() - test_nonsense_code = {'message': 'magic code for you'} - mock_auth_context.acquire_user_code.return_value = test_nonsense_code - mock_auth_context.acquire_token_with_device_code.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - # action - subs = finder.find_through_interactive_flow(None, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - mock_auth_context.acquire_user_code.assert_called_once_with( - mgmt_resource, mock.ANY) - mock_auth_context.acquire_token_with_device_code.assert_called_once_with( - mgmt_resource, test_nonsense_code, mock.ANY) - mock_auth_context.acquire_token.assert_called_once_with( - mgmt_resource, self.user1, mock.ANY) - - @mock.patch('adal.AuthenticationContext', autospec=True) - @mock.patch('azure.cli.core._profile._get_authorization_code', autospec=True) - def test_find_subscriptions_through_authorization_code_flow(self, _get_authorization_code_mock, mock_auth_context): - import adal - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - token_cache = adal.TokenCache() - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, token_cache, lambda _: mock_arm_client) - _get_authorization_code_mock.return_value = { - 'code': 'code1', - 'reply_url': 'http://localhost:8888' - } - mgmt_resource = 'https://management.core.windows.net/' - temp_token_cache = mock.MagicMock() - type(mock_auth_context).cache = temp_token_cache - temp_token_cache.read_items.return_value = [] - mock_auth_context.acquire_token_with_authorization_code.return_value = self.token_entry1 - - # action - subs = finder.find_through_authorization_code_flow(None, mgmt_resource, 'https:/some_aad_point/common') - - # assert - self.assertEqual([self.subscription1], subs) - mock_auth_context.acquire_token.assert_called_once_with(mgmt_resource, self.user1, mock.ANY) - mock_auth_context.acquire_token_with_authorization_code.assert_called_once_with('code1', - 'http://localhost:8888', - mgmt_resource, mock.ANY, - None) - _get_authorization_code_mock.assert_called_once_with(mgmt_resource, 'https:/some_aad_point/common') - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_interactive_from_particular_tenent(self, mock_auth_context): - def just_raise(ex): - raise ex - - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.side_effect = lambda: just_raise( - ValueError("'tenants.list' should not occur")) - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - # action - subs = finder.find_through_interactive_flow(self.tenant_id, 'http://someresource') - - # assert - self.assertEqual([self.subscription1], subs) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_from_service_principal_id(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - # action - subs = finder.find_from_service_principal_id('my app', ServicePrincipalAuth('my secret'), - self.tenant_id, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - mock_arm_client.tenants.list.assert_not_called() - mock_auth_context.acquire_token.assert_not_called() - mock_auth_context.acquire_token_with_client_credentials.assert_called_once_with( - mgmt_resource, 'my app', 'my secret') - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_from_service_principal_using_cert(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_client_certificate.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - - curr_dir = os.path.dirname(os.path.realpath(__file__)) - test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') - - # action - subs = finder.find_from_service_principal_id('my app', ServicePrincipalAuth(test_cert_file), - self.tenant_id, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - mock_arm_client.tenants.list.assert_not_called() - mock_auth_context.acquire_token.assert_not_called() - mock_auth_context.acquire_token_with_client_certificate.assert_called_once_with( - mgmt_resource, 'my app', mock.ANY, mock.ANY, None) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_find_subscriptions_from_service_principal_using_cert_sn_issuer(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_client_certificate.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - mgmt_resource = 'https://management.core.windows.net/' - - curr_dir = os.path.dirname(os.path.realpath(__file__)) - test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') - with open(test_cert_file) as cert_file: - cert_file_string = cert_file.read() - match = re.search(r'\-+BEGIN CERTIFICATE.+\-+(?P[^-]+)\-+END CERTIFICATE.+\-+', - cert_file_string, re.I) - public_certificate = match.group('public').strip() - # action - subs = finder.find_from_service_principal_id('my app', ServicePrincipalAuth(test_cert_file, use_cert_sn_issuer=True), - self.tenant_id, mgmt_resource) - - # assert - self.assertEqual([self.subscription1], subs) - mock_arm_client.tenants.list.assert_not_called() - mock_auth_context.acquire_token.assert_not_called() - mock_auth_context.acquire_token_with_client_certificate.assert_called_once_with( - mgmt_resource, 'my app', mock.ANY, mock.ANY, public_certificate) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_refresh_accounts_one_user_account(self, mock_auth_context): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False) - profile._set_subscriptions(consolidated) - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = deepcopy([self.subscription1_raw, self.subscription2_raw]) - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - # action - profile.refresh_accounts(finder) - - # assert - result = storage_mock['subscriptions'] - self.assertEqual(2, len(result)) - self.assertEqual(self.id1.split('/')[-1], result[0]['id']) - self.assertEqual(self.id2.split('/')[-1], result[1]['id']) - self.assertTrue(result[0]['isDefault']) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_refresh_accounts_one_user_account_one_sp_account(self, mock_auth_context): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - sp_subscription1 = SubscriptionStub('sp-sub/3', 'foo-subname', self.state1, 'foo_tenant.onmicrosoft.com') - consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False) - consolidated += profile._normalize_properties('http://foo', [sp_subscription1], True) - profile._set_subscriptions(consolidated) - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.return_value = self.token_entry1 - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.side_effect = deepcopy([[self.subscription1], [self.subscription2, sp_subscription1]]) - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - profile._creds_cache.retrieve_cred_for_service_principal = lambda _: 'verySecret' - profile._creds_cache.flush_to_disk = lambda _: '' - # action - profile.refresh_accounts(finder) - - # assert - result = storage_mock['subscriptions'] - self.assertEqual(3, len(result)) - self.assertEqual(self.id1.split('/')[-1], result[0]['id']) - self.assertEqual(self.id2.split('/')[-1], result[1]['id']) - self.assertEqual('3', result[2]['id']) - self.assertTrue(result[0]['isDefault']) - - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_refresh_accounts_with_nothing(self, mock_auth_context): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False) - profile._set_subscriptions(consolidated) - mock_auth_context.acquire_token_with_username_password.return_value = self.token_entry1 - mock_auth_context.acquire_token.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [] - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, None, lambda _: mock_arm_client) - # action - profile.refresh_accounts(finder) - - # assert - result = storage_mock['subscriptions'] - self.assertEqual(0, len(result)) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_credscache_load_tokens_and_sp_creds_with_secret(self, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_read_file.return_value = [self.token_entry1, test_sp] - - # action - creds_cache = CredsCache(cli, async_persist=False) - - # assert - token_entries = [entry for _, entry in creds_cache.load_adal_token_cache().read_items()] - self.assertEqual(token_entries, [self.token_entry1]) - self.assertEqual(creds_cache._service_principal_creds, [test_sp]) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_credscache_load_tokens_and_sp_creds_with_cert(self, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "certificateFile": 'junkcert.pem' - } - mock_read_file.return_value = [test_sp] - - # action - creds_cache = CredsCache(cli, async_persist=False) - creds_cache.load_adal_token_cache() - - # assert - self.assertEqual(creds_cache._service_principal_creds, [test_sp]) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - def test_credscache_retrieve_sp_cred(self, mock_read_file): - cli = DummyCli() - test_cache = [ - { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - }, - { - "servicePrincipalId": "myapp2", - "servicePrincipalTenant": "mytenant", - "certificateFile": 'junkcert.pem' - } - ] - mock_read_file.return_value = test_cache - - # action - creds_cache = CredsCache(cli, async_persist=False) - creds_cache.load_adal_token_cache() - - # assert - self.assertEqual(creds_cache.retrieve_cred_for_service_principal('myapp'), 'Secret') - self.assertEqual(creds_cache.retrieve_cred_for_service_principal('myapp2'), 'junkcert.pem') - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_add_new_sp_creds(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - test_sp2 = { - "servicePrincipalId": "myapp2", - "servicePrincipalTenant": "mytenant2", - "accessToken": "Secret2" - } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [self.token_entry1, test_sp] - creds_cache = CredsCache(cli, async_persist=False) - - # action - creds_cache.save_service_principal_cred(test_sp2) - - # assert - token_entries = [e for _, e in creds_cache.adal_token_cache.read_items()] # noqa: F812 - self.assertEqual(token_entries, [self.token_entry1]) - self.assertEqual(creds_cache._service_principal_creds, [test_sp, test_sp2]) - mock_open_for_write.assert_called_with(mock.ANY, 'w+') - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_add_preexisting_sp_creds(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [test_sp] - creds_cache = CredsCache(cli, async_persist=False) - - # action - creds_cache.save_service_principal_cred(test_sp) - - # assert - self.assertEqual(creds_cache._service_principal_creds, [test_sp]) - self.assertFalse(mock_open_for_write.called) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_add_preexisting_sp_new_secret(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [test_sp] - creds_cache = CredsCache(cli, async_persist=False) - - new_creds = test_sp.copy() - new_creds['accessToken'] = 'Secret2' - # action - creds_cache.save_service_principal_cred(new_creds) - - # assert - self.assertEqual(creds_cache._service_principal_creds, [new_creds]) - self.assertTrue(mock_open_for_write.called) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_match_service_principal_correctly(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [test_sp] - factory = mock.MagicMock() - factory.side_effect = ValueError('SP was found') - creds_cache = CredsCache(cli, factory, async_persist=False) - - # action and verify(we plant an exception to throw after the SP was found; so if the exception is thrown, - # we know the matching did go through) - self.assertRaises(ValueError, creds_cache.retrieve_token_for_service_principal, 'myapp', 'resource1', 'mytenant', False) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - def test_credscache_remove_creds(self, _, mock_open_for_write, mock_read_file): - cli = DummyCli() - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [self.token_entry1, test_sp] - creds_cache = CredsCache(cli, async_persist=False) - - # action #1, logout a user - creds_cache.remove_cached_creds(self.user1) - - # assert #1 - token_entries = [e for _, e in creds_cache.adal_token_cache.read_items()] # noqa: F812 - self.assertEqual(token_entries, []) - - # action #2 logout a service principal - creds_cache.remove_cached_creds('myapp') - - # assert #2 - self.assertEqual(creds_cache._service_principal_creds, []) - - mock_open_for_write.assert_called_with(mock.ANY, 'w+') - self.assertEqual(mock_open_for_write.call_count, 2) - - @mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) - @mock.patch('os.fdopen', autospec=True) - @mock.patch('os.open', autospec=True) - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_credscache_new_token_added_by_adal(self, mock_adal_auth_context, _, mock_open_for_write, mock_read_file): # pylint: disable=line-too-long - cli = DummyCli() - token_entry2 = { - "accessToken": "new token", - "tokenType": "Bearer", - "userId": self.user1 - } - - def acquire_token_side_effect(*args): # pylint: disable=unused-argument - creds_cache.adal_token_cache.has_state_changed = True - return token_entry2 - - def get_auth_context(_, authority, **kwargs): # pylint: disable=unused-argument - mock_adal_auth_context.cache = kwargs['cache'] - return mock_adal_auth_context - - mock_adal_auth_context.acquire_token.side_effect = acquire_token_side_effect - mock_open_for_write.return_value = FileHandleStub() - mock_read_file.return_value = [self.token_entry1] - creds_cache = CredsCache(cli, auth_ctx_factory=get_auth_context, async_persist=False) - - # action - mgmt_resource = 'https://management.core.windows.net/' - token_type, token, _ = creds_cache.retrieve_token_for_user(self.user1, self.tenant_id, - mgmt_resource) - mock_adal_auth_context.acquire_token.assert_called_once_with( - 'https://management.core.windows.net/', - self.user1, - mock.ANY) - - # assert - mock_open_for_write.assert_called_with(mock.ANY, 'w+') - self.assertEqual(token, 'new token') - self.assertEqual(token_type, token_entry2['tokenType']) - - @mock.patch('azure.cli.core._profile.get_file_json', autospec=True) - def test_credscache_good_error_on_file_corruption(self, mock_read_file): - mock_read_file.side_effect = ValueError('a bad error for you') - cli = DummyCli() - - # action - creds_cache = CredsCache(cli, async_persist=False) - - # assert - with self.assertRaises(CLIError) as context: - creds_cache.load_adal_token_cache() - - self.assertTrue(re.findall(r'bad error for you', str(context.exception))) - - def test_service_principal_auth_client_secret(self): - sp_auth = ServicePrincipalAuth('verySecret!') - result = sp_auth.get_entry_to_persist('sp_id1', 'tenant1') - self.assertEqual(result, { - 'servicePrincipalId': 'sp_id1', - 'servicePrincipalTenant': 'tenant1', - 'accessToken': 'verySecret!' - }) - - def test_service_principal_auth_client_cert(self): - curr_dir = os.path.dirname(os.path.realpath(__file__)) - test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') - sp_auth = ServicePrincipalAuth(test_cert_file) - - result = sp_auth.get_entry_to_persist('sp_id1', 'tenant1') - self.assertEqual(result, { - 'servicePrincipalId': 'sp_id1', - 'servicePrincipalTenant': 'tenant1', - 'certificateFile': test_cert_file, - 'thumbprint': 'F0:6A:53:84:8B:BE:71:4A:42:90:D6:9D:33:52:79:C1:D0:10:73:FD' - }) - - def test_detect_adfs_authority_url(self): - cli = DummyCli() - adfs_url_1 = 'https://adfs.redmond.ext-u15f2402.masd.stbtest.microsoft.com/adfs/' - cli.cloud.endpoints.active_directory = adfs_url_1 - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - # test w/ trailing slash - r = profile.auth_ctx_factory(cli, 'common', None) - self.assertEqual(r.authority.url, adfs_url_1.rstrip('/')) - - # test w/o trailing slash - adfs_url_2 = 'https://adfs.redmond.ext-u15f2402.masd.stbtest.microsoft.com/adfs' - cli.cloud.endpoints.active_directory = adfs_url_2 - r = profile.auth_ctx_factory(cli, 'common', None) - self.assertEqual(r.authority.url, adfs_url_2) - - # test w/ regular aad - aad_url = 'https://login.microsoftonline.com' - cli.cloud.endpoints.active_directory = aad_url - r = profile.auth_ctx_factory(cli, 'common', None) - self.assertEqual(r.authority.url, aad_url + '/common') - - -class FileHandleStub(object): # pylint: disable=too-few-public-methods - - def write(self, content): - pass - - def __enter__(self): - return self - - def __exit__(self, _2, _3, _4): - pass - - -class SubscriptionStub(Subscription): # pylint: disable=too-few-public-methods - - def __init__(self, id, display_name, state, tenant_id=None): # pylint: disable=redefined-builtin - policies = SubscriptionPolicies() - policies.spending_limit = SpendingLimit.current_period_off - policies.quota_id = 'some quota' - super(SubscriptionStub, self).__init__(subscription_policies=policies, authorization_source='some_authorization_source') - self.id = id - self.subscription_id = id.split('/')[1] - self.display_name = display_name - self.state = state - # for a SDK Subscription, tenant_id isn't present - # for a _find_using_specific_tenant Subscription, tenant_id means token tenant id - if tenant_id: - self.tenant_id = tenant_id - - -class TenantStub(object): # pylint: disable=too-few-public-methods - - def __init__(self, tenant_id): - self.tenant_id = tenant_id - - -class MSRestAzureAuthStub: - def __init__(self, *args, **kwargs): - self._token = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - self.set_token_invoked_count = 0 - self.token_read_count = 0 - self.client_id = kwargs.get('client_id') - self.object_id = kwargs.get('object_id') - self.msi_res_id = kwargs.get('msi_res_id') - - def set_token(self): - self.set_token_invoked_count += 1 - - @property - def token(self): - self.token_read_count += 1 - return self._token - - @token.setter - def token(self, value): - self._token = value - - -if __name__ == '__main__': - unittest.main() diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 5e1f56ef1c1..ad7e28983e2 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -50,8 +50,8 @@ 'humanfriendly>=4.7,<9.0', 'jmespath', 'knack==0.7.2', - 'msal~=1.0.0', - 'msal-extensions~=0.1.3', + 'azure-identity==1.5.0b2', + 'msrest>=0.4.4', 'msrestazure>=0.6.3', 'paramiko>=2.0.8,<3.0.0', 'PyJWT', diff --git a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py index d59833dfa16..4962936f225 100644 --- a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py +++ b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py @@ -43,6 +43,8 @@ def _handle_load_cached_subscription(*args, **kwargs): # pylint: disable=unused return [{ "id": MOCKED_SUBSCRIPTION_ID, "user": { + # TODO: Azure Identity may remove homeAccountId in the future, since it is internal to MSAL and + # may not be absolutely necessary "name": MOCKED_USER_NAME, "type": "user" }, @@ -57,21 +59,33 @@ def _handle_load_cached_subscription(*args, **kwargs): # pylint: disable=unused def patch_retrieve_token_for_user(unit_test): - def _retrieve_token_for_user(*args, **kwargs): # pylint: disable=unused-argument - import datetime - fake_token = 'top-secret-token-for-you' - return 'Bearer', fake_token, { - "tokenType": "Bearer", - "expiresIn": 3600, - "expiresOn": (datetime.datetime.now() + datetime.timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S.%f"), - "resource": args[3], - "accessToken": fake_token, - "refreshToken": fake_token - } - mock_in_unit_test(unit_test, - 'azure.cli.core._profile.CredsCache.retrieve_token_for_user', - _retrieve_token_for_user) + class PublicClientApplicationMock: + + def __init__(self, *args, **kwargs): + pass + + def get_accounts(self, username): + return [{ + 'home_account_id': '182c0000-0000-0000-0000-000000000000.54820000-0000-0000-0000-000000000000', + 'environment': 'login.microsoftonline.com', + 'realm': 'organizations', + 'local_account_id': '182c0000-0000-0000-0000-000000000000', + 'username': MOCKED_USER_NAME, + 'authority_type': 'MSSTS' + }] + + def _mock_get_token(*args, **kwargs): # pylint: disable=unused-argument + from azure.core.credentials import AccessToken + import time + fake_raw_token = 'top-secret-token-for-you' + now = int(time.time()) + # Mock sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py:230 + return AccessToken(fake_raw_token, now + 3600) + + # Creating a PublicClientApplication will trigger an HTTP request to validate the tenant. Patch it! + mock_in_unit_test(unit_test, 'msal.PublicClientApplication', PublicClientApplicationMock) + mock_in_unit_test(unit_test, 'azure.identity.InteractiveBrowserCredential.get_token', _mock_get_token) def patch_long_run_operation_delay(unit_test): diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py index 49f4e07e6b9..60d0daffbdf 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py @@ -14,7 +14,7 @@ from azure.mgmt.web import WebSiteManagementClient from azure.mgmt.web.models import HostingEnvironmentProfile from azure.mgmt.network.models import (Subnet, RouteTable, Route, NetworkSecurityGroup, SecurityRule) -from azure.cli.core.adal_authentication import AdalAuthentication +from azure.cli.core.credential import CredentialAdaptor from azure.cli.command_modules.appservice.appservice_environment import (show_appserviceenvironment, list_appserviceenvironments, @@ -30,7 +30,7 @@ def setUp(self): self.mock_logger = mock.MagicMock() self.mock_cmd = mock.MagicMock() self.mock_cmd.cli_ctx = mock.MagicMock() - self.client = WebSiteManagementClient(AdalAuthentication(lambda: ('bearer', 'secretToken')), '123455678') + self.client = WebSiteManagementClient(CredentialAdaptor(lambda: ('bearer', 'secretToken')), '123455678') @mock.patch('azure.cli.command_modules.appservice.appservice_environment._get_ase_client_factory', autospec=True) def test_app_service_environment_show(self, ase_client_factory_mock): diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py index d02ae20cbe2..cdac0a00969 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py @@ -7,7 +7,7 @@ import os from azure.mgmt.web import WebSiteManagementClient -from azure.cli.core.adal_authentication import AdalAuthentication +from azure.cli.core.credential import CredentialAdaptor from knack.util import CLIError from azure.cli.command_modules.appservice.custom import ( enable_zip_deploy_functionapp, @@ -34,7 +34,7 @@ def _get_test_cmd(): class TestFunctionappMocked(unittest.TestCase): def setUp(self): - self.client = WebSiteManagementClient(AdalAuthentication(lambda: ('bearer', 'secretToken')), '123455678') + self.client = WebSiteManagementClient(CredentialAdaptor(lambda: ('bearer', 'secretToken')), '123455678') @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) @mock.patch('azure.cli.command_modules.appservice.custom.parse_resource_id') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index c7bb56b26e3..49dd144098f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -8,7 +8,7 @@ from msrestazure.azure_exceptions import CloudError from azure.mgmt.web import WebSiteManagementClient -from azure.cli.core.adal_authentication import AdalAuthentication +from azure.cli.core.credential import CredentialAdaptor from knack.util import CLIError from azure.cli.command_modules.appservice.custom import (set_deployment_user, update_git_token, add_hostname, @@ -48,7 +48,7 @@ def _get_test_cmd(): class TestWebappMocked(unittest.TestCase): def setUp(self): - self.client = WebSiteManagementClient(AdalAuthentication(lambda: ('bearer', 'secretToken')), '123455678') + self.client = WebSiteManagementClient(CredentialAdaptor(lambda: ('bearer', 'secretToken')), '123455678') @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) def test_set_deployment_user_creds(self, client_factory_mock): diff --git a/src/azure-cli/azure/cli/command_modules/configure/_consts.py b/src/azure-cli/azure/cli/command_modules/configure/_consts.py index 04ca4af91bc..e23c53fa0e6 100644 --- a/src/azure-cli/azure/cli/command_modules/configure/_consts.py +++ b/src/azure-cli/azure/cli/command_modules/configure/_consts.py @@ -49,3 +49,5 @@ MSG_PROMPT_FILE_LOGGING = '\nWould you like to enable logging to file?' MSG_PROMPT_CACHE_TTL = '\nCLI object cache time-to-live (TTL) in minutes [Default: {}]: '.format(DEFAULT_CACHE_TTL) + +MSG_PROMPT_ALLOW_PLAINTEXT = '\nWould you like to allow fallback to plaintext if encrypt credential fail?' diff --git a/src/azure-cli/azure/cli/command_modules/configure/custom.py b/src/azure-cli/azure/cli/command_modules/configure/custom.py index dca05d41759..c55a181f048 100644 --- a/src/azure-cli/azure/cli/command_modules/configure/custom.py +++ b/src/azure-cli/azure/cli/command_modules/configure/custom.py @@ -27,7 +27,8 @@ MSG_PROMPT_FILE_LOGGING, MSG_PROMPT_CACHE_TTL, WARNING_CLOUD_FORBID_TELEMETRY, - DEFAULT_CACHE_TTL) + DEFAULT_CACHE_TTL, + MSG_PROMPT_ALLOW_PLAINTEXT) from azure.cli.command_modules.configure._utils import get_default_from_config answers = {} @@ -84,7 +85,7 @@ def _config_env_public_azure(cli_ctx, _): elif method_index == 3: # skip return try: - profile.find_subscriptions_on_login( + profile.login( interactive, username, password, @@ -135,11 +136,14 @@ def _handle_global_configuration(config, cloud_forbid_telemetry): except ValueError: logger.error('TTL must be a positive integer') cache_ttl = None + allow_fallback_to_plaintext = prompt_y_n(MSG_PROMPT_ALLOW_PLAINTEXT, default='y') + # save the global config config.set_value('core', 'output', OUTPUT_LIST[output_index]['name']) config.set_value('core', 'collect_telemetry', 'yes' if allow_telemetry else 'no') config.set_value('core', 'cache_ttl', cache_ttl) config.set_value('logging', 'enable_log_file', 'yes' if enable_file_logging else 'no') + config.set_value('core', 'allow_fallback_to_plaintext', 'yes' if allow_fallback_to_plaintext else 'no') # pylint: disable=inconsistent-return-statements diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_completers.py b/src/azure-cli/azure/cli/command_modules/keyvault/_completers.py index 501bb8e308b..8f644bd1475 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_completers.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_completers.py @@ -8,7 +8,7 @@ def _get_token(cli_ctx, server, resource, scope): # pylint: disable=unused-argument - return Profile(cli_ctx=cli_ctx).get_login_credentials(resource)[0]._token_retriever() # pylint: disable=protected-access + return 'Bearer', Profile(cli_ctx=cli_ctx).get_login_credentials(resource)[0].get_token(), None def get_keyvault_name_completion_list(resource_name): diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 634f3e177bf..0bd1f0e5926 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -38,28 +38,46 @@ def load_command_table(self, args): g.command('clear', 'account_clear') g.command('list-locations', 'list_locations') g.command('get-access-token', 'get_access_token') + g.command('export-msal-cache', 'export_msal_cache') return self.command_table # pylint: disable=line-too-long def load_arguments(self, command): from azure.cli.core.api import get_subscription_id_list + from azure.cli.core.commands.parameters import get_three_state_flag + from knack.arguments import CLIArgumentType + + clear_credential_type = CLIArgumentType(options_list=['--clear-credential', '-c'], + arg_type=get_three_state_flag(), + help="Clear the credential stored in MSAL encrypted cache. " + "The user will also be logged out from other SDK tools " + "which uses Azure CLI's credential via Single Sign-On.") with self.argument_context('login') as c: c.argument('password', options_list=['--password', '-p'], help="Credentials like user password, or for a service principal, provide client secret or a pem file with key and public certificate. Will prompt if not given.") c.argument('service_principal', action='store_true', help='The credential representing a service principal.') c.argument('username', options_list=['--username', '-u'], help='user name, service principal, or managed service identity ID') c.argument('tenant', options_list=['--tenant', '-t'], help='The AAD tenant, must provide when using service principals.', validator=validate_tenant) - c.argument('allow_no_subscriptions', action='store_true', help="Support access tenants without subscriptions. It's uncommon but useful to run tenant level commands, such as 'az ad'") + c.argument('tenant_access', action='store_true', + help='Only log in to the home tenant or the tenant specified by --tenant. CLI will not perform ' + 'ARM operations to list tenants and subscriptions. Then you may run tenant-level commands, ' + 'such as `az ad`, `az account get-access-token`.') + c.argument('allow_no_subscriptions', action='store_true', deprecate_info=c.deprecate(target='--allow-no-subscriptions', expiration='3.0.0', redirect="--tenant-access", hide=False), + help="Support access tenants without subscriptions. It's uncommon but useful to run tenant level commands, such as `az ad`") c.ignore('_subscription') # hide the global subscription parameter - c.argument('identity', options_list=('-i', '--identity'), action='store_true', help="Log in using the Virtual Machine's identity", arg_group='Managed Service Identity') - c.argument('identity_port', type=int, help="the port to retrieve tokens for login. Default: 50342", arg_group='Managed Service Identity') + c.argument('identity', options_list=('-i', '--identity'), action='store_true', help="Log in using the Virtual Machine's managed identity", arg_group='Managed Identity') + c.argument('identity_port', type=int, help="the port to retrieve tokens for login. Default: 50342", arg_group='Managed Identity') c.argument('use_device_code', action='store_true', help="Use CLI's old authentication flow based on device code. CLI will also use this if it can't launch a browser in your behalf, e.g. in remote SSH or Cloud Shell") c.argument('use_cert_sn_issuer', action='store_true', help='used with a service principal configured with Subject Name and Issuer Authentication in order to support automatic certificate rolls') + c.argument('environment', options_list=['--environment', '-e'], action='store_true', + help='Use EnvironmentCredential. Both user and service principal accounts are supported. ' + 'For required environment variables, see https://docs.microsoft.com/en-us/python/api/overview/azure/identity-readme?view=azure-python#environment-variables') with self.argument_context('logout') as c: - c.argument('username', help='account user, if missing, logout the current active account') + c.argument('username', options_list=['--username', '-u'], help='account user, if missing, logout the current active account') + c.argument('clear_credential', clear_credential_type) c.ignore('_subscription') # hide the global subscription parameter with self.argument_context('account') as c: @@ -75,8 +93,16 @@ def load_arguments(self, command): c.argument('show_auth_for_sdk', options_list=['--sdk-auth'], action='store_true', help='Output result to a file compatible with Azure SDK auth. Only applicable when authenticating with a Service Principal.') with self.argument_context('account get-access-token') as c: - c.argument('resource_type', get_enum_type(cloud_resource_types), options_list=['--resource-type'], arg_group='', help='Type of well-known resource.') + c.argument('resource', arg_group='ADAL', help='Azure resource endpoints in AAD v1.0. Default to Azure Resource Manager') + c.argument('resource_type', get_enum_type(cloud_resource_types), options_list=['--resource-type'], arg_group='ADAL', help='Type of well-known resource.') + c.argument('scopes', nargs='*', arg_group='MSAL', help='Space-separated AAD scopes in AAD v2.0.') c.argument('tenant', options_list=['--tenant', '-t'], help='Tenant ID for which the token is acquired. Only available for user and service principal account, not for MSI or Cloud Shell account') + with self.argument_context('account clear') as c: + c.argument('clear_credential', clear_credential_type) + + with self.argument_context('account export-msal-cache') as c: + c.argument('path', help='The path to export the MSAL cache.') + COMMAND_LOADER_CLS = ProfileCommandsLoader diff --git a/src/azure-cli/azure/cli/command_modules/profile/_help.py b/src/azure-cli/azure/cli/command_modules/profile/_help.py index 37e648e1fdb..da642f47925 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/_help.py +++ b/src/azure-cli/azure/cli/command_modules/profile/_help.py @@ -10,25 +10,48 @@ helps['login'] = """ type: command short-summary: Log in to Azure. +long-summary: >- + By default, this command logs in with a user account. To login with a service principal, specify --service-principal. + + + For user login, CLI will try to launch a web browser to log in interactively. If a web browser is not available, + CLI will fallback to device code login. + + + To retrieve the login credential from environment variables (EnvironmentCredential), specify --environment. + For details on using EnvironmentCredential, see + https://docs.microsoft.com/python/api/overview/azure/identity-readme#environment-variables examples: - name: Log in interactively. - text: > - az login + text: az login - name: Log in with user name and password. This doesn't work with Microsoft accounts or accounts that have two-factor authentication enabled. Use -p=secret if the first character of the password is '-'. - text: > - az login -u johndoe@contoso.com -p VerySecret + text: az login -u johndoe@contoso.com -p VerySecret - name: Log in with a service principal using client secret. Use -p=secret if the first character of the password is '-'. - text: > - az login --service-principal -u http://azure-cli-2016-08-05-14-31-15 -p VerySecret --tenant contoso.onmicrosoft.com + text: az login --service-principal -u http://azure-cli-2016-08-05-14-31-15 -p VerySecret --tenant contoso.onmicrosoft.com - name: Log in with a service principal using client certificate. - text: > - az login --service-principal -u http://azure-cli-2016-08-05-14-31-15 -p ~/mycertfile.pem --tenant contoso.onmicrosoft.com - - name: Log in using a VM's system assigned identity - text: > - az login --identity - - name: Log in using a VM's user assigned identity. Client or object ids of the service identity also work - text: > - az login --identity -u /subscriptions//resourcegroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myID + text: az login --service-principal -u http://azure-cli-2016-08-05-14-31-15 -p ~/mycertfile.pem --tenant contoso.onmicrosoft.com + - name: Log in using a VM's system-assigned managed identity. + text: az login --identity + - name: Log in using a VM's user-assigned managed identity. Client or object ids of the service identity also work. + text: az login --identity -u /subscriptions//resourcegroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myID + - name: Log in with a service principal using EnvironmentCredential. + text: |- + # Bash script + export AZURE_TENANT_ID='' + export AZURE_CLIENT_ID='' + # With secret + export AZURE_CLIENT_SECRET='' + # Or with certificate + # export AZURE_CLIENT_CERTIFICATE_PATH='' + az login --environment + - name: Log in with a user account using EnvironmentCredential. + text: |- + # Bash script + # AZURE_CLIENT_ID defaults to Azure CLI's client ID + # export AZURE_CLIENT_ID='04b07795-8ddb-461a-bbee-02f9e1bf7b46' + export AZURE_USERNAME='' + export AZURE_PASSWORD='' + az login --environment """ helps['account'] = """ @@ -97,6 +120,22 @@ az account get-access-token --resource-type ms-graph """ +helps['account export-msal-cache'] = """ +type: command +short-summary: Export MSAL cache, by default to `~/.azure/msal.cache.snapshot.json`. +long-summary: > + The exported cache is unencrypted. It contains login information of all logged-in users. Make sure you protect + it safely. + + You can mount the exported MSAL cache to a container at `~/.IdentityService/msal.cache`, so that Azure CLI + inside the container can automatically authenticate. +examples: + - name: Export MSAL cache to the default path. + text: az account export-msal-cache + - name: Export MSAL cache to a custom path. + text: az account export-msal-cache --path ~/msal_cache.json +""" + helps['self-test'] = """ type: command short-summary: Runs a self-test of the CLI. diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 23bd7384ae0..d7c595a85a6 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -63,20 +63,18 @@ def show_subscription(cmd, subscription=None, show_auth_for_sdk=None): return profile.get_subscription(subscription) -def get_access_token(cmd, subscription=None, resource=None, resource_type=None, tenant=None): +def get_access_token(cmd, subscription=None, resource=None, scopes=None, resource_type=None, tenant=None): """ - get AAD token to access to a specified resource - :param resource: Azure resource endpoints. Default to Azure Resource Manager - :param resource-type: Name of Azure resource endpoints. Can be used instead of resource. + get AAD token to access to a specified resource. Use 'az cloud show' command for other Azure resources """ - if resource is None and resource_type is not None: + if resource is None and resource_type: endpoints_attr_name = cloud_resource_type_mappings[resource_type] resource = getattr(cmd.cli_ctx.cloud.endpoints, endpoints_attr_name) - else: - resource = (resource or cmd.cli_ctx.cloud.endpoints.active_directory_resource_id) + profile = Profile(cli_ctx=cmd.cli_ctx) - creds, subscription, tenant = profile.get_raw_token(subscription=subscription, resource=resource, tenant=tenant) + creds, subscription, tenant = profile.get_raw_token(subscription=subscription, resource=resource, scopes=scopes, + tenant=tenant) token_entry = creds[2] # MSIAuthentication's token entry has `expires_on`, while ADAL's token entry has `expiresOn` @@ -105,17 +103,17 @@ def set_active_subscription(cmd, subscription): profile.set_active_subscription(subscription) -def account_clear(cmd): +def account_clear(cmd, clear_credential=False): """Clear all stored subscriptions. To clear individual, use 'logout'""" if in_cloud_console(): logger.warning(_CLOUD_CONSOLE_LOGOUT_WARNING) profile = Profile(cli_ctx=cmd.cli_ctx) - profile.logout_all() + profile.logout_all(clear_credential) -# pylint: disable=inconsistent-return-statements +# pylint: disable=inconsistent-return-statements, too-many-branches def login(cmd, username=None, password=None, service_principal=None, tenant=None, allow_no_subscriptions=False, - identity=False, use_device_code=False, use_cert_sn_issuer=None): + identity=False, use_device_code=False, use_cert_sn_issuer=None, tenant_access=False, environment=False): """Log in to access Azure subscriptions""" from adal.adal_error import AdalError import requests @@ -125,6 +123,8 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None raise CLIError("usage error: '--identity' is not applicable with other arguments") if any([password, service_principal, username, identity]) and use_device_code: raise CLIError("usage error: '--use-device-code' is not applicable with other arguments") + if any([password, service_principal, username, identity, use_device_code]) and environment: + raise CLIError("usage error: '--environment' is not applicable with other arguments") if use_cert_sn_issuer and not service_principal: raise CLIError("usage error: '--use-sn-issuer' is only applicable with a service principal") if service_principal and not username: @@ -136,8 +136,8 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None if identity: if in_cloud_console(): - return profile.find_subscriptions_in_cloud_console() - return profile.find_subscriptions_in_vm_with_msi(username, allow_no_subscriptions) + return profile.login_in_cloud_shell() + return profile.login_with_managed_identity(username, allow_no_subscriptions) if in_cloud_console(): # tell users they might not need login logger.warning(_CLOUD_CONSOLE_LOGIN_WARNING) @@ -150,8 +150,11 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None else: interactive = True + if environment: + return profile.login_with_environment_credential(find_subscriptions=not tenant_access) + try: - subscriptions = profile.find_subscriptions_on_login( + subscriptions = profile.login( interactive, username, password, @@ -159,7 +162,7 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None tenant, use_device_code=use_device_code, allow_no_subscriptions=allow_no_subscriptions, - use_cert_sn_issuer=use_cert_sn_issuer) + use_cert_sn_issuer=use_cert_sn_issuer, find_subscriptions=not tenant_access) except AdalError as err: # try polish unfriendly server errors if username: @@ -187,7 +190,7 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None return all_subscriptions -def logout(cmd, username=None): +def logout(cmd, username=None, clear_credential=False): """Log out to remove access to Azure subscriptions""" if in_cloud_console(): logger.warning(_CLOUD_CONSOLE_LOGOUT_WARNING) @@ -195,7 +198,7 @@ def logout(cmd, username=None): profile = Profile(cli_ctx=cmd.cli_ctx) if not username: username = profile.get_current_account_user() - profile.logout(username) + profile.logout(username, clear_credential) def list_locations(cmd): @@ -203,6 +206,12 @@ def list_locations(cmd): return get_subscription_locations(cmd.cli_ctx) +def export_msal_cache(cmd, path=None): # pylint: disable=unused-argument + from azure.cli.core._identity import Identity + identity = Identity() + identity.serialize_token_cache(path) + + def check_cli(cmd): from azure.cli.core.file_util import ( create_invoker_and_load_cmds_and_args, get_all_help) diff --git a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py index 907e5e8d3a4..62de1ccef6a 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py @@ -4,7 +4,7 @@ # -------------------------------------------------------------------------------------------- import unittest -import mock +from unittest import mock from azure.cli.command_modules.profile.custom import list_subscriptions, get_access_token, login from azure.cli.core.mock import DummyCli @@ -42,7 +42,7 @@ def test_get_raw_token(self, get_raw_token_mock): result = get_access_token(cmd) # assert - get_raw_token_mock.assert_called_with(mock.ANY, 'https://management.core.windows.net/', None, None) + get_raw_token_mock.assert_called_with(mock.ANY, None, None, None, None) expected_result = { 'tokenType': 'bearer', 'accessToken': 'token123', @@ -58,7 +58,7 @@ def test_get_raw_token(self, get_raw_token_mock): get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': '2100-01-01'}], subscription_id, 'tenant123') result = get_access_token(cmd, subscription=subscription_id, resource=resource) - get_raw_token_mock.assert_called_with(mock.ANY, resource, subscription_id, None) + get_raw_token_mock.assert_called_with(mock.ANY, resource, None, subscription_id, None) expected_result = { 'tokenType': 'bearer', 'accessToken': 'token123', @@ -72,7 +72,7 @@ def test_get_raw_token(self, get_raw_token_mock): tenant_id = '00000000-0000-0000-0000-000000000000' get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': '2100-01-01'}], None, tenant_id) result = get_access_token(cmd, tenant=tenant_id) - get_raw_token_mock.assert_called_with(mock.ANY, 'https://management.core.windows.net/', None, tenant_id) + get_raw_token_mock.assert_called_with(mock.ANY, None, None, None, tenant_id) expected_result = { 'tokenType': 'bearer', 'accessToken': 'token123', @@ -81,21 +81,12 @@ def test_get_raw_token(self, get_raw_token_mock): } self.assertEqual(result, expected_result) - @mock.patch('azure.cli.core._profile.Profile.get_raw_token', autospec=True) - def test_get_raw_token_managed_identity(self, get_raw_token_mock): - cmd = mock.MagicMock() - cmd.cli_ctx = DummyCli() - - # test get token with Managed Identity - tenant_id = '00000000-0000-0000-0000-000000000000' + # test get token with Managed Identity. + # This test can only pass on a system that uses UTC as the time zone. Change your system's time zone + # before running this test. get_raw_token_mock.return_value = (['bearer', 'token123', {'expires_on': '1593497681'}], None, tenant_id) - - import datetime - # Force POSIX timestamp to be converted to datetime in UTC during testing. - with mock.patch('azure.cli.command_modules.profile.custom._fromtimestamp', datetime.datetime.utcfromtimestamp): - result = get_access_token(cmd) - - get_raw_token_mock.assert_called_with(mock.ANY, 'https://management.core.windows.net/', None, None) + result = get_access_token(cmd, tenant=tenant_id) + get_raw_token_mock.assert_called_with(mock.ANY, None, None, None, tenant_id) expected_result = { 'tokenType': 'bearer', 'accessToken': 'token123', @@ -104,6 +95,10 @@ def test_get_raw_token_managed_identity(self, get_raw_token_mock): } self.assertEqual(result, expected_result) + get_access_token(cmd, scopes='https://graph.microsoft.com/.default') + get_raw_token_mock.assert_called_with(mock.ANY, None, scopes='https://graph.microsoft.com/.default', + subscription=None, tenant=None) + @mock.patch('azure.cli.command_modules.profile.custom.Profile', autospec=True) def test_get_login(self, profile_mock): invoked = [] @@ -113,7 +108,7 @@ def test_login(msi_port, identity_id=None): # mock the instance profile_instance = mock.MagicMock() - profile_instance.find_subscriptions_in_vm_with_msi = test_login + profile_instance.login_with_managed_identity = test_login # mock the constructor profile_mock.return_value = profile_instance diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index 7a0c83d3fae..e4aebce929a 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -1553,7 +1553,7 @@ def _get_keyvault_client(cli_ctx): version = str(get_api_version(cli_ctx, ResourceType.DATA_KEYVAULT)) def _get_token(server, resource, scope): # pylint: disable=unused-argument - return Profile(cli_ctx=cli_ctx).get_login_credentials(resource)[0]._token_retriever() # pylint: disable=protected-access + return 'Bearer', Profile(cli_ctx=cli_ctx).get_login_credentials(resource)[0].get_token(), None return KeyVaultClient(KeyVaultAuthentication(_get_token), api_version=version) diff --git a/src/azure-cli/azure/cli/command_modules/servicefabric/custom.py b/src/azure-cli/azure/cli/command_modules/servicefabric/custom.py index 3eef2ea382a..2616d75b7bf 100644 --- a/src/azure-cli/azure/cli/command_modules/servicefabric/custom.py +++ b/src/azure-cli/azure/cli/command_modules/servicefabric/custom.py @@ -1661,7 +1661,7 @@ def _get_keyVault_not_arm_client(cli_ctx): version = str(get_api_version(cli_ctx, ResourceType.DATA_KEYVAULT)) def get_token(server, resource, scope): # pylint: disable=unused-argument - return Profile(cli_ctx=cli_ctx).get_login_credentials(resource)[0]._token_retriever() # pylint: disable=protected-access + return 'Bearer', Profile(cli_ctx=cli_ctx).get_login_credentials(resource)[0].get_token(), None client = KeyVaultClient(KeyVaultAuthentication(get_token), api_version=version) return client diff --git a/src/azure-cli/azure/cli/command_modules/vm/_vm_utils.py b/src/azure-cli/azure/cli/command_modules/vm/_vm_utils.py index 7b6443ab28d..c0f33aa562e 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/_vm_utils.py +++ b/src/azure-cli/azure/cli/command_modules/vm/_vm_utils.py @@ -103,7 +103,7 @@ def create_keyvault_data_plane_client(cli_ctx): version = str(get_api_version(cli_ctx, ResourceType.DATA_KEYVAULT)) def get_token(server, resource, scope): # pylint: disable=unused-argument - return Profile(cli_ctx=cli_ctx).get_login_credentials(resource)[0]._token_retriever() # pylint: disable=protected-access + return 'Bearer', Profile(cli_ctx=cli_ctx).get_login_credentials(resource)[0].get_token(), None from azure.keyvault import KeyVaultAuthentication, KeyVaultClient return KeyVaultClient(KeyVaultAuthentication(get_token), api_version=version) diff --git a/src/azure-cli/azure/cli/command_modules/vm/custom.py b/src/azure-cli/azure/cli/command_modules/vm/custom.py index be8746d473b..70cff0839c9 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/custom.py +++ b/src/azure-cli/azure/cli/command_modules/vm/custom.py @@ -3272,8 +3272,8 @@ def create_image_version(cmd, resource_group_name, gallery_name, gallery_image_n resource = cmd.cli_ctx.cloud.endpoints.active_directory_resource_id cred, _, _ = profile.get_login_credentials(resource=resource, aux_subscriptions=aux_subscriptions) - _, _, _, external_tokens = cred.get_all_tokens('https://management.azure.com/.default') - external_bearer_token = external_tokens[0][0] + ' ' + external_tokens[0][1] + _, external_tokens = cred.get_all_tokens('https://management.azure.com/.default') + external_bearer_token = 'Bearer' + ' ' + external_tokens[0].token location = location or _get_resource_group_location(cmd.cli_ctx, resource_group_name) end_of_life_date = fix_gallery_image_date_info(end_of_life_date) diff --git a/src/azure-cli/requirements.opt.py3.Linux.txt b/src/azure-cli/requirements.opt.py3.Linux.txt new file mode 100644 index 00000000000..f5f65b31e84 --- /dev/null +++ b/src/azure-cli/requirements.opt.py3.Linux.txt @@ -0,0 +1,2 @@ +pycairo==1.19.1 +PyGObject==3.36.1 diff --git a/src/azure-cli/requirements.opt.py3.Trusty.txt b/src/azure-cli/requirements.opt.py3.Trusty.txt new file mode 100644 index 00000000000..319c82eead7 --- /dev/null +++ b/src/azure-cli/requirements.opt.py3.Trusty.txt @@ -0,0 +1,2 @@ +pycairo==1.19.1 +PyGObject==3.12.0 diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index b24d4292c1b..53672bc0211 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -13,6 +13,7 @@ azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 azure-graphrbac==0.60.0 +azure-identity==1.5.0b2 azure-keyvault==1.1.0 azure-keyvault-administration==4.0.0b1 azure-mgmt-advisor==2.0.1 @@ -111,7 +112,7 @@ msrestazure==0.6.3 oauthlib==3.0.1 paramiko==2.6.0 pbr==5.3.1 -portalocker==1.4.0 +portalocker==1.7 psutil==5.7.2 pycparser==2.19 PyJWT==1.7.1 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index b24d4292c1b..53672bc0211 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -13,6 +13,7 @@ azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 azure-graphrbac==0.60.0 +azure-identity==1.5.0b2 azure-keyvault==1.1.0 azure-keyvault-administration==4.0.0b1 azure-mgmt-advisor==2.0.1 @@ -111,7 +112,7 @@ msrestazure==0.6.3 oauthlib==3.0.1 paramiko==2.6.0 pbr==5.3.1 -portalocker==1.4.0 +portalocker==1.7 psutil==5.7.2 pycparser==2.19 PyJWT==1.7.1 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index b393f2c7430..8f6e431a63f 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -13,6 +13,7 @@ azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 azure-graphrbac==0.60.0 +azure-identity==1.5.0b2 azure-keyvault==1.1.0 azure-keyvault-administration==4.0.0b1 azure-mgmt-advisor==2.0.1 @@ -110,7 +111,7 @@ msrestazure==0.6.3 oauthlib==3.0.1 paramiko==2.6.0 pbr==5.3.1 -portalocker==1.2.1 +portalocker==1.7 psutil==5.7.2 pycparser==2.19 PyJWT==1.7.1 From 0be9ca2173804805bd4bb02ecee0afb745968a9f Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Tue, 5 Jan 2021 13:28:03 +0800 Subject: [PATCH 02/69] {Release} Release beta 2.17.10 (#16404) --- src/azure-cli-core/HISTORY.rst | 5 +++++ src/azure-cli-core/azure/cli/core/__init__.py | 2 +- src/azure-cli-core/azure/cli/core/_help.py | 2 +- src/azure-cli-core/setup.py | 5 ++--- src/azure-cli/HISTORY.rst | 5 +++++ src/azure-cli/azure/cli/__main__.py | 2 +- src/azure-cli/requirements.py3.Darwin.txt | 4 ++-- src/azure-cli/requirements.py3.Linux.txt | 4 ++-- src/azure-cli/requirements.py3.windows.txt | 4 ++-- src/azure-cli/setup.py | 3 +-- 10 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/azure-cli-core/HISTORY.rst b/src/azure-cli-core/HISTORY.rst index 5154843afd4..78ae85888b6 100644 --- a/src/azure-cli-core/HISTORY.rst +++ b/src/azure-cli-core/HISTORY.rst @@ -3,6 +3,11 @@ Release History =============== +2.17.10 ++++++++ + +* Migrate the authentication library from ADAL to MSAL. + 2.17.0 ++++++ * Minor fixes diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 68cf47589ce..5e509e601da 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -6,7 +6,7 @@ from __future__ import print_function -__version__ = "2.17.0" +__version__ = "2.17.10" import os import sys diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index d1e568bfb8d..b1ea47ee402 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -42,7 +42,7 @@ /_/ \_\/___|\__,_|_| \___| -Welcome to the cool new Azure CLI! +Welcome to Azure CLI v3 beta with MSAL support! Use `az --version` to display the current version. Here are the base commands: diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index ad7e28983e2..2263d840aa7 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -9,7 +9,7 @@ from codecs import open from setuptools import setup, find_packages -VERSION = "2.17.0" +VERSION = "2.17.10" # If we have source, validate that our version numbers match # This should prevent uploading releases with mismatched versions. @@ -43,7 +43,6 @@ ] DEPENDENCIES = [ - 'adal~=1.2.3', 'argcomplete~=1.8', 'azure-cli-telemetry==1.0.6.*', 'colorama~=0.4.1', @@ -54,7 +53,7 @@ 'msrest>=0.4.4', 'msrestazure>=0.6.3', 'paramiko>=2.0.8,<3.0.0', - 'PyJWT', + 'PyJWT==1.7.1', 'pyopenssl>=17.1.0', # https://github.com/pyca/pyopenssl/pull/612 'requests~=2.22', 'six~=1.12', diff --git a/src/azure-cli/HISTORY.rst b/src/azure-cli/HISTORY.rst index fc055aa8501..52209745396 100644 --- a/src/azure-cli/HISTORY.rst +++ b/src/azure-cli/HISTORY.rst @@ -3,6 +3,11 @@ Release History =============== +2.17.10 ++++++++ + +* Migrate the authentication library from ADAL to MSAL. + 2.17.0 ++++++ diff --git a/src/azure-cli/azure/cli/__main__.py b/src/azure-cli/azure/cli/__main__.py index fde564f6b5f..a6e65850529 100644 --- a/src/azure-cli/azure/cli/__main__.py +++ b/src/azure-cli/azure/cli/__main__.py @@ -17,7 +17,7 @@ from knack.log import get_logger __author__ = "Microsoft Corporation " -__version__ = "2.17.0" +__version__ = "2.17.10" # A workaround for https://bugs.python.org/issue32502 (https://github.com/Azure/azure-cli/issues/5184) diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 53672bc0211..2671049a064 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -5,8 +5,8 @@ argcomplete==1.11.1 asn1crypto==0.24.0 azure-appconfiguration==1.1.1 azure-batch==10.0.0 -azure-cli==2.17.0 -azure-cli-core==2.17.0 +azure-cli==2.17.10 +azure-cli-core==2.17.10 azure-cli-telemetry==1.0.6 azure-common==1.1.22 azure-cosmos==3.2.0 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 53672bc0211..2671049a064 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -5,8 +5,8 @@ argcomplete==1.11.1 asn1crypto==0.24.0 azure-appconfiguration==1.1.1 azure-batch==10.0.0 -azure-cli==2.17.0 -azure-cli-core==2.17.0 +azure-cli==2.17.10 +azure-cli-core==2.17.10 azure-cli-telemetry==1.0.6 azure-common==1.1.22 azure-cosmos==3.2.0 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 8f6e431a63f..001f92c714e 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -5,8 +5,8 @@ argcomplete==1.11.1 asn1crypto==0.24.0 azure-appconfiguration==1.1.1 azure-batch==10.0.0 -azure-cli==2.17.0 -azure-cli-core==2.17.0 +azure-cli==2.17.10 +azure-cli-core==2.17.10 azure-cli-telemetry==1.0.6 azure-common==1.1.22 azure-cosmos==3.2.0 diff --git a/src/azure-cli/setup.py b/src/azure-cli/setup.py index dcd6a019916..6cbed7e0401 100644 --- a/src/azure-cli/setup.py +++ b/src/azure-cli/setup.py @@ -18,7 +18,7 @@ logger.warn("Wheel is not available, disabling bdist_wheel hook") cmdclass = {} -VERSION = "2.17.0" +VERSION = "2.17.10" # If we have source, validate that our version numbers match # This should prevent uploading releases with mismatched versions. try: @@ -131,7 +131,6 @@ 'azure-synapse-accesscontrol~=0.2.0', 'azure-synapse-artifacts~=0.3.0', 'azure-synapse-spark~=0.2.0', - 'cryptography>=2.3.1,<3.0.0', 'fabric~=2.4', 'jsmin~=2.2.2', 'pytz==2019.1', From d3336a4b60c8b28c44542d0736f48a37625ffb47 Mon Sep 17 00:00:00 2001 From: Feng Zhou <55177366+fengzhou-msft@users.noreply.github.com> Date: Thu, 21 Jan 2021 13:49:26 +0800 Subject: [PATCH 03/69] {Identity} Add back get_msal_token (#16596) --- .../azure/cli/core/_identity.py | 6 +-- src/azure-cli-core/azure/cli/core/_profile.py | 31 ++++++++----- .../azure/cli/core/credential.py | 8 ++-- .../azure/cli/core/tests/test_profile.py | 43 ++++++++++++++++++- 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/_identity.py index 6813234c625..b524cd749ee 100644 --- a/src/azure-cli-core/azure/cli/core/_identity.py +++ b/src/azure-cli-core/azure/cli/core/_identity.py @@ -96,7 +96,7 @@ def _build_persistent_msal_app(self, authority): def _msal_app(self): if not self._msal_app_instance: # Build the authority in MSAL style, like https://login.microsoftonline.com/your_tenant - msal_authority = "https://{}/{}".format(self.authority, self.tenant_id) + msal_authority = "{}/{}".format(self.authority, self.tenant_id) self._msal_app_instance = self._build_persistent_msal_app(msal_authority) return self._msal_app_instance @@ -345,9 +345,9 @@ def get_service_principal_credential(self, client_id, use_cert_sn_issuer): self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) # TODO: support use_cert_sn_issuer in CertificateCredential if client_secret: - return ClientSecretCredential(self.tenant_id, client_id, client_secret) + return ClientSecretCredential(self.tenant_id, client_id, client_secret, **self._credential_kwargs) if certificate_path: - return CertificateCredential(self.tenant_id, client_id, certificate_path) + return CertificateCredential(self.tenant_id, client_id, certificate_path, **self._credential_kwargs) raise CLIError("Secret of service principle {} not found. Please run 'az login'".format(client_id)) def get_environment_credential(self): diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 42340581b6d..03b9b237aec 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -138,7 +138,7 @@ def __init__(self, cli_ctx=None, storage=None, auth_ctx_factory=None, use_global self._management_resource_uri = self.cli_ctx.cloud.endpoints.management self._ad_resource_uri = self.cli_ctx.cloud.endpoints.active_directory_resource_id - self._authority = self.cli_ctx.cloud.endpoints.active_directory.replace('https://', '') + self._authority = self.cli_ctx.cloud.endpoints.active_directory self._ad = self.cli_ctx.cloud.endpoints.active_directory self._adal_cache = None if store_adal_cache: @@ -624,19 +624,28 @@ def get_subscription(self, subscription=None): # take id or name def get_subscription_id(self, subscription=None): # take id or name return self.get_subscription(subscription)[_SUBSCRIPTION_ID] - def get_access_token_for_scopes(self, username, tenant, scopes): - tenant = tenant or 'common' - authority = self.cli_ctx.cloud.endpoints.active_directory.replace('https://', '') - identity = Identity(authority, tenant, cred_cache=self._adal_cache) - identity_credential = identity.get_user_credential(username) - from azure.cli.core.credential import CredentialAdaptor - auth = CredentialAdaptor(identity_credential) - token = auth.get_token(*scopes) + def get_access_token_for_scopes(self, username, tenant, *scopes, **kwargs): + """Get access token for user account. Service Principal is not supported.""" + identity = Identity(self._authority, tenant) + credential = identity.get_user_credential(username) + token = credential.get_token(*scopes, **kwargs) return token.token def get_access_token_for_resource(self, username, tenant, resource): """get access token for current user account, used by vsts and iot module""" - return self.get_access_token_for_scopes(username, tenant, resource_to_scopes(resource)) + return self.get_access_token_for_scopes(username, tenant, *resource_to_scopes(resource)) + + def get_msal_token(self, scopes, data): + """ + This is added for vmssh feature with backward compatible interface. + data contains token_type (ssh-cert), key_id and JWK. + """ + account = self.get_subscription() + username = account[_USER_ENTITY][_USER_NAME] + subscription_id = account[_SUBSCRIPTION_ID] + credential, _, _ = self.get_login_credentials(subscription_id=subscription_id) + certificate = credential.get_token(*scopes, data=data) + return username, certificate.token @staticmethod def _try_parse_msi_account_name(account): @@ -876,7 +885,7 @@ def __init__(self, cli_ctx, arm_client_factory=None, **kwargs): self.cli_ctx = cli_ctx self.secret = None self._arm_resource_id = cli_ctx.cloud.endpoints.active_directory_resource_id - self.authority = self.cli_ctx.cloud.endpoints.active_directory.replace('https://', '') + self.authority = self.cli_ctx.cloud.endpoints.active_directory self.adal_cache = kwargs.pop("adal_cache", None) def create_arm_client_factory(credentials): diff --git a/src/azure-cli-core/azure/cli/core/credential.py b/src/azure-cli-core/azure/cli/core/credential.py index 77ad74d224c..b843a9303f6 100644 --- a/src/azure-cli-core/azure/cli/core/credential.py +++ b/src/azure-cli-core/azure/cli/core/credential.py @@ -35,13 +35,13 @@ def __init__(self, credential, resource=None, external_credentials=None): self._external_credentials = external_credentials self._resource = resource - def _get_token(self, scopes=None): + def _get_token(self, scopes=None, **kwargs): external_tenant_tokens = [] # If scopes is not provided, use CLI-managed resource scopes = scopes or resource_to_scopes(self._resource) logger.debug("Retrieving token from MSAL for scopes %r", scopes) try: - token = self._credential.get_token(*scopes) + token = self._credential.get_token(*scopes, **kwargs) if self._external_credentials: external_tenant_tokens = [cred.get_token(*scopes) for cred in self._external_credentials] except CLIError as err: @@ -87,11 +87,11 @@ def signed_session(self, session=None): session.headers['x-ms-authorization-auxiliary'] = aux_tokens return session - def get_token(self, *scopes): + def get_token(self, *scopes, **kwargs): # type: (*str) -> AccessToken logger.debug("CredentialAdaptor.get_token invoked by Track 2 SDK with scopes=%r", scopes) scopes = _normalize_scopes(scopes) - token, _ = self._get_token(scopes) + token, _ = self._get_token(scopes, **kwargs) return token def get_all_tokens(self, *scopes): diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index c83e59a917d..1ba1171c2e9 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -14,7 +14,7 @@ from copy import deepcopy -from adal import AdalError +from azure.core.credentials import AccessToken from azure.cli.core._profile import (Profile, SubscriptionFinder, _USE_VENDORED_SUBSCRIPTION_SDK, _detect_adfs_authority, _attach_token_tenant) @@ -121,7 +121,6 @@ def setUpClass(cls): "accessToken": cls.raw_token1, "userId": cls.user1 } - from azure.core.credentials import AccessToken import time cls.access_token = AccessToken(cls.raw_token1, int(cls.token_entry1['expiresIn'] + time.time())) cls.user2 = 'bar@bar.com' @@ -1841,6 +1840,46 @@ def test_find_using_common_tenant_mfa_warning(self, _get_authorization_code_mock # With pytest, use -o log_cli=True to manually check the log + @mock.patch('azure.cli.core._identity.Identity.get_user_credential', autospec=True) + def test_get_access_token_for_scopes(self, get_user_credential_mock): + credential_mock = get_user_credential_mock.return_value + credential_mock.get_token.return_value = self.access_token + + cli = DummyCli() + profile = Profile(cli_ctx=cli) + token = profile.get_access_token_for_scopes(self.user1, self.tenant_id, *self.msal_scopes) + + get_user_credential_mock.assert_called_with(mock.ANY, self.user1) + credential_mock.get_token.assert_called_with(*self.msal_scopes) + self.assertEqual(token, self.raw_token1) + + @mock.patch('azure.cli.core._identity.Identity.get_user_credential', autospec=True) + def test_get_msal_token(self, get_user_credential_mock): + """ + This is added only for vmssh feature. + It is a temporary solution and will deprecate after MSAL adopted completely. + """ + credential_mock = get_user_credential_mock.return_value + credential_mock.get_token.return_value = self.access_token + + cli = DummyCli() + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock) + + consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) + profile._set_subscriptions(consolidated) + + scopes = ["https://pas.windows.net/CheckMyAccess/Linux/user_impersonation"] + data = { + "token_type": "ssh-cert", + "req_cnf": "fake_jwk", + "key_id": "fake_id" + } + username, access_token = profile.get_msal_token(scopes, data) + self.assertEqual(username, self.user1) + self.assertEqual(access_token, self.raw_token1) + credential_mock.get_token.assert_called_with(*scopes, data=data) + class FileHandleStub(object): # pylint: disable=too-few-public-methods From caf88e8ef8cc41ecf37eed9fffd7f3ed895c5a21 Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Mon, 25 Jan 2021 19:39:10 +0800 Subject: [PATCH 04/69] {Identity} Beta 2.18.0.1 (#16612) --- src/azure-cli-core/HISTORY.rst | 4 +-- src/azure-cli-core/azure/cli/core/__init__.py | 2 +- .../azure/cli/core/_identity.py | 24 ++++++++------- src/azure-cli-core/azure/cli/core/_profile.py | 4 +-- src/azure-cli-core/setup.py | 2 +- src/azure-cli/HISTORY.rst | 4 +-- src/azure-cli/azure/cli/__main__.py | 2 +- .../cli/command_modules/profile/__init__.py | 4 ++- .../cli/command_modules/profile/_help.py | 30 ++----------------- src/azure-cli/requirements.py3.Darwin.txt | 4 +-- src/azure-cli/requirements.py3.Linux.txt | 4 +-- src/azure-cli/requirements.py3.windows.txt | 4 +-- src/azure-cli/setup.py | 4 +-- 13 files changed, 37 insertions(+), 55 deletions(-) diff --git a/src/azure-cli-core/HISTORY.rst b/src/azure-cli-core/HISTORY.rst index 7e079a18db5..fd3bf568000 100644 --- a/src/azure-cli-core/HISTORY.rst +++ b/src/azure-cli-core/HISTORY.rst @@ -3,8 +3,8 @@ Release History =============== -2.17.11 -+++++++ +2.18.0.1 +++++++++ * Migrate the authentication library from ADAL to MSAL. diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 1d78bbc73df..b2b4af69469 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -6,7 +6,7 @@ from __future__ import print_function -__version__ = "2.18.10" +__version__ = "2.18.0.1" import os import sys diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/_identity.py index b524cd749ee..e24874fbba8 100644 --- a/src/azure-cli-core/azure/cli/core/_identity.py +++ b/src/azure-cli-core/azure/cli/core/_identity.py @@ -173,13 +173,15 @@ def login_with_service_principal_secret(self, client_id, client_secret): if self._cred_cache: self._cred_cache.save_service_principal_cred(entry) - credential = ClientSecretCredential(self.tenant_id, client_id, client_secret, authority=self.authority) + credential = ClientSecretCredential(self.tenant_id, client_id, client_secret, authority=self.authority, + **self._credential_kwargs) return credential def login_with_service_principal_certificate(self, client_id, certificate_path): # Use CertificateCredential # TODO: support use_cert_sn_issuer in CertificateCredential - credential = CertificateCredential(self.tenant_id, client_id, certificate_path, authority=self.authority) + credential = CertificateCredential(self.tenant_id, client_id, certificate_path, authority=self.authority, + **self._credential_kwargs) # CertificateCredential.__init__ will verify the certificate # Persist to encrypted cache @@ -207,14 +209,16 @@ def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disa if identity_id: # Try resource ID if is_valid_resource_id(identity_id): - credential = ManagedIdentityCredential(identity_config={"mi_res_id": identity_id}) + credential = ManagedIdentityCredential(identity_config={"mi_res_id": identity_id}, + **self._credential_kwargs) token = credential.get_token(*scopes) id_type = self.MANAGED_IDENTITY_RESOURCE_ID else: authenticated = False try: # Try client ID - credential = ManagedIdentityCredential(client_id=identity_id) + credential = ManagedIdentityCredential(client_id=identity_id, + **self._credential_kwargs) token = credential.get_token(*scopes) id_type = self.MANAGED_IDENTITY_CLIENT_ID authenticated = True @@ -230,7 +234,8 @@ def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disa if not authenticated: try: # Try object ID - credential = ManagedIdentityCredential(identity_config={"object_id": identity_id}) + credential = ManagedIdentityCredential(identity_config={"object_id": identity_id}, + **self._credential_kwargs) token = credential.get_token(*scopes) id_type = self.MANAGED_IDENTITY_OBJECT_ID authenticated = True @@ -248,7 +253,7 @@ def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disa else: # Use the default managed identity. It can be either system assigned or user assigned. - credential = ManagedIdentityCredential() + credential = ManagedIdentityCredential(**self._credential_kwargs) token = credential.get_token(*scopes) decoded = _decode_access_token(token) @@ -274,7 +279,7 @@ def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disa return credential, managed_identity_info def login_in_cloud_shell(self, scopes): - credential = ManagedIdentityCredential() + credential = ManagedIdentityCredential(**self._credential_kwargs) # As Managed Identity doesn't have ID token, we need to get an initial access token and extract info from it # The scopes is only used for acquiring the initial access token token = credential.get_token(*scopes) @@ -361,9 +366,8 @@ def get_environment_credential(self): return EnvironmentCredential(**self._credential_kwargs) - @staticmethod - def get_managed_identity_credential(client_id=None): - return ManagedIdentityCredential(client_id=client_id) + def get_managed_identity_credential(self, client_id=None): + return ManagedIdentityCredential(client_id=client_id, **self._credential_kwargs) def migrate_tokens(self): """Migrate ADAL token cache to MSAL.""" diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 03b9b237aec..d63474c0341 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -670,7 +670,7 @@ def _create_identity_credential(self, account, aux_tenant_id=None, client_id=Non if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): if aux_tenant_id: raise CLIError("Tenant shouldn't be specified for Cloud Shell account") - return Identity.get_managed_identity_credential() + return identity.get_managed_identity_credential() # EnvironmentCredential. Ignore user_type if is_environment: @@ -689,7 +689,7 @@ def _create_identity_credential(self, account, aux_tenant_id=None, client_id=Non # MSI if aux_tenant_id: raise CLIError("Tenant shouldn't be specified for MSI account") - return Identity.get_managed_identity_credential(identity_id) + return identity.get_managed_identity_credential(identity_id) def get_login_credentials(self, resource=None, client_id=None, subscription_id=None, aux_subscriptions=None, aux_tenants=None): diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 4ce0eaa428c..0d04f4f4828 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -9,7 +9,7 @@ from codecs import open from setuptools import setup, find_packages -VERSION = "2.18.10" +VERSION = "2.18.0.1" # If we have source, validate that our version numbers match # This should prevent uploading releases with mismatched versions. diff --git a/src/azure-cli/HISTORY.rst b/src/azure-cli/HISTORY.rst index 8ecd4fd6f55..8ecd61ab509 100644 --- a/src/azure-cli/HISTORY.rst +++ b/src/azure-cli/HISTORY.rst @@ -3,8 +3,8 @@ Release History =============== -2.18.10 -+++++++ +2.18.0.1 +++++++++ * Migrate the authentication library from ADAL to MSAL. diff --git a/src/azure-cli/azure/cli/__main__.py b/src/azure-cli/azure/cli/__main__.py index 5e69e114341..694013d1c0b 100644 --- a/src/azure-cli/azure/cli/__main__.py +++ b/src/azure-cli/azure/cli/__main__.py @@ -17,7 +17,7 @@ from knack.log import get_logger __author__ = "Microsoft Corporation " -__version__ = "2.18.10" +__version__ = "2.18.0.1" # A workaround for https://bugs.python.org/issue32502 (https://github.com/Azure/azure-cli/issues/5184) diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 0bd1f0e5926..2c4e7d95ded 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -60,10 +60,11 @@ def load_arguments(self, command): c.argument('username', options_list=['--username', '-u'], help='user name, service principal, or managed service identity ID') c.argument('tenant', options_list=['--tenant', '-t'], help='The AAD tenant, must provide when using service principals.', validator=validate_tenant) c.argument('tenant_access', action='store_true', + deprecate_info=c.deprecate(target='--tenant-access', hide=True), help='Only log in to the home tenant or the tenant specified by --tenant. CLI will not perform ' 'ARM operations to list tenants and subscriptions. Then you may run tenant-level commands, ' 'such as `az ad`, `az account get-access-token`.') - c.argument('allow_no_subscriptions', action='store_true', deprecate_info=c.deprecate(target='--allow-no-subscriptions', expiration='3.0.0', redirect="--tenant-access", hide=False), + c.argument('allow_no_subscriptions', action='store_true', help="Support access tenants without subscriptions. It's uncommon but useful to run tenant level commands, such as `az ad`") c.ignore('_subscription') # hide the global subscription parameter c.argument('identity', options_list=('-i', '--identity'), action='store_true', help="Log in using the Virtual Machine's managed identity", arg_group='Managed Identity') @@ -72,6 +73,7 @@ def load_arguments(self, command): help="Use CLI's old authentication flow based on device code. CLI will also use this if it can't launch a browser in your behalf, e.g. in remote SSH or Cloud Shell") c.argument('use_cert_sn_issuer', action='store_true', help='used with a service principal configured with Subject Name and Issuer Authentication in order to support automatic certificate rolls') c.argument('environment', options_list=['--environment', '-e'], action='store_true', + deprecate_info=c.deprecate(target='--environment', hide=True), help='Use EnvironmentCredential. Both user and service principal accounts are supported. ' 'For required environment variables, see https://docs.microsoft.com/en-us/python/api/overview/azure/identity-readme?view=azure-python#environment-variables') diff --git a/src/azure-cli/azure/cli/command_modules/profile/_help.py b/src/azure-cli/azure/cli/command_modules/profile/_help.py index da642f47925..c0acf7b7007 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/_help.py +++ b/src/azure-cli/azure/cli/command_modules/profile/_help.py @@ -11,16 +11,10 @@ type: command short-summary: Log in to Azure. long-summary: >- - By default, this command logs in with a user account. To login with a service principal, specify --service-principal. + By default, this command logs in with a user account. CLI will try to launch a web browser to log in interactively. + If a web browser is not available, CLI will fall back to device code login. - - For user login, CLI will try to launch a web browser to log in interactively. If a web browser is not available, - CLI will fallback to device code login. - - - To retrieve the login credential from environment variables (EnvironmentCredential), specify --environment. - For details on using EnvironmentCredential, see - https://docs.microsoft.com/python/api/overview/azure/identity-readme#environment-variables + To login with a service principal, specify --service-principal. examples: - name: Log in interactively. text: az login @@ -34,24 +28,6 @@ text: az login --identity - name: Log in using a VM's user-assigned managed identity. Client or object ids of the service identity also work. text: az login --identity -u /subscriptions//resourcegroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myID - - name: Log in with a service principal using EnvironmentCredential. - text: |- - # Bash script - export AZURE_TENANT_ID='' - export AZURE_CLIENT_ID='' - # With secret - export AZURE_CLIENT_SECRET='' - # Or with certificate - # export AZURE_CLIENT_CERTIFICATE_PATH='' - az login --environment - - name: Log in with a user account using EnvironmentCredential. - text: |- - # Bash script - # AZURE_CLIENT_ID defaults to Azure CLI's client ID - # export AZURE_CLIENT_ID='04b07795-8ddb-461a-bbee-02f9e1bf7b46' - export AZURE_USERNAME='' - export AZURE_PASSWORD='' - az login --environment """ helps['account'] = """ diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index a4a73dd689e..eca5c37fb64 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -5,8 +5,8 @@ argcomplete==1.11.1 asn1crypto==0.24.0 azure-appconfiguration==1.1.1 azure-batch==10.0.0 -azure-cli==2.18.10 -azure-cli-core==2.18.10 +azure-cli==2.18.0.1 +azure-cli-core==2.18.0.1 azure-cli-telemetry==1.0.6 azure-common==1.1.22 azure-cosmos==3.2.0 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index a4a73dd689e..eca5c37fb64 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -5,8 +5,8 @@ argcomplete==1.11.1 asn1crypto==0.24.0 azure-appconfiguration==1.1.1 azure-batch==10.0.0 -azure-cli==2.18.10 -azure-cli-core==2.18.10 +azure-cli==2.18.0.1 +azure-cli-core==2.18.0.1 azure-cli-telemetry==1.0.6 azure-common==1.1.22 azure-cosmos==3.2.0 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 2f1f9e14889..99fe1c92b0f 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -5,8 +5,8 @@ argcomplete==1.11.1 asn1crypto==0.24.0 azure-appconfiguration==1.1.1 azure-batch==10.0.0 -azure-cli==2.18.10 -azure-cli-core==2.18.10 +azure-cli==2.18.0.1 +azure-cli-core==2.18.0.1 azure-cli-telemetry==1.0.6 azure-common==1.1.22 azure-cosmos==3.2.0 diff --git a/src/azure-cli/setup.py b/src/azure-cli/setup.py index e25c38a4d43..092c6c9819d 100644 --- a/src/azure-cli/setup.py +++ b/src/azure-cli/setup.py @@ -18,7 +18,7 @@ logger.warn("Wheel is not available, disabling bdist_wheel hook") cmdclass = {} -VERSION = "2.18.10" +VERSION = "2.18.0.1" # If we have source, validate that our version numbers match # This should prevent uploading releases with mismatched versions. try: @@ -53,7 +53,7 @@ 'antlr4-python3-runtime~=4.7.2', 'azure-appconfiguration~=1.1.1', 'azure-batch~=10.0.0', - 'azure-cli-core=={}.*'.format(".".join(VERSION.split(".")[:3])), + 'azure-cli-core=={}'.format(VERSION), 'azure-cosmos~=3.0,>=3.0.2', 'azure-datalake-store~=0.0.49', 'azure-functions-devops-build~=0.0.22', From 76f06aecdc7496a4dd01f48669c1bb0f0a8c1b90 Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Thu, 4 Feb 2021 15:33:56 +0800 Subject: [PATCH 05/69] {Identity} Do not set logging_enable for Azure Identity credentials (#16728) --- src/azure-cli-core/azure/cli/core/_identity.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/_identity.py index e24874fbba8..f8fc541f558 100644 --- a/src/azure-cli-core/azure/cli/core/_identity.py +++ b/src/azure-cli-core/azure/cli/core/_identity.py @@ -74,8 +74,19 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): from azure.cli.core._debug import change_ssl_cert_verification_track2 self._credential_kwargs = {} self._credential_kwargs.update(change_ssl_cert_verification_track2()) - # Turn on NetworkTraceLoggingPolicy to show DEBUG logs - self._credential_kwargs['logging_enable'] = True + + # Turn on NetworkTraceLoggingPolicy to show DEBUG logs. + # WARNING: This argument is only for development purpose. It will make credentials be printed to + # - console log, when --debug is specified + # - file log, when logging.enable_log_file is enabled, even without --debug + # Credentials include and are not limited to: + # - Authorization code + # - Device code + # - Refresh token + # - Access token + # - Service principal secret + # - Service principal certificate + # self._credential_kwargs['logging_enable'] = True def _load_msal_cache(self): # sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py:95 From aa456a28c56c8436be1af6e9f2a3e96a21d293f0 Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Fri, 5 Mar 2021 11:47:46 +0800 Subject: [PATCH 06/69] {Beta} Revert scripts/ci/ (#17171) --- scripts/ci/dependency_check.sh | 4 -- scripts/install_full.sh | 5 +- scripts/release/debian/Dockerfile | 4 +- scripts/release/debian/build.sh | 14 ++--- scripts/release/debian/prepare.sh | 7 +-- scripts/release/rpm/Dockerfile.centos | 4 +- scripts/release/rpm/Dockerfile.fedora | 4 +- scripts/release/rpm/azure-cli.spec | 17 ++---- scripts/release/rpm/build.sh | 2 +- src/azure-cli-core/azure/cli/core/_profile.py | 54 +++---------------- .../azure/cli/core/tests/test_profile.py | 9 +--- src/azure-cli/requirements.py3.Darwin.txt | 1 - src/azure-cli/requirements.py3.Linux.txt | 1 - src/azure-cli/requirements.py3.windows.txt | 1 - 14 files changed, 24 insertions(+), 103 deletions(-) diff --git a/scripts/ci/dependency_check.sh b/scripts/ci/dependency_check.sh index 947877ed06e..be374175aab 100755 --- a/scripts/ci/dependency_check.sh +++ b/scripts/ci/dependency_check.sh @@ -2,10 +2,6 @@ set -ev -if [ "$(uname)" != "Darwin" ]; then - sudo apt-get -y install libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 -fi - REPO_ROOT="$(dirname ${BASH_SOURCE[0]})/../.." # Uninstall any cruft that can poison the rest of the checks in this script. diff --git a/scripts/install_full.sh b/scripts/install_full.sh index 736c7ad1bd0..bdda1836ef8 100755 --- a/scripts/install_full.sh +++ b/scripts/install_full.sh @@ -18,8 +18,5 @@ pushd ${REPO_ROOT} > /dev/null find src/ -name setup.py -type f | xargs -I {} dirname {} | grep -v azure-cli-testsdk | xargs pip install --no-deps pip install -r ./src/azure-cli/requirements.$(python ./scripts/get-python-version.py).$(uname).txt -if [ -f "./src/azure-cli/requirements.opt.$(python ./scripts/get-python-version.py).$(uname).txt" ]; then - echo "./src/azure-cli/requirements.opt.$(python ./scripts/get-python-version.py).$(uname).txt exists." - pip install -r ./src/azure-cli/requirements.opt.$(python ./scripts/get-python-version.py).$(uname).txt -fi + popd > /dev/null diff --git a/scripts/release/debian/Dockerfile b/scripts/release/debian/Dockerfile index 7fe630f18d8..3f621ac5c7d 100644 --- a/scripts/release/debian/Dockerfile +++ b/scripts/release/debian/Dockerfile @@ -3,8 +3,8 @@ FROM ${base_image} AS build-env # Update APT packages RUN apt-get update -RUN apt-get install -y libssl-dev libffi-dev python3-dev debhelper zlib1g-dev wget libgirepository1.0-dev \ - libcairo2-dev gir1.2-secret-1 gnome-keyring +RUN apt-get install -y libssl-dev libffi-dev python3-dev debhelper zlib1g-dev wget + # Download Python source code ARG python_version="3.6.10" ENV PYTHON_SRC_DIR=/usr/src/python diff --git a/scripts/release/debian/build.sh b/scripts/release/debian/build.sh index 332d9ca8e0f..1c3530afba4 100755 --- a/scripts/release/debian/build.sh +++ b/scripts/release/debian/build.sh @@ -22,8 +22,6 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" apt-get update apt-get install -y libssl-dev libffi-dev python3-dev debhelper zlib1g-dev apt-get install -y wget -apt-get install -y libgirepository1.0-dev libcairo2-dev gir1.2-secret-1 pkg-config gnome-keyring libgtk2.0-dev -apt-get install -y glib-2.0 gir1.2-gtk-3.0 # Download Python source code PYTHON_SRC_DIR=$(mktemp -d) @@ -35,22 +33,16 @@ $PYTHON_SRC_DIR/*/configure --srcdir $PYTHON_SRC_DIR/* --prefix $WORKDIR/python_ make make install +$WORKDIR/python_env/bin/python3 -m pip install --upgrade pip==21.0.1 + export PATH=$PATH:$WORKDIR/python_env/bin find ${WORKDIR}/src/ -name setup.py -type f | xargs -I {} dirname {} | grep -v azure-cli-testsdk | xargs pip3 install --no-deps pip3 install -r ${WORKDIR}/src/azure-cli/requirements.py3.$(uname).txt -if [[ -f "${WORKDIR}/src/azure-cli/requirements.opt.py3.$(uname).txt" && "${CLI_VERSION_REVISION:=1}" != *"trusty" && "${CLI_VERSION_REVISION:=1}" != *"jessie" ]]; then - pip3 install -r ${WORKDIR}/src/azure-cli/requirements.opt.py3.$(uname).txt -fi # Create create directory for debian build mkdir -p $WORKDIR/debian -if [[ "${CLI_VERSION_REVISION:=1}" == *"trusty" || "${CLI_VERSION_REVISION:=1}" == *"jessie" ]]; then - $SCRIPT_DIR/prepare.sh $WORKDIR/debian $WORKDIR/az.completion $WORKDIR -else - PYOBJECT_DEPENDENCY="gir1.2-secret-1, gnome-keyring" - $SCRIPT_DIR/prepare.sh $WORKDIR/debian $WORKDIR/az.completion $WORKDIR $PYOBJECT_DEPENDENCY -fi +$SCRIPT_DIR/prepare.sh $WORKDIR/debian $WORKDIR/az.completion $WORKDIR cd $WORKDIR dpkg-buildpackage -us -uc diff --git a/scripts/release/debian/prepare.sh b/scripts/release/debian/prepare.sh index 325509176fe..ab3ec5f9181 100755 --- a/scripts/release/debian/prepare.sh +++ b/scripts/release/debian/prepare.sh @@ -33,11 +33,6 @@ TAB=$'\t' debian_dir=$1 completion_script=$2 source_dir=$3 -setup_depends="" -if [ ! -z "$4" ]; then - setup_depends=$4 -fi - mkdir $debian_dir/source echo '1.0' > $debian_dir/source/format @@ -63,7 +58,7 @@ Homepage: https://github.com/azure/azure-cli Package: azure-cli Architecture: all -Depends: \${shlibs:Depends}, \${misc:Depends}, $setup_depends +Depends: \${shlibs:Depends}, \${misc:Depends} Description: Azure CLI A great cloud needs great tools; we're excited to introduce Azure CLI, our next generation multi-platform command line experience for Azure. diff --git a/scripts/release/rpm/Dockerfile.centos b/scripts/release/rpm/Dockerfile.centos index f02d34f4921..a3ca29f1324 100644 --- a/scripts/release/rpm/Dockerfile.centos +++ b/scripts/release/rpm/Dockerfile.centos @@ -4,7 +4,7 @@ FROM centos:${tag} AS build-env ARG cli_version=dev RUN yum update -y -RUN yum install -y wget rpm-build gcc libffi-devel python3-devel openssl-devel make bash coreutils diffutils patch dos2unix python3-virtualenv gobject-introspection-devel cairo-devel pkgconfig cairo-gobject-devel +RUN yum install -y wget rpm-build gcc libffi-devel python3-devel openssl-devel make bash coreutils diffutils patch dos2unix python3-virtualenv WORKDIR /azure-cli @@ -17,7 +17,7 @@ RUN dos2unix ./scripts/release/rpm/azure-cli.spec && \ FROM centos:${tag} AS execution-env RUN yum update -y -RUN yum install -y python3 python3-virtualenv cairo cairo-gobject +RUN yum install -y python3 python3-virtualenv COPY --from=build-env /azure-cli-dev.rpm ./ RUN rpm -i ./azure-cli-dev.rpm && \ diff --git a/scripts/release/rpm/Dockerfile.fedora b/scripts/release/rpm/Dockerfile.fedora index 9a926a3a06c..c95d7b31f39 100644 --- a/scripts/release/rpm/Dockerfile.fedora +++ b/scripts/release/rpm/Dockerfile.fedora @@ -4,7 +4,7 @@ FROM fedora:${tag} AS build-env ARG cli_version=dev RUN dnf update -y -RUN dnf install -y wget rpm-build gcc libffi-devel python3-devel python3-virtualenv openssl-devel make bash coreutils diffutils patch dos2unix perl gobject-introspection-devel cairo-devel pkgconfig cairo-gobject-devel +RUN dnf install -y wget rpm-build gcc libffi-devel python3-devel python3-virtualenv openssl-devel make bash coreutils diffutils patch dos2unix perl WORKDIR /azure-cli @@ -16,7 +16,7 @@ RUN dos2unix ./scripts/release/rpm/azure-cli.spec && \ FROM fedora:${tag} AS execution-env -RUN dnf install -y python3 python3-virtualenv cairo cairo-gobject +RUN dnf install -y python3 python3-virtualenv COPY --from=build-env /azure-cli-dev.rpm ./ RUN rpm -i ./azure-cli-dev.rpm diff --git a/scripts/release/rpm/azure-cli.spec b/scripts/release/rpm/azure-cli.spec index 1f601ee69ec..d172dc91092 100644 --- a/scripts/release/rpm/azure-cli.spec +++ b/scripts/release/rpm/azure-cli.spec @@ -25,10 +25,10 @@ Version: %{version} Release: %{release} Url: https://docs.microsoft.com/cli/azure/install-azure-cli BuildArch: x86_64 -Requires: %{python_cmd}, cairo, cairo-gobject +Requires: %{python_cmd} -BuildRequires: gcc, libffi-devel, openssl-devel, perl, binutils -BuildRequires: %{python_cmd}-devel, gobject-introspection-devel, cairo-devel, pkgconfig, cairo-gobject-devel +BuildRequires: gcc, libffi-devel, openssl-devel, perl +BuildRequires: %{python_cmd}-devel %global _python_bytecompile_errors_terminate_build 0 @@ -48,22 +48,13 @@ deactivate # Fix up %{buildroot} appearing in some files... for d in %{buildroot}%{cli_lib_dir}/bin/*; do perl -p -i -e "s#%{buildroot}##g" $d; done; -for d in %{buildroot}%{cli_lib_dir}/lib/pkgconfig/*; do perl -p -i -e "s#%{buildroot}##g" $d; done; # Create executable mkdir -p %{buildroot}%{_bindir} -python_version=$(ls %{buildroot}%{cli_lib_dir}/lib/ | grep "^python" | head -n 1) +python_version=$(ls %{buildroot}%{cli_lib_dir}/lib/ | head -n 1) printf "#!/usr/bin/env bash\nAZ_INSTALLER=RPM PYTHONPATH=%{cli_lib_dir}/lib/${python_version}/site-packages /usr/bin/%{python_cmd} -sm azure.cli \"\$@\"" > %{buildroot}%{_bindir}/az rm %{buildroot}%{cli_lib_dir}/bin/python* %{buildroot}%{cli_lib_dir}/bin/pip* -# strip debug info which contains build root info -set +e -find "%{buildroot}%{cli_lib_dir}/lib/${python_version}/site-packages/gi" -type f -name "*.so" | while read so_file -do - strip --strip-debug "$so_file" -done -set -e - # Remove unused Network SDK API versions pushd %{buildroot}%{cli_lib_dir}/lib/${python_version}/site-packages/azure/mgmt/network/ > /dev/null rm -rf v2016_09_01 v2016_12_01 v2017_03_01 v2017_06_01 v2017_08_01 v2017_09_01 v2017_11_01 v2018_02_01 v2018_04_01 v2018_06_01 v2018_10_01 v2018_12_01 v2019_04_01 v2019_08_01 v2019_09_01 v2019_11_01 v2019_12_01 v2020_03_01 diff --git a/scripts/release/rpm/build.sh b/scripts/release/rpm/build.sh index 269d63542bc..d59e9664b9c 100755 --- a/scripts/release/rpm/build.sh +++ b/scripts/release/rpm/build.sh @@ -5,7 +5,7 @@ yum check-update yum install -y gcc rpm-build rpm-level rpmlint make bash corutils diffutils \ path rpmdevtools python libffi-devel python3-devel openssl-devel \ - wget gobject-introspection-devel cairo-devel pkg-config cairo-gobject-devel + wget set -ev diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index c10aad50e9f..158e7a26f40 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -635,18 +635,6 @@ def get_access_token_for_resource(self, username, tenant, resource): """get access token for current user account, used by vsts and iot module""" return self.get_access_token_for_scopes(username, tenant, *resource_to_scopes(resource)) - def get_msal_token(self, scopes, data): - """ - This is added for vmssh feature with backward compatible interface. - data contains token_type (ssh-cert), key_id and JWK. - """ - account = self.get_subscription() - username = account[_USER_ENTITY][_USER_NAME] - subscription_id = account[_SUBSCRIPTION_ID] - credential, _, _ = self.get_login_credentials(subscription_id=subscription_id) - certificate = credential.get_token(*scopes, data=data) - return username, certificate.token - @staticmethod def _try_parse_msi_account_name(account): user_name = account[_USER_ENTITY].get(_USER_NAME) @@ -762,45 +750,15 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No def get_msal_token(self, scopes, data): """ - This is added only for vmssh feature. - It is a temporary solution and will deprecate after MSAL adopted completely. + This is added for vmssh feature with backward compatible interface. + data contains token_type (ssh-cert), key_id and JWK. """ - from msal import ClientApplication - import posixpath account = self.get_subscription() username = account[_USER_ENTITY][_USER_NAME] - tenant = account[_TENANT_ID] or 'common' - _, refresh_token, _, _ = self.get_refresh_token() - authority = posixpath.join(self.cli_ctx.cloud.endpoints.active_directory, tenant) - app = ClientApplication(_CLIENT_ID, authority=authority) - result = app.acquire_token_by_refresh_token(refresh_token, scopes, data=data) - return username, result["access_token"] - - def get_refresh_token(self, resource=None, - subscription=None): - account = self.get_subscription(subscription) - user_type = account[_USER_ENTITY][_USER_TYPE] - username_or_sp_id = account[_USER_ENTITY][_USER_NAME] - resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - - # Use ARM as the default scopes - if not scopes: - scopes = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) - - if subscription and tenant: - raise CLIError("Please specify only one of subscription and tenant, not both") - - account = self.get_subscription(subscription) - identity_credential = self._create_identity_credential(account, tenant) - - from azure.cli.core.credential import CredentialAdaptor, _convert_token_entry - auth = CredentialAdaptor(identity_credential) - token = auth.get_token(*scopes) - # (tokenType, accessToken, tokenEntry) - cred = 'Bearer', token.token, _convert_token_entry(token) - return (cred, - None if tenant else str(account[_SUBSCRIPTION_ID]), - str(tenant if tenant else account[_TENANT_ID])) + subscription_id = account[_SUBSCRIPTION_ID] + credential, _, _ = self.get_login_credentials(subscription_id=subscription_id) + certificate = credential.get_token(*scopes, data=data) + return username, certificate.token def refresh_accounts(self, subscription_finder=None): subscriptions = self.load_cached_subscriptions() diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index e99d98476b5..1ba1171c2e9 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -1853,13 +1853,8 @@ def test_get_access_token_for_scopes(self, get_user_credential_mock): credential_mock.get_token.assert_called_with(*self.msal_scopes) self.assertEqual(token, self.raw_token1) - self.assertEqual(len(all_subscriptions), 1) - self.assertEqual(all_subscriptions[0].tenant_id, token_tenant) - self.assertEqual(all_subscriptions[0].home_tenant_id, home_tenant) - - @mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) - @mock.patch('msal.ClientApplication.acquire_token_by_refresh_token', autospec=True) - def test_get_msal_token(self, mock_acquire_token, mock_retrieve_token_for_user): + @mock.patch('azure.cli.core._identity.Identity.get_user_credential', autospec=True) + def test_get_msal_token(self, get_user_credential_mock): """ This is added only for vmssh feature. It is a temporary solution and will deprecate after MSAL adopted completely. diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index c7c8458ffb0..1a5e2ff47e9 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -107,7 +107,6 @@ jsmin==2.2.2 knack==0.8.0rc2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.9.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index c7c8458ffb0..1a5e2ff47e9 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -107,7 +107,6 @@ jsmin==2.2.2 knack==0.8.0rc2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.9.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 7d156a16643..4e222215d84 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -106,7 +106,6 @@ jsmin==2.2.2 knack==0.8.0rc2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.9.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 From 5d0371856169abe43ee7ed2ddea8957c2c8864a8 Mon Sep 17 00:00:00 2001 From: Jiashuo Li Date: Fri, 26 Mar 2021 14:35:43 +0800 Subject: [PATCH 07/69] Remove extra msal and azure-identity --- src/azure-cli/requirements.py3.Linux.txt | 1 - src/azure-cli/setup.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 694456bb24c..3ee05b3e83f 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -109,7 +109,6 @@ jsmin==2.2.2 knack==0.8.0rc2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.9.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/setup.py b/src/azure-cli/setup.py index 1868e226cf8..cc5c2b803cc 100644 --- a/src/azure-cli/setup.py +++ b/src/azure-cli/setup.py @@ -58,7 +58,6 @@ 'azure-datalake-store~=0.0.49', 'azure-functions-devops-build~=0.0.22', 'azure-graphrbac~=0.60.0', - 'azure-identity', 'azure-keyvault-administration==4.0.0b3', 'azure-keyvault~=1.1.0', 'azure-loganalytics~=0.1.0', From 3823c762f25872e9cd3730a96f66e1b723ca0c68 Mon Sep 17 00:00:00 2001 From: Jiashuo Li <4003950+jiasli@users.noreply.github.com> Date: Mon, 12 Apr 2021 13:24:34 +0800 Subject: [PATCH 08/69] [Identity] CAE b3 (#17612) --- .../azure/cli/core/_identity.py | 47 ++--- .../azure/cli/core/_msal_patch.py | 173 ++++++++++++++++++ src/azure-cli-core/azure/cli/core/_profile.py | 25 ++- .../azure/cli/core/adal_authentication.py | 0 .../azure/cli/core/azclierror.py | 34 +++- .../azure/cli/core/azlogging.py | 18 +- .../azure/cli/core/commands/client_factory.py | 28 ++- .../azure/cli/core/credential.py | 81 +++++--- .../azure/cli/core/tests/test_credential.py | 30 +++ .../azure/cli/core/tests/test_identity.py | 2 +- .../azure/cli/core/tests/test_profile.py | 4 +- src/azure-cli-core/azure/cli/core/util.py | 4 +- src/azure-cli-core/setup.py | 5 +- .../azure/cli/testsdk/base.py | 3 + .../cli/command_modules/profile/__init__.py | 2 +- .../cli/command_modules/profile/_help.py | 8 +- .../cli/command_modules/profile/custom.py | 4 +- .../profile/tests/latest/test_auth_e2e.py | 62 +++++++ src/azure-cli/requirements.py3.Darwin.txt | 7 +- src/azure-cli/requirements.py3.Linux.txt | 8 +- src/azure-cli/requirements.py3.windows.txt | 8 +- 21 files changed, 445 insertions(+), 108 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/_msal_patch.py delete mode 100644 src/azure-cli-core/azure/cli/core/adal_authentication.py create mode 100644 src/azure-cli-core/azure/cli/core/tests/test_credential.py create mode 100644 src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/_identity.py index f8fc541f558..7beb05dd86d 100644 --- a/src/azure-cli-core/azure/cli/core/_identity.py +++ b/src/azure-cli-core/azure/cli/core/_identity.py @@ -17,7 +17,8 @@ ClientSecretCredential, CertificateCredential, ManagedIdentityCredential, - EnvironmentCredential + EnvironmentCredential, + TokenCachePersistenceOptions ) from ._environment import get_config_dir @@ -65,6 +66,7 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): self._msal_app_instance = None # Store for Service principal credential persistence self._msal_secret_store = MsalSecretStore(fallback_to_plaintext=self.allow_unencrypted) + self._cache_persistence_options = TokenCachePersistenceOptions(name="azcli", allow_unencrypted_storage=True) # TODO: Allow disabling SSL verification # The underlying requests lib of MSAL has been patched with Azure Core by MsalTransportAdapter @@ -86,13 +88,19 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): # - Access token # - Service principal secret # - Service principal certificate - # self._credential_kwargs['logging_enable'] = True + self._credential_kwargs['logging_enable'] = True + + # Make MSAL remove existing accounts on successful login. + # self._credential_kwargs['remove_existing_account'] = True + # from azure.cli.core._msal_patch import patch_token_cache_add + # patch_token_cache_add(self.msal_app.remove_account) def _load_msal_cache(self): # sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py:95 - from azure.identity._internal.persistent_cache import load_user_cache + from azure.identity._persistent_cache import _load_persistent_cache # Store for user token persistence - cache = load_user_cache(self.allow_unencrypted) + cache = _load_persistent_cache(self._cache_persistence_options) + cache._reload_if_necessary() # pylint: disable=protected-access return cache def _build_persistent_msal_app(self, authority): @@ -104,7 +112,7 @@ def _build_persistent_msal_app(self, authority): return msal_app @property - def _msal_app(self): + def msal_app(self): if not self._msal_app_instance: # Build the authority in MSAL style, like https://login.microsoftonline.com/your_tenant msal_authority = "{}/{}".format(self.authority, self.tenant_id) @@ -120,8 +128,7 @@ def login_with_interactive_browser(self, scopes=None): credential = InteractiveBrowserCredential(authority=self.authority, tenant_id=self.tenant_id, client_id=self.client_id, - enable_persistent_cache=True, - allow_unencrypted_cache=self.allow_unencrypted, + cache_persistence_options=self._cache_persistence_options, **self._credential_kwargs) auth_record = credential.authenticate(scopes=scopes) # todo: remove after ADAL token deprecation @@ -139,9 +146,8 @@ def prompt_callback(verification_uri, user_code, _): credential = DeviceCodeCredential(authority=self.authority, tenant_id=self.tenant_id, client_id=self.client_id, - enable_persistent_cache=True, prompt_callback=prompt_callback, - allow_unencrypted_cache=self.allow_unencrypted, + cache_persistence_options=self._cache_persistence_options, **self._credential_kwargs) auth_record = credential.authenticate(scopes=scopes) @@ -163,8 +169,7 @@ def login_with_username_password(self, username, password, scopes=None): client_id=self.client_id, username=username, password=password, - enable_persistent_cache=True, - allow_unencrypted_cache=self.allow_unencrypted, + cache_persistence_options=self._cache_persistence_options, **self._credential_kwargs) auth_record = credential.authenticate(scopes=scopes) @@ -305,15 +310,15 @@ def login_in_cloud_shell(self, scopes): return credential, cloud_shell_identity_info def logout_user(self, user): - accounts = self._msal_app.get_accounts(user) + accounts = self.msal_app.get_accounts(user) logger.info('Before account removal:') logger.info(json.dumps(accounts)) # `accounts` are the same user in all tenants, log out all of them for account in accounts: - self._msal_app.remove_account(account) + self.msal_app.remove_account(account) - accounts = self._msal_app.get_accounts(user) + accounts = self.msal_app.get_accounts(user) logger.info('After account removal:') logger.info(json.dumps(accounts)) @@ -323,25 +328,25 @@ def logout_sp(self, sp): def logout_all(self): # TODO: Support multi-authority logout - accounts = self._msal_app.get_accounts() + accounts = self.msal_app.get_accounts() logger.info('Before account removal:') logger.info(json.dumps(accounts)) for account in accounts: - self._msal_app.remove_account(account) + self.msal_app.remove_account(account) - accounts = self._msal_app.get_accounts() + accounts = self.msal_app.get_accounts() logger.info('After account removal:') logger.info(json.dumps(accounts)) # remove service principal secrets self._msal_secret_store.remove_all_cached_creds() def get_user(self, user=None): - accounts = self._msal_app.get_accounts(user) if user else self._msal_app.get_accounts() + accounts = self.msal_app.get_accounts(user) if user else self.msal_app.get_accounts() return accounts def get_user_credential(self, username): - accounts = self._msal_app.get_accounts(username) + accounts = self.msal_app.get_accounts(username) # TODO: Confirm with MSAL team that username can uniquely identify the account if not accounts: @@ -352,8 +357,7 @@ def get_user_credential(self, username): auth_record = AuthenticationRecord(self.tenant_id, self.client_id, self.authority, account['home_account_id'], username) return InteractiveBrowserCredential(authentication_record=auth_record, disable_automatic_authentication=True, - enable_persistent_cache=True, - allow_unencrypted_cache=self.allow_unencrypted, + cache_persistence_options=self._cache_persistence_options, **self._credential_kwargs) def get_service_principal_credential(self, client_id, use_cert_sn_issuer): @@ -427,7 +431,6 @@ def serialize_token_cache(self, path=None): "It contains login information of all logged-in users. Make sure you protect it safely.", path) cache = self._load_msal_cache() - cache._reload_if_necessary() # pylint: disable=protected-access with open(path, "w") as fd: fd.write(cache.serialize()) diff --git a/src/azure-cli-core/azure/cli/core/_msal_patch.py b/src/azure-cli-core/azure/cli/core/_msal_patch.py new file mode 100644 index 00000000000..15129d081de --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/_msal_patch.py @@ -0,0 +1,173 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +""" +A temporary workaround for MSAL limitation +https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/335 + +After a successful sign-in, if the sign-in account already exists in the token +cache, remove it first along with its tokens to prevent MSAL from returning +cached access tokens from the previous session that may have been revoked. + +Otherwise, MSAL will return revoked access tokens, resulting in 401 failure +which can't be handled by commands that don't support silent reauth. +""" + +# pylint: skip-file +# flake8: noqa + +import json +import time + +from knack.log import get_logger + +from msal.oauth2cli.oauth2 import Client +from msal.token_cache import decode_id_token, canonicalize, decode_part + +logger = get_logger(__name__) + + +def patch_token_cache_add(callback): + + def __add(self, event, now=None): + # event typically contains: client_id, scope, token_endpoint, + # response, params, data, grant_type + environment = realm = None + if "token_endpoint" in event: + _, environment, realm = canonicalize(event["token_endpoint"]) + if "environment" in event: # Always available unless in legacy test cases + environment = event["environment"] # Set by application.py + response = event.get("response", {}) + data = event.get("data", {}) + access_token = response.get("access_token") + refresh_token = response.get("refresh_token") + id_token = response.get("id_token") + id_token_claims = ( + decode_id_token(id_token, client_id=event["client_id"]) + if id_token else {}) + client_info = {} + home_account_id = None # It would remain None in client_credentials flow + if "client_info" in response: # We asked for it, and AAD will provide it + client_info = json.loads(decode_part(response["client_info"])) + home_account_id = "{uid}.{utid}".format(**client_info) + elif id_token_claims: # This would be an end user on ADFS-direct scenario + client_info["uid"] = id_token_claims.get("sub") + home_account_id = id_token_claims.get("sub") + + target = ' '.join(event.get("scope") or []) # Per schema, we don't sort it + + with self._lock: + now = int(time.time() if now is None else now) + + if client_info and not event.get("skip_account_creation"): + account = { + "home_account_id": home_account_id, + "environment": environment, + "realm": realm, + "local_account_id": id_token_claims.get( + "oid", id_token_claims.get("sub")), + "username": id_token_claims.get("preferred_username") # AAD + or id_token_claims.get("upn") # ADFS 2019 + or "", # The schema does not like null + "authority_type": + self.AuthorityType.ADFS if realm == "adfs" + else self.AuthorityType.MSSTS, + # "client_info": response.get("client_info"), # Optional + } + + logger.debug("Remove existing account %r", account) + logger.debug("Calling %r", callback) + callback(account) + self.modify(self.CredentialType.ACCOUNT, account, account) + + if id_token: + idt = { + "credential_type": self.CredentialType.ID_TOKEN, + "secret": id_token, + "home_account_id": home_account_id, + "environment": environment, + "realm": realm, + "client_id": event.get("client_id"), + # "authority": "it is optional", + } + self.modify(self.CredentialType.ID_TOKEN, idt, idt) + + if access_token: + expires_in = int( # AADv1-like endpoint returns a string + response.get("expires_in", 3599)) + ext_expires_in = int( # AADv1-like endpoint returns a string + response.get("ext_expires_in", expires_in)) + at = { + "credential_type": self.CredentialType.ACCESS_TOKEN, + "secret": access_token, + "home_account_id": home_account_id, + "environment": environment, + "client_id": event.get("client_id"), + "target": target, + "realm": realm, + "token_type": response.get("token_type", "Bearer"), + "cached_at": str(now), # Schema defines it as a string + "expires_on": str(now + expires_in), # Same here + "extended_expires_on": str(now + ext_expires_in) # Same here + } + if data.get("key_id"): # It happens in SSH-cert or POP scenario + at["key_id"] = data.get("key_id") + if "refresh_in" in response: + refresh_in = response["refresh_in"] # It is an integer + at["refresh_on"] = str(now + refresh_in) # Schema wants a string + self.modify(self.CredentialType.ACCESS_TOKEN, at, at) + + if refresh_token: + rt = { + "credential_type": self.CredentialType.REFRESH_TOKEN, + "secret": refresh_token, + "home_account_id": home_account_id, + "environment": environment, + "client_id": event.get("client_id"), + "target": target, # Optional per schema though + "last_modification_time": str(now), # Optional. Schema defines it as a string. + } + if "foci" in response: + rt["family_id"] = response["foci"] + self.modify(self.CredentialType.REFRESH_TOKEN, rt, rt) + + app_metadata = { + "client_id": event.get("client_id"), + "environment": environment, + } + if "foci" in response: + app_metadata["family_id"] = response.get("foci") + self.modify(self.CredentialType.APP_METADATA, app_metadata, app_metadata) + + def obtain_token_by_refresh_token(self, token_item, scope=None, + rt_getter=lambda token_item: token_item["refresh_token"], + on_removing_rt=None, + on_updating_rt=None, + on_obtaining_tokens=None, + **kwargs): + resp = super(Client, self).obtain_token_by_refresh_token( + rt_getter(token_item) + if not isinstance(token_item, str) else token_item, + scope=scope, + also_save_rt=on_updating_rt is False, + on_obtaining_tokens=on_obtaining_tokens, + **kwargs) + if resp.get('error') == 'invalid_grant': + (on_removing_rt or self.on_removing_rt)(token_item) # Discard old RT + RT = "refresh_token" + if on_updating_rt is not False and RT in resp: + (on_updating_rt or self.on_updating_rt)(token_item, resp[RT]) + return resp + + from unittest.mock import patch + + # Temporary patch for https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/335 + cm_add = patch('msal.token_cache.TokenCache._TokenCache__add', __add) + + # Temporary patch for https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/339 + cm_obtain_token_by_refresh_token = patch('msal.oauth2cli.oauth2.Client.obtain_token_by_refresh_token', + obtain_token_by_refresh_token) + cm_add.__enter__() + cm_obtain_token_by_refresh_token.__enter__() diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 158e7a26f40..9db526f8898 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -536,24 +536,24 @@ def logout(self, user_or_sp, clear_credential): adal_cache = AdalCredentialCache() adal_cache.remove_cached_creds(user_or_sp) - logger.warning('Account %s has been logged out from Azure CLI.', user_or_sp) + logger.warning("Account '%s' has been logged out from Azure CLI.", user_or_sp) else: # https://english.stackexchange.com/questions/5302/log-in-to-or-log-into-or-login-to - logger.warning("Account %s was not logged in to Azure CLI.", user_or_sp) + logger.warning("Account '%s' was not logged in to Azure CLI.", user_or_sp) # Log out from MSAL cache identity = Identity(self._authority) accounts = identity.get_user(user_or_sp) if accounts: - logger.info("The credential of %s were found from MSAL encrypted cache.", user_or_sp) + logger.info("The credential of '%s' were found from MSAL encrypted cache.", user_or_sp) if clear_credential: identity.logout_user(user_or_sp) - logger.warning("The credential of %s were cleared from MSAL encrypted cache. This account is " + logger.warning("The credential of '%s' were cleared from MSAL encrypted cache. This account is " "also logged out from other SDK tools which use Azure CLI's credential " "via Single Sign-On.", user_or_sp) else: - logger.warning('The credential of %s is still stored in MSAL encrypted cached. Other SDK tools may use ' - 'Azure CLI\'s credential via Single Sign-On. ' + logger.warning("The credential of '%s' is still stored in MSAL encrypted cached. Other SDK tools may " + "use Azure CLI\'s credential via Single Sign-On. " 'To clear the credential, run `az logout --username %s --clear-credential`.', user_or_sp, user_or_sp) else: @@ -582,7 +582,7 @@ def logout_all(self, clear_credential): logger.warning(account['username']) logger.warning('Other SDK tools may use Azure CLI\'s credential via Single Sign-On. ' 'To clear all credentials, run `az account clear --clear-credential`. ' - 'To clear one of them, run `az logout --username USERNAME` --clear-credential.') + 'To clear one of them, run `az logout --username USERNAME --clear-credential`.') else: logger.warning('No credential was not found from MSAL encrypted cache.') @@ -888,11 +888,11 @@ def __init__(self, cli_ctx, arm_client_factory=None, **kwargs): self.authority = self.cli_ctx.cloud.endpoints.active_directory self.adal_cache = kwargs.pop("adal_cache", None) - def create_arm_client_factory(credentials): + def create_arm_client_factory(credential): if arm_client_factory: - return arm_client_factory(credentials) + return arm_client_factory(credential) from azure.cli.core.profiles import ResourceType, get_api_version - from azure.cli.core.commands.client_factory import _prepare_client_kwargs_track2 + from azure.cli.core.commands.client_factory import _prepare_mgmt_client_kwargs_track2 client_type = self._get_subscription_client_class() if client_type is None: @@ -900,11 +900,10 @@ def create_arm_client_factory(credentials): raise CLIInternalError("Unable to get '{}' in profile '{}'" .format(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS, cli_ctx.cloud.profile)) api_version = get_api_version(cli_ctx, ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS) - client_kwargs = _prepare_client_kwargs_track2(cli_ctx) + client_kwargs = _prepare_mgmt_client_kwargs_track2(cli_ctx, credential) # We don't need to change credential_scopes as 'scopes' is ignored by BasicTokenCredential anyway - client = client_type(credentials, api_version=api_version, + client = client_type(credential, api_version=api_version, base_url=self.cli_ctx.cloud.endpoints.resource_manager, - credential_scopes=resource_to_scopes(self._arm_resource_id), **client_kwargs) return client diff --git a/src/azure-cli-core/azure/cli/core/adal_authentication.py b/src/azure-cli-core/azure/cli/core/adal_authentication.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/azure-cli-core/azure/cli/core/azclierror.py b/src/azure-cli-core/azure/cli/core/azclierror.py index 297b42bf9bb..e4390ec2b8d 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -25,9 +25,10 @@ class AzCLIError(CLIError): """ Base class for all the AzureCLI defined error classes. DO NOT raise this error class in your codes. """ - def __init__(self, error_msg, recommendation=None): + def __init__(self, error_msg, recommendation=None, original_error=None): # error message self.error_msg = error_msg + self.original_error = original_error # manual recommendations provided based on developers' knowledge self.recommendations = [] @@ -169,7 +170,32 @@ class BadRequestError(UserFault): class UnauthorizedError(UserFault): """ Unauthorized request: 401 error """ - pass + + def __init__(self, error_msg, recommendation=None, original_error=None): + + def _extract_claims(challenge): + # Copied from azure.mgmt.core.policies._authentication._parse_claims_challenge + from azure.mgmt.core.policies._authentication import _parse_challenges + parsed_challenges = _parse_challenges(challenge) + if len(parsed_challenges) != 1 or "claims" not in parsed_challenges[0].parameters: + # no or multiple challenges, or no claims directive + return None + + encoded_claims = parsed_challenges[0].parameters["claims"] + padding_needed = -len(encoded_claims) % 4 + return encoded_claims + "=" * padding_needed + + claims = _extract_claims(original_error.response.headers.get('WWW-Authenticate')) + + from azure.cli.core.credential import _generate_login_command, _generate_login_message + # login_command = _generate_login_command(claims=claims) + login_message = _generate_login_message(claims=claims) + + recommendation = ( + "The access token has expired or been revoked by Continuous Access Evaluation. " + "Silent re-authentication will be attempted in the future.\n{}") + recommendation = recommendation.format(login_message) + super().__init__(error_msg, recommendation=recommendation, original_error=original_error) class ForbiddenError(UserFault): @@ -259,7 +285,7 @@ class RecommendationError(ClientError): pass -class AuthenticationError(ServiceError): - """ Raised when AAD authentication fails. """ +class AuthenticationError(AzCLIError): + """ Raised when credential.get_token fails. """ # endregion diff --git a/src/azure-cli-core/azure/cli/core/azlogging.py b/src/azure-cli-core/azure/cli/core/azlogging.py index d2c3fbf1064..d30af54387a 100644 --- a/src/azure-cli-core/azure/cli/core/azlogging.py +++ b/src/azure-cli-core/azure/cli/core/azlogging.py @@ -51,12 +51,18 @@ def __init__(self, name, cli_ctx=None): def configure(self, args): super(AzCliLogging, self).configure(args) - from knack.log import CliLogLevel - if self.log_level == CliLogLevel.DEBUG: - # As azure.core.pipeline.policies.http_logging_policy is a redacted version of - # azure.core.pipeline.policies._universal, disable azure.core.pipeline.policies.http_logging_policy - # when debug log is shown. - logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.CRITICAL) + if self.log_level: + # When invoked by pytest, configure() is skipped and log_level will not be set. + from knack.log import CliLogLevel + if self.log_level == CliLogLevel.DEBUG: + # As azure.core.pipeline.policies.http_logging_policy is a redacted version of + # azure.core.pipeline.policies._universal, disable azure.core.pipeline.policies.http_logging_policy + # when debug log is shown. + logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.CRITICAL) + + if self.log_level <= CliLogLevel.WARNING: + # Disable warnings from Azure Identity + logging.getLogger("azure.identity").setLevel(logging.CRITICAL) def get_command_log_dir(self): return self.command_log_dir diff --git a/src/azure-cli-core/azure/cli/core/commands/client_factory.py b/src/azure-cli-core/azure/cli/core/commands/client_factory.py index 96e4d12fc73..ba6dbfe149a 100644 --- a/src/azure-cli-core/azure/cli/core/commands/client_factory.py +++ b/src/azure-cli-core/azure/cli/core/commands/client_factory.py @@ -117,7 +117,7 @@ def configure_common_settings(cli_ctx, client): def _prepare_client_kwargs_track2(cli_ctx): - """Prepare kwargs for Track 2 SDK client.""" + """Prepare kwargs for Track 2 data and mgmt SDK clients.""" client_kwargs = {} # Prepare connection_verify to change SSL verification behavior, used by ConnectionConfiguration @@ -160,6 +160,23 @@ def _prepare_client_kwargs_track2(cli_ctx): return client_kwargs +def _prepare_mgmt_client_kwargs_track2(cli_ctx, cred): + """Prepare kwargs for Track 2 SDK mgmt client.""" + client_kwargs = _prepare_client_kwargs_track2(cli_ctx) + + # Enable CAE support in mgmt SDK + from azure.mgmt.core.policies import ARMChallengeAuthenticationPolicy + + # Track 2 SDK maintains `scopes` and passes `scopes` to get_token. + scopes = resource_to_scopes(cli_ctx.cloud.endpoints.active_directory_resource_id) + policy = ARMChallengeAuthenticationPolicy(cred, *scopes) + + client_kwargs['credential_scopes'] = scopes + client_kwargs['authentication_policy'] = policy + + return client_kwargs + + def _get_mgmt_service_client(cli_ctx, client_type, subscription_bound=True, @@ -167,7 +184,6 @@ def _get_mgmt_service_client(cli_ctx, api_version=None, base_url_bound=True, resource=None, - credential_scopes=None, sdk_profile=None, aux_subscriptions=None, aux_tenants=None, @@ -181,8 +197,6 @@ def _get_mgmt_service_client(cli_ctx, :param api_version: :param base_url_bound: :param resource: For track 1 SDK which uses msrest and ADAL. It will be passed to get_login_credentials. - :param credential_scopes: For track 2 SDK which uses Azure Identity and MSAL. It will be passed to the client's - __init__ method. :param sdk_profile: :param aux_subscriptions: :param aux_tenants: @@ -211,11 +225,7 @@ def _get_mgmt_service_client(cli_ctx, client_kwargs.update(kwargs) if is_track2(client_type): - client_kwargs.update(_prepare_client_kwargs_track2(cli_ctx)) - # Track 2 SDK maintains `scopes` and passes `scopes` to get_token. Specify `scopes` via `credential_scopes` - # in client's __init__ method. - client_kwargs['credential_scopes'] = credential_scopes or \ - resource_to_scopes(cli_ctx.cloud.endpoints.active_directory_resource_id) + client_kwargs.update(_prepare_mgmt_client_kwargs_track2(cli_ctx, cred=cred)) if subscription_bound: client = client_type(cred, subscription_id, **client_kwargs) diff --git a/src/azure-cli-core/azure/cli/core/credential.py b/src/azure-cli-core/azure/cli/core/credential.py index b843a9303f6..c9c7d9e8c72 100644 --- a/src/azure-cli-core/azure/cli/core/credential.py +++ b/src/azure-cli-core/azure/cli/core/credential.py @@ -5,11 +5,12 @@ from typing import Tuple, List +import json import requests from azure.cli.core._identity import resource_to_scopes from azure.cli.core.util import in_cloud_console from azure.core.credentials import AccessToken -from azure.core.exceptions import ClientAuthenticationError +from azure.identity import CredentialUnavailableError, AuthenticationRequiredError from knack.log import get_logger from knack.util import CLIError @@ -44,40 +45,25 @@ def _get_token(self, scopes=None, **kwargs): token = self._credential.get_token(*scopes, **kwargs) if self._external_credentials: external_tenant_tokens = [cred.get_token(*scopes) for cred in self._external_credentials] + return token, external_tenant_tokens except CLIError as err: if in_cloud_console(): CredentialAdaptor._log_hostname() raise err - except ClientAuthenticationError as err: - # pylint: disable=no-member - if in_cloud_console(): - CredentialAdaptor._log_hostname() - - err = getattr(err, 'message', None) or '' - if 'authentication is required' in err: - raise CLIError("Authentication is migrated to Microsoft identity platform (v2.0). {}".format( - "Please run 'az login' to login." if not in_cloud_console() else '')) - if 'AADSTS70008' in err: # all errors starting with 70008 should be creds expiration related - raise CLIError("Credentials have expired due to inactivity. {}".format( - "Please run 'az login'" if not in_cloud_console() else '')) - if 'AADSTS50079' in err: - raise CLIError("Configuration of your account was changed. {}".format( - "Please run 'az login'" if not in_cloud_console() else '')) - if 'AADSTS50173' in err: - raise CLIError("The credential data used by CLI has been expired because you might have changed or " - "reset the password. {}".format( - "Please clear browser's cookies and run 'az login'" - if not in_cloud_console() else '')) - raise CLIError(err) + except AuthenticationRequiredError as err: + err_dict = json.loads(err.response.text()) + aad_error_handler(err_dict, scopes=err.scopes, claims=err.claims) + except CredentialUnavailableError as err: + err_dict = json.loads(err.response.text()) + aad_error_handler(err_dict) except requests.exceptions.SSLError as err: from .util import SSLERROR_TEMPLATE raise CLIError(SSLERROR_TEMPLATE.format(str(err))) except requests.exceptions.ConnectionError as err: raise CLIError('Please ensure you have network connection. Error detail: ' + str(err)) - return token, external_tenant_tokens def signed_session(self, session=None): - logger.debug("CredentialAdaptor.signed_session invoked by Track 1 SDK") + logger.debug("CredentialAdaptor.get_token") session = session or requests.Session() token, external_tenant_tokens = self._get_token() header = "{} {}".format('Bearer', token.token) @@ -88,8 +74,7 @@ def signed_session(self, session=None): return session def get_token(self, *scopes, **kwargs): - # type: (*str) -> AccessToken - logger.debug("CredentialAdaptor.get_token invoked by Track 2 SDK with scopes=%r", scopes) + logger.debug("CredentialAdaptor.get_token: scopes=%r, kwargs=%r", scopes, kwargs) scopes = _normalize_scopes(scopes) token, _ = self._get_token(scopes, **kwargs) return token @@ -125,3 +110,47 @@ def _normalize_scopes(scopes): return scopes[1:] return scopes + + +def _generate_login_command(scopes=None, claims=None): + login_command = ['az login'] + + if scopes: + login_command.append('--scope {}'.format(' '.join(scopes))) + + if claims: + import base64 + try: + base64.urlsafe_b64decode(claims) + is_base64 = True + except ValueError: + is_base64 = False + + if not is_base64: + claims = base64.urlsafe_b64encode(claims.encode()).decode() + + login_command.append('--claims {}'.format(claims)) + + return ' '.join(login_command) + + +def _generate_login_message(**kwargs): + login_command = _generate_login_command(**kwargs) + login_command = 'az logout\naz login' + msg = "To re-authenticate, please {}" \ + "If the problem persists, please contact your tenant administrator.".format( + "refresh Azure Portal." if in_cloud_console() else "run:\n{}\n".format(login_command)) + + return msg + + +def aad_error_handler(error, scopes=None, claims=None): + """ Handle the error from AAD server returned by ADAL or MSAL. """ + + # https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes + # Search for an error code at https://login.microsoftonline.com/error + msg = error.get('error_description') + login_message = _generate_login_message(scopes=scopes, claims=claims) + + from azure.cli.core.azclierror import AuthenticationError + raise AuthenticationError(msg, recommendation=login_message) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_credential.py b/src/azure-cli-core/azure/cli/core/tests/test_credential.py new file mode 100644 index 00000000000..a2d75827517 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/tests/test_credential.py @@ -0,0 +1,30 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest + +from azure.cli.core.credential import _generate_login_command + + +class TestUtils(unittest.TestCase): + def test_generate_login_command(self): + # No parameter is given + assert _generate_login_command() == 'az login' + + base64_claims = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxNzE3MjE1NiJ9fX0=" + json_claims = '{"access_token":{"nbf":{"essential":true, "value":"1617172156"}}}' + expect = 'az login --claims eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxNzE3MjE1NiJ9fX0=' + + # Base64 string is preserved + actual = _generate_login_command(claims=base64_claims) + assert actual == expect + + # JSON string is converted to base64 + actual = _generate_login_command(claims=json_claims) + assert actual == expect + + # scopes + actual = _generate_login_command(scopes=["https://management.core.windows.net//.default"]) + assert actual == 'az login --scope https://management.core.windows.net//.default' diff --git a/src/azure-cli-core/azure/cli/core/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/tests/test_identity.py index a82de7f3c9f..a73e413e315 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_identity.py @@ -57,7 +57,7 @@ def test_login_with_service_principal_certificate_cert_err(self): current_dir = os.path.dirname(os.path.realpath(__file__)) test_cert_file = os.path.join(current_dir, 'err_sp_cert.pem') # TODO: wrap exception - with self.assertRaisesRegex(ValueError, "Unable to load certificate."): + with self.assertRaisesRegex(ValueError, "Could not deserialize key data."): identity.login_with_service_principal_certificate("00000000-0000-0000-0000-000000000000", test_cert_file) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index 1ba1171c2e9..39b9556814e 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -384,7 +384,7 @@ def test_login_with_service_principal_cert_sn_issuer(self, get_token_mock): self.assertEqual(output, subs) @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - @mock.patch.dict('os.environ') + @mock.patch.dict('os.environ', clear=True) def test_login_with_environment_credential_service_principal(self, get_client_class_mock): os.environ['AZURE_TENANT_ID'] = self.service_principal_tenant_id os.environ['AZURE_CLIENT_ID'] = self.service_principal_id @@ -1616,7 +1616,7 @@ def test_refresh_accounts_one_user_account_one_sp_account(self, app_mock, retrie cli = DummyCli() storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - sp_subscription1 = SubscriptionStub('sp-sub/3', 'foo-subname', self.state1, 'foo_tenant.onmicrosoft.com') + sp_subscription1 = SubscriptionStub('sp-sub/3', 'foo-subname', self.state1, 'footenant.onmicrosoft.com') consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False, None, None) consolidated += profile._normalize_properties('http://foo', [sp_subscription1], True) profile._set_subscriptions(consolidated) diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index 50c19b33802..9761d8a13b7 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -90,7 +90,7 @@ def handle_exception(ex): # pylint: disable=too-many-locals, too-many-statement error_msg = extract_common_error_message(ex) status_code = str(getattr(ex, 'status_code', 'Unknown Code')) AzCLIErrorType = get_error_type_by_status_code(status_code) - az_error = AzCLIErrorType(error_msg) + az_error = AzCLIErrorType(error_msg, original_error=ex) elif isinstance(ex, ValidationError): az_error = azclierror.ValidationError(error_msg) @@ -103,7 +103,7 @@ def handle_exception(ex): # pylint: disable=too-many-locals, too-many-statement if extract_common_error_message(ex): error_msg = extract_common_error_message(ex) AzCLIErrorType = get_error_type_by_azure_error(ex) - az_error = AzCLIErrorType(error_msg) + az_error = AzCLIErrorType(error_msg, original_error=ex) elif isinstance(ex, AzureException): if is_azure_connection_error(error_msg): diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 223a4852f19..25868da2dc7 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -47,13 +47,14 @@ 'argcomplete~=1.8', 'azure-cli-telemetry==1.0.6.*', 'azure-common~=1.1', - 'azure-mgmt-core>=1.2.0,<2.0.0', + 'azure-core==1.14.0b1', + 'azure-mgmt-core==1.3.0b1', 'colorama~=0.4.1', 'cryptography>=3.2,<3.4', 'humanfriendly>=4.7,<10.0', 'jmespath', 'knack==0.8.0rc2', - 'azure-identity==1.5.0b2', + 'azure-identity==1.6.0b3', # Dependencies of the vendored subscription SDK # https://github.com/Azure/azure-sdk-for-python/blob/ab12b048ddf676fe0ccec16b2167117f0609700d/sdk/resources/azure-mgmt-resource/setup.py#L82-L86 'msrest>=0.5.0', diff --git a/src/azure-cli-testsdk/azure/cli/testsdk/base.py b/src/azure-cli-testsdk/azure/cli/testsdk/base.py index 62685d62613..babec2408cc 100644 --- a/src/azure-cli-testsdk/azure/cli/testsdk/base.py +++ b/src/azure-cli-testsdk/azure/cli/testsdk/base.py @@ -214,6 +214,9 @@ def __init__(self, method_name): self.kwargs = {} self.test_resources_count = 0 + def setUp(self): + patch_main_exception_handler(self) + def cmd(self, command, checks=None, expect_failure=False): command = self._apply_kwargs(command) return execute(self.cli_ctx, command, expect_failure=expect_failure).assert_with_checks(checks) diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 2c4e7d95ded..1dd3f5e5e86 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -104,7 +104,7 @@ def load_arguments(self, command): c.argument('clear_credential', clear_credential_type) with self.argument_context('account export-msal-cache') as c: - c.argument('path', help='The path to export the MSAL cache.') + c.argument('path', help='The path to export the MSAL cache.', default='~/.azure/msal.cache.snapshot.json') COMMAND_LOADER_CLS = ProfileCommandsLoader diff --git a/src/azure-cli/azure/cli/command_modules/profile/_help.py b/src/azure-cli/azure/cli/command_modules/profile/_help.py index c0acf7b7007..9a31b49cbe8 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/_help.py +++ b/src/azure-cli/azure/cli/command_modules/profile/_help.py @@ -98,12 +98,12 @@ helps['account export-msal-cache'] = """ type: command -short-summary: Export MSAL cache, by default to `~/.azure/msal.cache.snapshot.json`. +short-summary: Export MSAL cache in plain text. long-summary: > - The exported cache is unencrypted. It contains login information of all logged-in users. Make sure you protect - it safely. + The exported cache is unencrypted. + It contains login information of all logged-in users. Make sure you protect it safely. - You can mount the exported MSAL cache to a container at `~/.IdentityService/msal.cache`, so that Azure CLI + You can mount the exported MSAL cache to a container at '~/.IdentityService/msal.cache', so that Azure CLI inside the container can automatically authenticate. examples: - name: Export MSAL cache to the default path. diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index d7c595a85a6..020d044233a 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -103,7 +103,7 @@ def set_active_subscription(cmd, subscription): profile.set_active_subscription(subscription) -def account_clear(cmd, clear_credential=False): +def account_clear(cmd, clear_credential=True): """Clear all stored subscriptions. To clear individual, use 'logout'""" if in_cloud_console(): logger.warning(_CLOUD_CONSOLE_LOGOUT_WARNING) @@ -190,7 +190,7 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None return all_subscriptions -def logout(cmd, username=None, clear_credential=False): +def logout(cmd, username=None, clear_credential=True): """Log out to remove access to Azure subscriptions""" if in_cloud_console(): logger.warning(_CLOUD_CONSOLE_LOGOUT_WARNING) diff --git a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py new file mode 100644 index 00000000000..22339c36910 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py @@ -0,0 +1,62 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from time import sleep + +import jwt +from azure.cli.core.azclierror import AuthenticationError +from azure.cli.testsdk import LiveScenarioTest +from msrestazure.azure_exceptions import CloudError + +ARM_URL = "https://eastus2euap.management.azure.com/" # ARM canary +ARM_RETRY_INTERVAL = 10 + + +class CAEScenarioTest(LiveScenarioTest): + + def test_client_capabilities(self): + self.cmd('login') + + # Verify the access token has CAE enabled + out = self.cmd('account get-access-token').get_output_in_json() + access_token = out['accessToken'] + decoded = jwt.decode(access_token, verify=False, algorithms=['RS256']) + self.assertEqual(decoded['xms_cc'], ['CP1']) # xms_cc: extension microsoft client capabilities + self.assertEqual(decoded['xms_ssm'], '1') # xms_ssm: extension microsoft smart session management + + def _test_revoke_session(self, command, expected_error, checks=None): + self.test_client_capabilities() + + # Test access token is working + self.cmd(command) + + self._revoke_sign_in_sessions() + + # CAE is currently only available in canary endpoint + # with mock.patch.object(self.cli_ctx.cloud.endpoints, "resource_manager", ARM_URL): + exit_code = 0 + with self.assertRaises(expected_error) as ex: + while exit_code == 0: + exit_code = self.cmd(command).exit_code + sleep(ARM_RETRY_INTERVAL) + if checks: + checks(ex.exception) + + def test_revoke_session_track2(self): + def check_aad_error_code(ex): + self.assertIn('AADSTS50173', str(ex)) + + self._test_revoke_session("storage account list", AuthenticationError, check_aad_error_code) + + def test_revoke_session_track1(self): + def check_arm_error(ex): + self.assertEqual(ex.status_code, 401) + self.assertIsNotNone(ex.response.headers["WWW-Authenticate"]) + + self._test_revoke_session('group list', CloudError, check_arm_error) + + def _revoke_sign_in_sessions(self): + # Manually revoke sign in sessions + self.cmd('rest -m POST -u https://graph.microsoft.com/v1.0/me/revokeSignInSessions') diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 6dfc3dffa5f..2c673ff2569 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -9,13 +9,12 @@ azure-cli-core==2.21.0.1 azure-cli-telemetry==1.0.6 azure-cli==2.21.0.1 azure-common==1.1.22 -azure-core==1.10.0 +azure-core==1.14.0b1 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 azure-graphrbac==0.60.0 -azure-identity==1.5.0b2 -azure-keyvault==1.1.0 +azure-identity==1.6.0b3 azure-keyvault-administration==4.0.0b3 azure-keyvault==1.1.0 azure-loganalytics==0.1.0 @@ -35,7 +34,7 @@ azure-mgmt-consumption==2.0.0 azure-mgmt-containerinstance==1.5.0 azure-mgmt-containerregistry==3.0.0rc17 azure-mgmt-containerservice==11.1.0 -azure-mgmt-core==1.2.1 +azure-mgmt-core==1.3.0b1 azure-mgmt-cosmosdb==3.0.0 azure-mgmt-databoxedge==0.2.0 azure-mgmt-datalake-analytics==0.2.1 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 3ee05b3e83f..2c673ff2569 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -9,16 +9,14 @@ azure-cli-core==2.21.0.1 azure-cli-telemetry==1.0.6 azure-cli==2.21.0.1 azure-common==1.1.22 -azure-core==1.10.0 +azure-core==1.14.0b1 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 azure-graphrbac==0.60.0 -azure-identity==1.5.0b2 -azure-keyvault==1.1.0 +azure-identity==1.6.0b3 azure-keyvault-administration==4.0.0b3 azure-keyvault==1.1.0 -azure-keyvault==1.1.0 azure-loganalytics==0.1.0 azure-mgmt-advisor==2.0.1 azure-mgmt-apimanagement==0.2.0 @@ -36,7 +34,7 @@ azure-mgmt-consumption==2.0.0 azure-mgmt-containerinstance==1.5.0 azure-mgmt-containerregistry==3.0.0rc17 azure-mgmt-containerservice==11.1.0 -azure-mgmt-core==1.2.1 +azure-mgmt-core==1.3.0b1 azure-mgmt-cosmosdb==3.0.0 azure-mgmt-databoxedge==0.2.0 azure-mgmt-datalake-analytics==0.2.1 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 14f8dbc41c3..8f4bb47d2f5 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -9,16 +9,14 @@ azure-cli-core==2.21.0.1 azure-cli-telemetry==1.0.6 azure-cli==2.21.0.1 azure-common==1.1.22 -azure-core==1.10.0 +azure-core==1.14.0b1 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 azure-graphrbac==0.60.0 -azure-identity==1.5.0b2 -azure-keyvault==1.1.0 +azure-identity==1.6.0b3 azure-keyvault-administration==4.0.0b3 azure-keyvault==1.1.0 -azure-keyvault==1.1.0 azure-loganalytics==0.1.0 azure-mgmt-advisor==2.0.1 azure-mgmt-apimanagement==0.2.0 @@ -36,7 +34,7 @@ azure-mgmt-consumption==2.0.0 azure-mgmt-containerinstance==1.5.0 azure-mgmt-containerregistry==3.0.0rc17 azure-mgmt-containerservice==11.1.0 -azure-mgmt-core==1.2.1 +azure-mgmt-core==1.3.0b1 azure-mgmt-cosmosdb==3.0.0 azure-mgmt-databoxedge==0.2.0 azure-mgmt-datalake-analytics==0.2.1 From 30859db3f4799598b3247436212530eb946b3188 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 12 Apr 2021 13:49:01 +0800 Subject: [PATCH 09/69] Backport 17526 --- src/azure-cli-core/azure/cli/core/_profile.py | 33 +++++---- .../azure/cli/core/tests/test_profile.py | 73 ++++++++++++------- 2 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 9db526f8898..29342e3ed99 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -3,8 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from __future__ import print_function - import collections import os @@ -388,21 +386,8 @@ def _normalize_properties(self, user, subscriptions, is_service_principal, cert_ if is_environment: subscription_dict[_USER_ENTITY][_IS_ENVIRONMENT_CREDENTIAL] = True - # For subscription account from Subscriptions - List 2019-06-01 and later. if subscription_dict[_SUBSCRIPTION_NAME] != _TENANT_LEVEL_ACCOUNT_NAME: - if hasattr(s, 'home_tenant_id'): - subscription_dict[_HOME_TENANT_ID] = s.home_tenant_id - if hasattr(s, 'managed_by_tenants'): - if s.managed_by_tenants is None: - # managedByTenants is missing from the response. This is a known service issue: - # https://github.com/Azure/azure-rest-api-specs/issues/9567 - # pylint: disable=line-too-long - raise CLIError("Invalid profile is used for cloud '{cloud_name}'. " - "To configure the cloud profile, run `az cloud set --name {cloud_name} --profile (e.g. 2019-03-01-hybrid)`. " - "For more information about using Azure CLI with Azure Stack, see " - "https://docs.microsoft.com/azure-stack/user/azure-stack-version-profiles-azurecli2" - .format(cloud_name=self.cli_ctx.cloud.name)) - subscription_dict[_MANAGED_BY_TENANTS] = [{_TENANT_ID: t.tenant_id} for t in s.managed_by_tenants] + _transform_subscription_for_multiapi(s, subscription_dict) if cert_sn_issuer_auth: subscription_dict[_USER_ENTITY][_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH] = True @@ -1036,3 +1021,19 @@ def _get_subscription_client_class(self): # pylint: disable=no-self-use from azure.cli.core.profiles._shared import get_client_class client_type = get_client_class(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS) return client_type + + +def _transform_subscription_for_multiapi(s, s_dict): + """ + Transforms properties from Subscriptions - List 2019-06-01 and later to the subscription dict. + + :param s: subscription object + :param s_dict: subscription dict + """ + if hasattr(s, 'home_tenant_id'): + s_dict[_HOME_TENANT_ID] = s.home_tenant_id + if hasattr(s, 'managed_by_tenants'): + if s.managed_by_tenants is None: + s_dict[_MANAGED_BY_TENANTS] = None + else: + s_dict[_MANAGED_BY_TENANTS] = [{_TENANT_ID: t.tenant_id} for t in s.managed_by_tenants] diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index 39b9556814e..928ecd8e5c8 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -17,7 +17,8 @@ from azure.core.credentials import AccessToken from azure.cli.core._profile import (Profile, SubscriptionFinder, _USE_VENDORED_SUBSCRIPTION_SDK, - _detect_adfs_authority, _attach_token_tenant) + _detect_adfs_authority, _attach_token_tenant, + _transform_subscription_for_multiapi) if _USE_VENDORED_SUBSCRIPTION_SDK: from azure.cli.core.vendored_sdks.subscriptions.models import \ (Subscription, SubscriptionPolicies, SpendingLimit, ManagedByTenant) @@ -1953,30 +1954,6 @@ def token(self, value): self._token = value -class TestProfileUtils(unittest.TestCase): - def test_get_authority_and_tenant(self): - from azure.cli.core._profile import _detect_adfs_authority - - # Public cloud, without tenant - expected_authority = "https://login.microsoftonline.com" - self.assertEqual(_detect_adfs_authority("https://login.microsoftonline.com", None), - (expected_authority, None)) - # Public cloud, with tenant - self.assertEqual(_detect_adfs_authority("https://login.microsoftonline.com", '00000000-0000-0000-0000-000000000001'), - (expected_authority, '00000000-0000-0000-0000-000000000001')) - - # ADFS, without tenant - expected_authority = "https://adfs.redmond.azurestack.corp.microsoft.com" - self.assertEqual(_detect_adfs_authority("https://adfs.redmond.azurestack.corp.microsoft.com/adfs", None), - (expected_authority, 'adfs')) - # ADFS, without tenant (including a trailing /) - self.assertEqual(_detect_adfs_authority("https://adfs.redmond.azurestack.corp.microsoft.com/adfs/", None), - (expected_authority, 'adfs')) - # ADFS, with tenant - self.assertEqual(_detect_adfs_authority("https://adfs.redmond.azurestack.corp.microsoft.com/adfs", '00000000-0000-0000-0000-000000000001'), - (expected_authority, 'adfs')) - - class TestUtils(unittest.TestCase): def test_detect_adfs_authority(self): # Public cloud @@ -2018,6 +1995,52 @@ def test_attach_token_tenant_v2016_06_01(self): self.assertEqual(subscription.tenant_id, "token_tenant_1") self.assertEqual(subscription.home_tenant_id, "home_tenant_1") + def test_transform_subscription_for_multiapi(self): + + class SimpleSubscription: + pass + + class SimpleManagedByTenant: + pass + + tenant_id = "00000001-0000-0000-0000-000000000000" + + # No 2019-06-01 property is set. + s = SimpleSubscription() + d = {} + _transform_subscription_for_multiapi(s, d) + assert d == {} + + # home_tenant_id is set. + s = SimpleSubscription() + s.home_tenant_id = tenant_id + d = {} + _transform_subscription_for_multiapi(s, d) + assert d == {'homeTenantId': '00000001-0000-0000-0000-000000000000'} + + # managed_by_tenants is set, but is None. It is still preserved. + s = SimpleSubscription() + s.managed_by_tenants = None + d = {} + _transform_subscription_for_multiapi(s, d) + assert d == {'managedByTenants': None} + + # managed_by_tenants is set, but is []. It is still preserved. + s = SimpleSubscription() + s.managed_by_tenants = [] + d = {} + _transform_subscription_for_multiapi(s, d) + assert d == {'managedByTenants': []} + + # managed_by_tenants is set, and has valid items. It is preserved. + s = SimpleSubscription() + t = SimpleManagedByTenant() + t.tenant_id = tenant_id + s.managed_by_tenants = [t] + d = {} + _transform_subscription_for_multiapi(s, d) + assert d == {'managedByTenants': [{"tenantId": tenant_id}]} + if __name__ == '__main__': unittest.main() From d600567d3c2a6fef9fb214410f96d7183fc503c7 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 12 Apr 2021 15:56:29 +0800 Subject: [PATCH 10/69] Use MSAL directly --- .../azure/cli/core/_identity.py | 83 +++++-------------- src/azure-cli-core/azure/cli/core/_profile.py | 28 +++---- .../azure/cli/core/azlogging.py | 9 +- .../azure/cli/core/msal_authentication.py | 21 ++++- 4 files changed, 57 insertions(+), 84 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/_identity.py index 7beb05dd86d..d59cd403628 100644 --- a/src/azure-cli-core/azure/cli/core/_identity.py +++ b/src/azure-cli-core/azure/cli/core/_identity.py @@ -59,6 +59,7 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): """ self.authority = authority self.tenant_id = tenant_id or "organizations" + self.msal_authority = "{}/{}".format(self.authority, self.tenant_id) self.client_id = client_id or AZURE_CLI_CLIENT_ID # self._cred_cache = AdalCredentialCache() self._cred_cache = None @@ -108,75 +109,35 @@ def _build_persistent_msal_app(self, authority): from msal import PublicClientApplication msal_app = PublicClientApplication(authority=authority, client_id=self.client_id, token_cache=self._load_msal_cache(), - verify=self._credential_kwargs.get('connection_verify', True)) + verify=self._credential_kwargs.get('connection_verify', True), + client_capabilities=["CP1"]) return msal_app @property def msal_app(self): if not self._msal_app_instance: # Build the authority in MSAL style, like https://login.microsoftonline.com/your_tenant - msal_authority = "{}/{}".format(self.authority, self.tenant_id) - self._msal_app_instance = self._build_persistent_msal_app(msal_authority) + self._msal_app_instance = self._build_persistent_msal_app(self.msal_authority) return self._msal_app_instance def login_with_interactive_browser(self, scopes=None): - """ - :param scopes: Scopes for the `authenticate` method call (initial /authorize API) - :return: - """ - # Use InteractiveBrowserCredential - credential = InteractiveBrowserCredential(authority=self.authority, - tenant_id=self.tenant_id, - client_id=self.client_id, - cache_persistence_options=self._cache_persistence_options, - **self._credential_kwargs) - auth_record = credential.authenticate(scopes=scopes) - # todo: remove after ADAL token deprecation - if self._cred_cache: - self._cred_cache.add_credential(credential) - return credential, auth_record + result = self.msal_app.acquire_token_interactive(scopes) + if result: + return result['id_token_claims'] + return None def login_with_device_code(self, scopes=None): - # Use DeviceCodeCredential - def prompt_callback(verification_uri, user_code, _): - # expires_on is discarded - logger.warning("To sign in, use a web browser to open the page %s and enter the code %s to authenticate.", - verification_uri, user_code) - try: - credential = DeviceCodeCredential(authority=self.authority, - tenant_id=self.tenant_id, - client_id=self.client_id, - prompt_callback=prompt_callback, - cache_persistence_options=self._cache_persistence_options, - **self._credential_kwargs) - - auth_record = credential.authenticate(scopes=scopes) - # todo: remove after ADAL token deprecation - if self._cred_cache: - self._cred_cache.add_credential(credential) - return credential, auth_record - except ValueError as ex: - logger.debug('Device code authentication failed: %s', str(ex)) - if 'PyGObject' in str(ex): - raise CLIError("PyGObject is required to encrypt the persistent cache. Please install that lib or " - "allow fallback to plaintext if encrypt credential fail via 'az configure'.") - raise + flow = self.msal_app.initiate_device_flow(scopes) + if "user_code" not in flow: + raise ValueError( + "Fail to create device flow. Err: %s" % json.dumps(flow, indent=4)) + logger.warning(flow["message"]) + result = self.msal_app.acquire_token_by_device_flow(flow) # By default it will block + return result['id_token_claims'] def login_with_username_password(self, username, password, scopes=None): - # Use UsernamePasswordCredential - credential = UsernamePasswordCredential(authority=self.authority, - tenant_id=self.tenant_id, - client_id=self.client_id, - username=username, - password=password, - cache_persistence_options=self._cache_persistence_options, - **self._credential_kwargs) - auth_record = credential.authenticate(scopes=scopes) - - # todo: remove after ADAL token deprecation - if self._cred_cache: - self._cred_cache.add_credential(credential, scopes, self.authority) - return credential, auth_record + result = self.msal_app.acquire_token_by_username_password(username, password, scopes) + return result['id_token_claims'] def login_with_service_principal_secret(self, client_id, client_secret): # Use ClientSecretCredential @@ -354,11 +315,11 @@ def get_user_credential(self, username): "another application that uses Single Sign-On. " "Please run `az login` to re-login.".format(username)) account = accounts[0] - auth_record = AuthenticationRecord(self.tenant_id, self.client_id, self.authority, - account['home_account_id'], username) - return InteractiveBrowserCredential(authentication_record=auth_record, disable_automatic_authentication=True, - cache_persistence_options=self._cache_persistence_options, - **self._credential_kwargs) + from azure.cli.core.msal_authentication import UserCredential + cred = UserCredential(self.client_id, account=account, authority=self.msal_authority, + token_cache=self._load_msal_cache(), + verify=self._credential_kwargs.get('connection_verify', True)) + return cred def get_service_principal_credential(self, client_id, use_cert_sn_issuer): client_secret, certificate_path = \ diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 29342e3ed99..a1de5fc6f6d 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -170,6 +170,7 @@ def login(self, .getboolean('core', 'allow_fallback_to_plaintext', fallback=True), cred_cache=self._adal_cache) + id_token_claims = None if not subscription_finder: subscription_finder = SubscriptionFinder(self.cli_ctx, adal_cache=self._adal_cache) if interactive: @@ -180,13 +181,13 @@ def login(self, if not use_device_code: from azure.identity import CredentialUnavailableError try: - credential, auth_record = identity.login_with_interactive_browser(scopes=scopes) + id_token_claims = identity.login_with_interactive_browser(scopes=scopes) except CredentialUnavailableError: use_device_code = True logger.warning('Not able to launch a browser to log you in, falling back to device code...') if use_device_code: - credential, auth_record = identity.login_with_device_code(scopes=scopes) + id_token_claims = identity.login_with_device_code(scopes=scopes) else: if is_service_principal: if not tenant: @@ -196,14 +197,16 @@ def login(self, else: credential = identity.login_with_service_principal_secret(username, password) else: - credential, auth_record = identity.login_with_username_password(username, password, scopes=scopes) + id_token_claims = identity.login_with_username_password(username, password, scopes=scopes) + username = id_token_claims['preferred_username'] # List tenants and find subscriptions by calling ARM if find_subscriptions: + credential = identity.get_user_credential(username) if tenant: subscriptions = subscription_finder.find_using_specific_tenant(tenant, credential) else: - subscriptions = subscription_finder.find_using_common_tenant(auth_record.username, credential) + subscriptions = subscription_finder.find_using_common_tenant(username, credential) if not subscriptions and not allow_no_subscriptions: if username: @@ -226,9 +229,6 @@ def login(self, bare_tenant = tenant or auth_record.tenant_id subscriptions = self._build_tenant_level_accounts([bare_tenant]) - if auth_record: - username = auth_record.username - consolidated = self._normalize_properties(username, subscriptions, is_service_principal, bool(use_cert_sn_issuer)) @@ -917,12 +917,18 @@ def find_using_common_tenant(self, username, credential=None): for t in tenants: tenant_id = t.tenant_id - logger.debug("Finding subscriptions under tenant %s", tenant_id) # display_name is available since /tenants?api-version=2018-06-01, # not available in /tenants?api-version=2016-06-01 if not hasattr(t, 'display_name'): t.display_name = None + tenant_id_name = tenant_id + if t.display_name: + # e.g. '72f988bf-86f1-41af-91ab-2d7cd011db47 Microsoft' + tenant_id_name = "{} '{}'".format(tenant_id, t.display_name) + + logger.info("Finding subscriptions under tenant %s", tenant_id_name) + identity = Identity(self.authority, tenant_id, allow_unencrypted=self.cli_ctx.config .getboolean('core', 'allow_fallback_to_plaintext', fallback=True)) @@ -946,12 +952,6 @@ def find_using_common_tenant(self, username, credential=None): logger.warning("Failed to authenticate '%s' due to error '%s'", t, ex) continue - tenant_id_name = tenant_id - if t.display_name: - # e.g. '72f988bf-86f1-41af-91ab-2d7cd011db47 Microsoft' - tenant_id_name = "{} '{}'".format(tenant_id, t.display_name) - logger.info("Finding subscriptions under tenant %s", tenant_id_name) - subscriptions = self.find_using_specific_tenant( tenant_id, specific_tenant_credential) diff --git a/src/azure-cli-core/azure/cli/core/azlogging.py b/src/azure-cli-core/azure/cli/core/azlogging.py index d9a0a00060c..77d9abd9e7b 100644 --- a/src/azure-cli-core/azure/cli/core/azlogging.py +++ b/src/azure-cli-core/azure/cli/core/azlogging.py @@ -54,11 +54,10 @@ def configure(self, args): if self.log_level: # When invoked by pytest, configure() is skipped and log_level will not be set. from knack.log import CliLogLevel - if self.log_level == CliLogLevel.DEBUG: - # As azure.core.pipeline.policies.http_logging_policy is a redacted version of - # azure.core.pipeline.policies._universal, disable azure.core.pipeline.policies.http_logging_policy - # when debug log is shown. - logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.CRITICAL) + + # As azure.core.pipeline.policies.http_logging_policy is a redacted version of + # azure.core.pipeline.policies._universal, always disable it + logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.CRITICAL) if self.log_level <= CliLogLevel.WARNING: # Disable warnings from Azure Identity diff --git a/src/azure-cli-core/azure/cli/core/msal_authentication.py b/src/azure-cli-core/azure/cli/core/msal_authentication.py index ffaaba0d927..1a51b8b0480 100644 --- a/src/azure-cli-core/azure/cli/core/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/msal_authentication.py @@ -12,6 +12,8 @@ import os +from azure.cli.core.credential import aad_error_handler +from azure.core.credentials import AccessToken from knack.log import get_logger from msal import PublicClientApplication, ConfidentialClientApplication @@ -20,8 +22,19 @@ class UserCredential(PublicClientApplication): - def get_token(self, scopes, **kwargs): - raise NotImplementedError + def __init__(self, client_id, account=None, **kwargs): + super().__init__(client_id, **kwargs) + self.account = account + + def get_token(self, *scopes, **kwargs): + import time + request_time = int(time.time()) + result = self.acquire_token_silent_with_error(list(scopes), self.account, **kwargs) + + if result and "access_token" in result and "expires_in" in result: + return AccessToken(result["access_token"], request_time + int(result["expires_in"])) + else: + aad_error_handler(result) class ServicePrincipalCredential(ConfidentialClientApplication): @@ -45,6 +58,6 @@ def __init__(self, client_id, secret_or_certificate=None, **kwargs): super().__init__(client_id, client_credential=client_credential, **kwargs) - def get_token(self, scopes, **kwargs): + def get_token(self, *scopes, **kwargs): logger.debug("ServicePrincipalCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs) - return self.acquire_token_for_client(scopes=scopes, **kwargs) + return self.acquire_token_for_client(list(scopes), **kwargs) From e1d11be88b0848caf62af088d13a62780f833e46 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 12 Apr 2021 16:47:18 +0800 Subject: [PATCH 11/69] sp --- .../azure/cli/core/_identity.py | 52 ++++--------------- src/azure-cli-core/azure/cli/core/_profile.py | 42 +++++++-------- .../azure/cli/core/credential.py | 1 - .../azure/cli/core/msal_authentication.py | 35 +++++++++---- 4 files changed, 56 insertions(+), 74 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/_identity.py index d59cd403628..f0c5af09101 100644 --- a/src/azure-cli-core/azure/cli/core/_identity.py +++ b/src/azure-cli-core/azure/cli/core/_identity.py @@ -139,40 +139,17 @@ def login_with_username_password(self, username, password, scopes=None): result = self.msal_app.acquire_token_by_username_password(username, password, scopes) return result['id_token_claims'] - def login_with_service_principal_secret(self, client_id, client_secret): + def login_with_service_principal(self, client_id, secret_or_certificate): # Use ClientSecretCredential # TODO: Persist to encrypted cache # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/pull/44 - sp_auth = ServicePrincipalAuth(client_id, self.tenant_id, secret=client_secret) + sp_auth = ServicePrincipalAuth(client_id, self.tenant_id, secret=secret_or_certificate) entry = sp_auth.get_entry_to_persist() self._msal_secret_store.save_service_principal_cred(entry) # backward compatible with ADAL, to be deprecated if self._cred_cache: self._cred_cache.save_service_principal_cred(entry) - credential = ClientSecretCredential(self.tenant_id, client_id, client_secret, authority=self.authority, - **self._credential_kwargs) - return credential - - def login_with_service_principal_certificate(self, client_id, certificate_path): - # Use CertificateCredential - # TODO: support use_cert_sn_issuer in CertificateCredential - credential = CertificateCredential(self.tenant_id, client_id, certificate_path, authority=self.authority, - **self._credential_kwargs) - - # CertificateCredential.__init__ will verify the certificate - # Persist to encrypted cache - # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/pull/44 - sp_auth = ServicePrincipalAuth(client_id, self.tenant_id, certificate_file=certificate_path) - entry = sp_auth.get_entry_to_persist() - self._msal_secret_store.save_service_principal_cred(entry) - - # backward compatible with ADAL, to be deprecated - if self._cred_cache: - entry = sp_auth.get_entry_to_persist_legacy() - self._cred_cache.save_service_principal_cred(entry) - return credential - def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disable=too-many-statements from msrestazure.tools import is_valid_resource_id from requests import HTTPError @@ -307,29 +284,17 @@ def get_user(self, user=None): return accounts def get_user_credential(self, username): - accounts = self.msal_app.get_accounts(username) - - # TODO: Confirm with MSAL team that username can uniquely identify the account - if not accounts: - raise CLIError("User {} doesn't exist in the credential cache. The user could have been logged out by " - "another application that uses Single Sign-On. " - "Please run `az login` to re-login.".format(username)) - account = accounts[0] from azure.cli.core.msal_authentication import UserCredential - cred = UserCredential(self.client_id, account=account, authority=self.msal_authority, + cred = UserCredential(self.client_id, username=username, authority=self.msal_authority, token_cache=self._load_msal_cache(), verify=self._credential_kwargs.get('connection_verify', True)) return cred - def get_service_principal_credential(self, client_id, use_cert_sn_issuer): - client_secret, certificate_path = \ - self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) + def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): + secret_or_cert = self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) # TODO: support use_cert_sn_issuer in CertificateCredential - if client_secret: - return ClientSecretCredential(self.tenant_id, client_id, client_secret, **self._credential_kwargs) - if certificate_path: - return CertificateCredential(self.tenant_id, client_id, certificate_path, **self._credential_kwargs) - raise CLIError("Secret of service principle {} not found. Please run 'az login'".format(client_id)) + from azure.cli.core.msal_authentication import ServicePrincipalCredential + return ServicePrincipalCredential(client_id, secret_or_cert, authority=self.msal_authority) def get_environment_credential(self): username = os.environ.get('AZURE_USERNAME') @@ -649,7 +614,8 @@ def retrieve_secret_of_service_principal(self, sp_id, tenant): "Trying credential under tenant %s, assuming that is an app credential.", sp_id, tenant, matched[0][_SERVICE_PRINCIPAL_TENANT]) cred = matched[0] - return cred.get(_SERVICE_PRINCIPAL_SECRET, None), cred.get(_SERVICE_PRINCIPAL_CERT_FILE, None) + + return cred.get(_SERVICE_PRINCIPAL_SECRET, None) or cred.get(_SERVICE_PRINCIPAL_CERT_FILE, None) def save_service_principal_cred(self, sp_entry): self._load_cached_creds() diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index a1de5fc6f6d..dec08fe3bdc 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -170,7 +170,7 @@ def login(self, .getboolean('core', 'allow_fallback_to_plaintext', fallback=True), cred_cache=self._adal_cache) - id_token_claims = None + user_id_token_claims = None if not subscription_finder: subscription_finder = SubscriptionFinder(self.cli_ctx, adal_cache=self._adal_cache) if interactive: @@ -181,28 +181,34 @@ def login(self, if not use_device_code: from azure.identity import CredentialUnavailableError try: - id_token_claims = identity.login_with_interactive_browser(scopes=scopes) + user_id_token_claims = identity.login_with_interactive_browser(scopes=scopes) except CredentialUnavailableError: use_device_code = True logger.warning('Not able to launch a browser to log you in, falling back to device code...') if use_device_code: - id_token_claims = identity.login_with_device_code(scopes=scopes) + user_id_token_claims = identity.login_with_device_code(scopes=scopes) else: if is_service_principal: if not tenant: raise CLIError('Please supply tenant using "--tenant"') - if os.path.isfile(password): - credential = identity.login_with_service_principal_certificate(username, password) - else: - credential = identity.login_with_service_principal_secret(username, password) + + identity.login_with_service_principal(username, password) else: - id_token_claims = identity.login_with_username_password(username, password, scopes=scopes) + user_id_token_claims = identity.login_with_username_password(username, password, scopes=scopes) + + if user_id_token_claims: + # AAD returns "preferred_username", ADFS returns "upn" + username = user_id_token_claims.get("preferred_username") or user_id_token_claims["upn"] - username = id_token_claims['preferred_username'] # List tenants and find subscriptions by calling ARM if find_subscriptions: - credential = identity.get_user_credential(username) + # Create credentials + if user_id_token_claims: + credential = identity.get_user_credential(username) + else: + credential = identity.get_service_principal_credential(username) + if tenant: subscriptions = subscription_finder.find_using_specific_tenant(tenant, credential) else: @@ -922,12 +928,12 @@ def find_using_common_tenant(self, username, credential=None): if not hasattr(t, 'display_name'): t.display_name = None - tenant_id_name = tenant_id + t.tenant_id_name = tenant_id if t.display_name: # e.g. '72f988bf-86f1-41af-91ab-2d7cd011db47 Microsoft' - tenant_id_name = "{} '{}'".format(tenant_id, t.display_name) + t.tenant_id_name = "{} '{}'".format(tenant_id, t.display_name) - logger.info("Finding subscriptions under tenant %s", tenant_id_name) + logger.info("Finding subscriptions under tenant %s", t.tenant_id_name) identity = Identity(self.authority, tenant_id, allow_unencrypted=self.cli_ctx.config @@ -979,20 +985,14 @@ def find_using_common_tenant(self, username, credential=None): logger.warning("The following tenants don't contain accessible subscriptions. " "Use 'az login --allow-no-subscriptions' to have tenant level access.") for t in empty_tenants: - if t.display_name: - logger.warning("%s '%s'", t.tenant_id, t.display_name) - else: - logger.warning("%s", t.tenant_id) + logger.warning("%s", t.tenant_id_name) # Show warning for MFA tenants if mfa_tenants: logger.warning("The following tenants require Multi-Factor Authentication (MFA). " "Use 'az login --tenant TENANT_ID' to explicitly login to a tenant.") for t in mfa_tenants: - if t.display_name: - logger.warning("%s '%s'", t.tenant_id, t.display_name) - else: - logger.warning("%s", t.tenant_id) + logger.warning("%s", t.tenant_id_name) return all_subscriptions def find_using_specific_tenant(self, tenant, credential): diff --git a/src/azure-cli-core/azure/cli/core/credential.py b/src/azure-cli-core/azure/cli/core/credential.py index c9c7d9e8c72..0e3e7a9874d 100644 --- a/src/azure-cli-core/azure/cli/core/credential.py +++ b/src/azure-cli-core/azure/cli/core/credential.py @@ -40,7 +40,6 @@ def _get_token(self, scopes=None, **kwargs): external_tenant_tokens = [] # If scopes is not provided, use CLI-managed resource scopes = scopes or resource_to_scopes(self._resource) - logger.debug("Retrieving token from MSAL for scopes %r", scopes) try: token = self._credential.get_token(*scopes, **kwargs) if self._external_credentials: diff --git a/src/azure-cli-core/azure/cli/core/msal_authentication.py b/src/azure-cli-core/azure/cli/core/msal_authentication.py index 1a51b8b0480..cfbbfb851ef 100644 --- a/src/azure-cli-core/azure/cli/core/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/msal_authentication.py @@ -15,6 +15,7 @@ from azure.cli.core.credential import aad_error_handler from azure.core.credentials import AccessToken from knack.log import get_logger +from knack.util import CLIError from msal import PublicClientApplication, ConfidentialClientApplication logger = get_logger(__name__) @@ -22,19 +23,23 @@ class UserCredential(PublicClientApplication): - def __init__(self, client_id, account=None, **kwargs): + def __init__(self, client_id, username=None, **kwargs): super().__init__(client_id, **kwargs) + accounts = self.get_accounts(username) + + # TODO: Confirm with MSAL team that username can uniquely identify the account + if not accounts: + raise CLIError("User {} doesn't exist in the credential cache. The user could have been logged out by " + "another application that uses Single Sign-On. " + "Please run `az login` to re-login.".format(username)) + account = accounts[0] self.account = account def get_token(self, *scopes, **kwargs): - import time - request_time = int(time.time()) - result = self.acquire_token_silent_with_error(list(scopes), self.account, **kwargs) + logger.debug("UserCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs) - if result and "access_token" in result and "expires_in" in result: - return AccessToken(result["access_token"], request_time + int(result["expires_in"])) - else: - aad_error_handler(result) + result = self.acquire_token_silent_with_error(list(scopes), self.account, **kwargs) + return _convert_to_sdk_access_token(result) class ServicePrincipalCredential(ConfidentialClientApplication): @@ -60,4 +65,16 @@ def __init__(self, client_id, secret_or_certificate=None, **kwargs): def get_token(self, *scopes, **kwargs): logger.debug("ServicePrincipalCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs) - return self.acquire_token_for_client(list(scopes), **kwargs) + + result = self.acquire_token_for_client(list(scopes), **kwargs) + return _convert_to_sdk_access_token(result) + + +def _convert_to_sdk_access_token(token_entry): + import time + request_time = int(time.time()) + + if token_entry and "access_token" in token_entry and "expires_in" in token_entry: + return AccessToken(token_entry["access_token"], request_time + int(token_entry["expires_in"])) + else: + aad_error_handler(token_entry) From 77f297dfea26407252e107a2b29b1b8930972e42 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 12 Apr 2021 18:53:34 +0800 Subject: [PATCH 12/69] scopes --- .../azure/cli/core/_identity.py | 27 ++++++++----------- src/azure-cli-core/azure/cli/core/_profile.py | 22 +++++---------- .../azure/cli/core/msal_authentication.py | 21 ++++++++------- .../cli/command_modules/profile/__init__.py | 3 +++ .../cli/command_modules/profile/custom.py | 7 +++-- 5 files changed, 38 insertions(+), 42 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/_identity.py index f0c5af09101..24139a815b3 100644 --- a/src/azure-cli-core/azure/cli/core/_identity.py +++ b/src/azure-cli-core/azure/cli/core/_identity.py @@ -59,6 +59,7 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): """ self.authority = authority self.tenant_id = tenant_id or "organizations" + # Build the authority in MSAL style, like https://login.microsoftonline.com/your_tenant self.msal_authority = "{}/{}".format(self.authority, self.tenant_id) self.client_id = client_id or AZURE_CLI_CLIENT_ID # self._cred_cache = AdalCredentialCache() @@ -104,27 +105,25 @@ def _load_msal_cache(self): cache._reload_if_necessary() # pylint: disable=protected-access return cache - def _build_persistent_msal_app(self, authority): + def _build_persistent_msal_app(self, username=None): # Initialize _msal_app for logout, token migration which Azure Identity doesn't support - from msal import PublicClientApplication - msal_app = PublicClientApplication(authority=authority, client_id=self.client_id, - token_cache=self._load_msal_cache(), - verify=self._credential_kwargs.get('connection_verify', True), - client_capabilities=["CP1"]) + from azure.cli.core.msal_authentication import UserCredential + msal_app = UserCredential(self.client_id, username=username, + authority=self.msal_authority, + token_cache=self._load_msal_cache(), + verify=self._credential_kwargs.get('connection_verify', True), + client_capabilities=["CP1"]) return msal_app @property def msal_app(self): if not self._msal_app_instance: - # Build the authority in MSAL style, like https://login.microsoftonline.com/your_tenant - self._msal_app_instance = self._build_persistent_msal_app(self.msal_authority) + self._msal_app_instance = self._build_persistent_msal_app() return self._msal_app_instance def login_with_interactive_browser(self, scopes=None): result = self.msal_app.acquire_token_interactive(scopes) - if result: - return result['id_token_claims'] - return None + return result['id_token_claims'] def login_with_device_code(self, scopes=None): flow = self.msal_app.initiate_device_flow(scopes) @@ -284,11 +283,7 @@ def get_user(self, user=None): return accounts def get_user_credential(self, username): - from azure.cli.core.msal_authentication import UserCredential - cred = UserCredential(self.client_id, username=username, authority=self.msal_authority, - token_cache=self._load_msal_cache(), - verify=self._credential_kwargs.get('connection_verify', True)) - return cred + return self._build_persistent_msal_app(username) def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): secret_or_cert = self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index dec08fe3bdc..ee13c5e580a 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -135,10 +135,10 @@ def __init__(self, cli_ctx=None, storage=None, auth_ctx_factory=None, use_global self._storage = storage or ACCOUNT self._management_resource_uri = self.cli_ctx.cloud.endpoints.management - self._ad_resource_uri = self.cli_ctx.cloud.endpoints.active_directory_resource_id self._authority = self.cli_ctx.cloud.endpoints.active_directory self._ad = self.cli_ctx.cloud.endpoints.active_directory self._adal_cache = None + self.arm_scope = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) if store_adal_cache: self._adal_cache = AdalCredentialCache() @@ -157,7 +157,8 @@ def login(self, use_cert_sn_issuer=None, find_subscriptions=True): - scopes = self._prepare_authenticate_scopes(scopes) + if not scopes: + scopes = self.arm_scope credential = None auth_record = None @@ -232,7 +233,7 @@ def login(self, return [] else: # Build a tenant account - bare_tenant = tenant or auth_record.tenant_id + bare_tenant = tenant or user_id_token_claims['tid'] subscriptions = self._build_tenant_level_accounts([bare_tenant]) consolidated = self._normalize_properties(username, subscriptions, @@ -253,7 +254,9 @@ def login_with_managed_identity(self, identity_id=None, allow_no_subscriptions=N # Managed identities for Azure resources is the new name for the service formerly known as # Managed Service Identity (MSI). - scopes = self._prepare_authenticate_scopes(scopes) + if not scopes: + scopes = self.arm_scope + identity = Identity() credential, mi_info = identity.login_with_managed_identity(scopes=scopes, identity_id=identity_id) @@ -847,17 +850,6 @@ def get_installation_id(self): self._storage[_INSTALLATION_ID] = installation_id return installation_id - def _prepare_authenticate_scopes(self, scopes): - """Prepare the scopes to be sent to MSAL. If `scopes` is not a list, it will be put into a list.""" - if scopes: - if not isinstance(scopes, (list, tuple)): - # Put scopes into a list - scopes = [scopes] - else: - # If scope is not provided, use the ARM resource ID - scopes = resource_to_scopes(self._ad_resource_uri) - return scopes - # pylint: disable=no-method-argument,no-self-argument,too-few-public-methods class MsiAccountTypes: diff --git a/src/azure-cli-core/azure/cli/core/msal_authentication.py b/src/azure-cli-core/azure/cli/core/msal_authentication.py index cfbbfb851ef..7e4d8f304b3 100644 --- a/src/azure-cli-core/azure/cli/core/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/msal_authentication.py @@ -25,15 +25,18 @@ class UserCredential(PublicClientApplication): def __init__(self, client_id, username=None, **kwargs): super().__init__(client_id, **kwargs) - accounts = self.get_accounts(username) - - # TODO: Confirm with MSAL team that username can uniquely identify the account - if not accounts: - raise CLIError("User {} doesn't exist in the credential cache. The user could have been logged out by " - "another application that uses Single Sign-On. " - "Please run `az login` to re-login.".format(username)) - account = accounts[0] - self.account = account + if username: + accounts = self.get_accounts(username) + + # TODO: Confirm with MSAL team that username can uniquely identify the account + if not accounts: + raise CLIError("User {} doesn't exist in the credential cache. The user could have been logged out by " + "another application that uses Single Sign-On. " + "Please run `az login` to re-login.".format(username)) + account = accounts[0] + self.account = account + else: + self.account = None def get_token(self, *scopes, **kwargs): logger.debug("UserCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs) diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 1dd3f5e5e86..6f0f90cd338 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -76,6 +76,9 @@ def load_arguments(self, command): deprecate_info=c.deprecate(target='--environment', hide=True), help='Use EnvironmentCredential. Both user and service principal accounts are supported. ' 'For required environment variables, see https://docs.microsoft.com/en-us/python/api/overview/azure/identity-readme?view=azure-python#environment-variables') + c.argument('scopes', options_list=['--scope'], nargs="+", + help='A space-separated list of scopes to use in the /authorize request. ' + 'It can cover multiple resources.') with self.argument_context('logout') as c: c.argument('username', options_list=['--username', '-u'], help='account user, if missing, logout the current active account') diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 072dc34d5b7..b71f53511c6 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -111,7 +111,8 @@ def account_clear(cmd, clear_credential=True): # pylint: disable=inconsistent-return-statements, too-many-branches def login(cmd, username=None, password=None, service_principal=None, tenant=None, allow_no_subscriptions=False, - identity=False, use_device_code=False, use_cert_sn_issuer=None, tenant_access=False, environment=False): + identity=False, use_device_code=False, use_cert_sn_issuer=None, tenant_access=False, environment=False, + scopes=None): """Log in to access Azure subscriptions""" from adal.adal_error import AdalError import requests @@ -158,9 +159,11 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None password, service_principal, tenant, + scopes=scopes, use_device_code=use_device_code, allow_no_subscriptions=allow_no_subscriptions, - use_cert_sn_issuer=use_cert_sn_issuer, find_subscriptions=not tenant_access) + use_cert_sn_issuer=use_cert_sn_issuer, + find_subscriptions=not tenant_access) except AdalError as err: # try polish unfriendly server errors if username: From a1e48dba46ce6e92413b39c4e4445fc60e4940c4 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 12 Apr 2021 20:16:10 +0800 Subject: [PATCH 13/69] export --- src/azure-cli-core/azure/cli/core/_identity.py | 6 ------ src/azure-cli/azure/cli/command_modules/profile/__init__.py | 2 +- src/azure-cli/azure/cli/command_modules/profile/_help.py | 1 + 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/_identity.py index 24139a815b3..c850582c854 100644 --- a/src/azure-cli-core/azure/cli/core/_identity.py +++ b/src/azure-cli-core/azure/cli/core/_identity.py @@ -10,12 +10,6 @@ from knack.log import get_logger from azure.identity import ( - AuthenticationRecord, - InteractiveBrowserCredential, - DeviceCodeCredential, - UsernamePasswordCredential, - ClientSecretCredential, - CertificateCredential, ManagedIdentityCredential, EnvironmentCredential, TokenCachePersistenceOptions diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 6f0f90cd338..928bae0d637 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -107,7 +107,7 @@ def load_arguments(self, command): c.argument('clear_credential', clear_credential_type) with self.argument_context('account export-msal-cache') as c: - c.argument('path', help='The path to export the MSAL cache.', default='~/.azure/msal.cache.snapshot.json') + c.argument('path', help='The path to export the MSAL cache.') COMMAND_LOADER_CLS = ProfileCommandsLoader diff --git a/src/azure-cli/azure/cli/command_modules/profile/_help.py b/src/azure-cli/azure/cli/command_modules/profile/_help.py index 9a31b49cbe8..dbd20caa9e1 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/_help.py +++ b/src/azure-cli/azure/cli/command_modules/profile/_help.py @@ -100,6 +100,7 @@ type: command short-summary: Export MSAL cache in plain text. long-summary: > + By default export to '~/.azure/msal.cache.snapshot.json'. The exported cache is unencrypted. It contains login information of all logged-in users. Make sure you protect it safely. From a23cfdd7fd8990eaf5bf234349f0ae5412cd8157 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 14 Apr 2021 11:50:23 +0800 Subject: [PATCH 14/69] Manually merge 17147 --- src/azure-cli-core/azure/cli/core/_profile.py | 23 ++++++++++++---- .../azure/cli/core/tests/test_profile.py | 27 +++++++++++-------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index ee13c5e580a..555c51d3cd1 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -744,15 +744,28 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No def get_msal_token(self, scopes, data): """ - This is added for vmssh feature with backward compatible interface. + This is added for VM SSH feature with backward compatible interface. data contains token_type (ssh-cert), key_id and JWK. """ account = self.get_subscription() username = account[_USER_ENTITY][_USER_NAME] - subscription_id = account[_SUBSCRIPTION_ID] - credential, _, _ = self.get_login_credentials(subscription_id=subscription_id) - certificate = credential.get_token(*scopes, data=data) - return username, certificate.token + tenant = account[_TENANT_ID] or 'common' + app = Identity(authority=self._authority, tenant_id=tenant).msal_app + msal_accounts = app.get_accounts(username)[0] + result = app.acquire_token_silent_with_error(scopes, msal_accounts, data=data) + + # If acquire_token_silent_with_error failed, interactively get new RT and AT + if not result or 'error' in result: + if result: + logger.warning(result['error_description']) + + # Retry login with VM SSH as resource + result = app.acquire_token_interactive(scopes, prompt='select_account', data=data) + + if 'error' in result: + from azure.cli.core.credential import aad_error_handler + aad_error_handler(result) + return username, result["access_token"] def refresh_accounts(self, subscription_finder=None): subscriptions = self.load_cached_subscriptions() diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index 928ecd8e5c8..5175b5cd8eb 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -1854,15 +1854,9 @@ def test_get_access_token_for_scopes(self, get_user_credential_mock): credential_mock.get_token.assert_called_with(*self.msal_scopes) self.assertEqual(token, self.raw_token1) - @mock.patch('azure.cli.core._identity.Identity.get_user_credential', autospec=True) - def test_get_msal_token(self, get_user_credential_mock): - """ - This is added only for vmssh feature. - It is a temporary solution and will deprecate after MSAL adopted completely. - """ - credential_mock = get_user_credential_mock.return_value - credential_mock.get_token.return_value = self.access_token - + @mock.patch('msal.PublicClientApplication.acquire_token_silent_with_error', autospec=True) + @mock.patch('msal.PublicClientApplication.get_accounts', autospec=True) + def test_get_msal_token(self, get_accounts_mock, acquire_token_silent_with_error_mock): cli = DummyCli() storage_mock = {'subscriptions': None} profile = Profile(cli_ctx=cli, storage=storage_mock) @@ -1876,10 +1870,21 @@ def test_get_msal_token(self, get_user_credential_mock): "req_cnf": "fake_jwk", "key_id": "fake_id" } + mock_return_value = { + 'token_type': 'ssh-cert', + 'scope': 'https://pas.windows.net/CheckMyAccess/Linux/user_impersonation https://pas.windows.net/CheckMyAccess/Linux/.default', + 'expires_in': 3599, + 'ext_expires_in': 3599, + 'access_token': 'fake access token', + 'refresh_token': 'fake refresh token', + 'id_token': 'fake id token' + } + acquire_token_silent_with_error_mock.return_value = mock_return_value + username, access_token = profile.get_msal_token(scopes, data) self.assertEqual(username, self.user1) - self.assertEqual(access_token, self.raw_token1) - credential_mock.get_token.assert_called_with(*scopes, data=data) + self.assertEqual(access_token, 'fake access token') + acquire_token_silent_with_error_mock.assert_called_with(mock.ANY, scopes, get_accounts_mock.return_value[0], data=data) class FileHandleStub(object): # pylint: disable=too-few-public-methods From e6eb3ee3ef44fdcc5a5b3cc0dd7536ba05570137 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 14 Apr 2021 14:43:24 +0800 Subject: [PATCH 15/69] refactor --- src/azure-cli-core/azure/cli/core/_msal.py | 0 src/azure-cli-core/azure/cli/core/_profile.py | 16 ++-- .../azure/cli/core/auth/__init__.py | 8 ++ .../azure/cli/core/{ => auth}/_msal_patch.py | 0 .../azure/cli/core/{ => auth}/credential.py | 50 +---------- .../core/{_identity.py => auth/identity.py} | 26 +++--- .../core/{ => auth}/msal_authentication.py | 3 +- .../azure/cli/core/auth/util.py | 85 +++++++++++++++++++ .../cli/core/auth_landing_pages/fail.html | 11 --- .../azure/cli/core/auth_landing_pages/ok.html | 12 --- .../azure/cli/core/commands/client_factory.py | 6 +- src/azure-cli-core/azure/cli/core/util.py | 37 -------- 12 files changed, 122 insertions(+), 132 deletions(-) delete mode 100644 src/azure-cli-core/azure/cli/core/_msal.py create mode 100644 src/azure-cli-core/azure/cli/core/auth/__init__.py rename src/azure-cli-core/azure/cli/core/{ => auth}/_msal_patch.py (100%) rename src/azure-cli-core/azure/cli/core/{ => auth}/credential.py (76%) rename src/azure-cli-core/azure/cli/core/{_identity.py => auth/identity.py} (98%) rename src/azure-cli-core/azure/cli/core/{ => auth}/msal_authentication.py (98%) create mode 100644 src/azure-cli-core/azure/cli/core/auth/util.py delete mode 100644 src/azure-cli-core/azure/cli/core/auth_landing_pages/fail.html delete mode 100644 src/azure-cli-core/azure/cli/core/auth_landing_pages/ok.html diff --git a/src/azure-cli-core/azure/cli/core/_msal.py b/src/azure-cli-core/azure/cli/core/_msal.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 555c51d3cd1..a658affb5cd 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -14,9 +14,9 @@ from knack.log import get_logger from knack.util import CLIError from azure.cli.core._session import ACCOUNT -from azure.cli.core.util import in_cloud_console, can_launch_browser, resource_to_scopes +from azure.cli.core.util import in_cloud_console, can_launch_browser from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription -from azure.cli.core._identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID +from azure.cli.core.auth import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID, resource_to_scopes logger = get_logger(__name__) @@ -710,7 +710,7 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N external_credentials = [] for sub_tenant_id in external_tenants_info: external_credentials.append(self._create_identity_credential(account, sub_tenant_id, client_id=client_id)) - from azure.cli.core.credential import CredentialAdaptor + from azure.cli.core.auth import CredentialAdaptor auth_object = CredentialAdaptor(identity_credential, external_credentials=external_credentials if external_credentials else None, resource=resource) @@ -733,7 +733,7 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No account = self.get_subscription(subscription) identity_credential = self._create_identity_credential(account, tenant) - from azure.cli.core.credential import CredentialAdaptor, _convert_token_entry + from azure.cli.core.auth import CredentialAdaptor auth = CredentialAdaptor(identity_credential) token = auth.get_token(*scopes) # (tokenType, accessToken, tokenEntry) @@ -760,10 +760,10 @@ def get_msal_token(self, scopes, data): logger.warning(result['error_description']) # Retry login with VM SSH as resource - result = app.acquire_token_interactive(scopes, prompt='select_account', data=data) + result = app.acquire_token_interactive(scopes, login_hint=username, data=data) if 'error' in result: - from azure.cli.core.credential import aad_error_handler + from azure.cli.core.auth import aad_error_handler aad_error_handler(result) return username, result["access_token"] @@ -921,8 +921,6 @@ def find_using_common_tenant(self, username, credential=None): empty_tenants = [] mfa_tenants = [] - from azure.cli.core.credential import CredentialAdaptor - credential = CredentialAdaptor(credential) client = self._arm_client_factory(credential) tenants = client.tenants.list() @@ -1001,7 +999,7 @@ def find_using_common_tenant(self, username, credential=None): return all_subscriptions def find_using_specific_tenant(self, tenant, credential): - from azure.cli.core.credential import CredentialAdaptor + from azure.cli.core.auth import CredentialAdaptor track1_credential = CredentialAdaptor(credential) client = self._arm_client_factory(track1_credential) subscriptions = client.subscriptions.list() diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py new file mode 100644 index 00000000000..9ea9ed8674f --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -0,0 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from .credential import CredentialAdaptor +from .identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID +from .util import resource_to_scopes, aad_error_handler diff --git a/src/azure-cli-core/azure/cli/core/_msal_patch.py b/src/azure-cli-core/azure/cli/core/auth/_msal_patch.py similarity index 100% rename from src/azure-cli-core/azure/cli/core/_msal_patch.py rename to src/azure-cli-core/azure/cli/core/auth/_msal_patch.py diff --git a/src/azure-cli-core/azure/cli/core/credential.py b/src/azure-cli-core/azure/cli/core/auth/credential.py similarity index 76% rename from src/azure-cli-core/azure/cli/core/credential.py rename to src/azure-cli-core/azure/cli/core/auth/credential.py index 0e3e7a9874d..4274809acd1 100644 --- a/src/azure-cli-core/azure/cli/core/credential.py +++ b/src/azure-cli-core/azure/cli/core/auth/credential.py @@ -3,18 +3,18 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import json from typing import Tuple, List -import json import requests -from azure.cli.core._identity import resource_to_scopes from azure.cli.core.util import in_cloud_console from azure.core.credentials import AccessToken from azure.identity import CredentialUnavailableError, AuthenticationRequiredError - from knack.log import get_logger from knack.util import CLIError +from .util import resource_to_scopes, aad_error_handler + logger = get_logger(__name__) @@ -109,47 +109,3 @@ def _normalize_scopes(scopes): return scopes[1:] return scopes - - -def _generate_login_command(scopes=None, claims=None): - login_command = ['az login'] - - if scopes: - login_command.append('--scope {}'.format(' '.join(scopes))) - - if claims: - import base64 - try: - base64.urlsafe_b64decode(claims) - is_base64 = True - except ValueError: - is_base64 = False - - if not is_base64: - claims = base64.urlsafe_b64encode(claims.encode()).decode() - - login_command.append('--claims {}'.format(claims)) - - return ' '.join(login_command) - - -def _generate_login_message(**kwargs): - login_command = _generate_login_command(**kwargs) - login_command = 'az logout\naz login' - msg = "To re-authenticate, please {}" \ - "If the problem persists, please contact your tenant administrator.".format( - "refresh Azure Portal." if in_cloud_console() else "run:\n{}\n".format(login_command)) - - return msg - - -def aad_error_handler(error, scopes=None, claims=None): - """ Handle the error from AAD server returned by ADAL or MSAL. """ - - # https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes - # Search for an error code at https://login.microsoftonline.com/error - msg = error.get('error_description') - login_message = _generate_login_message(scopes=scopes, claims=claims) - - from azure.cli.core.azclierror import AuthenticationError - raise AuthenticationError(msg, recommendation=login_message) diff --git a/src/azure-cli-core/azure/cli/core/_identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py similarity index 98% rename from src/azure-cli-core/azure/cli/core/_identity.py rename to src/azure-cli-core/azure/cli/core/auth/identity.py index c850582c854..d17926bc2d0 100644 --- a/src/azure-cli-core/azure/cli/core/_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -3,25 +3,23 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import os import json +import os -from knack.util import CLIError -from knack.log import get_logger - +from azure.cli.core._environment import get_config_dir +from azure.cli.core.util import get_file_json from azure.identity import ( ManagedIdentityCredential, EnvironmentCredential, TokenCachePersistenceOptions ) +from knack.log import get_logger +from knack.util import CLIError -from ._environment import get_config_dir -from .util import get_file_json, resource_to_scopes, scopes_to_resource +from .util import aad_error_handler, resource_to_scopes, scopes_to_resource AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' -logger = get_logger(__name__) - _SERVICE_PRINCIPAL_ID = 'servicePrincipalId' _SERVICE_PRINCIPAL_TENANT = 'servicePrincipalTenant' _ACCESS_TOKEN = 'accessToken' @@ -29,6 +27,8 @@ _SERVICE_PRINCIPAL_CERT_FILE = 'certificateFile' _SERVICE_PRINCIPAL_CERT_THUMBPRINT = 'thumbprint' +logger = get_logger(__name__) + class Identity: # pylint: disable=too-many-instance-attributes """Class to interact with Azure Identity. @@ -101,7 +101,7 @@ def _load_msal_cache(self): def _build_persistent_msal_app(self, username=None): # Initialize _msal_app for logout, token migration which Azure Identity doesn't support - from azure.cli.core.msal_authentication import UserCredential + from .msal_authentication import UserCredential msal_app = UserCredential(self.client_id, username=username, authority=self.msal_authority, token_cache=self._load_msal_cache(), @@ -115,8 +115,10 @@ def msal_app(self): self._msal_app_instance = self._build_persistent_msal_app() return self._msal_app_instance - def login_with_interactive_browser(self, scopes=None): - result = self.msal_app.acquire_token_interactive(scopes) + def login_with_interactive_browser(self, scopes=None, **kwargs): + result = self.msal_app.acquire_token_interactive(scopes, **kwargs) + if not result or 'error' in result: + aad_error_handler(result) return result['id_token_claims'] def login_with_device_code(self, scopes=None): @@ -282,7 +284,7 @@ def get_user_credential(self, username): def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): secret_or_cert = self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) # TODO: support use_cert_sn_issuer in CertificateCredential - from azure.cli.core.msal_authentication import ServicePrincipalCredential + from .msal_authentication import ServicePrincipalCredential return ServicePrincipalCredential(client_id, secret_or_cert, authority=self.msal_authority) def get_environment_credential(self): diff --git a/src/azure-cli-core/azure/cli/core/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py similarity index 98% rename from src/azure-cli-core/azure/cli/core/msal_authentication.py rename to src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index 7e4d8f304b3..2c0e1fa7441 100644 --- a/src/azure-cli-core/azure/cli/core/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -12,12 +12,13 @@ import os -from azure.cli.core.credential import aad_error_handler from azure.core.credentials import AccessToken from knack.log import get_logger from knack.util import CLIError from msal import PublicClientApplication, ConfidentialClientApplication +from .util import aad_error_handler + logger = get_logger(__name__) diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py new file mode 100644 index 00000000000..26ded15eed7 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -0,0 +1,85 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +def aad_error_handler(error, scopes=None, claims=None): + """ Handle the error from AAD server returned by ADAL or MSAL. """ + + # https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes + # Search for an error code at https://login.microsoftonline.com/error + msg = error.get('error_description') + login_message = _generate_login_message(scopes=scopes, claims=claims) + + from azure.cli.core.azclierror import AuthenticationError + raise AuthenticationError(msg, recommendation=login_message) + + +def _generate_login_command(scopes=None, claims=None): + login_command = ['az login'] + + if scopes: + login_command.append('--scope {}'.format(' '.join(scopes))) + + if claims: + import base64 + try: + base64.urlsafe_b64decode(claims) + is_base64 = True + except ValueError: + is_base64 = False + + if not is_base64: + claims = base64.urlsafe_b64encode(claims.encode()).decode() + + login_command.append('--claims {}'.format(claims)) + + return ' '.join(login_command) + + +def _generate_login_message(**kwargs): + from azure.cli.core.util import in_cloud_console + login_command = _generate_login_command(**kwargs) + login_command = 'az logout\naz login' + msg = "To re-authenticate, please {}" \ + "If the problem persists, please contact your tenant administrator.".format( + "refresh Azure Portal." if in_cloud_console() else "run:\n{}\n".format(login_command)) + + return msg + + +def resource_to_scopes(resource): + """Convert the ADAL resource ID to MSAL scopes by appending the /.default suffix and return a list. + For example: + 'https://management.core.windows.net/' -> ['https://management.core.windows.net//.default'] + 'https://managedhsm.azure.com' -> ['https://managedhsm.azure.com/.default'] + + :param resource: The ADAL resource ID + :return: A list of scopes + """ + # https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#trailing-slash-and-default + # We should not trim the trailing slash, like in https://management.azure.com/ + # In other word, the trailing slash should be preserved and scope should be https://management.azure.com//.default + scope = resource + '/.default' + return [scope] + + +def scopes_to_resource(scopes): + """Convert MSAL scopes to ADAL resource by stripping the /.default suffix and return a str. + For example: + ['https://management.core.windows.net//.default'] -> 'https://management.core.windows.net/' + ['https://managedhsm.azure.com/.default'] -> 'https://managedhsm.azure.com' + + :param scopes: The MSAL scopes. It can be a list or tuple of string + :return: The ADAL resource + :rtype: str + """ + scope = scopes[0] + + suffixes = ['/.default', '/user_impersonation'] + + for s in suffixes: + if scope.endswith(s): + return scope[:-len(s)] + + return scope diff --git a/src/azure-cli-core/azure/cli/core/auth_landing_pages/fail.html b/src/azure-cli-core/azure/cli/core/auth_landing_pages/fail.html deleted file mode 100644 index e4635b0ea58..00000000000 --- a/src/azure-cli-core/azure/cli/core/auth_landing_pages/fail.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - Login failed - - -

Some failures occurred during the authentication

-

You can log an issue at Azure CLI GitHub Repository and we will assist you in resolving it.

- - diff --git a/src/azure-cli-core/azure/cli/core/auth_landing_pages/ok.html b/src/azure-cli-core/azure/cli/core/auth_landing_pages/ok.html deleted file mode 100644 index 8d506ffce17..00000000000 --- a/src/azure-cli-core/azure/cli/core/auth_landing_pages/ok.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Login successfully - - -

You have logged into Microsoft Azure!

-

You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.

- - \ No newline at end of file diff --git a/src/azure-cli-core/azure/cli/core/commands/client_factory.py b/src/azure-cli-core/azure/cli/core/commands/client_factory.py index 6ba5cad3850..39657934fd0 100644 --- a/src/azure-cli-core/azure/cli/core/commands/client_factory.py +++ b/src/azure-cli-core/azure/cli/core/commands/client_factory.py @@ -4,11 +4,11 @@ # -------------------------------------------------------------------------------------------- import azure.cli.core._debug as _debug +from azure.cli.core.auth.util import resource_to_scopes from azure.cli.core.extension import EXTENSIONS_MOD_PREFIX -from azure.cli.core.profiles._shared import get_client_class, SDKProfile from azure.cli.core.profiles import ResourceType, CustomResourceType, get_api_version, get_sdk -from azure.cli.core.util import get_az_user_agent, is_track2, resource_to_scopes - +from azure.cli.core.profiles._shared import get_client_class, SDKProfile +from azure.cli.core.util import get_az_user_agent, is_track2 from knack.log import get_logger from knack.util import CLIError diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index 8c944e17fa6..80c137ae8f1 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -1195,43 +1195,6 @@ def handle_version_update(): logger.warning(ex) -def resource_to_scopes(resource): - """Convert the ADAL resource ID to MSAL scopes by appending the /.default suffix and return a list. - For example: - 'https://management.core.windows.net/' -> ['https://management.core.windows.net//.default'] - 'https://managedhsm.azure.com' -> ['https://managedhsm.azure.com/.default'] - - :param resource: The ADAL resource ID - :return: A list of scopes - """ - # https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#trailing-slash-and-default - # We should not trim the trailing slash, like in https://management.azure.com/ - # In other word, the trailing slash should be preserved and scope should be https://management.azure.com//.default - scope = resource + '/.default' - return [scope] - - -def scopes_to_resource(scopes): - """Convert MSAL scopes to ADAL resource by stripping the /.default suffix and return a str. - For example: - ['https://management.core.windows.net//.default'] -> 'https://management.core.windows.net/' - ['https://managedhsm.azure.com/.default'] -> 'https://managedhsm.azure.com' - - :param scopes: The MSAL scopes. It can be a list or tuple of string - :return: The ADAL resource - :rtype: str - """ - scope = scopes[0] - - suffixes = ['/.default', '/user_impersonation'] - - for s in suffixes: - if scope.endswith(s): - return scope[:-len(s)] - - return scope - - def _get_parent_proc_name(): # Un-cached function to get parent process name. try: From 03818b4152d9b43e869b2d43d4a3e50f76cdfa33 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 14 Apr 2021 17:26:59 +0800 Subject: [PATCH 16/69] sp for vm ssh --- src/azure-cli-core/azure/cli/core/_profile.py | 48 ++++++++++++------- .../cli/core/auth/msal_authentication.py | 2 +- .../cli/command_modules/profile/custom.py | 2 +- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index a658affb5cd..0053ad9a826 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -748,24 +748,36 @@ def get_msal_token(self, scopes, data): data contains token_type (ssh-cert), key_id and JWK. """ account = self.get_subscription() - username = account[_USER_ENTITY][_USER_NAME] - tenant = account[_TENANT_ID] or 'common' - app = Identity(authority=self._authority, tenant_id=tenant).msal_app - msal_accounts = app.get_accounts(username)[0] - result = app.acquire_token_silent_with_error(scopes, msal_accounts, data=data) - - # If acquire_token_silent_with_error failed, interactively get new RT and AT - if not result or 'error' in result: - if result: - logger.warning(result['error_description']) - - # Retry login with VM SSH as resource - result = app.acquire_token_interactive(scopes, login_hint=username, data=data) - - if 'error' in result: - from azure.cli.core.auth import aad_error_handler - aad_error_handler(result) - return username, result["access_token"] + identity_type = account[_USER_ENTITY][_USER_TYPE] + username_or_sp_id = account[_USER_ENTITY][_USER_NAME] + tenant = account[_TENANT_ID] + identity = Identity(authority=self._authority, tenant_id=tenant) + + if identity_type == _USER: + username = username_or_sp_id + app = identity.get_user_credential(username) + result = app.acquire_token_silent_with_error(scopes, app.account, data=data) + + # If acquire_token_silent_with_error failed, interactively get new RT and AT + if not result or 'error' in result: + if result: + logger.warning(result['error_description']) + + # Retry login with VM SSH as resource + result = app.acquire_token_interactive(scopes, login_hint=username, data=data) + + elif identity_type == _SERVICE_PRINCIPAL: + app = identity.get_service_principal_credential(username_or_sp_id) + result = app.acquire_token_for_client(scopes, data=data) + + else: + raise CLIError("Identity type {} is currently unsupported".format(identity_type)) + + if 'error' in result: + from azure.cli.core.auth import aad_error_handler + aad_error_handler(result) + + return username_or_sp_id, result["access_token"] def refresh_accounts(self, subscription_finder=None): subscriptions = self.load_cached_subscriptions() diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index 2c0e1fa7441..d8734bfc67d 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -29,7 +29,7 @@ def __init__(self, client_id, username=None, **kwargs): if username: accounts = self.get_accounts(username) - # TODO: Confirm with MSAL team that username can uniquely identify the account + # TODO: Confirm with AAD team that username can uniquely identify the account if not accounts: raise CLIError("User {} doesn't exist in the credential cache. The user could have been logged out by " "another application that uses Single Sign-On. " diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index b71f53511c6..4af90edeff2 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -208,7 +208,7 @@ def list_locations(cmd): def export_msal_cache(cmd, path=None): # pylint: disable=unused-argument - from azure.cli.core._identity import Identity + from azure.cli.core.auth import Identity identity = Identity() identity.serialize_token_cache(path) From 0a105e0df983aae754f4baf29347ef17f524343f Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 14 Apr 2021 18:15:41 +0800 Subject: [PATCH 17/69] refactor --- .github/CODEOWNERS | 3 +-- src/azure-cli-core/azure/cli/core/_profile.py | 9 ++++----- src/azure-cli-core/azure/cli/core/auth/__init__.py | 4 ++-- .../core/auth/{credential.py => credential_adaptor.py} | 6 ------ .../azure/cli/core/auth/msal_authentication.py | 6 +++++- src/azure-cli-core/azure/cli/core/auth/util.py | 6 ++++++ src/azure-cli-core/azure/cli/core/azclierror.py | 2 +- .../azure/cli/core/tests/test_credential.py | 2 +- .../test_app_service_environment_commands_thru_mock.py | 2 +- .../tests/latest/test_functionapp_commands_thru_mock.py | 2 +- .../tests/latest/test_webapp_commands_thru_mock.py | 2 +- 11 files changed, 23 insertions(+), 21 deletions(-) rename src/azure-cli-core/azure/cli/core/auth/{credential.py => credential_adaptor.py} (95%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index daebf830641..8f576a87479 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,10 +12,9 @@ /scripts/live_test @qwordy /src/azure-cli-testsdk/ @jsntcy @jiasli @kairu-ms @qwordy /src/azure-cli-core/ @jiasli @Juliehzl @fengzhou-msft @evelyn-ys @jsntcy @houk-ms +/src/azure-cli-core/azure/cli/core/auth/ @jiasli /src/azure-cli-core/azure/cli/core/extension/ @msyyc @fengzhou-msft /src/azure-cli-core/azure/cli/core/_profile.py @jiasli @evelyn-ys -/src/azure-cli-core/azure/cli/core/adal_authentication.py @jiasli @evelyn-ys -/src/azure-cli-core/azure/cli/core/msal_authentication.py @jiasli @evelyn-ys /src/azure-cli-core/azure/cli/core/style.py @jiasli @evelyn-ys @zhoxing-ms /src/azure-cli/azure/cli/command_modules/acr/ @djyou @fengzhou-msft @yungezz /src/azure-cli/azure/cli/command_modules/acs/ @rjtsdl @fengzhou-msft diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 0053ad9a826..8f4f2ad3147 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -731,13 +731,12 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No raise CLIError("Please specify only one of subscription and tenant, not both") account = self.get_subscription(subscription) - identity_credential = self._create_identity_credential(account, tenant) + cred = self._create_identity_credential(account, tenant) - from azure.cli.core.auth import CredentialAdaptor - auth = CredentialAdaptor(identity_credential) - token = auth.get_token(*scopes) + from azure.cli.core.auth import sdk_access_token_to_adal_token_entry + token = cred.get_token(*scopes) # (tokenType, accessToken, tokenEntry) - cred = 'Bearer', token.token, _convert_token_entry(token) + cred = 'Bearer', token.token, sdk_access_token_to_adal_token_entry(token) return (cred, None if tenant else str(account[_SUBSCRIPTION_ID]), str(tenant if tenant else account[_TENANT_ID])) diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py index 9ea9ed8674f..6bb94254f4d 100644 --- a/src/azure-cli-core/azure/cli/core/auth/__init__.py +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -3,6 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from .credential import CredentialAdaptor +from .credential_adaptor import CredentialAdaptor from .identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID -from .util import resource_to_scopes, aad_error_handler +from .util import resource_to_scopes, aad_error_handler, sdk_access_token_to_adal_token_entry diff --git a/src/azure-cli-core/azure/cli/core/auth/credential.py b/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py similarity index 95% rename from src/azure-cli-core/azure/cli/core/auth/credential.py rename to src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py index 4274809acd1..ba733074c76 100644 --- a/src/azure-cli-core/azure/cli/core/auth/credential.py +++ b/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py @@ -18,12 +18,6 @@ logger = get_logger(__name__) -def _convert_token_entry(token): - import datetime - return {'accessToken': token.token, - 'expiresOn': datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")} - - class CredentialAdaptor: """Adaptor to both - Track 1: msrest.authentication.Authentication, which exposes signed_session diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index d8734bfc67d..44035913d42 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -29,11 +29,15 @@ def __init__(self, client_id, username=None, **kwargs): if username: accounts = self.get_accounts(username) - # TODO: Confirm with AAD team that username can uniquely identify the account if not accounts: raise CLIError("User {} doesn't exist in the credential cache. The user could have been logged out by " "another application that uses Single Sign-On. " "Please run `az login` to re-login.".format(username)) + + if len(accounts) > 1: + raise CLIError("Found multiple accounts with the same username. Please report to us via Github: " + "https://github.com/Azure/azure-cli/issues/new") + account = accounts[0] self.account = account else: diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index 26ded15eed7..e74af46936d 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -83,3 +83,9 @@ def scopes_to_resource(scopes): return scope[:-len(s)] return scope + + +def sdk_access_token_to_adal_token_entry(token): + import datetime + return {'accessToken': token.token, + 'expiresOn': datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")} diff --git a/src/azure-cli-core/azure/cli/core/azclierror.py b/src/azure-cli-core/azure/cli/core/azclierror.py index 6a707998e45..32b5710cd7e 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -187,7 +187,7 @@ def _extract_claims(challenge): claims = _extract_claims(original_error.response.headers.get('WWW-Authenticate')) - from azure.cli.core.credential import _generate_login_command, _generate_login_message + from azure.cli.core.auth import _generate_login_command, _generate_login_message # login_command = _generate_login_command(claims=claims) login_message = _generate_login_message(claims=claims) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_credential.py b/src/azure-cli-core/azure/cli/core/tests/test_credential.py index a2d75827517..2a9d8e1245e 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_credential.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_credential.py @@ -5,7 +5,7 @@ import unittest -from azure.cli.core.credential import _generate_login_command +from azure.cli.core.auth import _generate_login_command class TestUtils(unittest.TestCase): diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py index 33a9b6bdc31..9c57bb0000c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py @@ -14,7 +14,7 @@ from azure.mgmt.web import WebSiteManagementClient from azure.mgmt.web.models import HostingEnvironmentProfile from azure.mgmt.network.models import (Subnet, RouteTable, Route, NetworkSecurityGroup, SecurityRule, Delegation) -from azure.cli.core.credential import CredentialAdaptor +from azure.cli.core.auth import CredentialAdaptor from azure.cli.command_modules.appservice.appservice_environment import (show_appserviceenvironment, list_appserviceenvironments, diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py index cdac0a00969..2dc4d02685c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py @@ -7,7 +7,7 @@ import os from azure.mgmt.web import WebSiteManagementClient -from azure.cli.core.credential import CredentialAdaptor +from azure.cli.core.auth import CredentialAdaptor from knack.util import CLIError from azure.cli.command_modules.appservice.custom import ( enable_zip_deploy_functionapp, diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 49dd144098f..23b0f37671c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -8,7 +8,7 @@ from msrestazure.azure_exceptions import CloudError from azure.mgmt.web import WebSiteManagementClient -from azure.cli.core.credential import CredentialAdaptor +from azure.cli.core.auth import CredentialAdaptor from knack.util import CLIError from azure.cli.command_modules.appservice.custom import (set_deployment_user, update_git_token, add_hostname, From 9541ba8bd1be4b7a6aa90d3c0495ad5d64e4f961 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 14 Apr 2021 18:32:16 +0800 Subject: [PATCH 18/69] sp token cache --- .../azure/cli/core/auth/identity.py | 21 ++++++++++--------- .../cli/core/auth/msal_authentication.py | 5 ++++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index d17926bc2d0..0013ddae2be 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -16,6 +16,7 @@ from knack.log import get_logger from knack.util import CLIError +from .msal_authentication import UserCredential, ServicePrincipalCredential from .util import aad_error_handler, resource_to_scopes, scopes_to_resource AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' @@ -63,6 +64,11 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): # Store for Service principal credential persistence self._msal_secret_store = MsalSecretStore(fallback_to_plaintext=self.allow_unencrypted) self._cache_persistence_options = TokenCachePersistenceOptions(name="azcli", allow_unencrypted_storage=True) + self._msal_app_kwargs = { + "authority": self.msal_authority, + "token_cache": self._load_msal_cache(), + "client_capabilities": ["CP1"] + } # TODO: Allow disabling SSL verification # The underlying requests lib of MSAL has been patched with Azure Core by MsalTransportAdapter @@ -99,14 +105,10 @@ def _load_msal_cache(self): cache._reload_if_necessary() # pylint: disable=protected-access return cache - def _build_persistent_msal_app(self, username=None): + def _build_persistent_msal_app(self): # Initialize _msal_app for logout, token migration which Azure Identity doesn't support - from .msal_authentication import UserCredential - msal_app = UserCredential(self.client_id, username=username, - authority=self.msal_authority, - token_cache=self._load_msal_cache(), - verify=self._credential_kwargs.get('connection_verify', True), - client_capabilities=["CP1"]) + from msal import PublicClientApplication + msal_app = PublicClientApplication(self.client_id, **self._msal_app_kwargs) return msal_app @property @@ -279,13 +281,12 @@ def get_user(self, user=None): return accounts def get_user_credential(self, username): - return self._build_persistent_msal_app(username) + return UserCredential(self.client_id, username, **self._msal_app_kwargs) def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): secret_or_cert = self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) # TODO: support use_cert_sn_issuer in CertificateCredential - from .msal_authentication import ServicePrincipalCredential - return ServicePrincipalCredential(client_id, secret_or_cert, authority=self.msal_authority) + return ServicePrincipalCredential(client_id, secret_or_cert, **self._msal_app_kwargs) def get_environment_credential(self): username = os.environ.get('AZURE_USERNAME') diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index 44035913d42..a8b7e209616 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -74,7 +74,10 @@ def __init__(self, client_id, secret_or_certificate=None, **kwargs): def get_token(self, *scopes, **kwargs): logger.debug("ServicePrincipalCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs) - result = self.acquire_token_for_client(list(scopes), **kwargs) + scopes = list(scopes) + result = self.acquire_token_silent(scopes, None, **kwargs) + if not result: + result = self.acquire_token_for_client(scopes, **kwargs) return _convert_to_sdk_access_token(result) From 3de1591e22c383dece1561cb5ef6ae3bf87ea10f Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Thu, 15 Apr 2021 16:18:02 +0800 Subject: [PATCH 19/69] check_result --- src/azure-cli-core/azure/cli/core/_profile.py | 45 ++++++------------- .../azure/cli/core/auth/identity.py | 17 ++++--- .../azure/cli/core/auth/util.py | 20 +++++++++ .../azure/cli/core/azclierror.py | 5 ++- .../cli/command_modules/profile/__init__.py | 2 +- .../cli/command_modules/profile/custom.py | 2 +- 6 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 8f4f2ad3147..2788bffdd9c 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -114,20 +114,8 @@ def _attach_token_tenant(subscription, tenant): # pylint: disable=too-many-lines,too-many-instance-attributes,unused-argument class Profile: - def __init__(self, cli_ctx=None, storage=None, auth_ctx_factory=None, use_global_creds_cache=True, - async_persist=True, store_adal_cache=False): + def __init__(self, cli_ctx=None, storage=None): """Class to manage CLI's accounts (profiles) and identities (credentials). - - :param cli_ctx: - :param storage: - :param auth_ctx_factory: - :param use_global_creds_cache: - :param async_persist: - :param client_id: The AAD client ID for the CLI application. Default to Azure CLI's client ID. - :param scopes: The initial scopes for authentication (/authorize), it must include all scopes - for following get_token calls. Default to Azure Resource Manager of the current cloud. - :param store_adal_cache: Save tokens to the old ~/.azure/accessToken.json for backward compatibility. - This option will be deprecated very soon. """ from azure.cli.core import get_default_cli @@ -138,9 +126,7 @@ def __init__(self, cli_ctx=None, storage=None, auth_ctx_factory=None, use_global self._authority = self.cli_ctx.cloud.endpoints.active_directory self._ad = self.cli_ctx.cloud.endpoints.active_directory self._adal_cache = None - self.arm_scope = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) - if store_adal_cache: - self._adal_cache = AdalCredentialCache() + self._arm_scope = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) # pylint: disable=too-many-branches,too-many-statements,too-many-locals def login(self, @@ -158,10 +144,8 @@ def login(self, find_subscriptions=True): if not scopes: - scopes = self.arm_scope + scopes = self._arm_scope - credential = None - auth_record = None # For ADFS, auth_tenant is 'adfs' # https://github.com/Azure/azure-sdk-for-python/blob/661cd524e88f480c14220ed1f86de06aaff9a977/sdk/identity/azure-identity/CHANGELOG.md#L19 authority, auth_tenant = _detect_adfs_authority(self.cli_ctx.cloud.endpoints.active_directory, tenant) @@ -171,9 +155,7 @@ def login(self, .getboolean('core', 'allow_fallback_to_plaintext', fallback=True), cred_cache=self._adal_cache) - user_id_token_claims = None - if not subscription_finder: - subscription_finder = SubscriptionFinder(self.cli_ctx, adal_cache=self._adal_cache) + user_identity = None if interactive: if not use_device_code and (in_cloud_console() or not can_launch_browser()): logger.info('Detect no GUI is available, so fall back to device code') @@ -182,30 +164,29 @@ def login(self, if not use_device_code: from azure.identity import CredentialUnavailableError try: - user_id_token_claims = identity.login_with_interactive_browser(scopes=scopes) + user_identity = identity.login_with_interactive_browser(scopes=scopes) except CredentialUnavailableError: use_device_code = True logger.warning('Not able to launch a browser to log you in, falling back to device code...') if use_device_code: - user_id_token_claims = identity.login_with_device_code(scopes=scopes) + user_identity = identity.login_with_device_code(scopes=scopes) else: if is_service_principal: if not tenant: raise CLIError('Please supply tenant using "--tenant"') - identity.login_with_service_principal(username, password) + identity.login_with_service_principal(username, password, scopes=scopes) else: - user_id_token_claims = identity.login_with_username_password(username, password, scopes=scopes) + user_identity = identity.login_with_username_password(username, password, scopes=scopes) - if user_id_token_claims: - # AAD returns "preferred_username", ADFS returns "upn" - username = user_id_token_claims.get("preferred_username") or user_id_token_claims["upn"] + if user_identity: + username = user_identity['username'] # List tenants and find subscriptions by calling ARM if find_subscriptions: # Create credentials - if user_id_token_claims: + if user_identity: credential = identity.get_user_credential(username) else: credential = identity.get_service_principal_credential(username) @@ -233,7 +214,7 @@ def login(self, return [] else: # Build a tenant account - bare_tenant = tenant or user_id_token_claims['tid'] + bare_tenant = tenant or user_identity['tid'] subscriptions = self._build_tenant_level_accounts([bare_tenant]) consolidated = self._normalize_properties(username, subscriptions, @@ -255,7 +236,7 @@ def login_with_managed_identity(self, identity_id=None, allow_no_subscriptions=N # Managed Service Identity (MSI). if not scopes: - scopes = self.arm_scope + scopes = self._arm_scope identity = Identity() credential, mi_info = identity.login_with_managed_identity(scopes=scopes, identity_id=identity_id) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 0013ddae2be..61e67476453 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -17,7 +17,7 @@ from knack.util import CLIError from .msal_authentication import UserCredential, ServicePrincipalCredential -from .util import aad_error_handler, resource_to_scopes, scopes_to_resource +from .util import aad_error_handler, resource_to_scopes, scopes_to_resource, check_result AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' @@ -121,7 +121,7 @@ def login_with_interactive_browser(self, scopes=None, **kwargs): result = self.msal_app.acquire_token_interactive(scopes, **kwargs) if not result or 'error' in result: aad_error_handler(result) - return result['id_token_claims'] + return check_result(result) def login_with_device_code(self, scopes=None): flow = self.msal_app.initiate_device_flow(scopes) @@ -130,13 +130,16 @@ def login_with_device_code(self, scopes=None): "Fail to create device flow. Err: %s" % json.dumps(flow, indent=4)) logger.warning(flow["message"]) result = self.msal_app.acquire_token_by_device_flow(flow) # By default it will block - return result['id_token_claims'] + return check_result(result) def login_with_username_password(self, username, password, scopes=None): result = self.msal_app.acquire_token_by_username_password(username, password, scopes) - return result['id_token_claims'] + return check_result(result) - def login_with_service_principal(self, client_id, secret_or_certificate): + def login_with_service_principal(self, client_id, secret_or_certificate, scopes=None): + cred = ServicePrincipalCredential(client_id, secret_or_certificate, **self._msal_app_kwargs) + result = cred.acquire_token_for_client(scopes) + check_result(result) # Use ClientSecretCredential # TODO: Persist to encrypted cache # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/pull/44 @@ -284,9 +287,9 @@ def get_user_credential(self, username): return UserCredential(self.client_id, username, **self._msal_app_kwargs) def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): - secret_or_cert = self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) + secret_or_certificate = self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) # TODO: support use_cert_sn_issuer in CertificateCredential - return ServicePrincipalCredential(client_id, secret_or_cert, **self._msal_app_kwargs) + return ServicePrincipalCredential(client_id, secret_or_certificate, **self._msal_app_kwargs) def get_environment_credential(self): username = os.environ.get('AZURE_USERNAME') diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index e74af46936d..c5ae0b87ac4 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -89,3 +89,23 @@ def sdk_access_token_to_adal_token_entry(token): import datetime return {'accessToken': token.token, 'expiresOn': datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")} + + +def check_result(result): + from azure.cli.core.azclierror import AuthenticationError + + if not result: + raise AuthenticationError("Can't find token from MSAL cache.") + if 'error' in result: + raise AuthenticationError(result['error_description']) + + # For user authentication + if 'id_token_claims' in result: + idt = result['id_token_claims'] + return { + # AAD returns "preferred_username", ADFS returns "upn" + 'username': idt.get("preferred_username") or idt["upn"], + 'tenantId': idt['tid'] + } + + return None diff --git a/src/azure-cli-core/azure/cli/core/azclierror.py b/src/azure-cli-core/azure/cli/core/azclierror.py index 32b5710cd7e..8131286ba10 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -286,6 +286,9 @@ class RecommendationError(ClientError): class AuthenticationError(AzCLIError): - """ Raised when credential.get_token fails. """ + """ Raised when authentication fails. """ + def __init__(self, error_msg, recommendation=None, msal_result=None): + super().__init__(error_msg, recommendation) + self.msal_result = msal_result # endregion diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 928bae0d637..b6b12662982 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -100,7 +100,7 @@ def load_arguments(self, command): with self.argument_context('account get-access-token') as c: c.argument('resource', arg_group='ADAL', help='Azure resource endpoints in AAD v1.0. Default to Azure Resource Manager') c.argument('resource_type', get_enum_type(cloud_resource_types), options_list=['--resource-type'], arg_group='ADAL', help='Type of well-known resource.') - c.argument('scopes', nargs='*', arg_group='MSAL', help='Space-separated AAD scopes in AAD v2.0.') + c.argument('scopes', options_list=['--scope'], nargs='*', arg_group='MSAL', help='Space-separated AAD scopes in AAD v2.0.') c.argument('tenant', options_list=['--tenant', '-t'], help='Tenant ID for which the token is acquired. Only available for user and service principal account, not for MSI or Cloud Shell account') with self.argument_context('account clear') as c: diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 4af90edeff2..ea2c6e78f51 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -131,7 +131,7 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None interactive = False - profile = Profile(cli_ctx=cmd.cli_ctx, async_persist=False) + profile = Profile(cli_ctx=cmd.cli_ctx) if identity: if in_cloud_console(): From 44233c1d0db2059d11f4cf5eb9c05232aa08d251 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Thu, 15 Apr 2021 19:55:41 +0800 Subject: [PATCH 20/69] browser warning --- src/azure-cli-core/azure/cli/core/_profile.py | 48 +++++++------------ .../azure/cli/core/auth/__init__.py | 2 +- .../azure/cli/core/auth/identity.py | 10 +++- .../azure/cli/core/auth/util.py | 9 ++++ 4 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 2788bffdd9c..ea5c3f8ae8d 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -14,9 +14,10 @@ from knack.log import get_logger from knack.util import CLIError from azure.cli.core._session import ACCOUNT -from azure.cli.core.util import in_cloud_console, can_launch_browser +from azure.cli.core.util import in_cloud_console from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription -from azure.cli.core.auth import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID, resource_to_scopes +from azure.cli.core.auth import (Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID, + resource_to_scopes, can_launch_browser) logger = get_logger(__name__) @@ -124,8 +125,6 @@ def __init__(self, cli_ctx=None, storage=None): self._management_resource_uri = self.cli_ctx.cloud.endpoints.management self._authority = self.cli_ctx.cloud.endpoints.active_directory - self._ad = self.cli_ctx.cloud.endpoints.active_directory - self._adal_cache = None self._arm_scope = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) # pylint: disable=too-many-branches,too-many-statements,too-many-locals @@ -139,7 +138,6 @@ def login(self, client_id=AZURE_CLI_CLIENT_ID, use_device_code=False, allow_no_subscriptions=False, - subscription_finder=None, use_cert_sn_issuer=None, find_subscriptions=True): @@ -152,39 +150,33 @@ def login(self, identity = Identity(authority=authority, tenant_id=auth_tenant, client_id=client_id, allow_unencrypted=self.cli_ctx.config - .getboolean('core', 'allow_fallback_to_plaintext', fallback=True), - cred_cache=self._adal_cache) + .getboolean('core', 'allow_fallback_to_plaintext', fallback=True)) user_identity = None if interactive: - if not use_device_code and (in_cloud_console() or not can_launch_browser()): - logger.info('Detect no GUI is available, so fall back to device code') + if not use_device_code and not can_launch_browser(): + logger.info('No web browser is available. Fall back to device code.') use_device_code = True if not use_device_code: - from azure.identity import CredentialUnavailableError - try: - user_identity = identity.login_with_interactive_browser(scopes=scopes) - except CredentialUnavailableError: - use_device_code = True - logger.warning('Not able to launch a browser to log you in, falling back to device code...') - - if use_device_code: + user_identity = identity.login_with_auth_code(scopes=scopes) + else: user_identity = identity.login_with_device_code(scopes=scopes) else: - if is_service_principal: + if not is_service_principal: + user_identity = identity.login_with_username_password(username, password, scopes=scopes) + else: if not tenant: raise CLIError('Please supply tenant using "--tenant"') - identity.login_with_service_principal(username, password, scopes=scopes) - else: - user_identity = identity.login_with_username_password(username, password, scopes=scopes) if user_identity: username = user_identity['username'] # List tenants and find subscriptions by calling ARM if find_subscriptions: + subscription_finder = SubscriptionFinder(self.cli_ctx) + # Create credentials if user_identity: credential = identity.get_user_credential(username) @@ -221,10 +213,6 @@ def login(self, is_service_principal, bool(use_cert_sn_issuer)) self._set_subscriptions(consolidated) - # todo: remove after ADAL token deprecation - if self._adal_cache: - self._adal_cache.persist_cached_creds() - # use deepcopy as we don't want to persist these changes to file. return deepcopy(consolidated) def login_with_managed_identity(self, identity_id=None, allow_no_subscriptions=None, find_subscriptions=True, @@ -283,8 +271,9 @@ def login_with_managed_identity(self, identity_id=None, allow_no_subscriptions=N return deepcopy(consolidated) def login_in_cloud_shell(self, allow_no_subscriptions=None, find_subscriptions=True, scopes=None): - # TODO: deprecate allow_no_subscriptions - scopes = self._prepare_authenticate_scopes(scopes) + if not scopes: + scopes = self._arm_scope + identity = Identity() credential, identity_info = identity.login_in_cloud_shell(scopes) @@ -626,8 +615,7 @@ def _create_identity_credential(self, account, aux_tenant_id=None, client_id=Non # _IS_ENVIRONMENT_CREDENTIAL doesn't exist for normal account is_environment = account[_USER_ENTITY].get(_IS_ENVIRONMENT_CREDENTIAL) - identity = Identity(client_id=client_id, authority=self._authority, tenant_id=tenant_id, - cred_cache=self._adal_cache) + identity = Identity(client_id=client_id, authority=self._authority, tenant_id=tenant_id) if identity_type is None: if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): @@ -763,7 +751,7 @@ def refresh_accounts(self, subscription_finder=None): subscriptions = self.load_cached_subscriptions() to_refresh = subscriptions - subscription_finder = subscription_finder or SubscriptionFinder(self.cli_ctx, adal_cache=self._adal_cache) + subscription_finder = subscription_finder or SubscriptionFinder(self.cli_ctx) refreshed_list = set() result = [] for s in to_refresh: diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py index 6bb94254f4d..37e7077b95f 100644 --- a/src/azure-cli-core/azure/cli/core/auth/__init__.py +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -5,4 +5,4 @@ from .credential_adaptor import CredentialAdaptor from .identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID -from .util import resource_to_scopes, aad_error_handler, sdk_access_token_to_adal_token_entry +from .util import resource_to_scopes, aad_error_handler, sdk_access_token_to_adal_token_entry, can_launch_browser diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 61e67476453..f6b6e004a01 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -17,7 +17,7 @@ from knack.util import CLIError from .msal_authentication import UserCredential, ServicePrincipalCredential -from .util import aad_error_handler, resource_to_scopes, scopes_to_resource, check_result +from .util import aad_error_handler, resource_to_scopes, scopes_to_resource, check_result, can_launch_browser AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' @@ -117,7 +117,13 @@ def msal_app(self): self._msal_app_instance = self._build_persistent_msal_app() return self._msal_app_instance - def login_with_interactive_browser(self, scopes=None, **kwargs): + def login_with_auth_code(self, scopes=None, **kwargs): + # Emit a warning to inform that a browser is opened. + # Only show the path part of the URL and hide the query string. + logger.warning("The default web browser has been opened at %s/oauth2/v2.0/authorize. " + "Please continue the login in the web browser. " + "If no web browser is available or if the web browser fails to open, use device code flow " + "with `az login --use-device-code`.", self.msal_authority) result = self.msal_app.acquire_token_interactive(scopes, **kwargs) if not result or 'error' in result: aad_error_handler(result) diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index c5ae0b87ac4..3fbb6f9dd25 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -109,3 +109,12 @@ def check_result(result): } return None + + +def can_launch_browser(): + import webbrowser + try: + webbrowser.get() + return True + except webbrowser.Error: + return False From bdcd0b1e4837b1a706c6a60505ec3607cba3f70b Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 19 Apr 2021 14:43:32 +0800 Subject: [PATCH 21/69] show scope --- .../azure/cli/core/auth/__init__.py | 3 ++- .../azure/cli/core/auth/identity.py | 17 ++++--------- .../cli/core/auth/msal_authentication.py | 10 ++++---- .../azure/cli/core/auth/util.py | 24 ++++++++++++++----- src/azure-cli-core/azure/cli/core/cloud.py | 2 +- src/azure-cli-core/setup.py | 1 - .../cli/command_modules/profile/__init__.py | 2 ++ .../cli/command_modules/profile/custom.py | 9 ++++++- src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 11 files changed, 43 insertions(+), 31 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py index 37e7077b95f..0f48be976ab 100644 --- a/src/azure-cli-core/azure/cli/core/auth/__init__.py +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -5,4 +5,5 @@ from .credential_adaptor import CredentialAdaptor from .identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID -from .util import resource_to_scopes, aad_error_handler, sdk_access_token_to_adal_token_entry, can_launch_browser +from .util import resource_to_scopes, aad_error_handler, sdk_access_token_to_adal_token_entry, can_launch_browser, \ + decode_access_token diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index f6b6e004a01..a5f2cc4e23a 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -17,7 +17,8 @@ from knack.util import CLIError from .msal_authentication import UserCredential, ServicePrincipalCredential -from .util import aad_error_handler, resource_to_scopes, scopes_to_resource, check_result, can_launch_browser +from .util import aad_error_handler, resource_to_scopes, scopes_to_resource, check_result, can_launch_browser, \ + decode_access_token AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' @@ -216,7 +217,7 @@ def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disa credential = ManagedIdentityCredential(**self._credential_kwargs) token = credential.get_token(*scopes) - decoded = _decode_access_token(token) + decoded = decode_access_token(token) resource_id = decoded.get('xms_mirid') # User-assigned identity has resourceID as # /subscriptions/xxx/resourcegroups/xxx/providers/Microsoft.ManagedIdentity/userAssignedIdentities/xxx @@ -243,7 +244,7 @@ def login_in_cloud_shell(self, scopes): # As Managed Identity doesn't have ID token, we need to get an initial access token and extract info from it # The scopes is only used for acquiring the initial access token token = credential.get_token(*scopes) - decoded = _decode_access_token(token) + decoded = decode_access_token(token) cloud_shell_identity_info = { self.MANAGED_IDENTITY_TENANT_ID: decoded['tid'], @@ -712,13 +713,3 @@ def _serialize_secrets(self): logger.warning("Secrets are serialized as plain text and saved to `msalSecrets.cache.json`.") with open(self._token_file + ".json", "w") as fd: fd.write(json.dumps(self._service_principal_creds)) - - -def _decode_access_token(token): - # Decode the access token. We can do the same with https://jwt.ms - from msal.oauth2cli.oidc import decode_part - access_token = token.token - - # Access token consists of headers.claims.signature. Decode the claim part - decoded_str = decode_part(access_token.split('.')[1]) - return json.loads(decoded_str) diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index a8b7e209616..7edba124166 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -17,7 +17,7 @@ from knack.util import CLIError from msal import PublicClientApplication, ConfidentialClientApplication -from .util import aad_error_handler +from .util import aad_error_handler, check_result logger = get_logger(__name__) @@ -44,9 +44,11 @@ def __init__(self, client_id, username=None, **kwargs): self.account = None def get_token(self, *scopes, **kwargs): + # scopes = ['https://pas.windows.net/CheckMyAccess/Linux/.default'] logger.debug("UserCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs) result = self.acquire_token_silent_with_error(list(scopes), self.account, **kwargs) + check_result(result, scopes=scopes, **kwargs) return _convert_to_sdk_access_token(result) @@ -78,6 +80,7 @@ def get_token(self, *scopes, **kwargs): result = self.acquire_token_silent(scopes, None, **kwargs) if not result: result = self.acquire_token_for_client(scopes, **kwargs) + check_result(result, scopes=scopes, **kwargs) return _convert_to_sdk_access_token(result) @@ -85,7 +88,4 @@ def _convert_to_sdk_access_token(token_entry): import time request_time = int(time.time()) - if token_entry and "access_token" in token_entry and "expires_in" in token_entry: - return AccessToken(token_entry["access_token"], request_time + int(token_entry["expires_in"])) - else: - aad_error_handler(token_entry) + return AccessToken(token_entry["access_token"], request_time + int(token_entry["expires_in"])) diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index 3fbb6f9dd25..eb84d7e629b 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -3,13 +3,13 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -def aad_error_handler(error, scopes=None, claims=None): +def aad_error_handler(error, **kwargs): """ Handle the error from AAD server returned by ADAL or MSAL. """ # https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes # Search for an error code at https://login.microsoftonline.com/error msg = error.get('error_description') - login_message = _generate_login_message(scopes=scopes, claims=claims) + login_message = _generate_login_message(**kwargs) from azure.cli.core.azclierror import AuthenticationError raise AuthenticationError(msg, recommendation=login_message) @@ -33,6 +33,7 @@ def _generate_login_command(scopes=None, claims=None): claims = base64.urlsafe_b64encode(claims.encode()).decode() login_command.append('--claims {}'.format(claims)) + login_command.insert(0, 'az logout') return ' '.join(login_command) @@ -40,7 +41,7 @@ def _generate_login_command(scopes=None, claims=None): def _generate_login_message(**kwargs): from azure.cli.core.util import in_cloud_console login_command = _generate_login_command(**kwargs) - login_command = 'az logout\naz login' + msg = "To re-authenticate, please {}" \ "If the problem persists, please contact your tenant administrator.".format( "refresh Azure Portal." if in_cloud_console() else "run:\n{}\n".format(login_command)) @@ -91,13 +92,14 @@ def sdk_access_token_to_adal_token_entry(token): 'expiresOn': datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")} -def check_result(result): +def check_result(result, **kwargs): from azure.cli.core.azclierror import AuthenticationError if not result: - raise AuthenticationError("Can't find token from MSAL cache.") + raise AuthenticationError("Can't find token from MSAL cache.", + recommendation="To re-authenticate, please run:\naz login") if 'error' in result: - raise AuthenticationError(result['error_description']) + aad_error_handler(result, **kwargs) # For user authentication if 'id_token_claims' in result: @@ -118,3 +120,13 @@ def can_launch_browser(): return True except webbrowser.Error: return False + + +def decode_access_token(access_token): + # Decode the access token. We can do the same with https://jwt.ms + from msal.oauth2cli.oidc import decode_part + import json + + # Access token consists of headers.claims.signature. Decode the claim part + decoded_str = decode_part(access_token.split('.')[1]) + return json.loads(decoded_str) diff --git a/src/azure-cli-core/azure/cli/core/cloud.py b/src/azure-cli-core/azure/cli/core/cloud.py index 4a555a4fdf2..d61ea7c0be1 100644 --- a/src/azure-cli-core/azure/cli/core/cloud.py +++ b/src/azure-cli-core/azure/cli/core/cloud.py @@ -298,7 +298,7 @@ def from_json(cls, json_str): 'AzureCloud', endpoints=CloudEndpoints( management='https://management.core.windows.net/', - resource_manager='https://management.azure.com/', + resource_manager='https://eastus2euap.management.azure.com/', sql_management='https://management.core.windows.net:8443/', batch_resource_id='https://batch.core.windows.net/', gallery='https://gallery.azure.com/', diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index cfb285116bd..b698311fbe1 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -60,7 +60,6 @@ 'msrestazure>=0.6.3', 'paramiko>=2.0.8,<3.0.0', 'pkginfo>=1.5.0.1', - 'PyJWT==1.7.1', 'pyopenssl>=17.1.0', # https://github.com/pyca/pyopenssl/pull/612 'requests~=2.22', 'six~=1.12', diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index b6b12662982..93a69f607f1 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -102,6 +102,8 @@ def load_arguments(self, command): c.argument('resource_type', get_enum_type(cloud_resource_types), options_list=['--resource-type'], arg_group='ADAL', help='Type of well-known resource.') c.argument('scopes', options_list=['--scope'], nargs='*', arg_group='MSAL', help='Space-separated AAD scopes in AAD v2.0.') c.argument('tenant', options_list=['--tenant', '-t'], help='Tenant ID for which the token is acquired. Only available for user and service principal account, not for MSI or Cloud Shell account') + c.argument('decode', help='Show the decoded access token.', arg_type=get_three_state_flag(), + deprecate_info=c.deprecate(target='--decode', hide=True)) with self.argument_context('account clear') as c: c.argument('clear_credential', clear_credential_type) diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index ea2c6e78f51..1bce56b52a5 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -61,7 +61,8 @@ def show_subscription(cmd, subscription=None, show_auth_for_sdk=None): return profile.get_subscription(subscription) -def get_access_token(cmd, subscription=None, resource=None, scopes=None, resource_type=None, tenant=None): +def get_access_token(cmd, subscription=None, resource=None, scopes=None, resource_type=None, tenant=None, + decode=False): """ get AAD token to access to a specified resource. Use 'az cloud show' command for other Azure resources @@ -74,6 +75,11 @@ def get_access_token(cmd, subscription=None, resource=None, scopes=None, resourc creds, subscription, tenant = profile.get_raw_token(subscription=subscription, resource=resource, scopes=scopes, tenant=tenant) + # Debug switch for showing the decoded access token + if decode: + from azure.cli.core.auth import decode_access_token + return decode_access_token(creds[1]) + token_entry = creds[2] # MSIAuthentication's token entry has `expires_on`, while ADAL's token entry has `expiresOn` # Unify to ISO `expiresOn`, like "2020-06-30 06:14:41" @@ -90,6 +96,7 @@ def get_access_token(cmd, subscription=None, resource=None, scopes=None, resourc } if subscription: result['subscription'] = subscription + return result diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index b56562f3c8a..63ba91b8d3c 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -117,7 +117,7 @@ pbr==5.3.1 portalocker==1.7.1 psutil==5.8.0 pycparser==2.19 -PyJWT==1.7.1 +PyJWT==2.0.1 PyNaCl==1.4.0 pyOpenSSL==19.0.0 python-dateutil==2.8.0 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index b56562f3c8a..63ba91b8d3c 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -117,7 +117,7 @@ pbr==5.3.1 portalocker==1.7.1 psutil==5.8.0 pycparser==2.19 -PyJWT==1.7.1 +PyJWT==2.0.1 PyNaCl==1.4.0 pyOpenSSL==19.0.0 python-dateutil==2.8.0 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 7cb12d7dfc8..329b1a38f80 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -116,7 +116,7 @@ pbr==5.3.1 portalocker==1.7.1 psutil==5.8.0 pycparser==2.19 -PyJWT==1.7.1 +PyJWT==2.0.1 PyNaCl==1.4.0 pyOpenSSL==19.0.0 pypiwin32==223 From a53ff384b2a3e9299cd29ea700e9c1bed5c367f7 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 19 Apr 2021 18:14:37 +0800 Subject: [PATCH 22/69] cae --- src/azure-cli-core/azure/cli/core/_profile.py | 9 ++- .../azure/cli/core/auth/__init__.py | 2 +- .../azure/cli/core/auth/identity.py | 12 +-- .../cli/core/auth/msal_authentication.py | 6 +- .../azure/cli/core/auth/tests/__init__.py | 4 + .../azure/cli/core/auth/tests/test_util.py | 65 +++++++++++++++ .../azure/cli/core/auth/util.py | 81 ++++++++++++++----- .../azure/cli/core/azclierror.py | 26 +----- .../azure/cli/core/tests/test_util.py | 41 ---------- .../cli/command_modules/profile/__init__.py | 1 + .../cli/command_modules/profile/custom.py | 9 ++- 11 files changed, 157 insertions(+), 99 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/auth/tests/__init__.py create mode 100644 src/azure-cli-core/azure/cli/core/auth/tests/test_util.py diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index ea5c3f8ae8d..fea191244ab 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -139,7 +139,8 @@ def login(self, use_device_code=False, allow_no_subscriptions=False, use_cert_sn_issuer=None, - find_subscriptions=True): + find_subscriptions=True, + **kwargs): if not scopes: scopes = self._arm_scope @@ -159,12 +160,12 @@ def login(self, use_device_code = True if not use_device_code: - user_identity = identity.login_with_auth_code(scopes=scopes) + user_identity = identity.login_with_auth_code(scopes=scopes, **kwargs) else: - user_identity = identity.login_with_device_code(scopes=scopes) + user_identity = identity.login_with_device_code(scopes=scopes, **kwargs) else: if not is_service_principal: - user_identity = identity.login_with_username_password(username, password, scopes=scopes) + user_identity = identity.login_with_username_password(username, password, scopes=scopes, **kwargs) else: if not tenant: raise CLIError('Please supply tenant using "--tenant"') diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py index 0f48be976ab..3e698c467f6 100644 --- a/src/azure-cli-core/azure/cli/core/auth/__init__.py +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -6,4 +6,4 @@ from .credential_adaptor import CredentialAdaptor from .identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID from .util import resource_to_scopes, aad_error_handler, sdk_access_token_to_adal_token_entry, can_launch_browser, \ - decode_access_token + decode_access_token, generate_login_message diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index a5f2cc4e23a..d330c8da54e 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -17,7 +17,7 @@ from knack.util import CLIError from .msal_authentication import UserCredential, ServicePrincipalCredential -from .util import aad_error_handler, resource_to_scopes, scopes_to_resource, check_result, can_launch_browser, \ +from .util import aad_error_handler, resource_to_scopes, scopes_to_resource, check_result, \ decode_access_token AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' @@ -130,17 +130,17 @@ def login_with_auth_code(self, scopes=None, **kwargs): aad_error_handler(result) return check_result(result) - def login_with_device_code(self, scopes=None): - flow = self.msal_app.initiate_device_flow(scopes) + def login_with_device_code(self, scopes=None, **kwargs): + flow = self.msal_app.initiate_device_flow(scopes, **kwargs) if "user_code" not in flow: raise ValueError( "Fail to create device flow. Err: %s" % json.dumps(flow, indent=4)) logger.warning(flow["message"]) - result = self.msal_app.acquire_token_by_device_flow(flow) # By default it will block + result = self.msal_app.acquire_token_by_device_flow(flow, **kwargs) # By default it will block return check_result(result) - def login_with_username_password(self, username, password, scopes=None): - result = self.msal_app.acquire_token_by_username_password(username, password, scopes) + def login_with_username_password(self, username, password, scopes=None, **kwargs): + result = self.msal_app.acquire_token_by_username_password(username, password, scopes, **kwargs) return check_result(result) def login_with_service_principal(self, client_id, secret_or_certificate, scopes=None): diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index 7edba124166..2d39d304a40 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -47,8 +47,10 @@ def get_token(self, *scopes, **kwargs): # scopes = ['https://pas.windows.net/CheckMyAccess/Linux/.default'] logger.debug("UserCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs) - result = self.acquire_token_silent_with_error(list(scopes), self.account, **kwargs) - check_result(result, scopes=scopes, **kwargs) + claims = kwargs.pop('claims', None) + result = self.acquire_token_silent_with_error(list(scopes), self.account, claims_challenge=claims, + **kwargs) + check_result(result, scopes=scopes, claims=claims) return _convert_to_sdk_access_token(result) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/__init__.py b/src/azure-cli-core/azure/cli/core/auth/tests/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/tests/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py new file mode 100644 index 00000000000..af333ce8def --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py @@ -0,0 +1,65 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=protected-access + +import unittest +from ..util import _extract_claims, scopes_to_resource, resource_to_scopes + + +class TestUtil(unittest.TestCase): + + def test_extract_claims(self): + challenge = 'Bearer ' \ + 'authorization_uri="https://login.windows.net/54826b22-38d6-4fb2-bad9-b7b93a3e9c5a", ' \ + 'error="invalid_token", ' \ + 'error_description="User session has been revoked", ' \ + 'claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxODgyNjE0OSJ9fX0="' + expected = 'eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxODgyNjE0OSJ9fX0=' + result = _extract_claims(challenge) + assert expected == result + + def test_scopes_to_resource(self): + # scopes as a list + self.assertEqual(scopes_to_resource(['https://management.core.windows.net//.default']), + 'https://management.core.windows.net/') + # scopes as a tuple + self.assertEqual(scopes_to_resource(('https://storage.azure.com/.default',)), + 'https://storage.azure.com') + + # resource with trailing slash + self.assertEqual(scopes_to_resource(('https://management.azure.com//.default',)), + 'https://management.azure.com/') + self.assertEqual(scopes_to_resource(['https://datalake.azure.net//.default']), + 'https://datalake.azure.net/') + + # resource without trailing slash + self.assertEqual(scopes_to_resource(('https://managedhsm.azure.com/.default',)), + 'https://managedhsm.azure.com') + + # VM SSH + self.assertEqual(scopes_to_resource(["https://pas.windows.net/CheckMyAccess/Linux/.default"]), + 'https://pas.windows.net/CheckMyAccess/Linux') + self.assertEqual(scopes_to_resource(["https://pas.windows.net/CheckMyAccess/Linux/user_impersonation"]), + 'https://pas.windows.net/CheckMyAccess/Linux') + + def test_resource_to_scopes(self): + # resource converted to a scopes list + self.assertEqual(resource_to_scopes('https://management.core.windows.net/'), + ['https://management.core.windows.net//.default']) + + # resource with trailing slash + self.assertEqual(resource_to_scopes('https://management.azure.com/'), + ['https://management.azure.com//.default']) + self.assertEqual(resource_to_scopes('https://datalake.azure.net/'), + ['https://datalake.azure.net//.default']) + + # resource without trailing slash + self.assertEqual(resource_to_scopes('https://managedhsm.azure.com'), + ['https://managedhsm.azure.com/.default']) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index eb84d7e629b..2a95801f241 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -9,7 +9,7 @@ def aad_error_handler(error, **kwargs): # https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes # Search for an error code at https://login.microsoftonline.com/error msg = error.get('error_description') - login_message = _generate_login_message(**kwargs) + login_message = generate_login_message(**kwargs) from azure.cli.core.azclierror import AuthenticationError raise AuthenticationError(msg, recommendation=login_message) @@ -18,35 +18,27 @@ def aad_error_handler(error, **kwargs): def _generate_login_command(scopes=None, claims=None): login_command = ['az login'] - if scopes: - login_command.append('--scope {}'.format(' '.join(scopes))) - + # Rejected by Continuous Access Evaluation, then by Conditional Access if claims: - import base64 - try: - base64.urlsafe_b64decode(claims) - is_base64 = True - except ValueError: - is_base64 = False - - if not is_base64: - claims = base64.urlsafe_b64encode(claims.encode()).decode() + login_command.append('--claims {}'.format(encode_claims(claims))) + return 'az logout\n' + ' '.join(login_command) - login_command.append('--claims {}'.format(claims)) - login_command.insert(0, 'az logout') + # Rejected by Conditional Access policy, like MFA + elif scopes: + login_command.append('--scope {}'.format(' '.join(scopes))) return ' '.join(login_command) -def _generate_login_message(**kwargs): +def generate_login_message(**kwargs): from azure.cli.core.util import in_cloud_console login_command = _generate_login_command(**kwargs) - msg = "To re-authenticate, please {}" \ - "If the problem persists, please contact your tenant administrator.".format( - "refresh Azure Portal." if in_cloud_console() else "run:\n{}\n".format(login_command)) + login_msg = "To re-authenticate, please {}" .format( + "refresh Azure Portal." if in_cloud_console() else "run:\n{}".format(login_command)) - return msg + contact_admin_msg = "If the problem persists, please contact your tenant administrator." + return "{}\n\n{}".format(login_msg, contact_admin_msg) def resource_to_scopes(resource): @@ -130,3 +122,52 @@ def decode_access_token(access_token): # Access token consists of headers.claims.signature. Decode the claim part decoded_str = decode_part(access_token.split('.')[1]) return json.loads(decoded_str) + + +def encode_claims(claims: str): + import base64 + try: + base64.urlsafe_b64decode(claims) + is_base64 = True + except ValueError: + is_base64 = False + + if not is_base64: + claims = base64.urlsafe_b64encode(claims.encode()).decode() + + return claims + + +def decode_claims(claims: str): + import base64 + try: + claims = base64.urlsafe_b64decode(claims).decode() + except ValueError: + pass + + return claims + + +def handle_response_401_track1(response): + """Generate recommendation when ARM returns 401 to Track 1 SDK.""" + challenge = response.headers.get('WWW-Authenticate') + claims = _extract_claims(challenge) + + recommendation = ( + "The access token has expired or been revoked by Continuous Access Evaluation. " + "Silent re-authentication will be attempted in the future.\n{}") + login_message = generate_login_message(claims=claims) + return recommendation.format(login_message) + + +def _extract_claims(challenge): + # Copied from azure.mgmt.core.policies._authentication._parse_claims_challenge + from azure.mgmt.core.policies._authentication import _parse_challenges + parsed_challenges = _parse_challenges(challenge) + if len(parsed_challenges) != 1 or "claims" not in parsed_challenges[0].parameters: + # no or multiple challenges, or no claims directive + return None + + encoded_claims = parsed_challenges[0].parameters["claims"] + padding_needed = -len(encoded_claims) % 4 + return encoded_claims + "=" * padding_needed diff --git a/src/azure-cli-core/azure/cli/core/azclierror.py b/src/azure-cli-core/azure/cli/core/azclierror.py index 8131286ba10..f67ccbb8e56 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -173,29 +173,9 @@ class UnauthorizedError(UserFault): def __init__(self, error_msg, recommendation=None, original_error=None): - def _extract_claims(challenge): - # Copied from azure.mgmt.core.policies._authentication._parse_claims_challenge - from azure.mgmt.core.policies._authentication import _parse_challenges - parsed_challenges = _parse_challenges(challenge) - if len(parsed_challenges) != 1 or "claims" not in parsed_challenges[0].parameters: - # no or multiple challenges, or no claims directive - return None - - encoded_claims = parsed_challenges[0].parameters["claims"] - padding_needed = -len(encoded_claims) % 4 - return encoded_claims + "=" * padding_needed - - claims = _extract_claims(original_error.response.headers.get('WWW-Authenticate')) - - from azure.cli.core.auth import _generate_login_command, _generate_login_message - # login_command = _generate_login_command(claims=claims) - login_message = _generate_login_message(claims=claims) - - recommendation = ( - "The access token has expired or been revoked by Continuous Access Evaluation. " - "Silent re-authentication will be attempted in the future.\n{}") - recommendation = recommendation.format(login_message) - super().__init__(error_msg, recommendation=recommendation, original_error=original_error) + from azure.cli.core.auth.util import handle_response_401_track1 + super().__init__(error_msg, recommendation=handle_response_401_track1(original_error.response), + original_error=original_error) class ForbiddenError(UserFault): diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 700dbfac317..5695305968d 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -380,47 +380,6 @@ def test_send_raw_requests(self, send_mock, get_raw_token_mock): request = send_mock.call_args.args[1] self.assertEqual(request.headers['User-Agent'], get_az_rest_user_agent() + ' env-ua ARG-UA') - def test_scopes_to_resource(self): - from azure.cli.core.util import scopes_to_resource - # scopes as a list - self.assertEqual(scopes_to_resource(['https://management.core.windows.net//.default']), - 'https://management.core.windows.net/') - # scopes as a tuple - self.assertEqual(scopes_to_resource(('https://storage.azure.com/.default',)), - 'https://storage.azure.com') - - # resource with trailing slash - self.assertEqual(scopes_to_resource(('https://management.azure.com//.default',)), - 'https://management.azure.com/') - self.assertEqual(scopes_to_resource(['https://datalake.azure.net//.default']), - 'https://datalake.azure.net/') - - # resource without trailing slash - self.assertEqual(scopes_to_resource(('https://managedhsm.azure.com/.default',)), - 'https://managedhsm.azure.com') - - # VM SSH - self.assertEqual(scopes_to_resource(["https://pas.windows.net/CheckMyAccess/Linux/.default"]), - 'https://pas.windows.net/CheckMyAccess/Linux') - self.assertEqual(scopes_to_resource(["https://pas.windows.net/CheckMyAccess/Linux/user_impersonation"]), - 'https://pas.windows.net/CheckMyAccess/Linux') - - def test_resource_to_scopes(self): - from azure.cli.core.util import resource_to_scopes - # resource converted to a scopes list - self.assertEqual(resource_to_scopes('https://management.core.windows.net/'), - ['https://management.core.windows.net//.default']) - - # resource with trailing slash - self.assertEqual(resource_to_scopes('https://management.azure.com/'), - ['https://management.azure.com//.default']) - self.assertEqual(resource_to_scopes('https://datalake.azure.net/'), - ['https://datalake.azure.net//.default']) - - # resource without trailing slash - self.assertEqual(resource_to_scopes('https://managedhsm.azure.com'), - ['https://managedhsm.azure.com/.default']) - @mock.patch("psutil.Process") def test_get_parent_proc_name(self, mock_process_type): process = mock_process_type.return_value diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 93a69f607f1..5992ffb5d91 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -79,6 +79,7 @@ def load_arguments(self, command): c.argument('scopes', options_list=['--scope'], nargs="+", help='A space-separated list of scopes to use in the /authorize request. ' 'It can cover multiple resources.') + c.argument('claims_challenge', options_list=['--claims'], help='Claims challenge used for interactive authentication.') with self.argument_context('logout') as c: c.argument('username', options_list=['--username', '-u'], help='account user, if missing, logout the current active account') diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 1bce56b52a5..ad98577d7e5 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -119,7 +119,7 @@ def account_clear(cmd, clear_credential=True): # pylint: disable=inconsistent-return-statements, too-many-branches def login(cmd, username=None, password=None, service_principal=None, tenant=None, allow_no_subscriptions=False, identity=False, use_device_code=False, use_cert_sn_issuer=None, tenant_access=False, environment=False, - scopes=None): + scopes=None, claims_challenge=None): """Log in to access Azure subscriptions""" from adal.adal_error import AdalError import requests @@ -136,6 +136,10 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None if service_principal and not username: raise CLIError('usage error: --service-principal --username NAME --password SECRET --tenant TENANT') + if claims_challenge: + from azure.cli.core.auth.util import decode_claims + claims_challenge = decode_claims(claims_challenge) + interactive = False profile = Profile(cli_ctx=cmd.cli_ctx) @@ -170,7 +174,8 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None use_device_code=use_device_code, allow_no_subscriptions=allow_no_subscriptions, use_cert_sn_issuer=use_cert_sn_issuer, - find_subscriptions=not tenant_access) + find_subscriptions=not tenant_access, + claims_challenge=claims_challenge) except AdalError as err: # try polish unfriendly server errors if username: From 0da67b0ed98d5fe34aeea707d42c3e8c9ff95711 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 20 Apr 2021 18:05:06 +0800 Subject: [PATCH 23/69] conditional access scenario test --- .../azure/cli/core/auth/__init__.py | 2 +- .../azure/cli/core/auth/identity.py | 2 +- .../azure/cli/core/auth/tests/test_util.py | 4 ++ .../azure/cli/core/auth/util.py | 6 +- .../profile/tests/latest/test_auth_e2e.py | 69 +++++++++++++++++++ 5 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py index 3e698c467f6..0f48be976ab 100644 --- a/src/azure-cli-core/azure/cli/core/auth/__init__.py +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -6,4 +6,4 @@ from .credential_adaptor import CredentialAdaptor from .identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID from .util import resource_to_scopes, aad_error_handler, sdk_access_token_to_adal_token_entry, can_launch_browser, \ - decode_access_token, generate_login_message + decode_access_token diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index d330c8da54e..289224e4ba2 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -125,7 +125,7 @@ def login_with_auth_code(self, scopes=None, **kwargs): "Please continue the login in the web browser. " "If no web browser is available or if the web browser fails to open, use device code flow " "with `az login --use-device-code`.", self.msal_authority) - result = self.msal_app.acquire_token_interactive(scopes, **kwargs) + result = self.msal_app.acquire_token_interactive(scopes, prompt='select_account', **kwargs) if not result or 'error' in result: aad_error_handler(result) return check_result(result) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py index af333ce8def..5881b906226 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py @@ -21,6 +21,10 @@ def test_extract_claims(self): result = _extract_claims(challenge) assert expected == result + # Multiple www-authenticate headers + result = _extract_claims(', '.join((challenge, challenge))) + assert result is None + def test_scopes_to_resource(self): # scopes as a list self.assertEqual(scopes_to_resource(['https://management.core.windows.net//.default']), diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index 2a95801f241..0cee7665de6 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -9,7 +9,7 @@ def aad_error_handler(error, **kwargs): # https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes # Search for an error code at https://login.microsoftonline.com/error msg = error.get('error_description') - login_message = generate_login_message(**kwargs) + login_message = _generate_login_message(**kwargs) from azure.cli.core.azclierror import AuthenticationError raise AuthenticationError(msg, recommendation=login_message) @@ -30,7 +30,7 @@ def _generate_login_command(scopes=None, claims=None): return ' '.join(login_command) -def generate_login_message(**kwargs): +def _generate_login_message(**kwargs): from azure.cli.core.util import in_cloud_console login_command = _generate_login_command(**kwargs) @@ -156,7 +156,7 @@ def handle_response_401_track1(response): recommendation = ( "The access token has expired or been revoked by Continuous Access Evaluation. " "Silent re-authentication will be attempted in the future.\n{}") - login_message = generate_login_message(claims=claims) + login_message = _generate_login_message(claims=claims) return recommendation.format(login_message) diff --git a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py index 22339c36910..12b24ae538b 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py +++ b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py @@ -8,6 +8,7 @@ import jwt from azure.cli.core.azclierror import AuthenticationError from azure.cli.testsdk import LiveScenarioTest +from azure.cli.core.auth.util import decode_access_token from msrestazure.azure_exceptions import CloudError ARM_URL = "https://eastus2euap.management.azure.com/" # ARM canary @@ -60,3 +61,71 @@ def check_arm_error(ex): def _revoke_sign_in_sessions(self): # Manually revoke sign in sessions self.cmd('rest -m POST -u https://graph.microsoft.com/v1.0/me/revokeSignInSessions') + + +class ConditionalAccessScenarioTest(LiveScenarioTest): + + def setUp(self): + super().setUp() + # Clear MSAL cache to avoid unexpected tokens from cache + self.cmd('az account clear') + + def test_conditional_access_mfa(self): + """ + This test should be run using a user account that + - doesn't require MFA for ARM + - requires MFA for data-plane resource + + The result ATs are checked per + Microsoft identity platform access tokens + https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens + + Following claims are checked: + - aud (Audience): https://tools.ietf.org/html/rfc7519#section-4.1.3 + - amr (Authentication Method Reference): https://tools.ietf.org/html/rfc8176 + """ + + scope = 'https://pas.windows.net/CheckMyAccess/Linux/.default' + self.kwargs['scope'] = scope + + # region non-MFA session + + # Login to ARM (MFA not required) + # In the browser, if the user already exists, make sure to logout first and re-login to clear browser cache + self.cmd('az login') + + # Getting ARM AT and check claims + result = self.cmd('az account get-access-token').get_output_in_json() + decoded = decode_access_token(result['accessToken']) + assert decoded['aud'] == self.cli_ctx.cloud.endpoints.active_directory_resource_id + assert decoded['amr'] == ['pwd'] + + # Getting data-plane AT with ARM RT (step-up) fails + with self.assertRaises(AuthenticationError) as cm: + self.cmd('az account get-access-token --scope {scope}') + + # Check re-login recommendation + re_login_command = 'az login --scope {scope}'.format(**self.kwargs) + assert re_login_command in cm.exception.recommendations[0] + + # endregion + + # region MFA session + + # Re-login with data-plane scope (MFA required) + # Getting ARM AT with data-plane RT (step-down) succeeds + self.cmd(re_login_command) + + # Getting ARM AT and check claims + result = self.cmd('az account get-access-token').get_output_in_json() + decoded = decode_access_token(result['accessToken']) + assert decoded['aud'] == self.cli_ctx.cloud.endpoints.active_directory_resource_id + assert decoded['amr'] == ['pwd'] + + # Getting data-plane AT and check claims + result = self.cmd('az account get-access-token --scope {scope}').get_output_in_json() + decoded = decode_access_token(result['accessToken']) + assert decoded['aud'] in scope + assert decoded['amr'] == ['pwd', 'mfa'] + + # endregion From 0f7506351403d8fa6223ed4426b280dcca0eca8b Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 21 Apr 2021 18:09:33 +0800 Subject: [PATCH 24/69] cae test --- .../profile/tests/latest/test_auth_e2e.py | 65 +++++++++++-------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py index 12b24ae538b..517dac4280c 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py +++ b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py @@ -5,58 +5,71 @@ from time import sleep -import jwt from azure.cli.core.azclierror import AuthenticationError from azure.cli.testsdk import LiveScenarioTest from azure.cli.core.auth.util import decode_access_token from msrestazure.azure_exceptions import CloudError ARM_URL = "https://eastus2euap.management.azure.com/" # ARM canary +ARM_MAX_RETRY = 30 ARM_RETRY_INTERVAL = 10 class CAEScenarioTest(LiveScenarioTest): + def setUp(self): + super().setUp() + # Clear MSAL cache to avoid unexpected tokens from cache + self.cmd('az account clear') + + def _retry_until_error(self, cmd): + remaining_reties = ARM_MAX_RETRY + while remaining_reties > 0: + remaining_reties -= 1 + sleep(ARM_RETRY_INTERVAL) + self.cmd(cmd) + raise AssertionError("Retry chance exhausted.") + def test_client_capabilities(self): self.cmd('login') # Verify the access token has CAE enabled - out = self.cmd('account get-access-token').get_output_in_json() - access_token = out['accessToken'] - decoded = jwt.decode(access_token, verify=False, algorithms=['RS256']) + result = self.cmd('account get-access-token').get_output_in_json() + access_token = result['accessToken'] + decoded = decode_access_token(access_token) self.assertEqual(decoded['xms_cc'], ['CP1']) # xms_cc: extension microsoft client capabilities self.assertEqual(decoded['xms_ssm'], '1') # xms_ssm: extension microsoft smart session management - def _test_revoke_session(self, command, expected_error, checks=None): + def test_revoke_session(self): + track2_cmd = "storage account list" + track1_cmd = "group list" + self.test_client_capabilities() # Test access token is working - self.cmd(command) + self.cmd(track2_cmd) + self.cmd(track1_cmd) self._revoke_sign_in_sessions() # CAE is currently only available in canary endpoint # with mock.patch.object(self.cli_ctx.cloud.endpoints, "resource_manager", ARM_URL): - exit_code = 0 - with self.assertRaises(expected_error) as ex: - while exit_code == 0: - exit_code = self.cmd(command).exit_code - sleep(ARM_RETRY_INTERVAL) - if checks: - checks(ex.exception) - - def test_revoke_session_track2(self): - def check_aad_error_code(ex): - self.assertIn('AADSTS50173', str(ex)) - - self._test_revoke_session("storage account list", AuthenticationError, check_aad_error_code) - - def test_revoke_session_track1(self): - def check_arm_error(ex): - self.assertEqual(ex.status_code, 401) - self.assertIsNotNone(ex.response.headers["WWW-Authenticate"]) - - self._test_revoke_session('group list', CloudError, check_arm_error) + + # Keep trying until failure + + # Track 2 + with self.assertRaises(AuthenticationError) as cm: + self._retry_until_error(track2_cmd) + + assert 'AADSTS50173' in cm.exception.error_msg + assert 'az login --claims' in cm.exception.recommendations[0] + + # Track 1 + with self.assertRaises(CloudError) as cm: + self._retry_until_error(track1_cmd) + + self.assertEqual(cm.exception.status_code, 401) + self.assertIsNotNone(cm.exception.response.headers["WWW-Authenticate"]) def _revoke_sign_in_sessions(self): # Manually revoke sign in sessions From 94970bd2916f78cf10ded9f47e962c6e644323b4 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 21 Apr 2021 18:37:40 +0800 Subject: [PATCH 25/69] refactor handle_response_401_track1 --- .../azure/cli/core/auth/msal_authentication.py | 13 ++++++------- src/azure-cli-core/azure/cli/core/auth/util.py | 2 +- src/azure-cli-core/azure/cli/core/azclierror.py | 2 +- .../profile/tests/latest/test_auth_e2e.py | 1 + 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index 2d39d304a40..c3253838162 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -10,14 +10,12 @@ SDK invocation. """ -import os - from azure.core.credentials import AccessToken from knack.log import get_logger from knack.util import CLIError from msal import PublicClientApplication, ConfidentialClientApplication -from .util import aad_error_handler, check_result +from .util import check_result logger = get_logger(__name__) @@ -51,13 +49,14 @@ def get_token(self, *scopes, **kwargs): result = self.acquire_token_silent_with_error(list(scopes), self.account, claims_challenge=claims, **kwargs) check_result(result, scopes=scopes, claims=claims) - return _convert_to_sdk_access_token(result) + return _build_sdk_access_token(result) class ServicePrincipalCredential(ConfidentialClientApplication): def __init__(self, client_id, secret_or_certificate=None, **kwargs): + import os # If certificate file path is provided, transfer it to MSAL input if os.path.isfile(secret_or_certificate): cert_file = secret_or_certificate @@ -82,11 +81,11 @@ def get_token(self, *scopes, **kwargs): result = self.acquire_token_silent(scopes, None, **kwargs) if not result: result = self.acquire_token_for_client(scopes, **kwargs) - check_result(result, scopes=scopes, **kwargs) - return _convert_to_sdk_access_token(result) + check_result(result) + return _build_sdk_access_token(result) -def _convert_to_sdk_access_token(token_entry): +def _build_sdk_access_token(token_entry): import time request_time = int(time.time()) diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index 0cee7665de6..ad5dd07ba06 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -150,7 +150,7 @@ def decode_claims(claims: str): def handle_response_401_track1(response): """Generate recommendation when ARM returns 401 to Track 1 SDK.""" - challenge = response.headers.get('WWW-Authenticate') + challenge = response.response.headers.get('WWW-Authenticate') claims = _extract_claims(challenge) recommendation = ( diff --git a/src/azure-cli-core/azure/cli/core/azclierror.py b/src/azure-cli-core/azure/cli/core/azclierror.py index f67ccbb8e56..b00a0a9787c 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -174,7 +174,7 @@ class UnauthorizedError(UserFault): def __init__(self, error_msg, recommendation=None, original_error=None): from azure.cli.core.auth.util import handle_response_401_track1 - super().__init__(error_msg, recommendation=handle_response_401_track1(original_error.response), + super().__init__(error_msg, recommendation=handle_response_401_track1(original_error), original_error=original_error) diff --git a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py index 517dac4280c..6653e0256b2 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py +++ b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py @@ -119,6 +119,7 @@ def test_conditional_access_mfa(self): # Check re-login recommendation re_login_command = 'az login --scope {scope}'.format(**self.kwargs) + assert 'AADSTS50076' in cm.exception.error_msg assert re_login_command in cm.exception.recommendations[0] # endregion From 294c2c104a591bbade2be8f724dba0859206e17a Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 27 Apr 2021 14:02:45 +0800 Subject: [PATCH 26/69] tests --- .../core/{ => auth}/tests/test_identity.py | 0 .../azure/cli/core/auth/tests/test_util.py | 22 +++++++++++++- .../azure/cli/core/tests/test_credential.py | 30 ------------------- 3 files changed, 21 insertions(+), 31 deletions(-) rename src/azure-cli-core/azure/cli/core/{ => auth}/tests/test_identity.py (100%) delete mode 100644 src/azure-cli-core/azure/cli/core/tests/test_credential.py diff --git a/src/azure-cli-core/azure/cli/core/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py similarity index 100% rename from src/azure-cli-core/azure/cli/core/tests/test_identity.py rename to src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py index 5881b906226..23c019ab7d2 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py @@ -6,7 +6,7 @@ # pylint: disable=protected-access import unittest -from ..util import _extract_claims, scopes_to_resource, resource_to_scopes +from ..util import _extract_claims, scopes_to_resource, resource_to_scopes, _generate_login_command class TestUtil(unittest.TestCase): @@ -64,6 +64,26 @@ def test_resource_to_scopes(self): self.assertEqual(resource_to_scopes('https://managedhsm.azure.com'), ['https://managedhsm.azure.com/.default']) + def test_generate_login_command(self): + # No parameter is given + assert _generate_login_command() == 'az login' + + base64_claims = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxNzE3MjE1NiJ9fX0=" + json_claims = '{"access_token":{"nbf":{"essential":true, "value":"1617172156"}}}' + expect = 'az logout\naz login --claims eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxNzE3MjE1NiJ9fX0=' + + # Base64 string is preserved + actual = _generate_login_command(claims=base64_claims) + assert actual == expect + + # JSON string is converted to base64 + actual = _generate_login_command(claims=json_claims) + assert actual == expect + + # scopes + actual = _generate_login_command(scopes=["https://management.core.windows.net//.default"]) + assert actual == 'az login --scope https://management.core.windows.net//.default' + if __name__ == '__main__': unittest.main() diff --git a/src/azure-cli-core/azure/cli/core/tests/test_credential.py b/src/azure-cli-core/azure/cli/core/tests/test_credential.py deleted file mode 100644 index 2a9d8e1245e..00000000000 --- a/src/azure-cli-core/azure/cli/core/tests/test_credential.py +++ /dev/null @@ -1,30 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import unittest - -from azure.cli.core.auth import _generate_login_command - - -class TestUtils(unittest.TestCase): - def test_generate_login_command(self): - # No parameter is given - assert _generate_login_command() == 'az login' - - base64_claims = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxNzE3MjE1NiJ9fX0=" - json_claims = '{"access_token":{"nbf":{"essential":true, "value":"1617172156"}}}' - expect = 'az login --claims eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxNzE3MjE1NiJ9fX0=' - - # Base64 string is preserved - actual = _generate_login_command(claims=base64_claims) - assert actual == expect - - # JSON string is converted to base64 - actual = _generate_login_command(claims=json_claims) - assert actual == expect - - # scopes - actual = _generate_login_command(scopes=["https://management.core.windows.net//.default"]) - assert actual == 'az login --scope https://management.core.windows.net//.default' From e178f529440360b405ed18f8208abaefe17f460f Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 27 Apr 2021 15:04:01 +0800 Subject: [PATCH 27/69] epoch_expires_on --- src/azure-cli-core/azure/cli/core/_profile.py | 18 +++++++++++++++--- .../azure/cli/core/auth/__init__.py | 2 +- src/azure-cli-core/azure/cli/core/auth/util.py | 6 ------ .../cli/command_modules/profile/__init__.py | 2 ++ .../cli/command_modules/profile/custom.py | 4 ++-- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index fea191244ab..aa506bdd565 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -688,7 +688,7 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N str(account[_SUBSCRIPTION_ID]), str(account[_TENANT_ID])) - def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=None): + def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=None, epoch_expires_on=True): # Convert resource to scopes if resource and not scopes: scopes = resource_to_scopes(resource) @@ -703,10 +703,22 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No account = self.get_subscription(subscription) cred = self._create_identity_credential(account, tenant) - from azure.cli.core.auth import sdk_access_token_to_adal_token_entry token = cred.get_token(*scopes) + + if epoch_expires_on: + expires_on = token.expires_on + else: + import datetime + expires_on = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") + + token_entry = { + 'accessToken': token.token, + 'expiresOn': expires_on + } + # (tokenType, accessToken, tokenEntry) - cred = 'Bearer', token.token, sdk_access_token_to_adal_token_entry(token) + cred = 'Bearer', token.token, token_entry + # (cred, subscription, tenant) return (cred, None if tenant else str(account[_SUBSCRIPTION_ID]), str(tenant if tenant else account[_TENANT_ID])) diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py index 0f48be976ab..9159e7be837 100644 --- a/src/azure-cli-core/azure/cli/core/auth/__init__.py +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -5,5 +5,5 @@ from .credential_adaptor import CredentialAdaptor from .identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID -from .util import resource_to_scopes, aad_error_handler, sdk_access_token_to_adal_token_entry, can_launch_browser, \ +from .util import resource_to_scopes, aad_error_handler, can_launch_browser, \ decode_access_token diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index ad5dd07ba06..a4ad1d5a1b5 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -78,12 +78,6 @@ def scopes_to_resource(scopes): return scope -def sdk_access_token_to_adal_token_entry(token): - import datetime - return {'accessToken': token.token, - 'expiresOn': datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")} - - def check_result(result, **kwargs): from azure.cli.core.azclierror import AuthenticationError diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 5992ffb5d91..96af79f1668 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -105,6 +105,8 @@ def load_arguments(self, command): c.argument('tenant', options_list=['--tenant', '-t'], help='Tenant ID for which the token is acquired. Only available for user and service principal account, not for MSI or Cloud Shell account') c.argument('decode', help='Show the decoded access token.', arg_type=get_three_state_flag(), deprecate_info=c.deprecate(target='--decode', hide=True)) + c.argument('epoch_expires_on', help='Show expiresOn in epoch int.', arg_type=get_three_state_flag(), + deprecate_info=c.deprecate(target='--epoch-expires-on', hide=True)) with self.argument_context('account clear') as c: c.argument('clear_credential', clear_credential_type) diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index ad98577d7e5..ff352b4f8a0 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -62,7 +62,7 @@ def show_subscription(cmd, subscription=None, show_auth_for_sdk=None): def get_access_token(cmd, subscription=None, resource=None, scopes=None, resource_type=None, tenant=None, - decode=False): + decode=False, epoch_expires_on=False): """ get AAD token to access to a specified resource. Use 'az cloud show' command for other Azure resources @@ -73,7 +73,7 @@ def get_access_token(cmd, subscription=None, resource=None, scopes=None, resourc profile = Profile(cli_ctx=cmd.cli_ctx) creds, subscription, tenant = profile.get_raw_token(subscription=subscription, resource=resource, scopes=scopes, - tenant=tenant) + tenant=tenant, epoch_expires_on=epoch_expires_on) # Debug switch for showing the decoded access token if decode: From 402d9926c72bcbebd4005b6c14ec2d19a585a52f Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Thu, 17 Jun 2021 16:19:07 +0800 Subject: [PATCH 28/69] Bump MSAL --- src/azure-cli-core/setup.py | 2 +- src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 8ccd9633bda..05aa5e7992b 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -54,7 +54,7 @@ 'humanfriendly>=4.7,<10.0', 'jmespath', 'knack~=0.8.2', - 'msal>=1.10.0,<2.0.0', + 'msal>=1.12.0,<2.0.0', 'paramiko>=2.0.8,<3.0.0', 'pkginfo>=1.5.0.1', 'pyopenssl>=17.1.0', # https://github.com/pyca/pyopenssl/pull/612 diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 7519a3e2286..a3cd787b7bc 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -109,7 +109,7 @@ jsmin==2.2.2 knack==0.8.2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.10.0 +msal==1.12.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 79b35b88a61..670f30357c9 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -109,7 +109,7 @@ jsmin==2.2.2 knack==0.8.2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.10.0 +msal==1.12.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index e6afdd6045e..c3eaf8a5b98 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -108,7 +108,7 @@ jsmin==2.2.2 knack==0.8.2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.10.0 +msal==1.12.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 From deeffff31b1e0a945962700e91a8a913464e7236 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 21 Jul 2021 17:21:17 +0800 Subject: [PATCH 29/69] bump dependencies --- src/azure-cli-core/setup.py | 6 +++--- src/azure-cli/requirements.py3.Darwin.txt | 6 +++--- src/azure-cli/requirements.py3.Linux.txt | 6 +++--- src/azure-cli/requirements.py3.windows.txt | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 44191142439..764832693f5 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -47,14 +47,14 @@ 'argcomplete~=1.8', 'azure-cli-telemetry==1.0.6.*', 'azure-common~=1.1', - 'azure-core==1.14.0b1', + 'azure-core==1.16.0', 'azure-identity==1.6.0b3', - 'azure-mgmt-core==1.3.0b1', + 'azure-mgmt-core==1.3.0b3', 'cryptography>=3.2,<3.4', 'humanfriendly>=4.7,<10.0', 'jmespath', 'knack~=0.8.2', - 'msal>=1.12.0,<2.0.0', + 'msal>=1.13.0,<2.0.0', 'paramiko>=2.0.8,<3.0.0', 'pkginfo>=1.5.0.1', 'PyJWT>=2.1.0', diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 74dc059a7b9..54e5445c410 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -9,7 +9,7 @@ azure-cli-core==2.26.1.1 azure-cli-telemetry==1.0.6 azure-cli==2.26.1.1 azure-common==1.1.22 -azure-core==1.14.0b1 +azure-core==1.16.0 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 @@ -34,7 +34,7 @@ azure-mgmt-consumption==2.0.0 azure-mgmt-containerinstance==1.5.0 azure-mgmt-containerregistry==8.0.0 azure-mgmt-containerservice==16.0.0 -azure-mgmt-core==1.3.0b1 +azure-mgmt-core==1.3.0b3 azure-mgmt-cosmosdb==6.4.0 azure-mgmt-databoxedge==0.2.0 azure-mgmt-datalake-analytics==0.2.1 @@ -109,7 +109,7 @@ jsmin==2.2.2 knack==0.8.2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.12.0 +msal==1.13.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index fd19f503d55..753243da4a8 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -9,7 +9,7 @@ azure-cli-core==2.26.1.1 azure-cli-telemetry==1.0.6 azure-cli==2.26.1.1 azure-common==1.1.22 -azure-core==1.14.0b1 +azure-core==1.16.0 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 @@ -34,7 +34,7 @@ azure-mgmt-consumption==2.0.0 azure-mgmt-containerinstance==1.5.0 azure-mgmt-containerregistry==8.0.0 azure-mgmt-containerservice==16.0.0 -azure-mgmt-core==1.3.0b1 +azure-mgmt-core==1.3.0b3 azure-mgmt-cosmosdb==6.4.0 azure-mgmt-databoxedge==0.2.0 azure-mgmt-datalake-analytics==0.2.1 @@ -109,7 +109,7 @@ jsmin==2.2.2 knack==0.8.2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.12.0 +msal==1.13.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index d315915dcc6..d3082d7c92a 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -9,7 +9,7 @@ azure-cli-core==2.26.1.1 azure-cli-telemetry==1.0.6 azure-cli==2.26.1.1 azure-common==1.1.22 -azure-core==1.14.0b1 +azure-core==1.16.0 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 @@ -34,7 +34,7 @@ azure-mgmt-consumption==2.0.0 azure-mgmt-containerinstance==1.5.0 azure-mgmt-containerregistry==8.0.0 azure-mgmt-containerservice==16.0.0 -azure-mgmt-core==1.3.0b1 +azure-mgmt-core==1.3.0b3 azure-mgmt-cosmosdb==6.4.0 azure-mgmt-databoxedge==0.2.0 azure-mgmt-datalake-analytics==0.2.1 @@ -108,7 +108,7 @@ jsmin==2.2.2 knack==0.8.2 MarkupSafe==1.1.1 mock==4.0.2 -msal==1.12.0 +msal==1.13.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 From 4a54235ed1181891c5623407c65342e31f689335 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 4 Aug 2021 17:40:07 +0800 Subject: [PATCH 30/69] Add back auth_landing_pages --- .../core/auth/auth_landing_pages/error.html | 12 +++++++++++ .../core/auth/auth_landing_pages/success.html | 12 +++++++++++ .../azure/cli/core/auth/identity.py | 20 ++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/error.html create mode 100644 src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/success.html diff --git a/src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/error.html b/src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/error.html new file mode 100644 index 00000000000..a7998994c43 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/error.html @@ -0,0 +1,12 @@ + + + + + Login failed + + +

Authentication failed

+

$error: $error_description. ($error_uri)

+

You can log an issue at Azure CLI GitHub Repository and we will assist you in resolving it.

+ + diff --git a/src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/success.html b/src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/success.html new file mode 100644 index 00000000000..c39bcdaf7a6 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/success.html @@ -0,0 +1,12 @@ + + + + + + Login successfully + + +

You have logged into Microsoft Azure!

+

You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.

+ + diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 289224e4ba2..bf77ff33df9 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -125,7 +125,12 @@ def login_with_auth_code(self, scopes=None, **kwargs): "Please continue the login in the web browser. " "If no web browser is available or if the web browser fails to open, use device code flow " "with `az login --use-device-code`.", self.msal_authority) - result = self.msal_app.acquire_token_interactive(scopes, prompt='select_account', **kwargs) + + success_template, error_template = _read_response_templates() + + result = self.msal_app.acquire_token_interactive( + scopes, prompt='select_account', success_template=success_template, error_template=error_template, **kwargs) + if not result or 'error' in result: aad_error_handler(result) return check_result(result) @@ -713,3 +718,16 @@ def _serialize_secrets(self): logger.warning("Secrets are serialized as plain text and saved to `msalSecrets.cache.json`.") with open(self._token_file + ".json", "w") as fd: fd.write(json.dumps(self._service_principal_creds)) + + +def _read_response_templates(): + """Read from success.html and error.html to strings and pass them to MSAL. """ + success_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'auth_landing_pages', 'success.html') + with open(success_file) as f: + success_template = f.read() + + error_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'auth_landing_pages', 'error.html') + with open(error_file) as f: + error_template = f.read() + + return success_template, error_template From 957cfd88d5a011520366410a7e52e01401294b79 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 17 Aug 2021 15:34:38 +0800 Subject: [PATCH 31/69] azure-core==1.17.0 --- src/azure-cli-core/setup.py | 2 +- src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 3ff981b11fc..fdcb38ed78e 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -47,7 +47,7 @@ 'argcomplete~=1.8', 'azure-cli-telemetry==1.0.6.*', 'azure-common~=1.1', - 'azure-core==1.16.0', + 'azure-core==1.17.0', 'azure-identity==1.6.0b3', 'azure-mgmt-core==1.3.0b3', 'cryptography>=3.2,<3.4', diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 02fa84a98e2..0e620393ce3 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -9,7 +9,7 @@ azure-cli-core==2.27.1 azure-cli-telemetry==1.0.6 azure-cli==2.27.1 azure-common==1.1.22 -azure-core==1.16.0 +azure-core==1.17.0 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 590b375920e..45a98fbf442 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -9,7 +9,7 @@ azure-cli-core==2.27.1 azure-cli-telemetry==1.0.6 azure-cli==2.27.1 azure-common==1.1.22 -azure-core==1.16.0 +azure-core==1.17.0 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 25f09f4b757..20fc8fffa94 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -9,7 +9,7 @@ azure-cli-core==2.27.1 azure-cli-telemetry==1.0.6 azure-cli==2.27.1 azure-common==1.1.22 -azure-core==1.16.0 +azure-core==1.17.0 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 From a540fc0d71c646003460832f72583a66164bdc75 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 17 Aug 2021 17:06:58 +0800 Subject: [PATCH 32/69] Remove adal_cache --- src/azure-cli-core/azure/cli/core/_profile.py | 17 +- .../azure/cli/core/auth/__init__.py | 2 +- .../azure/cli/core/auth/identity.py | 178 ------------------ 3 files changed, 2 insertions(+), 195 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 9bed78c283e..3a51792e9a2 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -16,7 +16,7 @@ from azure.cli.core._session import ACCOUNT from azure.cli.core.util import in_cloud_console from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription -from azure.cli.core.auth import (Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID, +from azure.cli.core.auth import (Identity, MsalSecretStore, AZURE_CLI_CLIENT_ID, resource_to_scopes, can_launch_browser) logger = get_logger(__name__) @@ -497,11 +497,6 @@ def logout(self, user_or_sp, clear_credential): # Remove the account from the profile subscriptions = [x for x in subscriptions if x not in result] self._storage[_SUBSCRIPTIONS] = subscriptions - - # Always remove credential from the legacy cred cache, regardless of MSAL cache, to be deprecated - adal_cache = AdalCredentialCache() - adal_cache.remove_cached_creds(user_or_sp) - logger.warning("Account '%s' has been logged out from Azure CLI.", user_or_sp) else: # https://english.stackexchange.com/questions/5302/log-in-to-or-log-into-or-login-to @@ -528,10 +523,6 @@ def logout(self, user_or_sp, clear_credential): def logout_all(self, clear_credential): self._storage[_SUBSCRIPTIONS] = [] - - # Always remove credentials from the legacy cred cache, regardless of MSAL cache - adal_cache = AdalCredentialCache() - adal_cache.remove_all_cached_creds() logger.warning('All accounts were logged out.') # Deal with MSAL cache @@ -890,7 +881,6 @@ def __init__(self, cli_ctx, arm_client_factory=None, **kwargs): self.secret = None self._arm_resource_id = cli_ctx.cloud.endpoints.active_directory_resource_id self.authority = self.cli_ctx.cloud.endpoints.active_directory - self.adal_cache = kwargs.pop("adal_cache", None) def create_arm_client_factory(credential): if arm_client_factory: @@ -951,11 +941,6 @@ def find_using_common_tenant(self, username, credential=None): .getboolean('core', 'allow_fallback_to_plaintext', fallback=True)) try: specific_tenant_credential = identity.get_user_credential(username) - # todo: remove after ADAL deprecation - if self.adal_cache: - self.adal_cache.add_credential(specific_tenant_credential, - self.cli_ctx.cloud.endpoints.active_directory_resource_id, - self.authority) # TODO: handle MSAL exceptions except adal.AdalError as ex: # because user creds went through the 'common' tenant, the error here must be diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py index 9159e7be837..6497ffb70da 100644 --- a/src/azure-cli-core/azure/cli/core/auth/__init__.py +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -4,6 +4,6 @@ # -------------------------------------------------------------------------------------------- from .credential_adaptor import CredentialAdaptor -from .identity import Identity, AdalCredentialCache, MsalSecretStore, AZURE_CLI_CLIENT_ID +from .identity import Identity, MsalSecretStore, AZURE_CLI_CLIENT_ID from .util import resource_to_scopes, aad_error_handler, can_launch_browser, \ decode_access_token diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index bf77ff33df9..7a371288049 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -58,7 +58,6 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): # Build the authority in MSAL style, like https://login.microsoftonline.com/your_tenant self.msal_authority = "{}/{}".format(self.authority, self.tenant_id) self.client_id = client_id or AZURE_CLI_CLIENT_ID - # self._cred_cache = AdalCredentialCache() self._cred_cache = None self.allow_unencrypted = kwargs.pop('allow_unencrypted', True) self._msal_app_instance = None @@ -368,183 +367,6 @@ def serialize_token_cache(self, path=None): fd.write(cache.serialize()) -TOKEN_FIELDS_EXCLUDED_FROM_PERSISTENCE = ['familyName', - 'givenName', - 'isUserIdDisplayable', - 'tenantId'] -_TOKEN_ENTRY_USER_ID = 'userId' - - -class AdalCredentialCache: - """Caches secrets in ADAL format, will be deprecated - """ - - # TODO: Persist SP to encrypted cache - def __init__(self, async_persist=False): - - # AZURE_ACCESS_TOKEN_FILE is used by Cloud Console and not meant to be user configured - self._token_file = (os.environ.get('AZURE_ACCESS_TOKEN_FILE', None) or - os.path.join(get_config_dir(), 'accessTokens.json')) - self._service_principal_creds = [] - self._adal_token_cache_attr = None - self._should_flush_to_disk = False - self._async_persist = async_persist - if async_persist: - import atexit - atexit.register(self.flush_to_disk) - - def _load_tokens_from_file(self): - if os.path.isfile(self._token_file): - try: - return get_file_json(self._token_file, throw_on_empty=False) or [] - except (CLIError, ValueError) as ex: - raise CLIError("Failed to load token files. If you have a repro, please log an issue at " - "https://github.com/Azure/azure-cli/issues. At the same time, you can clean " - "up by running 'az account clear' and then 'az login'. (Inner Error: {})".format(ex)) - return [] - - def _delete_token_file(self): - try: - os.remove(self._token_file) - except FileNotFoundError: - pass - - def persist_cached_creds(self): - self._should_flush_to_disk = True - if not self._async_persist: - self.flush_to_disk() - - def flush_to_disk(self): - if self._should_flush_to_disk: - with os.fdopen(os.open(self._token_file, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), - 'w+') as cred_file: - items = self.adal_token_cache.read_items() - all_creds = [entry for _, entry in items] - - # trim away useless fields (needed for cred sharing with xplat) - for i in all_creds: - for key in TOKEN_FIELDS_EXCLUDED_FROM_PERSISTENCE: - i.pop(key, None) - - all_creds.extend(self._service_principal_creds) - cred_file.write(json.dumps(all_creds)) - - def retrieve_secret_of_service_principal(self, sp_id, tenant): - self.load_service_principal_creds() - matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] - if not matched: - raise CLIError("Could not retrieve credential from local cache for service principal {}. " - "Please run 'az login' for this service principal." - .format(sp_id)) - matched_with_tenant = [x for x in matched if tenant == x[_SERVICE_PRINCIPAL_TENANT]] - if matched_with_tenant: - cred = matched_with_tenant[0] - else: - logger.warning("Could not retrieve credential from local cache for service principal %s under tenant %s. " - "Trying credential under tenant %s, assuming that is an app credential.", - sp_id, tenant, matched[0][_SERVICE_PRINCIPAL_TENANT]) - cred = matched[0] - return cred.get(_ACCESS_TOKEN, None), cred.get(_SERVICE_PRINCIPAL_CERT_FILE, None) - - def save_service_principal_cred(self, sp_entry): - self.load_adal_token_cache() - matched = [x for x in self._service_principal_creds - if sp_entry[_SERVICE_PRINCIPAL_ID] == x[_SERVICE_PRINCIPAL_ID] and - sp_entry[_SERVICE_PRINCIPAL_TENANT] == x[_SERVICE_PRINCIPAL_TENANT]] - state_changed = False - if matched: - # pylint: disable=line-too-long - if (sp_entry.get(_ACCESS_TOKEN, None) != matched[0].get(_ACCESS_TOKEN, None) or - sp_entry.get(_SERVICE_PRINCIPAL_CERT_FILE, None) != matched[0].get(_SERVICE_PRINCIPAL_CERT_FILE, - None)): - self._service_principal_creds.remove(matched[0]) - self._service_principal_creds.append(sp_entry) - state_changed = True - else: - self._service_principal_creds.append(sp_entry) - state_changed = True - - if state_changed: - self.persist_cached_creds() - - # noinspection PyBroadException - # pylint: disable=protected-access - def add_credential(self, credential, scopes, authority): - try: - query = { - "client_id": AZURE_CLI_CLIENT_ID, - "environment": credential._auth_record.authority, - "home_account_id": credential._auth_record.home_account_id - } - refresh_token = credential._cache.find( - credential._cache.CredentialType.REFRESH_TOKEN, - # target=scopes, # AAD RTs are scope-independent - query=query) - access_token = credential.get_token(*scopes) - import datetime - entry = { - "tokenType": "Bearer", - "expiresOn": datetime.datetime.fromtimestamp(access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f"), - "resource": scopes_to_resource(scopes), - "userId": credential._auth_record.username, - "accessToken": access_token.token, - "refreshToken": refresh_token[0]['secret'], - "_clientId": AZURE_CLI_CLIENT_ID, - "_authority": '{}/{}'.format(authority, credential._auth_record.tenant_id), - "isMRRT": True - } - self.adal_token_cache.add([entry]) - self.persist_cached_creds() - except Exception as e: # pylint: disable=broad-except - logger.debug("Failed to store ADAL token: %s", e) - # swallow all errors since it does not impact az - - @property - def adal_token_cache(self): - return self.load_adal_token_cache() - - def load_adal_token_cache(self): - if self._adal_token_cache_attr is None: - import adal - all_entries = self._load_tokens_from_file() - self.load_service_principal_creds(all_entries=all_entries) - real_token = [x for x in all_entries if x not in self._service_principal_creds] - self._adal_token_cache_attr = adal.TokenCache(json.dumps(real_token)) - return self._adal_token_cache_attr - - def load_service_principal_creds(self, **kwargs): - creds = kwargs.pop("all_entries", None) - if not creds: - creds = self._load_tokens_from_file() - for c in creds: - if c.get(_SERVICE_PRINCIPAL_ID): - self._service_principal_creds.append(c) - return self._service_principal_creds - - def remove_cached_creds(self, user_or_sp): - state_changed = False - # clear AAD tokens - tokens = self.adal_token_cache.find({_TOKEN_ENTRY_USER_ID: user_or_sp}) - if tokens: - state_changed = True - self.adal_token_cache.remove(tokens) - - # clear service principal creds - matched = [x for x in self._service_principal_creds - if x[_SERVICE_PRINCIPAL_ID] == user_or_sp] - if matched: - state_changed = True - self._service_principal_creds = [x for x in self._service_principal_creds - if x not in matched] - - if state_changed: - self.persist_cached_creds() - - def remove_all_cached_creds(self): - # we can clear file contents, but deleting it is simpler - self._delete_token_file() - - class ServicePrincipalAuth: # pylint: disable=too-few-public-methods def __init__(self, client_id, tenant_id, secret=None, certificate_file=None, use_cert_sn_issuer=None): From ebf7fdc0fde4f5f021807b8462260af2bba99a8f Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 18 Aug 2021 15:01:29 +0800 Subject: [PATCH 33/69] cross tenant --- src/azure-cli-core/azure/cli/core/_profile.py | 29 ++++++----- .../azure/cli/core/auth/credential_adaptor.py | 8 +-- .../auth/tests/test_credential_adaptor.py | 18 +++++++ .../azure/cli/core/commands/client_factory.py | 10 ++++ .../azure/cli/core/commands/tests/__init__.py | 4 ++ .../commands/tests/test_client_factory.py | 50 +++++++++++++++++++ .../azure/cli/testsdk/constants.py | 7 +++ .../azure/cli/testsdk/patches.py | 35 +++++++++---- 8 files changed, 131 insertions(+), 30 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/auth/tests/test_credential_adaptor.py create mode 100644 src/azure-cli-core/azure/cli/core/commands/tests/__init__.py create mode 100644 src/azure-cli-core/azure/cli/core/commands/tests/test_client_factory.py create mode 100644 src/azure-cli-testsdk/azure/cli/testsdk/constants.py diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 3a51792e9a2..5d343ce3f7f 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -600,7 +600,7 @@ def _try_parse_msi_account_name(account): return user_name, account[_USER_ENTITY].get(_CLIENT_ID) return None, None - def _create_identity_credential(self, account, aux_tenant_id=None, client_id=None): + def _create_credential(self, account, aux_tenant_id=None, client_id=None): user_type = account[_USER_ENTITY][_USER_TYPE] username_or_sp_id = account[_USER_ENTITY][_USER_NAME] identity_type, identity_id = Profile._try_parse_msi_account_name(account) @@ -622,8 +622,6 @@ def _create_identity_credential(self, account, aux_tenant_id=None, client_id=Non # User if user_type == _USER: - # if not home_account_id: - # raise CLIError("CLI authentication is migrated to AADv2.0, please run 'az login' to re-login") return identity.get_user_credential(username_or_sp_id) # Service Principal @@ -659,24 +657,25 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N account = self.get_subscription(subscription_id) resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - external_tenants_info = [] + external_tenants = [] if aux_tenants: - external_tenants_info = [tenant for tenant in aux_tenants if tenant != account[_TENANT_ID]] + external_tenants = [tenant for tenant in aux_tenants if tenant != account[_TENANT_ID]] if aux_subscriptions: ext_subs = [aux_sub for aux_sub in aux_subscriptions if aux_sub != subscription_id] for ext_sub in ext_subs: sub = self.get_subscription(ext_sub) if sub[_TENANT_ID] != account[_TENANT_ID]: - external_tenants_info.append(sub[_TENANT_ID]) - identity_credential = self._create_identity_credential(account, client_id=client_id) + external_tenants.append(sub[_TENANT_ID]) + + credential = self._create_credential(account, client_id=client_id) external_credentials = [] - for sub_tenant_id in external_tenants_info: - external_credentials.append(self._create_identity_credential(account, sub_tenant_id, client_id=client_id)) + for external_tenant in external_tenants: + external_credentials.append(self._create_credential(account, external_tenant, client_id=client_id)) from azure.cli.core.auth import CredentialAdaptor - auth_object = CredentialAdaptor(identity_credential, - external_credentials=external_credentials if external_credentials else None, - resource=resource) - return (auth_object, + cred_adaptor = CredentialAdaptor(credential, + external_credentials=external_credentials, + resource=resource) + return (cred_adaptor, str(account[_SUBSCRIPTION_ID]), str(account[_TENANT_ID])) @@ -693,7 +692,7 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No raise CLIError("Please specify only one of subscription and tenant, not both") account = self.get_subscription(subscription) - cred = self._create_identity_credential(account, tenant) + cred = self._create_credential(account, tenant) token = cred.get_token(*scopes) @@ -782,7 +781,7 @@ def refresh_accounts(self, subscription_finder=None): tenant = s[_TENANT_ID] subscriptions = [] try: - identity_credential = self._create_identity_credential(s, tenant) + identity_credential = self._create_credential(s, tenant) if is_service_principal: subscriptions = subscription_finder.find_using_specific_tenant(tenant, identity_credential) else: diff --git a/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py b/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py index ba733074c76..7b71d30c149 100644 --- a/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py +++ b/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py @@ -72,10 +72,10 @@ def get_token(self, *scopes, **kwargs): token, _ = self._get_token(scopes, **kwargs) return token - def get_all_tokens(self, *scopes): - # type: (*str) -> Tuple[AccessToken, List[AccessToken]] - # TODO: Track 2 SDK should support external credentials. - return self._get_token(scopes) + def get_auxiliary_tokens(self, *scopes, **kwargs): + if self._external_credentials: + return [cred.get_token(*scopes, **kwargs) for cred in self._external_credentials] + return None @staticmethod def _log_hostname(): diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_credential_adaptor.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_credential_adaptor.py new file mode 100644 index 00000000000..e955f492cd2 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_credential_adaptor.py @@ -0,0 +1,18 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=protected-access +import os +import json +import unittest +from unittest import mock + + +class TestIdentity(unittest.TestCase): + pass + + +if __name__ == '__main__': + unittest.main() diff --git a/src/azure-cli-core/azure/cli/core/commands/client_factory.py b/src/azure-cli-core/azure/cli/core/commands/client_factory.py index 52d6c39aedc..29d45c172e0 100644 --- a/src/azure-cli-core/azure/cli/core/commands/client_factory.py +++ b/src/azure-cli-core/azure/cli/core/commands/client_factory.py @@ -177,6 +177,16 @@ def _prepare_mgmt_client_kwargs_track2(cli_ctx, cred): client_kwargs['credential_scopes'] = scopes client_kwargs['authentication_policy'] = policy + # Track 2 currently lacks the ability to take external credentials. + # https://github.com/Azure/azure-sdk-for-python/issues/8313 + # As a temporary workaround, manually add external tokens to 'x-ms-authorization-auxiliary' header. + # https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/authenticate-multi-tenant + aux_tokens = cred.get_auxiliary_tokens(*scopes) + if aux_tokens: + # Hard-code scheme to 'Bearer' as _BearerTokenCredentialPolicyBase._update_headers does. + client_kwargs['headers']['x-ms-authorization-auxiliary'] = \ + ', '.join("Bearer {}".format(token.token) for token in aux_tokens) + return client_kwargs diff --git a/src/azure-cli-core/azure/cli/core/commands/tests/__init__.py b/src/azure-cli-core/azure/cli/core/commands/tests/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/commands/tests/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/azure-cli-core/azure/cli/core/commands/tests/test_client_factory.py b/src/azure-cli-core/azure/cli/core/commands/tests/test_client_factory.py new file mode 100644 index 00000000000..55bb17fe66d --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/commands/tests/test_client_factory.py @@ -0,0 +1,50 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=protected-access +import unittest + +from azure.cli.core.commands.client_factory import get_mgmt_service_client +from azure.cli.core.mock import DummyCli +from azure.cli.core.profiles import ResourceType +from azure.cli.testsdk import ScenarioTest, LiveScenarioTest +from knack.util import CLIError + +AUX_SUBSCRIPTION = '1c638cf4-608f-4ee6-b680-c329e824c3a8' +AUX_TENANT = '72f988bf-86f1-41af-91ab-2d7cd011db47' + + +class TestClientFactory(ScenarioTest): + def test_get_mgmt_service_client(self): + cli = DummyCli() + client = get_mgmt_service_client(cli, ResourceType.MGMT_RESOURCE_RESOURCES) + assert client + + def test_get_mgmt_service_client_with_aux_subs_and_tenants(self): + cli = DummyCli() + + # Specify aux_subscriptions + client = get_mgmt_service_client(cli, ResourceType.MGMT_RESOURCE_RESOURCES, + aux_subscriptions=[AUX_SUBSCRIPTION]) + + assert client._config.headers_policy.headers.get('x-ms-authorization-auxiliary') + assert client._config.headers_policy.headers.get('x-ms-authorization-auxiliary').startswith("Bearer ") + + # Specify aux_tenants + client = get_mgmt_service_client(cli, ResourceType.MGMT_RESOURCE_RESOURCES, + aux_tenants=[AUX_TENANT]) + + assert client._config.headers_policy.headers.get('x-ms-authorization-auxiliary') + assert client._config.headers_policy.headers.get('x-ms-authorization-auxiliary').startswith("Bearer ") + + # But not both + with self.assertRaisesRegex(CLIError, "only one of aux_subscriptions and aux_tenants"): + client = get_mgmt_service_client(cli, ResourceType.MGMT_RESOURCE_RESOURCES, + aux_subscriptions=[AUX_SUBSCRIPTION], + aux_tenants=[AUX_TENANT]) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/azure-cli-testsdk/azure/cli/testsdk/constants.py b/src/azure-cli-testsdk/azure/cli/testsdk/constants.py new file mode 100644 index 00000000000..3a6454f35c0 --- /dev/null +++ b/src/azure-cli-testsdk/azure/cli/testsdk/constants.py @@ -0,0 +1,7 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +AUX_SUBSCRIPTION = '1c638cf4-608f-4ee6-b680-c329e824c3a8' +AUX_TENANT = '72f988bf-86f1-41af-91ab-2d7cd011db47' diff --git a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py index 4962936f225..58617d8ae3c 100644 --- a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py +++ b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py @@ -7,6 +7,7 @@ from azure_devtools.scenario_tests.const import MOCKED_SUBSCRIPTION_ID, MOCKED_TENANT_ID from .exceptions import CliExecutionError +from .constants import AUX_SUBSCRIPTION, AUX_TENANT MOCKED_USER_NAME = 'example@example.com' @@ -40,18 +41,30 @@ def _handle_main_exception(ex, *args, **kwargs): # pylint: disable=unused-argum def patch_load_cached_subscriptions(unit_test): def _handle_load_cached_subscription(*args, **kwargs): # pylint: disable=unused-argument - return [{ - "id": MOCKED_SUBSCRIPTION_ID, - "user": { - # TODO: Azure Identity may remove homeAccountId in the future, since it is internal to MSAL and - # may not be absolutely necessary - "name": MOCKED_USER_NAME, - "type": "user" + return [ + { + "id": MOCKED_SUBSCRIPTION_ID, + "state": "Enabled", + "name": "Example", + "tenantId": MOCKED_TENANT_ID, + "isDefault": True, + "user": { + "name": MOCKED_USER_NAME, + "type": "user" + } }, - "state": "Enabled", - "name": "Example", - "tenantId": MOCKED_TENANT_ID, - "isDefault": True}] + { + "id": AUX_SUBSCRIPTION, + "state": "Enabled", + "name": "Azure CLI Tests with TTL = 2 Days", + "tenantId": AUX_TENANT, + "isDefault": False, + "user": { + "name": MOCKED_USER_NAME, + "type": "user" + } + } + ] mock_in_unit_test(unit_test, 'azure.cli.core._profile.Profile.load_cached_subscriptions', From 74175a66ae21b10343fe317232c8d1b18ea17b1d Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 18 Aug 2021 16:22:42 +0800 Subject: [PATCH 34/69] Add test --- .../azure/cli/core/commands/client_factory.py | 11 +-- .../commands/tests/test_client_factory.py | 69 +++++++++++++++---- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/client_factory.py b/src/azure-cli-core/azure/cli/core/commands/client_factory.py index 29d45c172e0..39582f80915 100644 --- a/src/azure-cli-core/azure/cli/core/commands/client_factory.py +++ b/src/azure-cli-core/azure/cli/core/commands/client_factory.py @@ -181,11 +181,12 @@ def _prepare_mgmt_client_kwargs_track2(cli_ctx, cred): # https://github.com/Azure/azure-sdk-for-python/issues/8313 # As a temporary workaround, manually add external tokens to 'x-ms-authorization-auxiliary' header. # https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/authenticate-multi-tenant - aux_tokens = cred.get_auxiliary_tokens(*scopes) - if aux_tokens: - # Hard-code scheme to 'Bearer' as _BearerTokenCredentialPolicyBase._update_headers does. - client_kwargs['headers']['x-ms-authorization-auxiliary'] = \ - ', '.join("Bearer {}".format(token.token) for token in aux_tokens) + if hasattr(cred, "get_auxiliary_tokens"): + aux_tokens = cred.get_auxiliary_tokens(*scopes) + if aux_tokens: + # Hard-code scheme to 'Bearer' as _BearerTokenCredentialPolicyBase._update_headers does. + client_kwargs['headers']['x-ms-authorization-auxiliary'] = \ + ', '.join("Bearer {}".format(token.token) for token in aux_tokens) return client_kwargs diff --git a/src/azure-cli-core/azure/cli/core/commands/tests/test_client_factory.py b/src/azure-cli-core/azure/cli/core/commands/tests/test_client_factory.py index 55bb17fe66d..637b1a1d0d4 100644 --- a/src/azure-cli-core/azure/cli/core/commands/tests/test_client_factory.py +++ b/src/azure-cli-core/azure/cli/core/commands/tests/test_client_factory.py @@ -5,45 +5,88 @@ # pylint: disable=protected-access import unittest +from unittest import mock +import os from azure.cli.core.commands.client_factory import get_mgmt_service_client from azure.cli.core.mock import DummyCli from azure.cli.core.profiles import ResourceType from azure.cli.testsdk import ScenarioTest, LiveScenarioTest from knack.util import CLIError +from azure.cli.testsdk import live_only, MOCKED_USER_NAME +from azure.cli.testsdk.constants import AUX_SUBSCRIPTION, AUX_TENANT -AUX_SUBSCRIPTION = '1c638cf4-608f-4ee6-b680-c329e824c3a8' -AUX_TENANT = '72f988bf-86f1-41af-91ab-2d7cd011db47' +from azure_devtools.scenario_tests.const import MOCKED_SUBSCRIPTION_ID, MOCKED_TENANT_ID +mock_subscriptions = [ + { + "id": MOCKED_SUBSCRIPTION_ID, + "state": "Enabled", + "name": "Example", + "tenantId": MOCKED_TENANT_ID, + "isDefault": True, + "user": { + "name": MOCKED_USER_NAME, + "type": "user" + } + }, + { + "id": AUX_SUBSCRIPTION, + "state": "Enabled", + "name": "Auxiliary Subscription", + "tenantId": AUX_TENANT, + "isDefault": False, + "user": { + "name": MOCKED_USER_NAME, + "type": "user" + } + } +] -class TestClientFactory(ScenarioTest): + +class CredentialMock: + def __init__(self, *args, **kwargs): + super().__init__() + self._authority = kwargs.get('authority') + + def get_token(self, *scopes, **kwargs): # pylint: disable=unused-argument + from azure.core.credentials import AccessToken + import time + now = int(time.time()) + return AccessToken("access_token_from_" + self._authority, now + 3600) + + +class TestClientFactory(unittest.TestCase): def test_get_mgmt_service_client(self): cli = DummyCli() client = get_mgmt_service_client(cli, ResourceType.MGMT_RESOURCE_RESOURCES) assert client - def test_get_mgmt_service_client_with_aux_subs_and_tenants(self): + @mock.patch("azure.cli.core.auth.identity.UserCredential", CredentialMock) + @mock.patch('azure.cli.core._profile.Profile.load_cached_subscriptions', return_value=mock_subscriptions) + def test_get_mgmt_service_client_with_aux_subs_and_tenants(self, load_cached_subscriptions_mock): cli = DummyCli() + def _verify_client_aux_token(client_to_check): + aux_tokens = client_to_check._config.headers_policy.headers.get('x-ms-authorization-auxiliary') + assert aux_tokens + assert aux_tokens.startswith("Bearer ") + assert AUX_TENANT in aux_tokens + # Specify aux_subscriptions client = get_mgmt_service_client(cli, ResourceType.MGMT_RESOURCE_RESOURCES, aux_subscriptions=[AUX_SUBSCRIPTION]) - - assert client._config.headers_policy.headers.get('x-ms-authorization-auxiliary') - assert client._config.headers_policy.headers.get('x-ms-authorization-auxiliary').startswith("Bearer ") + _verify_client_aux_token(client) # Specify aux_tenants client = get_mgmt_service_client(cli, ResourceType.MGMT_RESOURCE_RESOURCES, aux_tenants=[AUX_TENANT]) - - assert client._config.headers_policy.headers.get('x-ms-authorization-auxiliary') - assert client._config.headers_policy.headers.get('x-ms-authorization-auxiliary').startswith("Bearer ") + _verify_client_aux_token(client) # But not both with self.assertRaisesRegex(CLIError, "only one of aux_subscriptions and aux_tenants"): - client = get_mgmt_service_client(cli, ResourceType.MGMT_RESOURCE_RESOURCES, - aux_subscriptions=[AUX_SUBSCRIPTION], - aux_tenants=[AUX_TENANT]) + get_mgmt_service_client(cli, ResourceType.MGMT_RESOURCE_RESOURCES, + aux_subscriptions=[AUX_SUBSCRIPTION], aux_tenants=[AUX_TENANT]) if __name__ == '__main__': From a96c537871a4cd9b9cd0c024f22480aeb3aa1116 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 25 Aug 2021 15:51:53 +0800 Subject: [PATCH 35/69] Use msrestazure for managed identity --- src/azure-cli-core/azure/cli/core/_profile.py | 222 +++++++++++------- .../cli/core/auth/adal_authentication.py | 67 ++++++ .../azure/cli/core/auth/identity.py | 2 +- .../azure/cli/core/auth/util.py | 27 +++ src/azure-cli-core/setup.py | 1 - src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 8 files changed, 229 insertions(+), 96 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/auth/adal_authentication.py diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 5d343ce3f7f..c99ae2bb61b 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -216,58 +216,66 @@ def login(self, self._set_subscriptions(consolidated) return deepcopy(consolidated) - def login_with_managed_identity(self, identity_id=None, allow_no_subscriptions=None, find_subscriptions=True, - scopes=None): - # pylint: disable=too-many-statements - - # https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview - # Managed identities for Azure resources is the new name for the service formerly known as - # Managed Service Identity (MSI). - - if not scopes: - scopes = self._arm_scope - - identity = Identity() - credential, mi_info = identity.login_with_managed_identity(scopes=scopes, identity_id=identity_id) + def login_with_managed_identity(self, identity_id=None, allow_no_subscriptions=None): + import jwt + from msrestazure.tools import is_valid_resource_id + from azure.cli.core.auth.adal_authentication import MSIAuthenticationWrapper + resource = self.cli_ctx.cloud.endpoints.active_directory_resource_id + + if identity_id: + if is_valid_resource_id(identity_id): + msi_creds = MSIAuthenticationWrapper(resource=resource, msi_res_id=identity_id) + identity_type = MsiAccountTypes.user_assigned_resource_id + else: + authenticated = False + from azure.cli.core.azclierror import AzureResponseError + try: + msi_creds = MSIAuthenticationWrapper(resource=resource, client_id=identity_id) + identity_type = MsiAccountTypes.user_assigned_client_id + authenticated = True + except AzureResponseError as ex: + if 'http error: 400, reason: Bad Request' in ex.error_msg: + logger.info('Sniff: not an MSI client id') + else: + raise + + if not authenticated: + try: + identity_type = MsiAccountTypes.user_assigned_object_id + msi_creds = MSIAuthenticationWrapper(resource=resource, object_id=identity_id) + authenticated = True + except AzureResponseError as ex: + if 'http error: 400, reason: Bad Request' in ex.error_msg: + logger.info('Sniff: not an MSI object id') + else: + raise + + if not authenticated: + raise CLIError('Failed to connect to MSI, check your managed service identity id.') - tenant = mi_info[Identity.MANAGED_IDENTITY_TENANT_ID] - if find_subscriptions: - logger.info('Finding subscriptions...') - subscription_finder = SubscriptionFinder(self.cli_ctx) - subscriptions = subscription_finder.find_using_specific_tenant(tenant, credential) - if not subscriptions: - if allow_no_subscriptions: - subscriptions = self._build_tenant_level_accounts([tenant]) - else: - raise CLIError('No access was configured for the VM, hence no subscriptions were found. ' - "If this is expected, use '--allow-no-subscriptions' to have tenant level access.") else: - subscriptions = self._build_tenant_level_accounts([tenant]) - - # Get info for persistence - user_name = mi_info[Identity.MANAGED_IDENTITY_TYPE] - id_type_to_identity_type = { - Identity.MANAGED_IDENTITY_CLIENT_ID: MsiAccountTypes.user_assigned_client_id, - Identity.MANAGED_IDENTITY_OBJECT_ID: MsiAccountTypes.user_assigned_object_id, - Identity.MANAGED_IDENTITY_RESOURCE_ID: MsiAccountTypes.user_assigned_resource_id, - None: MsiAccountTypes.system_assigned - } + identity_type = MsiAccountTypes.system_assigned + msi_creds = MSIAuthenticationWrapper(resource=resource) + + token_entry = msi_creds.token + token = token_entry['access_token'] + logger.info('MSI: token was retrieved. Now trying to initialize local accounts...') + decode = jwt.decode(token, algorithms=['RS256'], options={"verify_signature": False}) + tenant = decode['tid'] + + subscription_finder = SubscriptionFinder(self.cli_ctx) + subscriptions = subscription_finder.find_using_specific_tenant(tenant, msi_creds) + base_name = ('{}-{}'.format(identity_type, identity_id) if identity_id else identity_type) + user = _USER_ASSIGNED_IDENTITY if identity_id else _SYSTEM_ASSIGNED_IDENTITY + if not subscriptions: + if allow_no_subscriptions: + subscriptions = self._build_tenant_level_accounts([tenant]) + else: + raise CLIError('No access was configured for the VM, hence no subscriptions were found. ' + "If this is expected, use '--allow-no-subscriptions' to have tenant level access.") - # Previously we persist user's input in assignedIdentityInfo: - # "assignedIdentityInfo": "MSI", - # "assignedIdentityInfo": "MSIClient-eecb2419-a29d-4580-a92a-f6a7b7b71300", - # "assignedIdentityInfo": "MSIObject-27c363a5-7016-4ae0-8540-818ec05673f1", - # "assignedIdentityInfo": "MSIResource-/subscriptions/.../providers/Microsoft.ManagedIdentity/ - # userAssignedIdentities/id", - # Now we persist the output - info extracted from the access token. - # All client_id, object_id, and resource_id are preserved. - # Also, the name "MSI" is deprecated. So will be assignedIdentityInfo. - legacy_identity_type = id_type_to_identity_type[mi_info[Identity.MANAGED_IDENTITY_ID_TYPE]] - legacy_base_name = ('{}-{}'.format(legacy_identity_type, identity_id) if identity_id else legacy_identity_type) - - consolidated = self._normalize_properties(user_name, subscriptions, is_service_principal=True, - user_assigned_identity_id=legacy_base_name, - managed_identity_info=mi_info) + consolidated = self._normalize_properties(user, subscriptions, is_service_principal=True, + user_assigned_identity_id=base_name) self._set_subscriptions(consolidated) return deepcopy(consolidated) @@ -600,38 +608,38 @@ def _try_parse_msi_account_name(account): return user_name, account[_USER_ENTITY].get(_CLIENT_ID) return None, None - def _create_credential(self, account, aux_tenant_id=None, client_id=None): + def _create_credential(self, account, tenant_id=None, client_id=None): + """Create a credential object driven by MSAL + + :param account: + :param tenant_id: If not None, override tenantId from 'account' + :param client_id: + :return: + """ user_type = account[_USER_ENTITY][_USER_TYPE] username_or_sp_id = account[_USER_ENTITY][_USER_NAME] - identity_type, identity_id = Profile._try_parse_msi_account_name(account) - tenant_id = aux_tenant_id if aux_tenant_id else account[_TENANT_ID] + tenant_id = tenant_id if tenant_id else account[_TENANT_ID] # _IS_ENVIRONMENT_CREDENTIAL doesn't exist for normal account is_environment = account[_USER_ENTITY].get(_IS_ENVIRONMENT_CREDENTIAL) identity = Identity(client_id=client_id, authority=self._authority, tenant_id=tenant_id) - if identity_type is None: - if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): - if aux_tenant_id: - raise CLIError("Tenant shouldn't be specified for Cloud Shell account") - return identity.get_managed_identity_credential() - - # EnvironmentCredential. Ignore user_type - if is_environment: - return identity.get_environment_credential() + if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): + if tenant_id: + raise CLIError("Tenant shouldn't be specified for Cloud Shell account") + return identity.get_managed_identity_credential() - # User - if user_type == _USER: - return identity.get_user_credential(username_or_sp_id) + # EnvironmentCredential. Ignore user_type + if is_environment: + return identity.get_environment_credential() - # Service Principal - use_cert_sn_issuer = account[_USER_ENTITY].get(_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH) - return identity.get_service_principal_credential(username_or_sp_id, use_cert_sn_issuer) + # User + if user_type == _USER: + return identity.get_user_credential(username_or_sp_id) - # MSI - if aux_tenant_id: - raise CLIError("Tenant shouldn't be specified for MSI account") - return identity.get_managed_identity_credential(identity_id) + # Service Principal + use_cert_sn_issuer = account[_USER_ENTITY].get(_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH) + return identity.get_service_principal_credential(username_or_sp_id, use_cert_sn_issuer) def get_login_credentials(self, resource=None, client_id=None, subscription_id=None, aux_subscriptions=None, aux_tenants=None): @@ -645,6 +653,8 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N """ # Check if the token has been migrated to MSAL by checking "useMsalTokenCache": true # If not yet, do it now. + resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id + use_msal = self._storage.get(_USE_MSAL_TOKEN_CACHE) if not use_msal: identity = Identity() @@ -657,25 +667,37 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N account = self.get_subscription(subscription_id) resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - external_tenants = [] - if aux_tenants: - external_tenants = [tenant for tenant in aux_tenants if tenant != account[_TENANT_ID]] - if aux_subscriptions: - ext_subs = [aux_sub for aux_sub in aux_subscriptions if aux_sub != subscription_id] - for ext_sub in ext_subs: - sub = self.get_subscription(ext_sub) - if sub[_TENANT_ID] != account[_TENANT_ID]: - external_tenants.append(sub[_TENANT_ID]) - - credential = self._create_credential(account, client_id=client_id) - external_credentials = [] - for external_tenant in external_tenants: - external_credentials.append(self._create_credential(account, external_tenant, client_id=client_id)) - from azure.cli.core.auth import CredentialAdaptor - cred_adaptor = CredentialAdaptor(credential, - external_credentials=external_credentials, - resource=resource) - return (cred_adaptor, + + managed_identity_type, managed_identity_id = Profile._try_parse_msi_account_name(account) + + # Cloud Shell is just a system assignment managed identity + if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): + managed_identity_type = MsiAccountTypes.system_assigned + + if managed_identity_type is None: + # user and service principal + external_tenants = [] + if aux_tenants: + external_tenants = [tenant for tenant in aux_tenants if tenant != account[_TENANT_ID]] + if aux_subscriptions: + ext_subs = [aux_sub for aux_sub in aux_subscriptions if aux_sub != subscription_id] + for ext_sub in ext_subs: + sub = self.get_subscription(ext_sub) + if sub[_TENANT_ID] != account[_TENANT_ID]: + external_tenants.append(sub[_TENANT_ID]) + + credential = self._create_credential(account, client_id=client_id) + external_credentials = [] + for external_tenant in external_tenants: + external_credentials.append(self._create_credential(account, external_tenant, client_id=client_id)) + from azure.cli.core.auth import CredentialAdaptor + cred = CredentialAdaptor(credential, + external_credentials=external_credentials, + resource=resource) + else: + # managed identity + cred = MsiAccountTypes.msi_auth_factory(managed_identity_type, managed_identity_id, resource) + return (cred, str(account[_SUBSCRIPTION_ID]), str(account[_TENANT_ID])) @@ -862,13 +884,31 @@ def get_installation_id(self): return installation_id -# pylint: disable=no-method-argument,no-self-argument,too-few-public-methods class MsiAccountTypes: + # pylint: disable=no-method-argument,no-self-argument system_assigned = 'MSI' user_assigned_client_id = 'MSIClient' user_assigned_object_id = 'MSIObject' user_assigned_resource_id = 'MSIResource' + @staticmethod + def valid_msi_account_types(): + return [MsiAccountTypes.system_assigned, MsiAccountTypes.user_assigned_client_id, + MsiAccountTypes.user_assigned_object_id, MsiAccountTypes.user_assigned_resource_id] + + @staticmethod + def msi_auth_factory(cli_account_name, identity, resource): + from azure.cli.core.auth.adal_authentication import MSIAuthenticationWrapper + if cli_account_name == MsiAccountTypes.system_assigned: + return MSIAuthenticationWrapper(resource=resource) + if cli_account_name == MsiAccountTypes.user_assigned_client_id: + return MSIAuthenticationWrapper(resource=resource, client_id=identity) + if cli_account_name == MsiAccountTypes.user_assigned_object_id: + return MSIAuthenticationWrapper(resource=resource, object_id=identity) + if cli_account_name == MsiAccountTypes.user_assigned_resource_id: + return MSIAuthenticationWrapper(resource=resource, msi_res_id=identity) + raise ValueError("unrecognized msi account name '{}'".format(cli_account_name)) + class SubscriptionFinder: # An ARM client. It finds subscriptions for a user or service principal. It shouldn't do any diff --git a/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py new file mode 100644 index 00000000000..227da740f6b --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py @@ -0,0 +1,67 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import requests + +from msrestazure.azure_active_directory import MSIAuthentication +from azure.core.credentials import AccessToken +from azure.cli.core.auth.util import try_scopes_to_resource + +from knack.log import get_logger + +logger = get_logger(__name__) + + +class MSIAuthenticationWrapper(MSIAuthentication): + # This method is exposed for Azure Core. Add *scopes, **kwargs to fit azure.core requirement + def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument + logger.debug("MSIAuthenticationWrapper.get_token invoked by Track 2 SDK with scopes=%s", scopes) + resource = try_scopes_to_resource(scopes) + if resource: + # If available, use resource provided by SDK + self.resource = resource + self.set_token() + # Managed Identity token entry sample: + # { + # "access_token": "eyJ0eXAiOiJKV...", + # "client_id": "da95e381-d7ab-4fdc-8047-2457909c723b", + # "expires_in": "86386", + # "expires_on": "1605238724", + # "ext_expires_in": "86399", + # "not_before": "1605152024", + # "resource": "https://management.azure.com/", + # "token_type": "Bearer" + # } + return AccessToken(self.token['access_token'], int(self.token['expires_on'])) + + def set_token(self): + import traceback + from azure.cli.core.azclierror import AzureConnectionError, AzureResponseError + try: + super(MSIAuthenticationWrapper, self).set_token() + except requests.exceptions.ConnectionError as err: + logger.debug('throw requests.exceptions.ConnectionError when doing MSIAuthentication: \n%s', + traceback.format_exc()) + raise AzureConnectionError('Failed to connect to MSI. Please make sure MSI is configured correctly ' + 'and check the network connection.\nError detail: {}'.format(str(err))) + except requests.exceptions.HTTPError as err: + logger.debug('throw requests.exceptions.HTTPError when doing MSIAuthentication: \n%s', + traceback.format_exc()) + try: + raise AzureResponseError('Failed to connect to MSI. Please make sure MSI is configured correctly.\n' + 'Get Token request returned http error: {}, reason: {}' + .format(err.response.status, err.response.reason)) + except AttributeError: + raise AzureResponseError('Failed to connect to MSI. Please make sure MSI is configured correctly.\n' + 'Get Token request returned: {}'.format(err.response)) + except TimeoutError as err: + logger.debug('throw TimeoutError when doing MSIAuthentication: \n%s', + traceback.format_exc()) + raise AzureConnectionError('MSI endpoint is not responding. Please make sure MSI is configured correctly.\n' + 'Error detail: {}'.format(str(err))) + + def signed_session(self, session=None): + logger.debug("MSIAuthenticationWrapper.signed_session invoked by Track 1 SDK") + super().signed_session(session) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 7a371288049..963fefd8cb0 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -314,7 +314,7 @@ def get_environment_credential(self): return EnvironmentCredential(**self._credential_kwargs) def get_managed_identity_credential(self, client_id=None): - return ManagedIdentityCredential(client_id=client_id, **self._credential_kwargs) + raise NotImplemented def migrate_tokens(self): """Migrate ADAL token cache to MSAL.""" diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index a4ad1d5a1b5..f47776bc6c3 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -3,6 +3,11 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from knack.log import get_logger + +logger = get_logger(__name__) + + def aad_error_handler(error, **kwargs): """ Handle the error from AAD server returned by ADAL or MSAL. """ @@ -78,6 +83,28 @@ def scopes_to_resource(scopes): return scope +def try_scopes_to_resource(scopes): + """Wrap scopes_to_resource to workaround some SDK issues.""" + + # Track 2 SDKs generated before https://github.com/Azure/autorest.python/pull/239 don't maintain + # credential_scopes and call `get_token` with empty scopes. + # As a workaround, return None so that the CLI-managed resource is used. + if not scopes: + logger.debug("No scope is provided by the SDK, use the CLI-managed resource.") + return None + + # Track 2 SDKs generated before https://github.com/Azure/autorest.python/pull/745 extend default + # credential_scopes with custom credential_scopes. Instead, credential_scopes should be replaced by + # custom credential_scopes. https://github.com/Azure/azure-sdk-for-python/issues/12947 + # As a workaround, remove the first one if there are multiple scopes provided. + if len(scopes) > 1: + logger.debug("Multiple scopes are provided by the SDK, discarding the first one: %s", scopes[0]) + return scopes_to_resource(scopes[1:]) + + # Exactly only one scope is provided + return scopes_to_resource(scopes) + + def check_result(result, **kwargs): from azure.cli.core.azclierror import AuthenticationError diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index fdcb38ed78e..21049b56440 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -48,7 +48,6 @@ 'azure-cli-telemetry==1.0.6.*', 'azure-common~=1.1', 'azure-core==1.17.0', - 'azure-identity==1.6.0b3', 'azure-mgmt-core==1.3.0b3', 'cryptography>=3.2,<3.4', 'humanfriendly>=4.7,<10.0', diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 1f50f8c6362..a3540218304 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -14,7 +14,7 @@ azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 azure-graphrbac==0.60.0 -azure-identity==1.6.0b3 +azure-identity==1.6.1 azure-keyvault-administration==4.0.0b3 azure-keyvault==1.1.0 azure-loganalytics==0.1.0 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index ea2f63b3a4b..771c09065c7 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -14,7 +14,7 @@ azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 azure-graphrbac==0.60.0 -azure-identity==1.6.0b3 +azure-identity==1.6.1 azure-keyvault-administration==4.0.0b3 azure-keyvault==1.1.0 azure-loganalytics==0.1.0 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 846eaca8fef..5c3140dd42f 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -14,7 +14,7 @@ azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 azure-graphrbac==0.60.0 -azure-identity==1.6.0b3 +azure-identity==1.6.1 azure-keyvault-administration==4.0.0b3 azure-keyvault==1.1.0 azure-loganalytics==0.1.0 From 419e07214d676f04b00cc2416299c9f4bdab5117 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 25 Aug 2021 08:30:59 +0000 Subject: [PATCH 36/69] parse --- src/azure-cli-core/azure/cli/core/_profile.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index c99ae2bb61b..892714ab0d3 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -602,10 +602,14 @@ def get_access_token_for_resource(self, username, tenant, resource): @staticmethod def _try_parse_msi_account_name(account): - user_name = account[_USER_ENTITY].get(_USER_NAME) - - if user_name in [_SYSTEM_ASSIGNED_IDENTITY, _USER_ASSIGNED_IDENTITY]: - return user_name, account[_USER_ENTITY].get(_CLIENT_ID) + msi_info, user = account[_USER_ENTITY].get(_ASSIGNED_IDENTITY_INFO), account[_USER_ENTITY].get(_USER_NAME) + + if user in [_SYSTEM_ASSIGNED_IDENTITY, _USER_ASSIGNED_IDENTITY]: + if not msi_info: + msi_info = account[_SUBSCRIPTION_NAME] # fall back to old persisting way + parts = msi_info.split('-', 1) + if parts[0] in MsiAccountTypes.valid_msi_account_types(): + return parts[0], (None if len(parts) <= 1 else parts[1]) return None, None def _create_credential(self, account, tenant_id=None, client_id=None): From 22b3b5612d3c11e42077d918f2efccbdf8fc7649 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 25 Aug 2021 16:38:09 +0800 Subject: [PATCH 37/69] roll back cloud shell --- src/azure-cli-core/azure/cli/core/_profile.py | 43 +++------ .../azure/cli/core/auth/identity.py | 95 +------------------ 2 files changed, 17 insertions(+), 121 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 892714ab0d3..c654701d9f1 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -279,29 +279,24 @@ def login_with_managed_identity(self, identity_id=None, allow_no_subscriptions=N self._set_subscriptions(consolidated) return deepcopy(consolidated) - def login_in_cloud_shell(self, allow_no_subscriptions=None, find_subscriptions=True, scopes=None): - if not scopes: - scopes = self._arm_scope + def login_in_cloud_shell(self): + import jwt + from azure.cli.core.auth.adal_authentication import MSIAuthenticationWrapper - identity = Identity() - credential, identity_info = identity.login_in_cloud_shell(scopes) + msi_creds = MSIAuthenticationWrapper(resource=self.cli_ctx.cloud.endpoints.active_directory_resource_id) + token_entry = msi_creds.token + token = token_entry['access_token'] + logger.info('MSI: token was retrieved. Now trying to initialize local accounts...') + decode = jwt.decode(token, algorithms=['RS256'], options={"verify_signature": False}) + tenant = decode['tid'] - tenant = identity_info[Identity.MANAGED_IDENTITY_TENANT_ID] - if find_subscriptions: - logger.info('Finding subscriptions...') - subscription_finder = SubscriptionFinder(self.cli_ctx) - subscriptions = subscription_finder.find_using_specific_tenant(tenant, credential) - if not subscriptions: - if allow_no_subscriptions: - subscriptions = self._build_tenant_level_accounts([tenant]) - else: - raise CLIError('No access was configured for the VM, hence no subscriptions were found. ' - "If this is expected, use '--allow-no-subscriptions' to have tenant level access.") - else: - subscriptions = self._build_tenant_level_accounts([tenant]) + subscription_finder = SubscriptionFinder(self.cli_ctx) + subscriptions = subscription_finder.find_using_specific_tenant(tenant, msi_creds) + if not subscriptions: + raise CLIError('No subscriptions were found in the cloud shell') + user = decode.get('unique_name', 'N/A') - consolidated = self._normalize_properties(identity_info[Identity.CLOUD_SHELL_IDENTITY_UNIQUE_NAME], - subscriptions, is_service_principal=False) + consolidated = self._normalize_properties(user, subscriptions, is_service_principal=False) for s in consolidated: s[_USER_ENTITY][_CLOUD_SHELL_ID] = True self._set_subscriptions(consolidated) @@ -947,14 +942,6 @@ def create_arm_client_factory(credential): self._arm_client_factory = create_arm_client_factory self.tenants = [] - # only occur inside cloud console or VM with identity - def find_from_raw_token(self, tenant, token): - # decode the token, so we know the tenant - # msal : todo - result = self.find_using_specific_tenant(tenant, token) - self.tenants = [tenant] - return result - def find_using_common_tenant(self, username, credential=None): # pylint: disable=too-many-statements import adal diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 963fefd8cb0..649878478be 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -162,101 +162,10 @@ def login_with_service_principal(self, client_id, secret_or_certificate, scopes= self._cred_cache.save_service_principal_cred(entry) def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disable=too-many-statements - from msrestazure.tools import is_valid_resource_id - from requests import HTTPError - from azure.core.exceptions import ClientAuthenticationError - - credential = None - id_type = None - token = None - - # https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http - if identity_id: - # Try resource ID - if is_valid_resource_id(identity_id): - credential = ManagedIdentityCredential(identity_config={"mi_res_id": identity_id}, - **self._credential_kwargs) - token = credential.get_token(*scopes) - id_type = self.MANAGED_IDENTITY_RESOURCE_ID - else: - authenticated = False - try: - # Try client ID - credential = ManagedIdentityCredential(client_id=identity_id, - **self._credential_kwargs) - token = credential.get_token(*scopes) - id_type = self.MANAGED_IDENTITY_CLIENT_ID - authenticated = True - except ClientAuthenticationError as e: - logger.debug('Managed Identity authentication error: %s', e.message) - logger.info('Username is not an MSI client id') - except HTTPError as ex: - if ex.response.reason == 'Bad Request' and ex.response.status == 400: - logger.info('Username is not an MSI client id') - else: - raise - - if not authenticated: - try: - # Try object ID - credential = ManagedIdentityCredential(identity_config={"object_id": identity_id}, - **self._credential_kwargs) - token = credential.get_token(*scopes) - id_type = self.MANAGED_IDENTITY_OBJECT_ID - authenticated = True - except ClientAuthenticationError as e: - logger.debug('Managed Identity authentication error: %s', e.message) - logger.info('Username is not an MSI object id') - except HTTPError as ex: - if ex.response.reason == 'Bad Request' and ex.response.status == 400: - logger.info('Username is not an MSI object id') - else: - raise - - if not authenticated: - raise CLIError('Failed to connect to MSI, check your managed service identity id.') - - else: - # Use the default managed identity. It can be either system assigned or user assigned. - credential = ManagedIdentityCredential(**self._credential_kwargs) - token = credential.get_token(*scopes) - - decoded = decode_access_token(token) - resource_id = decoded.get('xms_mirid') - # User-assigned identity has resourceID as - # /subscriptions/xxx/resourcegroups/xxx/providers/Microsoft.ManagedIdentity/userAssignedIdentities/xxx - if resource_id and 'Microsoft.ManagedIdentity' in resource_id: - mi_type = self.MANAGED_IDENTITY_USER_ASSIGNED - else: - mi_type = self.MANAGED_IDENTITY_SYSTEM_ASSIGNED - - managed_identity_info = { - self.MANAGED_IDENTITY_TYPE: mi_type, - # The type of the ID provided with --username, only valid for a user-assigned managed identity - self.MANAGED_IDENTITY_ID_TYPE: id_type, - self.MANAGED_IDENTITY_TENANT_ID: decoded['tid'], - self.MANAGED_IDENTITY_CLIENT_ID: decoded['appid'], - self.MANAGED_IDENTITY_OBJECT_ID: decoded['oid'], - self.MANAGED_IDENTITY_RESOURCE_ID: resource_id, - } - logger.debug('Using Managed Identity: %s', json.dumps(managed_identity_info)) - - return credential, managed_identity_info + raise NotImplemented def login_in_cloud_shell(self, scopes): - credential = ManagedIdentityCredential(**self._credential_kwargs) - # As Managed Identity doesn't have ID token, we need to get an initial access token and extract info from it - # The scopes is only used for acquiring the initial access token - token = credential.get_token(*scopes) - decoded = decode_access_token(token) - - cloud_shell_identity_info = { - self.MANAGED_IDENTITY_TENANT_ID: decoded['tid'], - # For getting the user email in Cloud Shell, maybe 'email' can also be used - self.CLOUD_SHELL_IDENTITY_UNIQUE_NAME: decoded.get('unique_name', 'N/A') - } - logger.warning('Using Cloud Shell Managed Identity: %s', json.dumps(cloud_shell_identity_info)) - return credential, cloud_shell_identity_info + raise NotImplemented def logout_user(self, user): accounts = self.msal_app.get_accounts(user) From 88d1b727ffdd31f34099b15207ef0e029259d23a Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 25 Aug 2021 17:50:07 +0800 Subject: [PATCH 38/69] load_persisted_token_cache --- src/azure-cli-core/azure/cli/core/_profile.py | 1 + .../azure/cli/core/auth/identity.py | 38 +++++++-------- .../azure/cli/core/auth/token_cache.py | 46 +++++++++++++++++++ src/azure-cli/setup.py | 1 + 4 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/auth/token_cache.py diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index c654701d9f1..dd525662f85 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -830,6 +830,7 @@ def refresh_accounts(self, subscription_finder=None): self._set_subscriptions(result, merge=False) def get_sp_auth_info(self, subscription_id=None, name=None, password=None, cert_file=None): + # TODO: Use MSAL from collections import OrderedDict account = self.get_subscription(subscription_id) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 649878478be..7af7b46fa3e 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -7,12 +7,6 @@ import os from azure.cli.core._environment import get_config_dir -from azure.cli.core.util import get_file_json -from azure.identity import ( - ManagedIdentityCredential, - EnvironmentCredential, - TokenCachePersistenceOptions -) from knack.log import get_logger from knack.util import CLIError @@ -59,11 +53,14 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): self.msal_authority = "{}/{}".format(self.authority, self.tenant_id) self.client_id = client_id or AZURE_CLI_CLIENT_ID self._cred_cache = None - self.allow_unencrypted = kwargs.pop('allow_unencrypted', True) + + self._cache_file = os.path.join(get_config_dir(), "tokenCache.bin") + self._secret_file = os.path.join(get_config_dir(), "secrets.bin") + self._fallback_to_plaintext = kwargs.pop('fallback_to_plaintext', True) + self._msal_app_instance = None # Store for Service principal credential persistence - self._msal_secret_store = MsalSecretStore(fallback_to_plaintext=self.allow_unencrypted) - self._cache_persistence_options = TokenCachePersistenceOptions(name="azcli", allow_unencrypted_storage=True) + self._msal_secret_store = MsalSecretStore(self._secret_file, fallback_to_plaintext=self._fallback_to_plaintext) self._msal_app_kwargs = { "authority": self.msal_authority, "token_cache": self._load_msal_cache(), @@ -98,10 +95,9 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): # patch_token_cache_add(self.msal_app.remove_account) def _load_msal_cache(self): - # sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py:95 - from azure.identity._persistent_cache import _load_persistent_cache + from .token_cache import load_persisted_token_cache # Store for user token persistence - cache = _load_persistent_cache(self._cache_persistence_options) + cache = load_persisted_token_cache(self._cache_file, self._fallback_to_plaintext) cache._reload_if_necessary() # pylint: disable=protected-access return cache @@ -331,9 +327,9 @@ class MsalSecretStore: """Caches secrets in MSAL custom secret store for Service Principal authentication. """ - def __init__(self, fallback_to_plaintext=True): - self._token_file = os.path.join(get_config_dir(), 'msalSecrets.cache') - self._lock_file = self._token_file + '.lock' + def __init__(self, secret_file, fallback_to_plaintext=True): + self._secret_file = secret_file + self._lock_file = self._secret_file + '.lock' self._service_principal_creds = [] self._fallback_to_plaintext = fallback_to_plaintext @@ -393,7 +389,7 @@ def remove_cached_creds(self, user_or_sp): def remove_all_cached_creds(self): try: - os.remove(self._token_file) + os.remove(self._secret_file) except FileNotFoundError: pass @@ -426,14 +422,14 @@ def _build_persistence(self): import sys if sys.platform.startswith('win'): - return FilePersistenceWithDataProtection(self._token_file) + return FilePersistenceWithDataProtection(self._secret_file) if sys.platform.startswith('darwin'): # todo: support darwin - return KeychainPersistence(self._token_file, "Microsoft.Developer.IdentityService", "MSALCustomCache") + return KeychainPersistence(self._secret_file, "Microsoft.Developer.IdentityService", "MSALCustomCache") if sys.platform.startswith('linux'): try: return LibsecretPersistence( - self._token_file, + self._secret_file, schema_name="MSALCustomToken", attributes={"MsalClientID": "Microsoft.Developer.IdentityService"} ) @@ -442,12 +438,12 @@ def _build_persistence(self): raise # todo: add missing lib in message logger.warning("Encryption unavailable. Opting in to plain text.") - return FilePersistence(self._token_file) + return FilePersistence(self._secret_file) def _serialize_secrets(self): # ONLY FOR DEBUGGING PURPOSE. DO NOT USE IN PRODUCTION CODE. logger.warning("Secrets are serialized as plain text and saved to `msalSecrets.cache.json`.") - with open(self._token_file + ".json", "w") as fd: + with open(self._secret_file + ".json", "w") as fd: fd.write(json.dumps(self._service_principal_creds)) diff --git a/src/azure-cli-core/azure/cli/core/auth/token_cache.py b/src/azure-cli-core/azure/cli/core/auth/token_cache.py new file mode 100644 index 00000000000..a43eb8262b5 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/token_cache.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# This file is modified from +# https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/dev/sample/token_cache_sample.py + +import os +import sys + + +def load_persisted_token_cache(location, fallback_to_plaintext): + import msal_extensions + + persistence = _get_persistence(location, fallback_to_plaintext, account_name="MSALCache") + return msal_extensions.PersistedTokenCache(persistence) + + +def _get_persistence(location, fallback_to_plaintext, account_name): + import msal_extensions + + if sys.platform.startswith("win") and "LOCALAPPDATA" in os.environ: + return msal_extensions.FilePersistenceWithDataProtection(location) + + if sys.platform.startswith("darwin"): + # the cache uses this file's modified timestamp to decide whether to reload + return msal_extensions.KeychainPersistence(location, "Microsoft.Developer.IdentityService", account_name) + + if sys.platform.startswith("linux"): + # The cache uses this file's modified timestamp to decide whether to reload. Note this path is the same + # as that of the plaintext fallback: a new encrypted cache will stomp an unencrypted cache. + file_path = os.path.expanduser(os.path.join("~", ".IdentityService", location)) + try: + return msal_extensions.LibsecretPersistence( + file_path, location, {"MsalClientID": "Microsoft.Developer.IdentityService"}, label=account_name + ) + except ImportError: + if not fallback_to_plaintext: + raise ValueError( + "PyGObject is required to encrypt the persistent cache. Please install that library or " + + 'specify "allow_unencrypted_cache=True" to store the cache without encryption.' + ) + return msal_extensions.FilePersistence(file_path) + + raise NotImplementedError("A persistent cache is not available in this environment.") diff --git a/src/azure-cli/setup.py b/src/azure-cli/setup.py index 692344714c2..7fbd67c44e1 100644 --- a/src/azure-cli/setup.py +++ b/src/azure-cli/setup.py @@ -58,6 +58,7 @@ 'azure-datalake-store~=0.0.49', 'azure-functions-devops-build~=0.0.22', 'azure-graphrbac~=0.60.0', + 'azure-identity', 'azure-keyvault-administration==4.0.0b3', 'azure-keyvault~=1.1.0', 'azure-loganalytics~=0.1.0', From 9badebacc8a99be2095a7a8a7beebe60821df916 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 27 Aug 2021 17:17:12 +0800 Subject: [PATCH 39/69] Bump MSAL to 1.14.0 --- src/azure-cli-core/setup.py | 2 +- src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 946ca58253b..d593d545944 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -53,7 +53,7 @@ 'humanfriendly>=4.7,<10.0', 'jmespath', 'knack~=0.8.2', - 'msal>=1.13.0,<2.0.0', + 'msal>=1.14.0,<2.0.0', 'paramiko>=2.0.8,<3.0.0', 'pkginfo>=1.5.0.1', 'PyJWT>=2.1.0', diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 052e03e7a0c..acc97c4017d 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -109,7 +109,7 @@ jmespath==0.9.5 jsmin==2.2.2 knack==0.8.2 MarkupSafe==1.1.1 -msal==1.13.0 +msal==1.14.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index eaac02a2c57..f9209ba575d 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -109,7 +109,7 @@ jmespath==0.9.5 jsmin==2.2.2 knack==0.8.2 MarkupSafe==1.1.1 -msal==1.13.0 +msal==1.14.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index aab390ae18c..4de760907d0 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -108,7 +108,7 @@ jmespath==0.9.5 jsmin==2.2.2 knack==0.8.2 MarkupSafe==1.1.1 -msal==1.13.0 +msal==1.14.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 From 9c9cee4b7a0bc7de5554e9406e6025d073cfc82c Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 30 Aug 2021 17:49:33 +0800 Subject: [PATCH 40/69] Fix tests --- src/azure-cli-core/azure/cli/core/_profile.py | 96 ++++++------------- src/azure-cli-core/azure/cli/core/_session.py | 15 +-- .../azure/cli/core/tests/test_profile.py | 94 +++++++----------- .../core/tests/test_profile_v2016_06_01.py | 0 4 files changed, 66 insertions(+), 139 deletions(-) delete mode 100644 src/azure-cli-core/azure/cli/core/tests/test_profile_v2016_06_01.py diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index dd525662f85..4fc17f99f13 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -207,7 +207,7 @@ def login(self, return [] else: # Build a tenant account - bare_tenant = tenant or user_identity['tid'] + bare_tenant = tenant or user_identity['tenantId'] subscriptions = self._build_tenant_level_accounts([bare_tenant]) consolidated = self._normalize_properties(username, subscriptions, @@ -340,21 +340,13 @@ def login_with_environment_credential(self, find_subscriptions=True): return deepcopy(consolidated) def _normalize_properties(self, user, subscriptions, is_service_principal, cert_sn_issuer_auth=None, - user_assigned_identity_id=None, managed_identity_info=None, is_environment=False): + user_assigned_identity_id=None, managed_identity_info=None): import sys consolidated = [] for s in subscriptions: - display_name = s.display_name - if display_name is None: - display_name = '' - try: - display_name.encode(sys.getdefaultencoding()) - except (UnicodeEncodeError, UnicodeDecodeError): # mainly for Python 2.7 with ascii as the default encoding - display_name = re.sub(r'[^\x00-\x7f]', lambda x: '?', display_name) - subscription_dict = { _SUBSCRIPTION_ID: s.id.rpartition('/')[2], - _SUBSCRIPTION_NAME: display_name, + _SUBSCRIPTION_NAME: s.display_name, _STATE: s.state, _USER_ENTITY: { _USER_NAME: user, @@ -365,28 +357,17 @@ def _normalize_properties(self, user, subscriptions, is_service_principal, cert_ _ENVIRONMENT_NAME: self.cli_ctx.cloud.name } - # Add _IS_ENVIRONMENT_CREDENTIAL for environment credential accounts, but not for normal accounts. - if is_environment: - subscription_dict[_USER_ENTITY][_IS_ENVIRONMENT_CREDENTIAL] = True - if subscription_dict[_SUBSCRIPTION_NAME] != _TENANT_LEVEL_ACCOUNT_NAME: _transform_subscription_for_multiapi(s, subscription_dict) if cert_sn_issuer_auth: subscription_dict[_USER_ENTITY][_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH] = True - if managed_identity_info: - subscription_dict[_USER_ENTITY]['clientId'] = \ - managed_identity_info[Identity.MANAGED_IDENTITY_CLIENT_ID] - subscription_dict[_USER_ENTITY]['objectId'] = \ - managed_identity_info[Identity.MANAGED_IDENTITY_OBJECT_ID] - subscription_dict[_USER_ENTITY]['resourceId'] = \ - managed_identity_info[Identity.MANAGED_IDENTITY_RESOURCE_ID] # This will be deprecated and client_id will be the only persisted ID + if cert_sn_issuer_auth: + consolidated[-1][_USER_ENTITY][_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH] = True if user_assigned_identity_id: - logger.warning("assignedIdentityInfo will be deprecated in the future. All IDs of the identity " - "are now preserved.") - subscription_dict[_USER_ENTITY][_ASSIGNED_IDENTITY_INFO] = user_assigned_identity_id + consolidated[-1][_USER_ENTITY][_ASSIGNED_IDENTITY_INFO] = user_assigned_identity_id consolidated.append(subscription_dict) return consolidated @@ -436,9 +417,9 @@ def _match_account(account, subscription_id, secondary_key_name, secondary_key_v # merge with existing ones if merge: - dic = collections.OrderedDict((_get_key_name(x, secondary_key_name), x) for x in existing_ones) + dic = {_get_key_name(x, secondary_key_name): x for x in existing_ones} else: - dic = collections.OrderedDict() + dic = {} dic.update((_get_key_name(x, secondary_key_name), x) for x in new_subscriptions) subscriptions = list(dic.values()) @@ -913,44 +894,22 @@ def msi_auth_factory(cli_account_name, identity, resource): class SubscriptionFinder: # An ARM client. It finds subscriptions for a user or service principal. It shouldn't do any # authentication work, but only find subscriptions - def __init__(self, cli_ctx, arm_client_factory=None, **kwargs): + def __init__(self, cli_ctx): self.user_id = None # will figure out after log user in self.cli_ctx = cli_ctx self.secret = None self._arm_resource_id = cli_ctx.cloud.endpoints.active_directory_resource_id self.authority = self.cli_ctx.cloud.endpoints.active_directory - - def create_arm_client_factory(credential): - if arm_client_factory: - return arm_client_factory(credential) - from azure.cli.core.profiles import ResourceType, get_api_version - from azure.cli.core.commands.client_factory import _prepare_mgmt_client_kwargs_track2 - - client_type = self._get_subscription_client_class() - if client_type is None: - from azure.cli.core.azclierror import CLIInternalError - raise CLIInternalError("Unable to get '{}' in profile '{}'" - .format(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS, cli_ctx.cloud.profile)) - api_version = get_api_version(cli_ctx, ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS) - client_kwargs = _prepare_mgmt_client_kwargs_track2(cli_ctx, credential) - # We don't need to change credential_scopes as 'scopes' is ignored by BasicTokenCredential anyway - client = client_type(credential, api_version=api_version, - base_url=self.cli_ctx.cloud.endpoints.resource_manager, - **client_kwargs) - return client - - self._arm_client_factory = create_arm_client_factory self.tenants = [] def find_using_common_tenant(self, username, credential=None): # pylint: disable=too-many-statements - import adal all_subscriptions = [] empty_tenants = [] mfa_tenants = [] - client = self._arm_client_factory(credential) + client = self._create_subscription_client(credential) tenants = client.tenants.list() for t in tenants: @@ -1024,8 +983,7 @@ def find_using_common_tenant(self, username, credential=None): def find_using_specific_tenant(self, tenant, credential): from azure.cli.core.auth import CredentialAdaptor - track1_credential = CredentialAdaptor(credential) - client = self._arm_client_factory(track1_credential) + client = self._create_subscription_client(credential) subscriptions = client.subscriptions.list() all_subscriptions = [] for s in subscriptions: @@ -1034,21 +992,23 @@ def find_using_specific_tenant(self, tenant, credential): self.tenants.append(tenant) return all_subscriptions - def _get_subscription_client_class(self): # pylint: disable=no-self-use - """Get the subscription client class. It can come from either the vendored SDK or public SDK, depending - on the design of architecture. - """ - if _USE_VENDORED_SUBSCRIPTION_SDK: - # Use vendered subscription SDK to decouple from `resource` command module - # pylint: disable=no-name-in-module, import-error - from azure.cli.core.vendored_sdks.subscriptions import SubscriptionClient - client_type = SubscriptionClient - else: - # Use the public SDK - from azure.cli.core.profiles import ResourceType - from azure.cli.core.profiles._shared import get_client_class - client_type = get_client_class(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS) - return client_type + def _create_subscription_client(self, credential): + from azure.cli.core.profiles import ResourceType, get_api_version + from azure.cli.core.profiles._shared import get_client_class + from azure.cli.core.commands.client_factory import _prepare_mgmt_client_kwargs_track2 + + client_type = get_client_class(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS) + if client_type is None: + from azure.cli.core.azclierror import CLIInternalError + raise CLIInternalError("Unable to get '{}' in profile '{}'" + .format(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS, self.cli_ctx.cloud.profile)) + api_version = get_api_version(self.cli_ctx, ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS) + client_kwargs = _prepare_mgmt_client_kwargs_track2(self.cli_ctx, credential) + # TODO: Support CAE + client = client_type(credential, api_version=api_version, + base_url=self.cli_ctx.cloud.endpoints.resource_manager, + **client_kwargs) + return client def _transform_subscription_for_multiapi(s, s_dict): diff --git a/src/azure-cli-core/azure/cli/core/_session.py b/src/azure-cli-core/azure/cli/core/_session.py index 52f83bb79d2..75441073f75 100644 --- a/src/azure-cli-core/azure/cli/core/_session.py +++ b/src/azure-cli-core/azure/cli/core/_session.py @@ -13,15 +13,8 @@ except ImportError: import collections -from codecs import open as codecs_open - from knack.log import get_logger -try: - t_JSONDecodeError = json.JSONDecodeError -except AttributeError: # in Python 2.7 - t_JSONDecodeError = ValueError - class Session(collections.MutableMapping): """ @@ -45,14 +38,14 @@ def load(self, filename, max_age=0): st = os.stat(self.filename) if st.st_mtime + max_age < time.time(): self.save() - with codecs_open(self.filename, 'r', encoding=self._encoding) as f: + with open(self.filename, 'r', encoding=self._encoding) as f: self.data = json.load(f) - except (OSError, IOError, t_JSONDecodeError) as load_exception: + except (OSError, IOError, json.JSONDecodeError) as load_exception: # OSError / IOError should imply file not found issues which are expected on fresh runs (e.g. on build # agents or new systems). A parse error indicates invalid/bad data in the file. We do not wish to warn # on missing files since we expect that, but do if the data isn't parsing as expected. log_level = logging.INFO - if isinstance(load_exception, t_JSONDecodeError): + if isinstance(load_exception, json.JSONDecodeError): log_level = logging.WARNING get_logger(__name__).log(log_level, @@ -62,7 +55,7 @@ def load(self, filename, max_age=0): def save(self): if self.filename: - with codecs_open(self.filename, 'w', encoding=self._encoding) as f: + with open(self.filename, 'w', encoding=self._encoding) as f: json.dump(self.data, f) def save_with_retry(self, retries=5): diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index 30ced8a606e..383584eca3d 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -231,24 +231,31 @@ def setUpClass(cls): cls.service_principal_secret = "test_secret" cls.service_principal_tenant_id = "00000001-0000-0000-0000-000000000000" - @mock.patch('azure.identity.InteractiveBrowserCredential.authenticate', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_auth_code', autospec=True) @mock.patch('azure.cli.core._profile.can_launch_browser', autospec=True, return_value=True) - def test_login_with_interactive_browser(self, can_launch_browser_mock, app_mock, authenticate_mock): - authenticate_mock.return_value = self.authentication_record + def test_login_with_auth_code(self, can_launch_browser_mock, login_with_auth_code_mock, get_user_credential_mock, + create_subscription_client_mock): + user_identity_mock = { + 'username': self.user1, + 'tenantId': self.tenant_id + } + login_with_auth_code_mock.return_value = user_identity_mock cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + mock_subscription_client = mock.MagicMock() + mock_subscription_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - subs = profile.login(True, None, None, False, None, use_device_code=False, - allow_no_subscriptions=False, subscription_finder=finder) + profile = Profile(cli_ctx=cli, storage=storage_mock) + subs = profile.login(True, None, None, False, None, use_device_code=False, allow_no_subscriptions=False) # assert + login_with_auth_code_mock.assert_called_once() + get_user_credential_mock.assert_called() self.assertEqual(self.subscription1_output, subs) @mock.patch('azure.identity.UsernamePasswordCredential.authenticate', autospec=True) @@ -456,31 +463,28 @@ def test_login_with_environment_credential_username_password(self, app_mock, aut def test_normalize(self): cli = DummyCli() storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) expected = self.subscription1_normalized self.assertEqual(expected, consolidated[0]) # verify serialization works self.assertIsNotNone(json.dumps(consolidated[0])) - # Test is_environment is mapped to user.isEnvironmentCredential - consolidated = profile._normalize_properties(self.user1, [self.subscription1], False, is_environment=True) - self.assertEqual(consolidated[0]['user']['isEnvironmentCredential'], True) - def test_normalize_v2016_06_01(self): cli = DummyCli() storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - from azure.cli.core.vendored_sdks.subscriptions.v2016_06_01.models import Subscription \ + profile = Profile(cli_ctx=cli, storage=storage_mock) + from azure.mgmt.resource.subscriptions.v2016_06_01.models import Subscription \ as Subscription_v2016_06_01 subscription = Subscription_v2016_06_01() subscription.id = self.id1 subscription.display_name = self.display_name1 subscription.state = self.state1 subscription.tenant_id = self.tenant_id - # The subscription shouldn't have managed_by_tenants and home_tenant_id consolidated = profile._normalize_properties(self.user1, [subscription], False) + + # The subscription shouldn't have managed_by_tenants and home_tenant_id expected = { 'id': '1', 'name': self.display_name1, @@ -497,40 +501,10 @@ def test_normalize_v2016_06_01(self): # verify serialization works self.assertIsNotNone(json.dumps(consolidated[0])) - def test_normalize_with_unicode_in_subscription_name(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - test_display_name = 'sub' + chr(255) - polished_display_name = 'sub?' - test_subscription = SubscriptionStub('subscriptions/sub1', - test_display_name, - 'Enabled', - 'tenant1') - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [test_subscription], - False) - self.assertTrue(consolidated[0]['name'] in [polished_display_name, test_display_name]) - - def test_normalize_with_none_subscription_name(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - test_display_name = None - polished_display_name = '' - test_subscription = SubscriptionStub('subscriptions/sub1', - test_display_name, - 'Enabled', - 'tenant1') - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, - [test_subscription], - False) - self.assertTrue(consolidated[0]['name'] == polished_display_name) - def test_update_add_two_different_subscriptions(self): cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + storage_mock = {'subscriptions': []} + profile = Profile(cli_ctx=cli, storage=storage_mock) # add the first and verify consolidated = profile._normalize_properties(self.user1, @@ -563,8 +537,8 @@ def test_update_add_two_different_subscriptions(self): def test_update_with_same_subscription_added_twice(self): cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + storage_mock = {'subscriptions': []} + profile = Profile(cli_ctx=cli, storage=storage_mock) # add one twice and verify we will have one but with new token consolidated = profile._normalize_properties(self.user1, @@ -586,8 +560,8 @@ def test_update_with_same_subscription_added_twice(self): def test_set_active_subscription(self): cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + storage_mock = {'subscriptions': []} + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, [self.subscription1], @@ -607,8 +581,8 @@ def test_set_active_subscription(self): def test_default_active_subscription_to_non_disabled_one(self): cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + storage_mock = {'subscriptions': []} + profile = Profile(cli_ctx=cli, storage=storage_mock) subscriptions = profile._normalize_properties( self.user2, [self.subscription2, self.subscription1], False) @@ -621,8 +595,8 @@ def test_default_active_subscription_to_non_disabled_one(self): def test_get_subscription(self): cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + storage_mock = {'subscriptions': []} + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, [self.subscription1], @@ -641,7 +615,7 @@ def test_get_subscription(self): def test_get_auth_info_fail_on_user_account(self): cli = DummyCli() storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, [self.subscription1], diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile_v2016_06_01.py b/src/azure-cli-core/azure/cli/core/tests/test_profile_v2016_06_01.py deleted file mode 100644 index e69de29bb2d..00000000000 From 7f2a021db536c74bb2d6304d2a205d79b2ba3441 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 1 Sep 2021 17:23:52 +0800 Subject: [PATCH 41/69] refine --- src/azure-cli-core/azure/cli/core/_profile.py | 474 ++++++++---------- .../azure/cli/core/auth/identity.py | 137 ++--- .../cli/core/auth/msal_authentication.py | 22 +- .../cli/core/auth/tests/test_identity.py | 4 +- .../azure/cli/core/tests/test_profile.py | 177 ++----- 5 files changed, 312 insertions(+), 502 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 4fc17f99f13..f7d7a0646c8 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -59,8 +59,6 @@ _AZ_LOGIN_MESSAGE = "Please run 'az login' to setup account." -_USE_VENDORED_SUBSCRIPTION_SDK = False - def load_subscriptions(cli_ctx, all_clouds=False, refresh=False): profile = Profile(cli_ctx=cli_ctx) @@ -117,13 +115,14 @@ class Profile: def __init__(self, cli_ctx=None, storage=None): """Class to manage CLI's accounts (profiles) and identities (credentials). + + :param cli_ctx: The CLI context + :param storage: A dict to store accounts, by default persisted to ~/.azure/azureProfile.json as JSON """ from azure.cli.core import get_default_cli self.cli_ctx = cli_ctx or get_default_cli() self._storage = storage or ACCOUNT - - self._management_resource_uri = self.cli_ctx.cloud.endpoints.management self._authority = self.cli_ctx.cloud.endpoints.active_directory self._arm_scope = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) @@ -302,42 +301,221 @@ def login_in_cloud_shell(self): self._set_subscriptions(consolidated) return deepcopy(consolidated) - def login_with_environment_credential(self, find_subscriptions=True): - # pylint: disable=protected-access - identity = Identity() + def logout(self, user_or_sp, clear_credential): + subscriptions = self.load_cached_subscriptions(all_clouds=True) + result = [x for x in subscriptions + if user_or_sp.lower() == x[_USER_ENTITY][_USER_NAME].lower()] - tenant_id = os.environ.get('AZURE_TENANT_ID') - username = os.environ.get('AZURE_USERNAME') - client_id = os.environ.get('AZURE_CLIENT_ID') + if result: + # Remove the account from the profile + subscriptions = [x for x in subscriptions if x not in result] + self._storage[_SUBSCRIPTIONS] = subscriptions + logger.warning("Account '%s' has been logged out from Azure CLI.", user_or_sp) + else: + # https://english.stackexchange.com/questions/5302/log-in-to-or-log-into-or-login-to + logger.warning("Account '%s' was not logged in to Azure CLI.", user_or_sp) - credential = identity.get_environment_credential() + # Log out from MSAL cache + identity = Identity(self._authority) + accounts = identity.get_user(user_or_sp) + if accounts: + logger.info("The credential of '%s' were found from MSAL encrypted cache.", user_or_sp) + if clear_credential: + identity.logout_user(user_or_sp) + logger.warning("The credential of '%s' were cleared from MSAL encrypted cache. This account is " + "also logged out from other SDK tools which use Azure CLI's credential " + "via Single Sign-On.", user_or_sp) + else: + logger.warning("The credential of '%s' is still stored in MSAL encrypted cached. Other SDK tools may " + "use Azure CLI\'s credential via Single Sign-On. " + 'To clear the credential, run `az logout --username %s --clear-credential`.', + user_or_sp, user_or_sp) + else: + # remove service principle secret + identity.logout_sp(user_or_sp) + + def logout_all(self, clear_credential): + self._storage[_SUBSCRIPTIONS] = [] + logger.warning('All accounts were logged out.') - authentication_record = None - if credential._credential.__class__.__name__ == 'UsernamePasswordCredential': - user_type = _USER - # For user account, credential._credential is a UsernamePasswordCredential. - # Login the user so that MSAL has it in cache. - authentication_record = credential._credential.authenticate() + # Deal with MSAL cache + identity = Identity(self._authority) + accounts = identity.get_user() + if accounts: + logger.info("These credentials were found from MSAL encrypted cache: %s", accounts) + if clear_credential: + identity.logout_all() + logger.warning('All credentials store in MSAL encrypted cache were cleared.') + else: + logger.warning('These credentials are still stored in MSAL encrypted cached:') + for account in identity.get_user(): + logger.warning(account['username']) + logger.warning('Other SDK tools may use Azure CLI\'s credential via Single Sign-On. ' + 'To clear all credentials, run `az account clear --clear-credential`. ' + 'To clear one of them, run `az logout --username USERNAME --clear-credential`.') else: - user_type = _SERVICE_PRINCIPAL + logger.warning('No credential was not found from MSAL encrypted cache.') - if find_subscriptions: - subscription_finder = SubscriptionFinder(self.cli_ctx) - if tenant_id: - logger.info('Finding subscriptions under tenant %s.', tenant_id) - subscriptions = subscription_finder.find_using_specific_tenant(tenant_id, credential) + def get_login_credentials(self, resource=None, client_id=None, subscription_id=None, aux_subscriptions=None, + aux_tenants=None): + """Get a CredentialAdaptor instance to be used with both Track 1 and Track 2 SDKs. + + :param resource: The resource ID to acquire an access token. Only provide it for Track 1 SDKs. + :param client_id: + :param subscription_id: + :param aux_subscriptions: + :param aux_tenants: + """ + # Check if the token has been migrated to MSAL by checking "useMsalTokenCache": true + # If not yet, do it now. + resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id + + use_msal = self._storage.get(_USE_MSAL_TOKEN_CACHE) + if not use_msal: + identity = Identity() + identity.migrate_tokens() + self._storage[_USE_MSAL_TOKEN_CACHE] = True + + if aux_tenants and aux_subscriptions: + raise CLIError("Please specify only one of aux_subscriptions and aux_tenants, not both") + + account = self.get_subscription(subscription_id) + + resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id + + managed_identity_type, managed_identity_id = Profile._try_parse_msi_account_name(account) + + # Cloud Shell is just a system assignment managed identity + if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): + managed_identity_type = MsiAccountTypes.system_assigned + + if managed_identity_type is None: + # user and service principal + external_tenants = [] + if aux_tenants: + external_tenants = [tenant for tenant in aux_tenants if tenant != account[_TENANT_ID]] + if aux_subscriptions: + ext_subs = [aux_sub for aux_sub in aux_subscriptions if aux_sub != subscription_id] + for ext_sub in ext_subs: + sub = self.get_subscription(ext_sub) + if sub[_TENANT_ID] != account[_TENANT_ID]: + external_tenants.append(sub[_TENANT_ID]) + + credential = self._create_credential(account, client_id=client_id) + external_credentials = [] + for external_tenant in external_tenants: + external_credentials.append(self._create_credential(account, external_tenant, client_id=client_id)) + from azure.cli.core.auth import CredentialAdaptor + cred = CredentialAdaptor(credential, + external_credentials=external_credentials, + resource=resource) + else: + # managed identity + cred = MsiAccountTypes.msi_auth_factory(managed_identity_type, managed_identity_id, resource) + return (cred, + str(account[_SUBSCRIPTION_ID]), + str(account[_TENANT_ID])) + + def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=None, epoch_expires_on=True): + # Convert resource to scopes + if resource and not scopes: + scopes = resource_to_scopes(resource) + + # Use ARM as the default scopes + if not scopes: + scopes = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) + + if subscription and tenant: + raise CLIError("Please specify only one of subscription and tenant, not both") + + account = self.get_subscription(subscription) + resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id + + identity_type, identity_id = Profile._try_parse_msi_account_name(account) + if identity_type: + # MSI + if tenant: + raise CLIError("Tenant shouldn't be specified for MSI account") + msi_creds = MsiAccountTypes.msi_auth_factory(identity_type, identity_id, resource) + msi_creds.set_token() + token_entry = msi_creds.token + creds = (token_entry['token_type'], token_entry['access_token'], token_entry) + elif in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): + # Cloud Shell + if tenant: + raise CLIError("Tenant shouldn't be specified for Cloud Shell account") + creds = self._get_token_from_cloud_shell(resource) + else: + credential = self._create_credential(account, tenant) + token = credential.get_token(*scopes) + if epoch_expires_on: + expires_on = token.expires_on else: - logger.info('Finding subscriptions under all available tenants.') - subscriptions = subscription_finder.find_using_common_tenant(username, credential) + import datetime + expires_on = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") + + token_entry = { + 'accessToken': token.token, + 'expiresOn': expires_on + } + + # (tokenType, accessToken, tokenEntry) + creds = 'Bearer', token.token, token_entry + # (cred, subscription, tenant) + return (creds, + None if tenant else str(account[_SUBSCRIPTION_ID]), + str(tenant if tenant else account[_TENANT_ID])) + + def get_msal_token(self, scopes, data): + """ + This is added for VM SSH feature with backward compatible interface. + data contains token_type (ssh-cert), key_id and JWK. + """ + account = self.get_subscription() + identity_type = account[_USER_ENTITY][_USER_TYPE] + username_or_sp_id = account[_USER_ENTITY][_USER_NAME] + tenant = account[_TENANT_ID] + identity = Identity(authority=self._authority, tenant_id=tenant) + + # Raise error for managed identity and Cloud Shell + not_support_message = "VM SSH currently doesn't support {}." + + # managed identity + managed_identity_type, _ = Profile._try_parse_msi_account_name(account) + if managed_identity_type: + raise CLIError(not_support_message.format("managed identity")) + + # Cloud Shell + if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): + raise CLIError(not_support_message.format("Cloud Shell")) + + # user + if identity_type == _USER: + username = username_or_sp_id + app = identity.get_user_credential(username) + result = app.acquire_token_silent_with_error(scopes, app.account, data=data) + + # If acquire_token_silent_with_error failed, interactively get new RT and AT + if not result or 'error' in result: + if result: + logger.warning(result['error_description']) + + # Retry login with VM SSH as resource + result = app.acquire_token_interactive(scopes, login_hint=username, data=data) + + # service principal + elif identity_type == _SERVICE_PRINCIPAL: + app = identity.get_service_principal_credential(username_or_sp_id) + result = app.acquire_token_for_client(scopes, data=data) + else: - # Use home tenant ID if tenant_id is not given - subscriptions = self._build_tenant_level_accounts([tenant_id or authentication_record.tenant_id]) + raise CLIError("Unknown identity type {}".format(identity_type)) - consolidated = self._normalize_properties(username or client_id, subscriptions, - is_service_principal=(user_type == _SERVICE_PRINCIPAL), - is_environment=True) - self._set_subscriptions(consolidated) - return deepcopy(consolidated) + if 'error' in result: + from azure.cli.core.auth import aad_error_handler + aad_error_handler(result) + + return username_or_sp_id, result["access_token"] def _normalize_properties(self, user, subscriptions, is_service_principal, cert_sn_issuer_auth=None, user_assigned_identity_id=None, managed_identity_info=None): @@ -386,14 +564,9 @@ def _build_tenant_level_accounts(self, tenants): def _new_account(self): """Build an empty Subscription which will be used as a tenant account. API version doesn't matter as only specified attributes are preserved by _normalize_properties.""" - if _USE_VENDORED_SUBSCRIPTION_SDK: - # pylint: disable=no-name-in-module, import-error - from azure.cli.core.vendored_sdks.subscriptions.models import Subscription - SubscriptionType = Subscription - else: - from azure.cli.core.profiles import ResourceType, get_sdk - SubscriptionType = get_sdk(self.cli_ctx, ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS, - 'Subscription', mod='models') + from azure.cli.core.profiles import ResourceType, get_sdk + SubscriptionType = get_sdk(self.cli_ctx, ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS, + 'Subscription', mod='models') s = SubscriptionType() s.state = 'Enabled' return s @@ -472,61 +645,6 @@ def set_active_subscription(self, subscription): # take id or name set_cloud_subscription(self.cli_ctx, active_cloud.name, result[0][_SUBSCRIPTION_ID]) self._storage[_SUBSCRIPTIONS] = subscriptions - def logout(self, user_or_sp, clear_credential): - subscriptions = self.load_cached_subscriptions(all_clouds=True) - result = [x for x in subscriptions - if user_or_sp.lower() == x[_USER_ENTITY][_USER_NAME].lower()] - - if result: - # Remove the account from the profile - subscriptions = [x for x in subscriptions if x not in result] - self._storage[_SUBSCRIPTIONS] = subscriptions - logger.warning("Account '%s' has been logged out from Azure CLI.", user_or_sp) - else: - # https://english.stackexchange.com/questions/5302/log-in-to-or-log-into-or-login-to - logger.warning("Account '%s' was not logged in to Azure CLI.", user_or_sp) - - # Log out from MSAL cache - identity = Identity(self._authority) - accounts = identity.get_user(user_or_sp) - if accounts: - logger.info("The credential of '%s' were found from MSAL encrypted cache.", user_or_sp) - if clear_credential: - identity.logout_user(user_or_sp) - logger.warning("The credential of '%s' were cleared from MSAL encrypted cache. This account is " - "also logged out from other SDK tools which use Azure CLI's credential " - "via Single Sign-On.", user_or_sp) - else: - logger.warning("The credential of '%s' is still stored in MSAL encrypted cached. Other SDK tools may " - "use Azure CLI\'s credential via Single Sign-On. " - 'To clear the credential, run `az logout --username %s --clear-credential`.', - user_or_sp, user_or_sp) - else: - # remove service principle secret - identity.logout_sp(user_or_sp) - - def logout_all(self, clear_credential): - self._storage[_SUBSCRIPTIONS] = [] - logger.warning('All accounts were logged out.') - - # Deal with MSAL cache - identity = Identity(self._authority) - accounts = identity.get_user() - if accounts: - logger.info("These credentials were found from MSAL encrypted cache: %s", accounts) - if clear_credential: - identity.logout_all() - logger.warning('All credentials store in MSAL encrypted cache were cleared.') - else: - logger.warning('These credentials are still stored in MSAL encrypted cached:') - for account in identity.get_user(): - logger.warning(account['username']) - logger.warning('Other SDK tools may use Azure CLI\'s credential via Single Sign-On. ' - 'To clear all credentials, run `az account clear --clear-credential`. ' - 'To clear one of them, run `az logout --username USERNAME --clear-credential`.') - else: - logger.warning('No credential was not found from MSAL encrypted cache.') - def load_cached_subscriptions(self, all_clouds=False): subscriptions = self._storage.get(_SUBSCRIPTIONS) or [] active_cloud = self.cli_ctx.cloud @@ -565,17 +683,6 @@ def get_subscription(self, subscription=None): # take id or name def get_subscription_id(self, subscription=None): # take id or name return self.get_subscription(subscription)[_SUBSCRIPTION_ID] - def get_access_token_for_scopes(self, username, tenant, *scopes, **kwargs): - """Get access token for user account. Service Principal is not supported.""" - identity = Identity(self._authority, tenant) - credential = identity.get_user_credential(username) - token = credential.get_token(*scopes, **kwargs) - return token.token - - def get_access_token_for_resource(self, username, tenant, resource): - """get access token for current user account, used by vsts and iot module""" - return self.get_access_token_for_scopes(username, tenant, *resource_to_scopes(resource)) - @staticmethod def _try_parse_msi_account_name(account): msi_info, user = account[_USER_ENTITY].get(_ASSIGNED_IDENTITY_INFO), account[_USER_ENTITY].get(_USER_NAME) @@ -621,152 +728,6 @@ def _create_credential(self, account, tenant_id=None, client_id=None): use_cert_sn_issuer = account[_USER_ENTITY].get(_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH) return identity.get_service_principal_credential(username_or_sp_id, use_cert_sn_issuer) - def get_login_credentials(self, resource=None, client_id=None, subscription_id=None, aux_subscriptions=None, - aux_tenants=None): - """Get a CredentialAdaptor instance to be used with both Track 1 and Track 2 SDKs. - - :param resource: The resource ID to acquire an access token. Only provide it for Track 1 SDKs. - :param client_id: - :param subscription_id: - :param aux_subscriptions: - :param aux_tenants: - """ - # Check if the token has been migrated to MSAL by checking "useMsalTokenCache": true - # If not yet, do it now. - resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - - use_msal = self._storage.get(_USE_MSAL_TOKEN_CACHE) - if not use_msal: - identity = Identity() - identity.migrate_tokens() - self._storage[_USE_MSAL_TOKEN_CACHE] = True - - if aux_tenants and aux_subscriptions: - raise CLIError("Please specify only one of aux_subscriptions and aux_tenants, not both") - - account = self.get_subscription(subscription_id) - - resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - - managed_identity_type, managed_identity_id = Profile._try_parse_msi_account_name(account) - - # Cloud Shell is just a system assignment managed identity - if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): - managed_identity_type = MsiAccountTypes.system_assigned - - if managed_identity_type is None: - # user and service principal - external_tenants = [] - if aux_tenants: - external_tenants = [tenant for tenant in aux_tenants if tenant != account[_TENANT_ID]] - if aux_subscriptions: - ext_subs = [aux_sub for aux_sub in aux_subscriptions if aux_sub != subscription_id] - for ext_sub in ext_subs: - sub = self.get_subscription(ext_sub) - if sub[_TENANT_ID] != account[_TENANT_ID]: - external_tenants.append(sub[_TENANT_ID]) - - credential = self._create_credential(account, client_id=client_id) - external_credentials = [] - for external_tenant in external_tenants: - external_credentials.append(self._create_credential(account, external_tenant, client_id=client_id)) - from azure.cli.core.auth import CredentialAdaptor - cred = CredentialAdaptor(credential, - external_credentials=external_credentials, - resource=resource) - else: - # managed identity - cred = MsiAccountTypes.msi_auth_factory(managed_identity_type, managed_identity_id, resource) - return (cred, - str(account[_SUBSCRIPTION_ID]), - str(account[_TENANT_ID])) - - def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=None, epoch_expires_on=True): - # Convert resource to scopes - if resource and not scopes: - scopes = resource_to_scopes(resource) - - # Use ARM as the default scopes - if not scopes: - scopes = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) - - if subscription and tenant: - raise CLIError("Please specify only one of subscription and tenant, not both") - - account = self.get_subscription(subscription) - cred = self._create_credential(account, tenant) - - token = cred.get_token(*scopes) - - if epoch_expires_on: - expires_on = token.expires_on - else: - import datetime - expires_on = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") - - token_entry = { - 'accessToken': token.token, - 'expiresOn': expires_on - } - - # (tokenType, accessToken, tokenEntry) - cred = 'Bearer', token.token, token_entry - # (cred, subscription, tenant) - return (cred, - None if tenant else str(account[_SUBSCRIPTION_ID]), - str(tenant if tenant else account[_TENANT_ID])) - - def get_msal_token(self, scopes, data): - """ - This is added for VM SSH feature with backward compatible interface. - data contains token_type (ssh-cert), key_id and JWK. - """ - account = self.get_subscription() - identity_type = account[_USER_ENTITY][_USER_TYPE] - username_or_sp_id = account[_USER_ENTITY][_USER_NAME] - tenant = account[_TENANT_ID] - identity = Identity(authority=self._authority, tenant_id=tenant) - - # Raise error for managed identity and Cloud Shell - not_support_message = "VM SSH currently doesn't support {}." - - # managed identity - managed_identity_type, _ = Profile._try_parse_msi_account_name(account) - if managed_identity_type: - raise CLIError(not_support_message.format("managed identity")) - - # Cloud Shell - if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): - raise CLIError(not_support_message.format("Cloud Shell")) - - # user - if identity_type == _USER: - username = username_or_sp_id - app = identity.get_user_credential(username) - result = app.acquire_token_silent_with_error(scopes, app.account, data=data) - - # If acquire_token_silent_with_error failed, interactively get new RT and AT - if not result or 'error' in result: - if result: - logger.warning(result['error_description']) - - # Retry login with VM SSH as resource - result = app.acquire_token_interactive(scopes, login_hint=username, data=data) - - # service principal - elif identity_type == _SERVICE_PRINCIPAL: - app = identity.get_service_principal_credential(username_or_sp_id) - result = app.acquire_token_for_client(scopes, data=data) - - else: - raise CLIError("Unknown identity type {}".format(identity_type)) - - if 'error' in result: - from azure.cli.core.auth import aad_error_handler - aad_error_handler(result) - - return username_or_sp_id, result["access_token"] - def refresh_accounts(self, subscription_finder=None): subscriptions = self.load_cached_subscriptions() to_refresh = subscriptions @@ -829,7 +790,7 @@ def get_sp_auth_info(self, subscription_id=None, name=None, password=None, cert_ if user_type == _SERVICE_PRINCIPAL: result['clientId'] = account[_USER_ENTITY][_USER_NAME] msal_cache = MsalSecretStore(True) - secret, certificate_file = msal_cache.retrieve_secret_of_service_principal( + secret, certificate_file = msal_cache.load_service_principal_cred( account[_USER_ENTITY][_USER_NAME], account[_TENANT_ID]) if secret: result['clientSecret'] = secret @@ -864,6 +825,13 @@ def get_installation_id(self): self._storage[_INSTALLATION_ID] = installation_id return installation_id + def _get_token_from_cloud_shell(self, resource): # pylint: disable=no-self-use + from azure.cli.core.auth.adal_authentication import MSIAuthenticationWrapper + auth = MSIAuthenticationWrapper(resource=resource) + auth.set_token() + token_entry = auth.token + return (token_entry['token_type'], token_entry['access_token'], token_entry) + class MsiAccountTypes: # pylint: disable=no-method-argument,no-self-argument diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 7af7b46fa3e..44e555b535e 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -5,14 +5,14 @@ import json import os +import re from azure.cli.core._environment import get_config_dir from knack.log import get_logger from knack.util import CLIError from .msal_authentication import UserCredential, ServicePrincipalCredential -from .util import aad_error_handler, resource_to_scopes, scopes_to_resource, check_result, \ - decode_access_token +from .util import aad_error_handler, resource_to_scopes, check_result AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' @@ -52,7 +52,6 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): # Build the authority in MSAL style, like https://login.microsoftonline.com/your_tenant self.msal_authority = "{}/{}".format(self.authority, self.tenant_id) self.client_id = client_id or AZURE_CLI_CLIENT_ID - self._cred_cache = None self._cache_file = os.path.join(get_config_dir(), "tokenCache.bin") self._secret_file = os.path.join(get_config_dir(), "secrets.bin") @@ -143,19 +142,14 @@ def login_with_username_password(self, username, password, scopes=None, **kwargs result = self.msal_app.acquire_token_by_username_password(username, password, scopes, **kwargs) return check_result(result) - def login_with_service_principal(self, client_id, secret_or_certificate, scopes=None): - cred = ServicePrincipalCredential(client_id, secret_or_certificate, **self._msal_app_kwargs) + def login_with_service_principal(self, client_id, secret_or_certificate, use_cert_sn_issuer=None, scopes=None): + sp_auth = ServicePrincipalAuth(self.tenant_id, client_id, + secret_or_certificate, use_cert_sn_issuer=use_cert_sn_issuer) + cred = ServicePrincipalCredential(sp_auth, **self._msal_app_kwargs) result = cred.acquire_token_for_client(scopes) check_result(result) - # Use ClientSecretCredential - # TODO: Persist to encrypted cache - # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/pull/44 - sp_auth = ServicePrincipalAuth(client_id, self.tenant_id, secret=secret_or_certificate) entry = sp_auth.get_entry_to_persist() self._msal_secret_store.save_service_principal_cred(entry) - # backward compatible with ADAL, to be deprecated - if self._cred_cache: - self._cred_cache.save_service_principal_cred(entry) def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disable=too-many-statements raise NotImplemented @@ -203,64 +197,14 @@ def get_user_credential(self, username): return UserCredential(self.client_id, username, **self._msal_app_kwargs) def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): - secret_or_certificate = self._msal_secret_store.retrieve_secret_of_service_principal(client_id, self.tenant_id) + entry = self._msal_secret_store.load_service_principal_cred(client_id, self.tenant_id) # TODO: support use_cert_sn_issuer in CertificateCredential - return ServicePrincipalCredential(client_id, secret_or_certificate, **self._msal_app_kwargs) - - def get_environment_credential(self): - username = os.environ.get('AZURE_USERNAME') - client_id = os.environ.get('AZURE_CLIENT_ID') - - # If the user doesn't provide AZURE_CLIENT_ID, fill it will Azure CLI's client ID - if username and not client_id: - logger.info("set AZURE_CLIENT_ID=%s", AZURE_CLI_CLIENT_ID) - os.environ['AZURE_CLIENT_ID'] = AZURE_CLI_CLIENT_ID - - return EnvironmentCredential(**self._credential_kwargs) + sp_auth = ServicePrincipalAuth.build_from_entry(entry) + return ServicePrincipalCredential(sp_auth, **self._msal_app_kwargs) def get_managed_identity_credential(self, client_id=None): raise NotImplemented - def migrate_tokens(self): - """Migrate ADAL token cache to MSAL.""" - logger.warning("Migrating token cache from ADAL to MSAL.") - - entries = AdalCredentialCache()._load_tokens_from_file() # pylint: disable=protected-access - if not entries: - logger.debug("No ADAL token cache found.") - return - - for entry in entries: - try: - # TODO: refine the filter logic - if 'userId' in entry: - # User account - username = entry['userId'] - authority = entry['_authority'] - scopes = resource_to_scopes(entry['resource']) - refresh_token = entry['refreshToken'] - - msal_app = self._build_persistent_msal_app(authority) - # TODO: Not work in ADFS: - # {'error': 'invalid_grant', 'error_description': "MSIS9614: The refresh token received in - # 'refresh_token' parameter is invalid."} - logger.warning("Migrating refresh token: username: %s, authority: %s, scopes: %s", - username, authority, scopes) - token_dict = msal_app.acquire_token_by_refresh_token(refresh_token, scopes) - if 'error' in token_dict: - raise CLIError("Failed to migrate token from ADAL cache to MSAL cache. {}".format(token_dict)) - else: - # Service principal account - logger.warning("Migrating service principal secret: servicePrincipalId: %s, " - "servicePrincipalTenant: %s", - entry['servicePrincipalId'], entry['servicePrincipalTenant']) - self._msal_secret_store.save_service_principal_cred(entry) - except CLIError: - # Ignore failed tokens - continue - - # TODO: Delete accessToken.json after migration (accessToken.json deprecation) - def serialize_token_cache(self, path=None): path = path or os.path.join(get_config_dir(), "msal.cache.snapshot.json") path = os.path.expanduser(path) @@ -274,42 +218,40 @@ def serialize_token_cache(self, path=None): class ServicePrincipalAuth: # pylint: disable=too-few-public-methods - def __init__(self, client_id, tenant_id, secret=None, certificate_file=None, use_cert_sn_issuer=None): - if not (secret or certificate_file): - raise CLIError('Missing secret or certificate in order to ' + def __init__(self, tenant_id, client_id, password_arg_value, use_cert_sn_issuer=None): + if not password_arg_value: + raise CLIError('missing secret or certificate in order to ' 'authenticate through a service principal') + self.client_id = client_id self.tenant_id = tenant_id - if certificate_file: - from OpenSSL.crypto import load_certificate, FILETYPE_PEM + + if os.path.isfile(password_arg_value): + certificate_file = password_arg_value + from OpenSSL.crypto import load_certificate, FILETYPE_PEM, Error self.certificate_file = certificate_file self.public_certificate = None - with open(certificate_file, 'r') as file_reader: - self.cert_file_string = file_reader.read() - cert = load_certificate(FILETYPE_PEM, self.cert_file_string) - self.thumbprint = cert.digest("sha1").decode() - if use_cert_sn_issuer: - import re - # low-tech but safe parsing based on - # https://github.com/libressl-portable/openbsd/blob/master/src/lib/libcrypto/pem/pem.h - match = re.search(r'\-+BEGIN CERTIFICATE.+\-+(?P[^-]+)\-+END CERTIFICATE.+\-+', - self.cert_file_string, re.I) - self.public_certificate = match.group('public').strip() - else: - self.secret = secret - - def get_entry_to_persist_legacy(self): - entry = { - _SERVICE_PRINCIPAL_ID: self.client_id, - _SERVICE_PRINCIPAL_TENANT: self.tenant_id, - } - if hasattr(self, 'secret'): - entry[_ACCESS_TOKEN] = self.secret + try: + with open(certificate_file, 'r') as file_reader: + self.cert_file_string = file_reader.read() + cert = load_certificate(FILETYPE_PEM, self.cert_file_string) + self.thumbprint = cert.digest("sha1").decode() + if use_cert_sn_issuer: + # low-tech but safe parsing based on + # https://github.com/libressl-portable/openbsd/blob/master/src/lib/libcrypto/pem/pem.h + match = re.search(r'\-+BEGIN CERTIFICATE.+\-+(?P[^-]+)\-+END CERTIFICATE.+\-+', + self.cert_file_string, re.I) + self.public_certificate = match.group('public').strip() + except (UnicodeDecodeError, Error): + raise CLIError('Invalid certificate, please use a valid PEM file.') else: - entry[_SERVICE_PRINCIPAL_CERT_FILE] = self.certificate_file - entry[_SERVICE_PRINCIPAL_CERT_THUMBPRINT] = self.thumbprint + self.secret = password_arg_value - return entry + @classmethod + def build_from_entry(cls, entry): + return ServicePrincipalAuth(entry.get(_SERVICE_PRINCIPAL_TENANT), + entry.get(_SERVICE_PRINCIPAL_ID), + entry.get(_SERVICE_PRINCIPAL_SECRET) or entry.get(_SERVICE_PRINCIPAL_CERT_FILE)) def get_entry_to_persist(self): entry = { @@ -333,7 +275,7 @@ def __init__(self, secret_file, fallback_to_plaintext=True): self._service_principal_creds = [] self._fallback_to_plaintext = fallback_to_plaintext - def retrieve_secret_of_service_principal(self, sp_id, tenant): + def load_service_principal_cred(self, sp_id, tenant): self._load_cached_creds() matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] if not matched: @@ -349,7 +291,7 @@ def retrieve_secret_of_service_principal(self, sp_id, tenant): sp_id, tenant, matched[0][_SERVICE_PRINCIPAL_TENANT]) cred = matched[0] - return cred.get(_SERVICE_PRINCIPAL_SECRET, None) or cred.get(_SERVICE_PRINCIPAL_CERT_FILE, None) + return cred def save_service_principal_cred(self, sp_entry): self._load_cached_creds() @@ -371,6 +313,7 @@ def save_service_principal_cred(self, sp_entry): if state_changed: self._persist_cached_creds() + self._serialize_secrets() def remove_cached_creds(self, user_or_sp): self._load_cached_creds() @@ -444,7 +387,7 @@ def _serialize_secrets(self): # ONLY FOR DEBUGGING PURPOSE. DO NOT USE IN PRODUCTION CODE. logger.warning("Secrets are serialized as plain text and saved to `msalSecrets.cache.json`.") with open(self._secret_file + ".json", "w") as fd: - fd.write(json.dumps(self._service_principal_creds)) + fd.write(json.dumps(self._service_principal_creds, indent=4)) def _read_response_templates(): diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index c3253838162..21daaf7f79e 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -54,25 +54,15 @@ def get_token(self, *scopes, **kwargs): class ServicePrincipalCredential(ConfidentialClientApplication): - def __init__(self, client_id, secret_or_certificate=None, **kwargs): + def __init__(self, service_principal_auth, **kwargs): - import os - # If certificate file path is provided, transfer it to MSAL input - if os.path.isfile(secret_or_certificate): - cert_file = secret_or_certificate - with open(cert_file, 'r') as f: - cert_str = f.read() - - # Compute the thumbprint - from OpenSSL.crypto import load_certificate, FILETYPE_PEM - cert = load_certificate(FILETYPE_PEM, cert_str) - thumbprint = cert.digest("sha1").decode().replace(' ', '').replace(':', '') - - client_credential = {"private_key": cert_str, "thumbprint": thumbprint} + if hasattr(service_principal_auth, 'secret'): + client_credential = service_principal_auth.secret else: - client_credential = secret_or_certificate + client_credential = {"private_key": service_principal_auth.cert_file_string, + "thumbprint": service_principal_auth.thumbprint.replace(':', '')} - super().__init__(client_id, client_credential=client_credential, **kwargs) + super().__init__(service_principal_auth.client_id, client_credential=client_credential, **kwargs) def get_token(self, *scopes, **kwargs): logger.debug("ServicePrincipalCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index a73e413e315..2504fb182a6 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -102,7 +102,7 @@ def test_retrieve_secret_of_service_principal_with_secret(self, mock_read_file, from azure.cli.core._identity import MsalSecretStore # action secret_store = MsalSecretStore() - token, file = secret_store.retrieve_secret_of_service_principal("myapp", "mytenant") + token, file = secret_store.load_service_principal_cred("myapp", "mytenant") self.assertEqual(token, "Secret") @@ -121,7 +121,7 @@ def test_retrieve_secret_of_service_principal_with_cert(self, mock_read_file, mo from azure.cli.core._identity import MsalSecretStore # action creds_cache = MsalSecretStore() - token, file = creds_cache.retrieve_secret_of_service_principal("myapp", "mytenant") + token, file = creds_cache.load_service_principal_cred("myapp", "mytenant") # assert self.assertEqual(file, 'junkcert.pem') diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index 383584eca3d..c2858b7610c 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -16,15 +16,12 @@ from azure.core.credentials import AccessToken -from azure.cli.core._profile import (Profile, SubscriptionFinder, _USE_VENDORED_SUBSCRIPTION_SDK, +from azure.cli.core._profile import (Profile, SubscriptionFinder, _detect_adfs_authority, _attach_token_tenant, _transform_subscription_for_multiapi) -if _USE_VENDORED_SUBSCRIPTION_SDK: - from azure.cli.core.vendored_sdks.subscriptions.models import \ - (Subscription, SubscriptionPolicies, SpendingLimit, ManagedByTenant) -else: - from azure.mgmt.resource.subscriptions.models import \ - (Subscription, SubscriptionPolicies, SpendingLimit, ManagedByTenant) + +from azure.mgmt.resource.subscriptions.models import \ + (Subscription, SubscriptionPolicies, SpendingLimit, ManagedByTenant, TenantIdDescription) from azure.cli.core.mock import DummyCli from azure.identity import AuthenticationRecord @@ -391,75 +388,6 @@ def test_login_with_service_principal_cert_sn_issuer(self, get_token_mock): # assert self.assertEqual(output, subs) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - @mock.patch.dict('os.environ', clear=True) - def test_login_with_environment_credential_service_principal(self, get_client_class_mock): - os.environ['AZURE_TENANT_ID'] = self.service_principal_tenant_id - os.environ['AZURE_CLIENT_ID'] = self.service_principal_id - os.environ['AZURE_CLIENT_SECRET'] = self.service_principal_secret - - client_mock = mock.MagicMock() - get_client_class_mock.return_value = mock.MagicMock(return_value=client_mock) - client_mock.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - subs = profile.login_with_environment_credential() - output = [{'environmentName': 'AzureCloud', - 'homeTenantId': 'microsoft.com', - 'id': '1', - 'isDefault': True, - 'managedByTenants': [{'tenantId': '00000003-0000-0000-0000-000000000000'}, - {'tenantId': '00000004-0000-0000-0000-000000000000'}], - 'name': 'foo account', - 'state': 'Enabled', - 'tenantId': self.service_principal_tenant_id, - 'user': { - 'isEnvironmentCredential': True, - 'name': self.service_principal_id, - 'type': 'servicePrincipal'}}] - # assert - self.assertEqual(output, subs) - - @mock.patch('azure.cli.core._profile.SubscriptionFinder._get_subscription_client_class', autospec=True) - @mock.patch('azure.identity.UsernamePasswordCredential.authenticate', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - @mock.patch.dict('os.environ') - def test_login_with_environment_credential_username_password(self, app_mock, authenticate_mock, get_client_class_mock): - os.environ['AZURE_USERNAME'] = self.user1 - os.environ['AZURE_PASSWORD'] = "test_user_password" - - authenticate_mock.return_value = self.authentication_record - - arm_client_mock = mock.MagicMock() - get_client_class_mock.return_value = mock.MagicMock(return_value=arm_client_mock) - arm_client_mock.tenants.list.return_value = [TenantStub(self.tenant_id)] - arm_client_mock.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - subs = profile.login_with_environment_credential() - output = [{'environmentName': 'AzureCloud', - 'homeTenantId': 'microsoft.com', - 'id': '1', - 'isDefault': True, - 'managedByTenants': [{'tenantId': '00000003-0000-0000-0000-000000000000'}, - {'tenantId': '00000004-0000-0000-0000-000000000000'}], - 'name': 'foo account', - 'state': 'Enabled', - 'tenantId': self.tenant_id, - 'user': { - 'isEnvironmentCredential': True, - 'name': self.user1, - 'type': 'user'}}] - # assert - self.assertEqual(output, subs) - def test_normalize(self): cli = DummyCli() storage_mock = {'subscriptions': None} @@ -628,10 +556,10 @@ def test_get_auth_info_fail_on_user_account(self): @mock.patch('azure.cli.core.profiles.get_api_version', autospec=True) def test_subscription_finder_constructor(self, get_api_mock): cli = DummyCli() - get_api_mock.return_value = '2016-06-01' + get_api_mock.return_value = '2019-11-01' cli.cloud.endpoints.resource_manager = 'http://foo_arm' finder = SubscriptionFinder(cli) - result = finder._arm_client_factory(mock.MagicMock()) + result = finder._create_subscription_client(mock.MagicMock()) self.assertEqual(result._client._base_url, 'http://foo_arm') @mock.patch('adal.AuthenticationContext', autospec=True) @@ -673,14 +601,19 @@ def test_get_auth_info_for_newly_created_service_principal(self): self.assertEqual('https://login.microsoftonline.com', extended_info['activeDirectoryEndpointUrl']) self.assertEqual('https://management.azure.com/', extended_info['resourceManagerEndpointUrl']) - def test_create_account_without_subscriptions_thru_service_principal(self): + @mock.patch('azure.cli.core.auth.identity.Identity.get_service_principal_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_service_principal', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + def test_create_account_without_subscriptions_thru_service_principal(self, create_subscription_client_mock, + login_with_service_principal_mock, + get_service_principal_credential_mock): cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + mock_subscription_client = mock.MagicMock() + mock_subscription_client.subscriptions.list.return_value = [] + create_subscription_client_mock.return_value = mock_subscription_client storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) profile._management_resource_uri = 'https://management.core.windows.net/' # action @@ -690,8 +623,7 @@ def test_create_account_without_subscriptions_thru_service_principal(self): True, self.tenant_id, use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) + allow_no_subscriptions=True) # assert self.assertEqual(1, len(result)) self.assertEqual(result[0]['id'], self.tenant_id) @@ -700,27 +632,29 @@ def test_create_account_without_subscriptions_thru_service_principal(self): self.assertEqual(result[0]['name'], 'N/A(tenant level account)') self.assertTrue(profile.is_tenant_level_account()) - def test_create_account_with_subscriptions_allow_no_subscriptions_thru_service_principal(self): + @mock.patch('azure.cli.core.auth.identity.Identity.get_service_principal_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_service_principal', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + def test_create_account_with_subscriptions_allow_no_subscriptions_thru_service_principal( + self, create_subscription_client_mock, login_with_service_principal_mock, + get_service_principal_credential_mock): """test subscription is returned even with --allow-no-subscriptions. """ cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + mock_subscription_client = mock.MagicMock() + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - profile._management_resource_uri = 'https://management.core.windows.net/' + profile = Profile(cli_ctx=cli, storage=storage_mock) - # action result = profile.login(False, '1234', 'my-secret', True, self.tenant_id, use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) - # assert + allow_no_subscriptions=True) + self.assertEqual(1, len(result)) self.assertEqual(result[0]['id'], self.id1.split('/')[-1]) self.assertEqual(result[0]['state'], 'Enabled') @@ -728,26 +662,25 @@ def test_create_account_with_subscriptions_allow_no_subscriptions_thru_service_p self.assertEqual(result[0]['name'], self.display_name1) self.assertFalse(profile.is_tenant_level_account()) - @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) - @mock.patch('azure.identity.UsernamePasswordCredential.authenticate', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_create_account_without_subscriptions_thru_common_tenant(self, app_mock, authenticate_mock, get_token_mock): - get_token_mock.return_value = self.access_token - authenticate_mock.return_value = self.authentication_record + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_username_password', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + def test_create_account_without_subscriptions_thru_common_tenant(self, create_subscription_client_mock, + login_with_username_password_mock, + get_user_credential_mock): cli = DummyCli() - tenant_object = mock.MagicMock() + tenant_object = TenantIdDescription() tenant_object.id = "foo-bar" tenant_object.tenant_id = self.tenant_id + mock_arm_client = mock.MagicMock() mock_arm_client.subscriptions.list.return_value = [] - mock_arm_client.tenants.list.return_value = (x for x in [tenant_object]) - - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + mock_arm_client.tenants.list.return_value = [tenant_object] + create_subscription_client_mock.return_value = mock_arm_client storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - profile._management_resource_uri = 'https://management.core.windows.net/' + profile = Profile(cli_ctx=cli, storage=storage_mock) # action result = profile.login(False, @@ -756,8 +689,7 @@ def test_create_account_without_subscriptions_thru_common_tenant(self, app_mock, False, None, use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) + allow_no_subscriptions=True) # assert self.assertEqual(1, len(result)) @@ -834,29 +766,6 @@ def test_get_login_credentials(self, app_mock, get_token_mock): token = cred.get_token() self.assertEqual(token, self.raw_token1) - @mock.patch('azure.cli.core._identity.Identity.migrate_tokens', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_get_login_credentials_with_token_migration(self, app_mock, migrate_tokens_mock): - # Mimic an old subscription storage without 'useMsalTokenCache' - adal_storage_mock = { - 'subscriptions': [{ - 'id': '12345678-1bf0-4dda-aec3-cb9272f09590', - 'name': 'MSI-DEV-INC', - 'state': 'Enabled', - 'user': {'name': 'foo@foo.com', 'type': 'user'}, - 'isDefault': True, - 'tenantId': '12345678-38d6-4fb2-bad9-b7b93a3e1234', - 'environmentName': 'AzureCloud', - 'managedByTenants': [] - }] - } - - cli = DummyCli() - profile = Profile(cli_ctx=cli, storage=adal_storage_mock) - cred, subscription_id, _ = profile.get_login_credentials() - # make sure migrate_tokens_mock is called - migrate_tokens_mock.assert_called() - @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) def test_get_login_credentials_aux_subscriptions(self, app_mock, get_token_mock): @@ -1958,7 +1867,7 @@ def test_detect_adfs_authority(self): ('https://adfs.redmond.azurestack.corp.microsoft.com', 'adfs')) def test_attach_token_tenant(self): - from azure.cli.core.vendored_sdks.subscriptions.v2016_06_01.models import Subscription \ + from azure.mgmt.resource.subscriptions.v2016_06_01.models import Subscription \ as Subscription_v2016_06_01 subscription = Subscription_v2016_06_01() _attach_token_tenant(subscription, "token_tenant_1") @@ -1966,7 +1875,7 @@ def test_attach_token_tenant(self): self.assertFalse(hasattr(subscription, "home_tenant_id")) def test_attach_token_tenant_v2016_06_01(self): - from azure.cli.core.vendored_sdks.subscriptions.v2019_11_01.models import Subscription \ + from azure.mgmt.resource.subscriptions.v2019_11_01.models import Subscription \ as Subscription_v2019_11_01 subscription = Subscription_v2019_11_01() subscription.tenant_id = "home_tenant_1" From 2b29f085158f5b74cef4c5914042af9d6ba41fa4 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Thu, 2 Sep 2021 17:30:06 +0800 Subject: [PATCH 42/69] refine sp persistence --- src/azure-cli-core/azure/cli/core/_profile.py | 136 ++------------ .../azure/cli/core/auth/_msal_patch.py | 173 ------------------ .../azure/cli/core/auth/identity.py | 110 ++++------- .../cli/core/auth/msal_authentication.py | 2 +- .../azure/cli/core/auth/persistence.py | 44 +++++ .../cli/core/{ => auth}/tests/err_sp_cert.pem | 0 .../cli/core/{ => auth}/tests/sp_cert.pem | 0 .../cli/core/auth/tests/test_identity.py | 143 ++++++--------- .../azure/cli/core/auth/token_cache.py | 46 ----- .../azure/cli/core/tests/test_profile.py | 67 ++----- .../cli/command_modules/profile/__init__.py | 46 +---- .../cli/command_modules/profile/custom.py | 7 +- 12 files changed, 172 insertions(+), 602 deletions(-) delete mode 100644 src/azure-cli-core/azure/cli/core/auth/_msal_patch.py create mode 100644 src/azure-cli-core/azure/cli/core/auth/persistence.py rename src/azure-cli-core/azure/cli/core/{ => auth}/tests/err_sp_cert.pem (100%) rename src/azure-cli-core/azure/cli/core/{ => auth}/tests/sp_cert.pem (100%) delete mode 100644 src/azure-cli-core/azure/cli/core/auth/token_cache.py diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index f7d7a0646c8..b53f324e3e2 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -46,7 +46,6 @@ _USER_TYPE = 'type' _USER = 'user' _SERVICE_PRINCIPAL = 'servicePrincipal' -_IS_ENVIRONMENT_CREDENTIAL = 'isEnvironmentCredential' _SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH = 'useCertSNIssuerAuth' _TOKEN_ENTRY_USER_ID = 'userId' _TOKEN_ENTRY_TOKEN_TYPE = 'tokenType' @@ -305,56 +304,18 @@ def logout(self, user_or_sp, clear_credential): subscriptions = self.load_cached_subscriptions(all_clouds=True) result = [x for x in subscriptions if user_or_sp.lower() == x[_USER_ENTITY][_USER_NAME].lower()] + subscriptions = [x for x in subscriptions if x not in result] + #self._storage[_SUBSCRIPTIONS] = subscriptions - if result: - # Remove the account from the profile - subscriptions = [x for x in subscriptions if x not in result] - self._storage[_SUBSCRIPTIONS] = subscriptions - logger.warning("Account '%s' has been logged out from Azure CLI.", user_or_sp) - else: - # https://english.stackexchange.com/questions/5302/log-in-to-or-log-into-or-login-to - logger.warning("Account '%s' was not logged in to Azure CLI.", user_or_sp) - - # Log out from MSAL cache identity = Identity(self._authority) - accounts = identity.get_user(user_or_sp) - if accounts: - logger.info("The credential of '%s' were found from MSAL encrypted cache.", user_or_sp) - if clear_credential: - identity.logout_user(user_or_sp) - logger.warning("The credential of '%s' were cleared from MSAL encrypted cache. This account is " - "also logged out from other SDK tools which use Azure CLI's credential " - "via Single Sign-On.", user_or_sp) - else: - logger.warning("The credential of '%s' is still stored in MSAL encrypted cached. Other SDK tools may " - "use Azure CLI\'s credential via Single Sign-On. " - 'To clear the credential, run `az logout --username %s --clear-credential`.', - user_or_sp, user_or_sp) - else: - # remove service principle secret - identity.logout_sp(user_or_sp) + identity.logout_user(user_or_sp) + identity.logout_service_principal(user_or_sp) - def logout_all(self, clear_credential): + def logout_all(self): self._storage[_SUBSCRIPTIONS] = [] - logger.warning('All accounts were logged out.') - # Deal with MSAL cache identity = Identity(self._authority) - accounts = identity.get_user() - if accounts: - logger.info("These credentials were found from MSAL encrypted cache: %s", accounts) - if clear_credential: - identity.logout_all() - logger.warning('All credentials store in MSAL encrypted cache were cleared.') - else: - logger.warning('These credentials are still stored in MSAL encrypted cached:') - for account in identity.get_user(): - logger.warning(account['username']) - logger.warning('Other SDK tools may use Azure CLI\'s credential via Single Sign-On. ' - 'To clear all credentials, run `az account clear --clear-credential`. ' - 'To clear one of them, run `az logout --username USERNAME --clear-credential`.') - else: - logger.warning('No credential was not found from MSAL encrypted cache.') + accounts = identity.logout_all_users() def get_login_credentials(self, resource=None, client_id=None, subscription_id=None, aux_subscriptions=None, aux_tenants=None): @@ -372,8 +333,6 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N use_msal = self._storage.get(_USE_MSAL_TOKEN_CACHE) if not use_msal: - identity = Identity() - identity.migrate_tokens() self._storage[_USE_MSAL_TOKEN_CACHE] = True if aux_tenants and aux_subscriptions: @@ -381,8 +340,6 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N account = self.get_subscription(subscription_id) - resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - managed_identity_type, managed_identity_id = Profile._try_parse_msi_account_name(account) # Cloud Shell is just a system assignment managed identity @@ -416,7 +373,7 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N str(account[_SUBSCRIPTION_ID]), str(account[_TENANT_ID])) - def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=None, epoch_expires_on=True): + def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=None): # Convert resource to scopes if resource and not scopes: scopes = resource_to_scopes(resource) @@ -448,11 +405,8 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No else: credential = self._create_credential(account, tenant) token = credential.get_token(*scopes) - if epoch_expires_on: - expires_on = token.expires_on - else: - import datetime - expires_on = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") + import datetime + expires_on = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") token_entry = { 'accessToken': token.token, @@ -466,57 +420,6 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No None if tenant else str(account[_SUBSCRIPTION_ID]), str(tenant if tenant else account[_TENANT_ID])) - def get_msal_token(self, scopes, data): - """ - This is added for VM SSH feature with backward compatible interface. - data contains token_type (ssh-cert), key_id and JWK. - """ - account = self.get_subscription() - identity_type = account[_USER_ENTITY][_USER_TYPE] - username_or_sp_id = account[_USER_ENTITY][_USER_NAME] - tenant = account[_TENANT_ID] - identity = Identity(authority=self._authority, tenant_id=tenant) - - # Raise error for managed identity and Cloud Shell - not_support_message = "VM SSH currently doesn't support {}." - - # managed identity - managed_identity_type, _ = Profile._try_parse_msi_account_name(account) - if managed_identity_type: - raise CLIError(not_support_message.format("managed identity")) - - # Cloud Shell - if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): - raise CLIError(not_support_message.format("Cloud Shell")) - - # user - if identity_type == _USER: - username = username_or_sp_id - app = identity.get_user_credential(username) - result = app.acquire_token_silent_with_error(scopes, app.account, data=data) - - # If acquire_token_silent_with_error failed, interactively get new RT and AT - if not result or 'error' in result: - if result: - logger.warning(result['error_description']) - - # Retry login with VM SSH as resource - result = app.acquire_token_interactive(scopes, login_hint=username, data=data) - - # service principal - elif identity_type == _SERVICE_PRINCIPAL: - app = identity.get_service_principal_credential(username_or_sp_id) - result = app.acquire_token_for_client(scopes, data=data) - - else: - raise CLIError("Unknown identity type {}".format(identity_type)) - - if 'error' in result: - from azure.cli.core.auth import aad_error_handler - aad_error_handler(result) - - return username_or_sp_id, result["access_token"] - def _normalize_properties(self, user, subscriptions, is_service_principal, cert_sn_issuer_auth=None, user_assigned_identity_id=None, managed_identity_info=None): import sys @@ -706,27 +609,18 @@ def _create_credential(self, account, tenant_id=None, client_id=None): user_type = account[_USER_ENTITY][_USER_TYPE] username_or_sp_id = account[_USER_ENTITY][_USER_NAME] tenant_id = tenant_id if tenant_id else account[_TENANT_ID] - # _IS_ENVIRONMENT_CREDENTIAL doesn't exist for normal account - is_environment = account[_USER_ENTITY].get(_IS_ENVIRONMENT_CREDENTIAL) - identity = Identity(client_id=client_id, authority=self._authority, tenant_id=tenant_id) - if in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): - if tenant_id: - raise CLIError("Tenant shouldn't be specified for Cloud Shell account") - return identity.get_managed_identity_credential() - - # EnvironmentCredential. Ignore user_type - if is_environment: - return identity.get_environment_credential() - # User if user_type == _USER: return identity.get_user_credential(username_or_sp_id) # Service Principal - use_cert_sn_issuer = account[_USER_ENTITY].get(_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH) - return identity.get_service_principal_credential(username_or_sp_id, use_cert_sn_issuer) + if user_type == _SERVICE_PRINCIPAL: + use_cert_sn_issuer = account[_USER_ENTITY].get(_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH) + return identity.get_service_principal_credential(username_or_sp_id, use_cert_sn_issuer) + + raise NotImplementedError def refresh_accounts(self, subscription_finder=None): subscriptions = self.load_cached_subscriptions() @@ -790,7 +684,7 @@ def get_sp_auth_info(self, subscription_id=None, name=None, password=None, cert_ if user_type == _SERVICE_PRINCIPAL: result['clientId'] = account[_USER_ENTITY][_USER_NAME] msal_cache = MsalSecretStore(True) - secret, certificate_file = msal_cache.load_service_principal_cred( + secret, certificate_file = msal_cache.load_credential( account[_USER_ENTITY][_USER_NAME], account[_TENANT_ID]) if secret: result['clientSecret'] = secret diff --git a/src/azure-cli-core/azure/cli/core/auth/_msal_patch.py b/src/azure-cli-core/azure/cli/core/auth/_msal_patch.py deleted file mode 100644 index 15129d081de..00000000000 --- a/src/azure-cli-core/azure/cli/core/auth/_msal_patch.py +++ /dev/null @@ -1,173 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -""" -A temporary workaround for MSAL limitation -https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/335 - -After a successful sign-in, if the sign-in account already exists in the token -cache, remove it first along with its tokens to prevent MSAL from returning -cached access tokens from the previous session that may have been revoked. - -Otherwise, MSAL will return revoked access tokens, resulting in 401 failure -which can't be handled by commands that don't support silent reauth. -""" - -# pylint: skip-file -# flake8: noqa - -import json -import time - -from knack.log import get_logger - -from msal.oauth2cli.oauth2 import Client -from msal.token_cache import decode_id_token, canonicalize, decode_part - -logger = get_logger(__name__) - - -def patch_token_cache_add(callback): - - def __add(self, event, now=None): - # event typically contains: client_id, scope, token_endpoint, - # response, params, data, grant_type - environment = realm = None - if "token_endpoint" in event: - _, environment, realm = canonicalize(event["token_endpoint"]) - if "environment" in event: # Always available unless in legacy test cases - environment = event["environment"] # Set by application.py - response = event.get("response", {}) - data = event.get("data", {}) - access_token = response.get("access_token") - refresh_token = response.get("refresh_token") - id_token = response.get("id_token") - id_token_claims = ( - decode_id_token(id_token, client_id=event["client_id"]) - if id_token else {}) - client_info = {} - home_account_id = None # It would remain None in client_credentials flow - if "client_info" in response: # We asked for it, and AAD will provide it - client_info = json.loads(decode_part(response["client_info"])) - home_account_id = "{uid}.{utid}".format(**client_info) - elif id_token_claims: # This would be an end user on ADFS-direct scenario - client_info["uid"] = id_token_claims.get("sub") - home_account_id = id_token_claims.get("sub") - - target = ' '.join(event.get("scope") or []) # Per schema, we don't sort it - - with self._lock: - now = int(time.time() if now is None else now) - - if client_info and not event.get("skip_account_creation"): - account = { - "home_account_id": home_account_id, - "environment": environment, - "realm": realm, - "local_account_id": id_token_claims.get( - "oid", id_token_claims.get("sub")), - "username": id_token_claims.get("preferred_username") # AAD - or id_token_claims.get("upn") # ADFS 2019 - or "", # The schema does not like null - "authority_type": - self.AuthorityType.ADFS if realm == "adfs" - else self.AuthorityType.MSSTS, - # "client_info": response.get("client_info"), # Optional - } - - logger.debug("Remove existing account %r", account) - logger.debug("Calling %r", callback) - callback(account) - self.modify(self.CredentialType.ACCOUNT, account, account) - - if id_token: - idt = { - "credential_type": self.CredentialType.ID_TOKEN, - "secret": id_token, - "home_account_id": home_account_id, - "environment": environment, - "realm": realm, - "client_id": event.get("client_id"), - # "authority": "it is optional", - } - self.modify(self.CredentialType.ID_TOKEN, idt, idt) - - if access_token: - expires_in = int( # AADv1-like endpoint returns a string - response.get("expires_in", 3599)) - ext_expires_in = int( # AADv1-like endpoint returns a string - response.get("ext_expires_in", expires_in)) - at = { - "credential_type": self.CredentialType.ACCESS_TOKEN, - "secret": access_token, - "home_account_id": home_account_id, - "environment": environment, - "client_id": event.get("client_id"), - "target": target, - "realm": realm, - "token_type": response.get("token_type", "Bearer"), - "cached_at": str(now), # Schema defines it as a string - "expires_on": str(now + expires_in), # Same here - "extended_expires_on": str(now + ext_expires_in) # Same here - } - if data.get("key_id"): # It happens in SSH-cert or POP scenario - at["key_id"] = data.get("key_id") - if "refresh_in" in response: - refresh_in = response["refresh_in"] # It is an integer - at["refresh_on"] = str(now + refresh_in) # Schema wants a string - self.modify(self.CredentialType.ACCESS_TOKEN, at, at) - - if refresh_token: - rt = { - "credential_type": self.CredentialType.REFRESH_TOKEN, - "secret": refresh_token, - "home_account_id": home_account_id, - "environment": environment, - "client_id": event.get("client_id"), - "target": target, # Optional per schema though - "last_modification_time": str(now), # Optional. Schema defines it as a string. - } - if "foci" in response: - rt["family_id"] = response["foci"] - self.modify(self.CredentialType.REFRESH_TOKEN, rt, rt) - - app_metadata = { - "client_id": event.get("client_id"), - "environment": environment, - } - if "foci" in response: - app_metadata["family_id"] = response.get("foci") - self.modify(self.CredentialType.APP_METADATA, app_metadata, app_metadata) - - def obtain_token_by_refresh_token(self, token_item, scope=None, - rt_getter=lambda token_item: token_item["refresh_token"], - on_removing_rt=None, - on_updating_rt=None, - on_obtaining_tokens=None, - **kwargs): - resp = super(Client, self).obtain_token_by_refresh_token( - rt_getter(token_item) - if not isinstance(token_item, str) else token_item, - scope=scope, - also_save_rt=on_updating_rt is False, - on_obtaining_tokens=on_obtaining_tokens, - **kwargs) - if resp.get('error') == 'invalid_grant': - (on_removing_rt or self.on_removing_rt)(token_item) # Discard old RT - RT = "refresh_token" - if on_updating_rt is not False and RT in resp: - (on_updating_rt or self.on_updating_rt)(token_item, resp[RT]) - return resp - - from unittest.mock import patch - - # Temporary patch for https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/335 - cm_add = patch('msal.token_cache.TokenCache._TokenCache__add', __add) - - # Temporary patch for https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/339 - cm_obtain_token_by_refresh_token = patch('msal.oauth2cli.oauth2.Client.obtain_token_by_refresh_token', - obtain_token_by_refresh_token) - cm_add.__enter__() - cm_obtain_token_by_refresh_token.__enter__() diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 44e555b535e..badb1a706e8 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -53,7 +53,7 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): self.msal_authority = "{}/{}".format(self.authority, self.tenant_id) self.client_id = client_id or AZURE_CLI_CLIENT_ID - self._cache_file = os.path.join(get_config_dir(), "tokenCache.bin") + self._token_cache_file = os.path.join(get_config_dir(), "tokenCache.bin") self._secret_file = os.path.join(get_config_dir(), "secrets.bin") self._fallback_to_plaintext = kwargs.pop('fallback_to_plaintext', True) @@ -94,9 +94,9 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): # patch_token_cache_add(self.msal_app.remove_account) def _load_msal_cache(self): - from .token_cache import load_persisted_token_cache + from .persistence import load_persisted_token_cache # Store for user token persistence - cache = load_persisted_token_cache(self._cache_file, self._fallback_to_plaintext) + cache = load_persisted_token_cache(self._token_cache_file, self._fallback_to_plaintext) cache._reload_if_necessary() # pylint: disable=protected-access return cache @@ -149,45 +149,32 @@ def login_with_service_principal(self, client_id, secret_or_certificate, use_cer result = cred.acquire_token_for_client(scopes) check_result(result) entry = sp_auth.get_entry_to_persist() - self._msal_secret_store.save_service_principal_cred(entry) + self._msal_secret_store.save_credential(entry) def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disable=too-many-statements - raise NotImplemented + raise NotImplementedError def login_in_cloud_shell(self, scopes): - raise NotImplemented + raise NotImplementedError def logout_user(self, user): accounts = self.msal_app.get_accounts(user) - logger.info('Before account removal:') - logger.info(json.dumps(accounts)) - - # `accounts` are the same user in all tenants, log out all of them for account in accounts: self.msal_app.remove_account(account) - accounts = self.msal_app.get_accounts(user) - logger.info('After account removal:') - logger.info(json.dumps(accounts)) + def logout_all_users(self): + try: + os.remove(self._token_cache_file) + except FileNotFoundError: + pass - def logout_sp(self, sp): + def logout_service_principal(self, sp): # remove service principal secrets - self._msal_secret_store.remove_cached_creds(sp) + self._msal_secret_store.remove_credential(sp) - def logout_all(self): - # TODO: Support multi-authority logout - accounts = self.msal_app.get_accounts() - logger.info('Before account removal:') - logger.info(json.dumps(accounts)) - - for account in accounts: - self.msal_app.remove_account(account) - - accounts = self.msal_app.get_accounts() - logger.info('After account removal:') - logger.info(json.dumps(accounts)) + def logout_all_service_principal(self, sp): # remove service principal secrets - self._msal_secret_store.remove_all_cached_creds() + self._msal_secret_store.remove_all_credentials() def get_user(self, user=None): accounts = self.msal_app.get_accounts(user) if user else self.msal_app.get_accounts() @@ -197,7 +184,7 @@ def get_user_credential(self, username): return UserCredential(self.client_id, username, **self._msal_app_kwargs) def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): - entry = self._msal_secret_store.load_service_principal_cred(client_id, self.tenant_id) + entry = self._msal_secret_store.load_credential(client_id, self.tenant_id) # TODO: support use_cert_sn_issuer in CertificateCredential sp_auth = ServicePrincipalAuth.build_from_entry(entry) return ServicePrincipalCredential(sp_auth, **self._msal_app_kwargs) @@ -235,15 +222,15 @@ def __init__(self, tenant_id, client_id, password_arg_value, use_cert_sn_issuer= with open(certificate_file, 'r') as file_reader: self.cert_file_string = file_reader.read() cert = load_certificate(FILETYPE_PEM, self.cert_file_string) - self.thumbprint = cert.digest("sha1").decode() + self.thumbprint = cert.digest("sha1").decode().replace(':', '') if use_cert_sn_issuer: # low-tech but safe parsing based on # https://github.com/libressl-portable/openbsd/blob/master/src/lib/libcrypto/pem/pem.h - match = re.search(r'\-+BEGIN CERTIFICATE.+\-+(?P[^-]+)\-+END CERTIFICATE.+\-+', + match = re.search(r'-+BEGIN CERTIFICATE.+-+(?P[^-]+)-+END CERTIFICATE.+-+', self.cert_file_string, re.I) self.public_certificate = match.group('public').strip() - except (UnicodeDecodeError, Error): - raise CLIError('Invalid certificate, please use a valid PEM file.') + except (UnicodeDecodeError, Error) as ex: + raise CLIError('Invalid certificate, please use a valid PEM file. Error detail: {}'.format(ex)) else: self.secret = password_arg_value @@ -275,8 +262,8 @@ def __init__(self, secret_file, fallback_to_plaintext=True): self._service_principal_creds = [] self._fallback_to_plaintext = fallback_to_plaintext - def load_service_principal_cred(self, sp_id, tenant): - self._load_cached_creds() + def load_credential(self, sp_id, tenant): + self._load_persistence() matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] if not matched: raise CLIError("Could not retrieve credential from local cache for service principal {}. " @@ -293,8 +280,8 @@ def load_service_principal_cred(self, sp_id, tenant): return cred - def save_service_principal_cred(self, sp_entry): - self._load_cached_creds() + def save_credential(self, sp_entry): + self._load_persistence() matched = [x for x in self._service_principal_creds if sp_entry[_SERVICE_PRINCIPAL_ID] == x[_SERVICE_PRINCIPAL_ID] and sp_entry[_SERVICE_PRINCIPAL_TENANT] == x[_SERVICE_PRINCIPAL_TENANT]] @@ -312,38 +299,40 @@ def save_service_principal_cred(self, sp_entry): state_changed = True if state_changed: - self._persist_cached_creds() + self._save_persistence() self._serialize_secrets() - def remove_cached_creds(self, user_or_sp): - self._load_cached_creds() + def remove_credential(self, sp_id): + self._load_persistence() state_changed = False # clear service principal creds matched = [x for x in self._service_principal_creds - if x[_SERVICE_PRINCIPAL_ID] == user_or_sp] + if x[_SERVICE_PRINCIPAL_ID] == sp_id] if matched: state_changed = True self._service_principal_creds = [x for x in self._service_principal_creds if x not in matched] if state_changed: - self._persist_cached_creds() + self._save_persistence() - def remove_all_cached_creds(self): + def remove_all_credentials(self): try: os.remove(self._secret_file) except FileNotFoundError: pass - def _persist_cached_creds(self): - persistence = self._build_persistence() + def _save_persistence(self): + from .persistence import build_persistence + persistence = build_persistence(self._secret_file) from msal_extensions import CrossPlatLock with CrossPlatLock(self._lock_file): persistence.save(json.dumps(self._service_principal_creds)) - def _load_cached_creds(self): - persistence = self._build_persistence() + def _load_persistence(self): + from .persistence import build_persistence + persistence = build_persistence(self._secret_file) from msal_extensions import CrossPlatLock from msal_extensions.persistence import PersistenceNotFound with CrossPlatLock(self._lock_file): @@ -356,33 +345,6 @@ def _load_cached_creds(self): "https://github.com/Azure/azure-cli/issues. At the same time, you can clean " "up by running 'az account clear' and then 'az login'. (Inner Error: {})".format(ex)) - def _build_persistence(self): - # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/0.2.2/sample/persistence_sample.py - from msal_extensions import FilePersistenceWithDataProtection, \ - KeychainPersistence, \ - LibsecretPersistence, \ - FilePersistence - - import sys - if sys.platform.startswith('win'): - return FilePersistenceWithDataProtection(self._secret_file) - if sys.platform.startswith('darwin'): - # todo: support darwin - return KeychainPersistence(self._secret_file, "Microsoft.Developer.IdentityService", "MSALCustomCache") - if sys.platform.startswith('linux'): - try: - return LibsecretPersistence( - self._secret_file, - schema_name="MSALCustomToken", - attributes={"MsalClientID": "Microsoft.Developer.IdentityService"} - ) - except: # pylint: disable=bare-except - if not self._fallback_to_plaintext: - raise - # todo: add missing lib in message - logger.warning("Encryption unavailable. Opting in to plain text.") - return FilePersistence(self._secret_file) - def _serialize_secrets(self): # ONLY FOR DEBUGGING PURPOSE. DO NOT USE IN PRODUCTION CODE. logger.warning("Secrets are serialized as plain text and saved to `msalSecrets.cache.json`.") diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index 21daaf7f79e..2456086ecb6 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -60,7 +60,7 @@ def __init__(self, service_principal_auth, **kwargs): client_credential = service_principal_auth.secret else: client_credential = {"private_key": service_principal_auth.cert_file_string, - "thumbprint": service_principal_auth.thumbprint.replace(':', '')} + "thumbprint": service_principal_auth.thumbprint} super().__init__(service_principal_auth.client_id, client_credential=client_credential, **kwargs) diff --git a/src/azure-cli-core/azure/cli/core/auth/persistence.py b/src/azure-cli-core/azure/cli/core/auth/persistence.py new file mode 100644 index 00000000000..1263229da29 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/persistence.py @@ -0,0 +1,44 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# This file is modified from +# https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/dev/sample/token_cache_sample.py + +import logging +import sys + +from msal_extensions import (FilePersistenceWithDataProtection, KeychainPersistence, LibsecretPersistence, + FilePersistence, PersistedTokenCache) + + +def load_persisted_token_cache(location, fallback_to_plaintext): + persistence = build_persistence(location, fallback_to_plaintext) + return PersistedTokenCache(persistence) + + +def build_persistence(location, fallback_to_plaintext=False): + """Build a suitable persistence instance based your current OS""" + if sys.platform.startswith('win'): + return FilePersistenceWithDataProtection(location) + if sys.platform.startswith('darwin'): + return KeychainPersistence(location, "my_service_name", "my_account_name") + if sys.platform.startswith('linux'): + try: + return LibsecretPersistence( + # By using same location as the fall back option below, + # this would override the unencrypted data stored by the + # fall back option. It is probably OK, or even desirable + # (in order to aggressively wipe out plain-text persisted data), + # unless there would frequently be a desktop session and + # a remote ssh session being active simultaneously. + location, + schema_name="my_schema_name", + attributes={"my_attr1": "foo", "my_attr2": "bar"}, + ) + except: # pylint: disable=bare-except + if not fallback_to_plaintext: + raise + logging.exception("Encryption unavailable. Opting in to plain text.") + return FilePersistence(location) diff --git a/src/azure-cli-core/azure/cli/core/tests/err_sp_cert.pem b/src/azure-cli-core/azure/cli/core/auth/tests/err_sp_cert.pem similarity index 100% rename from src/azure-cli-core/azure/cli/core/tests/err_sp_cert.pem rename to src/azure-cli-core/azure/cli/core/auth/tests/err_sp_cert.pem diff --git a/src/azure-cli-core/azure/cli/core/tests/sp_cert.pem b/src/azure-cli-core/azure/cli/core/auth/tests/sp_cert.pem similarity index 100% rename from src/azure-cli-core/azure/cli/core/tests/sp_cert.pem rename to src/azure-cli-core/azure/cli/core/auth/tests/sp_cert.pem diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index 2504fb182a6..b3325b50114 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -3,128 +3,101 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -# pylint: disable=protected-access -import os import json +import os import unittest from unittest import mock -from azure.cli.core._identity import Identity, ServicePrincipalAuth, MsalSecretStore +from azure.cli.core.auth.identity import Identity, ServicePrincipalAuth, MsalSecretStore +from knack.util import CLIError +from msal_extensions import FilePersistence class TestIdentity(unittest.TestCase): - @classmethod - def setUpClass(cls): - pass - - @mock.patch('azure.cli.core._identity.MsalSecretStore.save_service_principal_cred', autospec=True) - @mock.patch('azure.cli.core._identity.Identity._build_persistent_msal_app', autospec=True) - @mock.patch('azure.cli.core._identity.AdalCredentialCache._load_tokens_from_file', autospec=True) - def test_migrate_tokens(self, load_tokens_from_file_mock, build_persistent_msal_app_mock, - save_service_principal_cred_mock): - adal_tokens = [ - { - "tokenType": "Bearer", - "expiresOn": "2020-08-03 19:00:36.784501", - "resource": "https://management.core.windows.net/", - "userId": "test_user@microsoft.com", - "accessToken": "test_access_token", - "refreshToken": "test_refresh_token", - "_clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", - "_authority": "https://login.microsoftonline.com/00000001-0000-0000-0000-000000000000", - "isMRRT": True, - "expiresIn": 3599 - }, - { - "servicePrincipalId": "00000002-0000-0000-0000-000000000000", - "servicePrincipalTenant": "00000001-0000-0000-0000-000000000000", - "accessToken": "test_sp_secret" - } - ] - load_tokens_from_file_mock.return_value = adal_tokens - - identity = Identity() - identity.migrate_tokens() - msal_app_mock = build_persistent_msal_app_mock.return_value - msal_app_mock.acquire_token_by_refresh_token.assert_called_with( - 'test_refresh_token', ['https://management.core.windows.net//.default']) - save_service_principal_cred_mock.assert_called_with(mock.ANY, adal_tokens[1]) - def test_login_with_service_principal_certificate_cert_err(self): import os identity = Identity() current_dir = os.path.dirname(os.path.realpath(__file__)) test_cert_file = os.path.join(current_dir, 'err_sp_cert.pem') - # TODO: wrap exception - with self.assertRaisesRegex(ValueError, "Could not deserialize key data."): - identity.login_with_service_principal_certificate("00000000-0000-0000-0000-000000000000", test_cert_file) + + with self.assertRaisesRegex(CLIError, "Invalid certificate"): + identity.login_with_service_principal("00000000-0000-0000-0000-000000000000", test_cert_file) class TestServicePrincipalAuth(unittest.TestCase): def test_service_principal_auth_client_secret(self): - sp_auth = ServicePrincipalAuth('sp_id1', 'tenant1', 'verySecret!') + sp_auth = ServicePrincipalAuth('tenant1', 'sp_id1', 'verySecret!') result = sp_auth.get_entry_to_persist() - self.assertEqual(result, { + + assert result == { 'servicePrincipalId': 'sp_id1', 'servicePrincipalTenant': 'tenant1', 'secret': 'verySecret!' - }) + } def test_service_principal_auth_client_cert(self): curr_dir = os.path.dirname(os.path.realpath(__file__)) test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') - sp_auth = ServicePrincipalAuth('sp_id1', 'tenant1', None, test_cert_file) + sp_auth = ServicePrincipalAuth('tenant1', 'sp_id1', test_cert_file) result = sp_auth.get_entry_to_persist() - self.assertEqual(result, { + # To compute the thumb print: + # openssl x509 -in sp_cert.pem -noout -fingerprint + assert sp_auth.thumbprint == 'F06A53848BBE714A4290D69D335279C1D01073FD' + assert result == { 'servicePrincipalId': 'sp_id1', 'servicePrincipalTenant': 'tenant1', - 'certificateFile': test_cert_file, - }) + 'certificateFile': test_cert_file + } class TestMsalSecretStore(unittest.TestCase): - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - def test_retrieve_secret_of_service_principal_with_secret(self, mock_read_file, mock_read_file2, mock_read_file3): - test_sp = [{ + @mock.patch('azure.cli.core.auth.persistence.build_persistence', autospec=True) + def test_load_service_principal_secret(self, build_persistence_mock): + test_sp = { 'servicePrincipalId': 'myapp', 'servicePrincipalTenant': 'mytenant', 'secret': 'Secret' - }] - mock_read_file.return_value = json.dumps(test_sp) - mock_read_file2.return_value = json.dumps(test_sp) - mock_read_file3.return_value = json.dumps(test_sp) - from azure.cli.core._identity import MsalSecretStore - # action - secret_store = MsalSecretStore() - token, file = secret_store.load_service_principal_cred("myapp", "mytenant") - - self.assertEqual(token, "Secret") - - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - def test_retrieve_secret_of_service_principal_with_cert(self, mock_read_file, mock_read_file2, mock_read_file3): - test_sp = [{ - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "certificateFile": 'junkcert.pem' - }] - mock_read_file.return_value = json.dumps(test_sp) - mock_read_file2.return_value = json.dumps(test_sp) - mock_read_file3.return_value = json.dumps(test_sp) - from azure.cli.core._identity import MsalSecretStore - # action - creds_cache = MsalSecretStore() - token, file = creds_cache.load_service_principal_cred("myapp", "mytenant") - - # assert - self.assertEqual(file, 'junkcert.pem') + } + + test_file = os.path.join(os.path.dirname(__file__), "test.json") + with open(test_file, 'w') as f: + json.dump([test_sp], f) + + build_persistence_mock.return_value = FilePersistence(test_file) + secret_store = MsalSecretStore(test_file) + entry = secret_store.load_credential("myapp", "mytenant") + self.assertEqual(entry['secret'], "Secret") + + try: + os.remove(test_file) + except: + pass + + @mock.patch('azure.cli.core.auth.persistence.build_persistence', autospec=True) + def test_save_service_principal_secret(self, build_persistence_mock): + test_sp = { + 'servicePrincipalId': 'myapp', + 'servicePrincipalTenant': 'mytenant', + 'secret': 'Secret' + } + + test_file = os.path.join(os.path.dirname(__file__), "test.json") + build_persistence_mock.return_value = FilePersistence(test_file) + secret_store = MsalSecretStore(test_file) + secret_store.save_credential(test_sp) + + with open(test_file, 'r') as f: + result = json.load(f) + assert result[0] == test_sp + + try: + os.remove(test_file) + except: + pass if __name__ == '__main__': diff --git a/src/azure-cli-core/azure/cli/core/auth/token_cache.py b/src/azure-cli-core/azure/cli/core/auth/token_cache.py deleted file mode 100644 index a43eb8262b5..00000000000 --- a/src/azure-cli-core/azure/cli/core/auth/token_cache.py +++ /dev/null @@ -1,46 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -# This file is modified from -# https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/dev/sample/token_cache_sample.py - -import os -import sys - - -def load_persisted_token_cache(location, fallback_to_plaintext): - import msal_extensions - - persistence = _get_persistence(location, fallback_to_plaintext, account_name="MSALCache") - return msal_extensions.PersistedTokenCache(persistence) - - -def _get_persistence(location, fallback_to_plaintext, account_name): - import msal_extensions - - if sys.platform.startswith("win") and "LOCALAPPDATA" in os.environ: - return msal_extensions.FilePersistenceWithDataProtection(location) - - if sys.platform.startswith("darwin"): - # the cache uses this file's modified timestamp to decide whether to reload - return msal_extensions.KeychainPersistence(location, "Microsoft.Developer.IdentityService", account_name) - - if sys.platform.startswith("linux"): - # The cache uses this file's modified timestamp to decide whether to reload. Note this path is the same - # as that of the plaintext fallback: a new encrypted cache will stomp an unencrypted cache. - file_path = os.path.expanduser(os.path.join("~", ".IdentityService", location)) - try: - return msal_extensions.LibsecretPersistence( - file_path, location, {"MsalClientID": "Microsoft.Developer.IdentityService"}, label=account_name - ) - except ImportError: - if not fallback_to_plaintext: - raise ValueError( - "PyGObject is required to encrypt the persistent cache. Please install that library or " - + 'specify "allow_unencrypted_cache=True" to store the cache without encryption.' - ) - return msal_extensions.FilePersistence(file_path) - - raise NotImplementedError("A persistent cache is not available in this environment.") diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index c2858b7610c..9ac69b329e6 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -741,14 +741,14 @@ def test_get_current_account_user(self): # verify self.assertEqual(user, self.user1) - @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_get_login_credentials(self, app_mock, get_token_mock): + @mock.patch('azure.cli.core.auth.identity.UserCredential') + def test_get_login_credentials(self, user_credential_mock): + user_credential_mock.get_token.return_value = self.access_token + cli = DummyCli() - get_token_mock.return_value = TestProfile.raw_token1 # setup storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' test_subscription = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id), 'MSI-DEV-INC', self.state1, '12345678-38d6-4fb2-bad9-b7b93a3e1234') @@ -764,7 +764,7 @@ def test_get_login_credentials(self, app_mock, get_token_mock): # verify the cred.get_token() token = cred.get_token() - self.assertEqual(token, self.raw_token1) + self.assertEqual(token, self.access_token) @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) @@ -1572,7 +1572,7 @@ def test_credscache_add_new_sp_creds(self, mock_open_for_write1, mock_open_for_w creds_cache = MsalSecretStore() # action - creds_cache.save_service_principal_cred(test_sp2) + creds_cache.save_credential(test_sp2) # assert self.assertEqual(creds_cache._service_principal_creds, [test_sp, test_sp2]) @@ -1600,7 +1600,7 @@ def test_credscache_add_preexisting_sp_creds(self, mock_open_for_write1, mock_op creds_cache = MsalSecretStore() # action - creds_cache.save_service_principal_cred(test_sp) + creds_cache.save_credential(test_sp) # assert self.assertEqual(creds_cache._service_principal_creds, [test_sp]) @@ -1630,7 +1630,7 @@ def test_credscache_add_preexisting_sp_new_secret(self, mock_open_for_write1, mo new_creds = test_sp.copy() new_creds['accessToken'] = 'Secret2' # action - creds_cache.save_service_principal_cred(new_creds) + creds_cache.save_credential(new_creds) # assert self.assertEqual(creds_cache._service_principal_creds, [new_creds]) @@ -1658,7 +1658,7 @@ def test_credscache_remove_creds(self, mock_open_for_write1, mock_open_for_write creds_cache = MsalSecretStore() # action logout a service principal - creds_cache.remove_cached_creds('myapp') + creds_cache.remove_credential('myapp') # assert self.assertEqual(creds_cache._service_principal_creds, []) @@ -1676,7 +1676,7 @@ def test_credscache_good_error_on_file_corruption(self, mock_read_file1, mock_re # assert with self.assertRaises(CLIError) as context: - creds_cache._load_cached_creds() + creds_cache._load_persistence() self.assertTrue(re.findall(r'bad error for you', str(context.exception))) @@ -1724,51 +1724,6 @@ def test_find_using_common_tenant_mfa_warning(self, _get_authorization_code_mock # With pytest, use -o log_cli=True to manually check the log - @mock.patch('azure.cli.core._identity.Identity.get_user_credential', autospec=True) - def test_get_access_token_for_scopes(self, get_user_credential_mock): - credential_mock = get_user_credential_mock.return_value - credential_mock.get_token.return_value = self.access_token - - cli = DummyCli() - profile = Profile(cli_ctx=cli) - token = profile.get_access_token_for_scopes(self.user1, self.tenant_id, *self.msal_scopes) - - get_user_credential_mock.assert_called_with(mock.ANY, self.user1) - credential_mock.get_token.assert_called_with(*self.msal_scopes) - self.assertEqual(token, self.raw_token1) - - @mock.patch('msal.PublicClientApplication.acquire_token_silent_with_error', autospec=True) - @mock.patch('msal.PublicClientApplication.get_accounts', autospec=True) - def test_get_msal_token(self, get_accounts_mock, acquire_token_silent_with_error_mock): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock) - - consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) - profile._set_subscriptions(consolidated) - - scopes = ["https://pas.windows.net/CheckMyAccess/Linux/user_impersonation"] - data = { - "token_type": "ssh-cert", - "req_cnf": "fake_jwk", - "key_id": "fake_id" - } - mock_return_value = { - 'token_type': 'ssh-cert', - 'scope': 'https://pas.windows.net/CheckMyAccess/Linux/user_impersonation https://pas.windows.net/CheckMyAccess/Linux/.default', - 'expires_in': 3599, - 'ext_expires_in': 3599, - 'access_token': 'fake access token', - 'refresh_token': 'fake refresh token', - 'id_token': 'fake id token' - } - acquire_token_silent_with_error_mock.return_value = mock_return_value - - username, access_token = profile.get_msal_token(scopes, data) - self.assertEqual(username, self.user1) - self.assertEqual(access_token, 'fake access token') - acquire_token_silent_with_error_mock.assert_called_with(mock.ANY, scopes, get_accounts_mock.return_value[0], data=data) - class FileHandleStub(object): # pylint: disable=too-few-public-methods diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 96af79f1668..c45aaee8d21 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -38,52 +38,29 @@ def load_command_table(self, args): g.command('clear', 'account_clear') g.command('list-locations', 'list_locations') g.command('get-access-token', 'get_access_token') - g.command('export-msal-cache', 'export_msal_cache') return self.command_table # pylint: disable=line-too-long def load_arguments(self, command): from azure.cli.core.api import get_subscription_id_list - from azure.cli.core.commands.parameters import get_three_state_flag - from knack.arguments import CLIArgumentType - - clear_credential_type = CLIArgumentType(options_list=['--clear-credential', '-c'], - arg_type=get_three_state_flag(), - help="Clear the credential stored in MSAL encrypted cache. " - "The user will also be logged out from other SDK tools " - "which uses Azure CLI's credential via Single Sign-On.") with self.argument_context('login') as c: c.argument('password', options_list=['--password', '-p'], help="Credentials like user password, or for a service principal, provide client secret or a pem file with key and public certificate. Will prompt if not given.") c.argument('service_principal', action='store_true', help='The credential representing a service principal.') c.argument('username', options_list=['--username', '-u'], help='user name, service principal, or managed service identity ID') c.argument('tenant', options_list=['--tenant', '-t'], help='The AAD tenant, must provide when using service principals.', validator=validate_tenant) - c.argument('tenant_access', action='store_true', - deprecate_info=c.deprecate(target='--tenant-access', hide=True), - help='Only log in to the home tenant or the tenant specified by --tenant. CLI will not perform ' - 'ARM operations to list tenants and subscriptions. Then you may run tenant-level commands, ' - 'such as `az ad`, `az account get-access-token`.') - c.argument('allow_no_subscriptions', action='store_true', - help="Support access tenants without subscriptions. It's uncommon but useful to run tenant level commands, such as `az ad`") + c.argument('allow_no_subscriptions', action='store_true', help="Support access tenants without subscriptions. It's uncommon but useful to run tenant level commands, such as 'az ad'") c.ignore('_subscription') # hide the global subscription parameter - c.argument('identity', options_list=('-i', '--identity'), action='store_true', help="Log in using the Virtual Machine's managed identity", arg_group='Managed Identity') - c.argument('identity_port', type=int, help="the port to retrieve tokens for login. Default: 50342", arg_group='Managed Identity') + c.argument('identity', options_list=('-i', '--identity'), action='store_true', help="Log in using the Virtual Machine's identity", arg_group='Managed Service Identity') + c.argument('identity_port', type=int, help="the port to retrieve tokens for login. Default: 50342", arg_group='Managed Service Identity') c.argument('use_device_code', action='store_true', help="Use CLI's old authentication flow based on device code. CLI will also use this if it can't launch a browser in your behalf, e.g. in remote SSH or Cloud Shell") c.argument('use_cert_sn_issuer', action='store_true', help='used with a service principal configured with Subject Name and Issuer Authentication in order to support automatic certificate rolls') - c.argument('environment', options_list=['--environment', '-e'], action='store_true', - deprecate_info=c.deprecate(target='--environment', hide=True), - help='Use EnvironmentCredential. Both user and service principal accounts are supported. ' - 'For required environment variables, see https://docs.microsoft.com/en-us/python/api/overview/azure/identity-readme?view=azure-python#environment-variables') - c.argument('scopes', options_list=['--scope'], nargs="+", - help='A space-separated list of scopes to use in the /authorize request. ' - 'It can cover multiple resources.') - c.argument('claims_challenge', options_list=['--claims'], help='Claims challenge used for interactive authentication.') + c.argument('scopes', options_list=['--scope'], nargs='+', help='Used in the /authorize request. It can cover only one static resource.') with self.argument_context('logout') as c: - c.argument('username', options_list=['--username', '-u'], help='account user, if missing, logout the current active account') - c.argument('clear_credential', clear_credential_type) + c.argument('username', help='account user, if missing, logout the current active account') c.ignore('_subscription') # hide the global subscription parameter with self.argument_context('account') as c: @@ -99,20 +76,9 @@ def load_arguments(self, command): c.argument('show_auth_for_sdk', options_list=['--sdk-auth'], action='store_true', help='Output result to a file compatible with Azure SDK auth. Only applicable when authenticating with a Service Principal.') with self.argument_context('account get-access-token') as c: - c.argument('resource', arg_group='ADAL', help='Azure resource endpoints in AAD v1.0. Default to Azure Resource Manager') - c.argument('resource_type', get_enum_type(cloud_resource_types), options_list=['--resource-type'], arg_group='ADAL', help='Type of well-known resource.') + c.argument('resource_type', get_enum_type(cloud_resource_types), options_list=['--resource-type'], arg_group='', help='Type of well-known resource.') c.argument('scopes', options_list=['--scope'], nargs='*', arg_group='MSAL', help='Space-separated AAD scopes in AAD v2.0.') c.argument('tenant', options_list=['--tenant', '-t'], help='Tenant ID for which the token is acquired. Only available for user and service principal account, not for MSI or Cloud Shell account') - c.argument('decode', help='Show the decoded access token.', arg_type=get_three_state_flag(), - deprecate_info=c.deprecate(target='--decode', hide=True)) - c.argument('epoch_expires_on', help='Show expiresOn in epoch int.', arg_type=get_three_state_flag(), - deprecate_info=c.deprecate(target='--epoch-expires-on', hide=True)) - - with self.argument_context('account clear') as c: - c.argument('clear_credential', clear_credential_type) - - with self.argument_context('account export-msal-cache') as c: - c.argument('path', help='The path to export the MSAL cache.') COMMAND_LOADER_CLS = ProfileCommandsLoader diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 51f2306f973..6cef7d393a8 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -62,7 +62,7 @@ def show_subscription(cmd, subscription=None, show_auth_for_sdk=None): def get_access_token(cmd, subscription=None, resource=None, scopes=None, resource_type=None, tenant=None, - decode=False, epoch_expires_on=False): + epoch_expires_on=False): """ get AAD token to access to a specified resource. Use 'az cloud show' command for other Azure resources @@ -75,11 +75,6 @@ def get_access_token(cmd, subscription=None, resource=None, scopes=None, resourc creds, subscription, tenant = profile.get_raw_token(subscription=subscription, resource=resource, scopes=scopes, tenant=tenant, epoch_expires_on=epoch_expires_on) - # Debug switch for showing the decoded access token - if decode: - from azure.cli.core.auth import decode_access_token - return decode_access_token(creds[1]) - token_entry = creds[2] # MSIAuthentication's token entry has `expires_on`, while ADAL's token entry has `expiresOn` # Unify to ISO `expiresOn`, like "2020-06-30 06:14:41" From 0293a0bb6f7b16931aeaace02c7dcdbeed96d1e3 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 6 Sep 2021 18:00:32 +0800 Subject: [PATCH 43/69] Fix tests --- src/azure-cli-core/azure/cli/core/_profile.py | 12 +- .../azure/cli/core/tests/test_profile.py | 465 +++++++----------- .../cli/command_modules/profile/custom.py | 5 +- 3 files changed, 182 insertions(+), 300 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index b53f324e3e2..f59e6787d97 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -300,12 +300,12 @@ def login_in_cloud_shell(self): self._set_subscriptions(consolidated) return deepcopy(consolidated) - def logout(self, user_or_sp, clear_credential): + def logout(self, user_or_sp): subscriptions = self.load_cached_subscriptions(all_clouds=True) result = [x for x in subscriptions if user_or_sp.lower() == x[_USER_ENTITY][_USER_NAME].lower()] subscriptions = [x for x in subscriptions if x not in result] - #self._storage[_SUBSCRIPTIONS] = subscriptions + self._storage[_SUBSCRIPTIONS] = subscriptions identity = Identity(self._authority) identity.logout_user(user_or_sp) @@ -315,7 +315,7 @@ def logout_all(self): self._storage[_SUBSCRIPTIONS] = [] identity = Identity(self._authority) - accounts = identity.logout_all_users() + identity.logout_all_users() def get_login_credentials(self, resource=None, client_id=None, subscription_id=None, aux_subscriptions=None, aux_tenants=None): @@ -406,11 +406,13 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No credential = self._create_credential(account, tenant) token = credential.get_token(*scopes) import datetime - expires_on = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") + + # BREAKING CHANGE + # expires_on = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") token_entry = { 'accessToken': token.token, - 'expiresOn': expires_on + 'expiresOn': token.expires_on } # (tokenType, accessToken, tokenEntry) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index 9ac69b329e6..201b82d01c5 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -29,12 +29,55 @@ from knack.util import CLIError +MOCK_ACCESS_TOKEN = "mock_access_token" +MOCK_EXPIRES_ON = 1630920323 +BEARER = 'Bearer' + + class PublicClientApplicationMock(mock.MagicMock): def get_accounts(self, username): return [account for account in TestProfile.msal_accounts if account['username'] == username] +class MockCredential: + + def __init__(self, *args, **kwargs): + super().__init__() + + def get_token(self, *scopes, **kwargs): + from azure.core.credentials import AccessToken + import time + now = int(time.time()) + # Mock sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py:230 + return AccessToken(MOCK_ACCESS_TOKEN, MOCK_EXPIRES_ON) + + +class MSRestAzureAuthStub: + def __init__(self, *args, **kwargs): + self._token = { + 'token_type': 'Bearer', + 'access_token': TestProfile.test_msi_access_token + } + self.set_token_invoked_count = 0 + self.token_read_count = 0 + self.client_id = kwargs.get('client_id') + self.object_id = kwargs.get('object_id') + self.msi_res_id = kwargs.get('msi_res_id') + + def set_token(self): + self.set_token_invoked_count += 1 + + @property + def token(self): + self.token_read_count += 1 + return self._token + + @token.setter + def token(self, value): + self._token = value + + class TestProfile(unittest.TestCase): @classmethod @@ -741,10 +784,8 @@ def test_get_current_account_user(self): # verify self.assertEqual(user, self.user1) - @mock.patch('azure.cli.core.auth.identity.UserCredential') - def test_get_login_credentials(self, user_credential_mock): - user_credential_mock.get_token.return_value = self.access_token - + @mock.patch('azure.cli.core.auth.identity.UserCredential', MockCredential) + def test_get_login_credentials(self): cli = DummyCli() # setup storage_mock = {'subscriptions': None} @@ -764,54 +805,49 @@ def test_get_login_credentials(self, user_credential_mock): # verify the cred.get_token() token = cred.get_token() - self.assertEqual(token, self.access_token) + self.assertEqual(token.token, MOCK_ACCESS_TOKEN) - @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_get_login_credentials_aux_subscriptions(self, app_mock, get_token_mock): + @mock.patch('azure.cli.core.auth.identity.UserCredential', MockCredential) + def test_get_login_credentials_aux_subscriptions(self): cli = DummyCli() - get_token_mock.return_value = TestProfile.raw_token1 - # setup + storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' + profile = Profile(cli_ctx=cli, storage=storage_mock) + test_subscription_id1 = '12345678-1bf0-4dda-aec3-cb9272f09590' test_subscription_id2 = '12345678-1bf0-4dda-aec3-cb9272f09591' - test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' + test_tenant_id1 = '12345678-38d6-4fb2-bad9-b7b93a3e1234' test_tenant_id2 = '12345678-38d6-4fb2-bad9-b7b93a3e4321' - test_subscription = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id), - 'MSI-DEV-INC', self.state1, test_tenant_id) + test_subscription1 = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id1), + 'MSI-DEV-INC', self.state1, test_tenant_id1) test_subscription2 = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id2), 'MSI-DEV-INC2', self.state1, test_tenant_id2) consolidated = profile._normalize_properties(self.user1, - [test_subscription, test_subscription2], + [test_subscription1, test_subscription2], False, None, None) profile._set_subscriptions(consolidated) - # action - cred, subscription_id, _ = profile.get_login_credentials(subscription_id=test_subscription_id, + + cred, subscription_id, _ = profile.get_login_credentials(subscription_id=test_subscription_id1, aux_subscriptions=[test_subscription_id2]) - # verify - self.assertEqual(subscription_id, test_subscription_id) + self.assertEqual(subscription_id, test_subscription_id1) - # verify the cred._get_token - token, external_tokens = cred._get_token() - self.assertEqual(token, self.raw_token1) - self.assertEqual(external_tokens[0], self.raw_token1) + token = cred.get_token() + aux_tokens = cred.get_auxiliary_tokens() + self.assertEqual(token.token, MOCK_ACCESS_TOKEN) + self.assertEqual(aux_tokens[0].token, MOCK_ACCESS_TOKEN) - @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_get_login_credentials_aux_tenants(self, app_mock, get_token_mock): + @mock.patch('azure.cli.core.auth.identity.UserCredential', MockCredential) + def test_get_login_credentials_aux_tenants(self): cli = DummyCli() - get_token_mock.return_value = TestProfile.raw_token1 - # setup + storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' + profile = Profile(cli_ctx=cli, storage=storage_mock) + test_subscription_id1 = '12345678-1bf0-4dda-aec3-cb9272f09590' test_subscription_id2 = '12345678-1bf0-4dda-aec3-cb9272f09591' - test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' + test_tenant_id1 = '12345678-38d6-4fb2-bad9-b7b93a3e1234' test_tenant_id2 = '12345678-38d6-4fb2-bad9-b7b93a3e4321' - test_subscription = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id), - 'MSI-DEV-INC', self.state1, test_tenant_id) + test_subscription = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id1), + 'MSI-DEV-INC', self.state1, test_tenant_id1) test_subscription2 = SubscriptionStub('/subscriptions/{}'.format(test_subscription_id2), 'MSI-DEV-INC2', self.state1, test_tenant_id2) consolidated = profile._normalize_properties(self.user1, @@ -819,82 +855,75 @@ def test_get_login_credentials_aux_tenants(self, app_mock, get_token_mock): False, None, None) profile._set_subscriptions(consolidated) # test only input aux_tenants - cred, subscription_id, _ = profile.get_login_credentials(subscription_id=test_subscription_id, + cred, subscription_id, _ = profile.get_login_credentials(subscription_id=test_subscription_id1, aux_tenants=[test_tenant_id2]) - # verify - self.assertEqual(subscription_id, test_subscription_id) + self.assertEqual(subscription_id, test_subscription_id1) - # verify the cred._get_token - token, external_tokens = cred._get_token() - self.assertEqual(token, self.raw_token1) - self.assertEqual(external_tokens[0], self.raw_token1) + token = cred.get_token() + aux_tokens = cred.get_auxiliary_tokens() + self.assertEqual(token.token, MOCK_ACCESS_TOKEN) + self.assertEqual(aux_tokens[0].token, MOCK_ACCESS_TOKEN) # test input aux_tenants and aux_subscriptions with self.assertRaisesRegexp(CLIError, "Please specify only one of aux_subscriptions and aux_tenants, not both"): - cred, subscription_id, _ = profile.get_login_credentials(subscription_id=test_subscription_id, + cred, subscription_id, _ = profile.get_login_credentials(subscription_id=test_subscription_id1, aux_subscriptions=[test_subscription_id2], aux_tenants=[test_tenant_id2]) - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - def test_get_login_credentials_msi_system_assigned(self, get_token_mock): - get_token_mock.return_value = TestProfile.raw_token1 + @mock.patch('azure.cli.core.auth.adal_authentication.MSIAuthenticationWrapper', MSRestAzureAuthStub) + def test_get_login_credentials_msi_system_assigned(self): # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}) test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' test_user = 'systemAssignedIdentity' - msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, 'MSI', self.state1, - test_tenant_id) + msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, 'MSI', self.state1, test_tenant_id) consolidated = profile._normalize_properties(test_user, [msi_subscription], True) profile._set_subscriptions(consolidated) - # action cred, subscription_id, _ = profile.get_login_credentials() - # assert self.assertEqual(subscription_id, test_subscription_id) - token = cred.get_token() - self.assertEqual(token, self.raw_token1) - - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - def test_get_login_credentials_msi_user_assigned_with_client_id(self, get_token_mock): - get_token_mock.return_value = TestProfile.raw_token1 + # sniff test the msi_auth object + cred.set_token() + cred.token + self.assertTrue(cred.set_token_invoked_count) + self.assertTrue(cred.token_read_count) + @mock.patch('azure.cli.core.auth.adal_authentication.MSIAuthenticationWrapper', MSRestAzureAuthStub) + def test_get_login_credentials_msi_user_assigned_with_client_id(self): # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}) test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' test_user = 'userAssignedIdentity' test_client_id = '12345678-38d6-4fb2-bad9-b7b93a3e8888' - msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, - 'MSIClient-{}'.format(test_client_id), self.state1, test_tenant_id) + msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, 'MSIClient-{}'.format(test_client_id), self.state1, test_tenant_id) consolidated = profile._normalize_properties(test_user, [msi_subscription], True) profile._set_subscriptions(consolidated, secondary_key_name='name') - # action cred, subscription_id, _ = profile.get_login_credentials() - # assert self.assertEqual(subscription_id, test_subscription_id) - token = cred.get_token() - self.assertEqual(token, self.raw_token1) + # sniff test the msi_auth object + cred.set_token() + cred.token + self.assertTrue(cred.set_token_invoked_count) + self.assertTrue(cred.token_read_count) + self.assertTrue(cred.client_id, test_client_id) - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - def test_get_login_credentials_msi_user_assigned_with_object_id(self, get_token_mock): - get_token_mock.return_value = TestProfile.raw_token1 + @mock.patch('azure.cli.core.auth.adal_authentication.MSIAuthenticationWrapper', MSRestAzureAuthStub) + def test_get_login_credentials_msi_user_assigned_with_object_id(self): # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}) test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' test_object_id = '12345678-38d6-4fb2-bad9-b7b93a3e9999' msi_subscription = SubscriptionStub('/subscriptions/12345678-1bf0-4dda-aec3-cb9272f09590', @@ -903,22 +932,21 @@ def test_get_login_credentials_msi_user_assigned_with_object_id(self, get_token_ consolidated = profile._normalize_properties('userAssignedIdentity', [msi_subscription], True) profile._set_subscriptions(consolidated, secondary_key_name='name') - # action cred, subscription_id, _ = profile.get_login_credentials() - # assert self.assertEqual(subscription_id, test_subscription_id) - token = cred.get_token() - self.assertEqual(token, self.raw_token1) - - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - def test_get_login_credentials_msi_user_assigned_with_res_id(self, get_token_mock): - get_token_mock.return_value = self.access_token + # sniff test the msi_auth object + cred.set_token() + cred.token + self.assertTrue(cred.set_token_invoked_count) + self.assertTrue(cred.token_read_count) + self.assertTrue(cred.object_id, test_object_id) + @mock.patch('azure.cli.core.auth.adal_authentication.MSIAuthenticationWrapper', MSRestAzureAuthStub) + def test_get_login_credentials_msi_user_assigned_with_res_id(self): # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}) test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' test_res_id = ('/subscriptions/{}/resourceGroups/r1/providers/Microsoft.ManagedIdentity/' 'userAssignedIdentities/id1').format(test_subscription_id) @@ -928,24 +956,23 @@ def test_get_login_credentials_msi_user_assigned_with_res_id(self, get_token_moc consolidated = profile._normalize_properties('userAssignedIdentity', [msi_subscription], True) profile._set_subscriptions(consolidated, secondary_key_name='name') - # action cred, subscription_id, _ = profile.get_login_credentials() - # assert self.assertEqual(subscription_id, test_subscription_id) - token = cred.get_token() - self.assertEqual(token, self.access_token) + # sniff test the msi_auth object + cred.set_token() + cred.token + self.assertTrue(cred.set_token_invoked_count) + self.assertTrue(cred.token_read_count) + self.assertTrue(cred.msi_res_id, test_res_id) - @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_get_raw_token(self, app_mock, get_token_mock): + @mock.patch('azure.cli.core.auth.identity.UserCredential', MockCredential) + def test_get_raw_token(self): cli = DummyCli() - get_token_mock.return_value = self.access_token - # setup storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, [self.subscription1], False, None, None) @@ -961,65 +988,64 @@ def test_get_raw_token(self, app_mock, get_token_mock): self.assertEqual(resource_result, scopes_result) creds, sub, tenant = scopes_result - self.assertEqual(creds[0], self.token_entry1['tokenType']) - self.assertEqual(creds[1], self.raw_token1) - import datetime - # the last in the tuple is the whole token entry which has several fields - self.assertEqual(creds[2]['expiresOn'], - datetime.datetime.fromtimestamp(self.access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")) + self.assertEqual(creds[0], 'Bearer') + self.assertEqual(creds[1], MOCK_ACCESS_TOKEN) + self.assertEqual(creds[2]['expiresOn'], MOCK_EXPIRES_ON) + + # subscription should be set + self.assertEqual(sub, self.subscription1.subscription_id) + self.assertEqual(tenant, self.tenant_id) # Test get_raw_token with tenant creds, sub, tenant = profile.get_raw_token(resource='https://foo', tenant=self.tenant_id) - self.assertEqual(creds[0], self.token_entry1['tokenType']) - self.assertEqual(creds[1], self.raw_token1) - self.assertEqual(creds[2]['expiresOn'], - datetime.datetime.fromtimestamp(self.access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")) + self.assertEqual(creds[0], 'Bearer') + self.assertEqual(creds[1], MOCK_ACCESS_TOKEN) + self.assertEqual(creds[2]['expiresOn'], MOCK_EXPIRES_ON) + + # subscription shouldn't be set self.assertIsNone(sub) self.assertEqual(tenant, self.tenant_id) - @mock.patch('azure.identity.ClientSecretCredential.get_token', autospec=True) - @mock.patch('azure.cli.core._identity.MsalSecretStore.retrieve_secret_of_service_principal', autospec=True) - def test_get_raw_token_for_sp(self, retrieve_secret_of_service_principal, get_token_mock): + @mock.patch('azure.cli.core.auth.identity.Identity.get_service_principal_credential') + def test_get_raw_token_for_sp(self, get_service_principal_credential_mock): + get_service_principal_credential_mock.return_value = MockCredential() cli = DummyCli() - retrieve_secret_of_service_principal.return_value = 'fake', 'fake' - get_token_mock.return_value = self.access_token # setup storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties('sp1', [self.subscription1], - True, None, None) + True) profile._set_subscriptions(consolidated) # action creds, sub, tenant = profile.get_raw_token(resource='https://foo') # verify - self.assertEqual(creds[0], self.token_entry1['tokenType']) - self.assertEqual(creds[1], self.raw_token1) + self.assertEqual(creds[0], BEARER) + self.assertEqual(creds[1], MOCK_ACCESS_TOKEN) # the last in the tuple is the whole token entry which has several fields - self.assertEqual(creds[2]['expiresOn'], - datetime.datetime.fromtimestamp(self.access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")) - self.assertEqual(sub, '1') + self.assertEqual(creds[2]['expiresOn'], MOCK_EXPIRES_ON) + + # subscription should be set + self.assertEqual(sub, self.subscription1.subscription_id) self.assertEqual(tenant, self.tenant_id) # Test get_raw_token with tenant creds, sub, tenant = profile.get_raw_token(resource='https://foo', tenant=self.tenant_id) - self.assertEqual(creds[0], self.token_entry1['tokenType']) - self.assertEqual(creds[1], self.raw_token1) - self.assertEqual(creds[2]['expiresOn'], - datetime.datetime.fromtimestamp(self.access_token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f")) + self.assertEqual(creds[0], BEARER) + self.assertEqual(creds[1], MOCK_ACCESS_TOKEN) + self.assertEqual(creds[2]['expiresOn'], MOCK_EXPIRES_ON) + + # subscription shouldn't be set self.assertIsNone(sub) self.assertEqual(tenant, self.tenant_id) - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - def test_get_raw_token_msi_system_assigned(self, get_token_mock): - get_token_mock.return_value = self.access_token - + @mock.patch('azure.cli.core.auth.adal_authentication.MSIAuthenticationWrapper', autospec=True) + def test_get_raw_token_msi_system_assigned(self, mock_msi_auth): # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}) test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' test_user = 'systemAssignedIdentity' @@ -1030,13 +1056,15 @@ def test_get_raw_token_msi_system_assigned(self, get_token_mock): True) profile._set_subscriptions(consolidated) + mock_msi_auth.side_effect = MSRestAzureAuthStub + # action cred, subscription_id, tenant_id = profile.get_raw_token(resource='http://test_resource') # assert self.assertEqual(subscription_id, test_subscription_id) self.assertEqual(cred[0], 'Bearer') - self.assertEqual(cred[1], self.raw_token1) + self.assertEqual(cred[1], TestProfile.test_msi_access_token) self.assertEqual(subscription_id, test_subscription_id) self.assertEqual(tenant_id, test_tenant_id) @@ -1044,14 +1072,13 @@ def test_get_raw_token_msi_system_assigned(self, get_token_mock): with self.assertRaisesRegexp(CLIError, "MSI"): cred, subscription_id, _ = profile.get_raw_token(resource='http://test_resource', tenant=self.tenant_id) - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True, return_value=True) @mock.patch('azure.cli.core._profile.in_cloud_console', autospec=True) - def test_get_raw_token_in_cloud_console(self, mock_in_cloud_console, get_token_mock): - get_token_mock.return_value = self.access_token + @mock.patch('azure.cli.core.auth.adal_authentication.MSIAuthenticationWrapper', autospec=True) + def test_get_raw_token_in_cloud_console(self, mock_msi_auth, mock_in_cloud_console): + mock_in_cloud_console.return_value = True # setup an existing msi subscription - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}) test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, @@ -1062,13 +1089,15 @@ def test_get_raw_token_in_cloud_console(self, mock_in_cloud_console, get_token_m consolidated[0]['user']['cloudShellID'] = True profile._set_subscriptions(consolidated) + mock_msi_auth.side_effect = MSRestAzureAuthStub + # action cred, subscription_id, tenant_id = profile.get_raw_token(resource='http://test_resource') # assert self.assertEqual(subscription_id, test_subscription_id) self.assertEqual(cred[0], 'Bearer') - self.assertEqual(cred[1], self.raw_token1) + self.assertEqual(cred[1], TestProfile.test_msi_access_token) self.assertEqual(subscription_id, test_subscription_id) self.assertEqual(tenant_id, test_tenant_id) @@ -1076,168 +1105,45 @@ def test_get_raw_token_in_cloud_console(self, mock_in_cloud_console, get_token_m with self.assertRaisesRegexp(CLIError, 'Cloud Shell'): cred, subscription_id, _ = profile.get_raw_token(resource='http://test_resource', tenant=self.tenant_id) - @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_get_login_credentials_for_graph_client(self, app_mock, get_token_mock): - cli = DummyCli() - get_token_mock.return_value = self.access_token - # setup - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, [self.subscription1], - False, None, None) - profile._set_subscriptions(consolidated) - # action - cred, _, tenant_id = profile.get_login_credentials( - resource=cli.cloud.endpoints.active_directory_graph_resource_id) - _, _ = cred.get_token() - # verify - get_token_mock.assert_called_once_with(mock.ANY, 'https://graph.windows.net//.default') - self.assertEqual(tenant_id, self.tenant_id) - - @mock.patch('azure.identity.InteractiveBrowserCredential.get_token', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_get_login_credentials_for_data_lake_client(self, app_mock, get_token_mock): - cli = DummyCli() - get_token_mock.return_value = self.access_token - # setup - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, [self.subscription1], - False, None, None) - profile._set_subscriptions(consolidated) - # action - cred, _, tenant_id = profile.get_login_credentials( - resource=cli.cloud.endpoints.active_directory_data_lake_resource_id) - _, _ = cred.get_token() - # verify - get_token_mock.assert_called_once_with(mock.ANY, 'https://datalake.azure.net//.default') - self.assertEqual(tenant_id, self.tenant_id) - - @mock.patch('msal.PublicClientApplication.remove_account', autospec=True) - @mock.patch('msal.PublicClientApplication.get_accounts', autospec=True) - def test_logout(self, mock_get_accounts, mock_remove_account): + @mock.patch('azure.cli.core.auth.identity.Identity.logout_user') + def test_logout(self, logout_user_mock): cli = DummyCli() storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) - - # 1. Log out from CLI, but not from MSAL profile._set_subscriptions(consolidated) self.assertEqual(1, len(storage_mock['subscriptions'])) + # action + profile.logout(self.user1) - profile.logout(self.user1, clear_credential=False) - - self.assertEqual(0, len(storage_mock['subscriptions'])) - mock_get_accounts.assert_called_with(mock.ANY, self.user1) - mock_remove_account.assert_not_called() - - # 2. Log out from both CLI and MSAL - profile._set_subscriptions(consolidated) - mock_get_accounts.reset_mock() - mock_remove_account.reset_mock() - mock_get_accounts.return_value = self.msal_accounts - - profile.logout(self.user1, True) - - self.assertEqual(0, len(storage_mock['subscriptions'])) - mock_get_accounts.assert_called_with(mock.ANY, self.user1) - mock_remove_account.assert_has_calls([mock.call(mock.ANY, self.msal_accounts[0]), - mock.call(mock.ANY, self.msal_accounts[1])]) - - # 3. When already logged out from CLI, log out from MSAL - profile._set_subscriptions([]) - mock_get_accounts.reset_mock() - mock_remove_account.reset_mock() - profile.logout(self.user1, True) - mock_get_accounts.assert_called_with(mock.ANY, self.user1) - mock_remove_account.assert_has_calls([mock.call(mock.ANY, self.msal_accounts[0]), - mock.call(mock.ANY, self.msal_accounts[1])]) - - # 4. Log out from CLI, when already logged out from MSAL - profile._set_subscriptions(consolidated) - mock_get_accounts.reset_mock() - mock_remove_account.reset_mock() - mock_get_accounts.return_value = [] - profile.logout(self.user1, True) - self.assertEqual(0, len(storage_mock['subscriptions'])) - mock_get_accounts.assert_called_with(mock.ANY, self.user1) - mock_remove_account.assert_not_called() - - # 5. Not logged in to CLI or MSAL - profile._set_subscriptions([]) - mock_get_accounts.reset_mock() - mock_remove_account.reset_mock() - mock_get_accounts.return_value = [] - profile.logout(self.user1, True) + # verify self.assertEqual(0, len(storage_mock['subscriptions'])) - mock_get_accounts.assert_called_with(mock.ANY, self.user1) - mock_remove_account.assert_not_called() + logout_user_mock.assert_called_with(self.user1) - @mock.patch('msal.PublicClientApplication.remove_account', autospec=True) - @mock.patch('msal.PublicClientApplication.get_accounts', autospec=True) - def test_logout_all(self, mock_get_accounts, mock_remove_account): + @mock.patch('azure.cli.core.auth.identity.Identity.logout_all_users') + def test_logout_all(self, logout_all_users_mock): cli = DummyCli() # setup storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) consolidated2 = profile._normalize_properties(self.user2, [self.subscription2], False) - # 1. Log out from CLI, but not from MSAL profile._set_subscriptions(consolidated + consolidated2) - self.assertEqual(2, len(storage_mock['subscriptions'])) - profile.logout_all(clear_credential=False) - self.assertEqual([], storage_mock['subscriptions']) - mock_get_accounts.assert_called_with(mock.ANY) - mock_remove_account.assert_not_called() - - # 2. Log out from both CLI and MSAL - profile._set_subscriptions(consolidated + consolidated2) - mock_get_accounts.reset_mock() - mock_remove_account.reset_mock() - mock_get_accounts.return_value = self.msal_accounts - profile.logout_all(clear_credential=True) - self.assertEqual([], storage_mock['subscriptions']) - mock_get_accounts.assert_called_with(mock.ANY) - self.assertEqual(mock_remove_account.call_count, 4) - - # 3. When already logged out from CLI, log out from MSAL - profile._set_subscriptions([]) - mock_get_accounts.reset_mock() - mock_remove_account.reset_mock() - mock_get_accounts.return_value = self.msal_accounts - profile.logout_all(clear_credential=True) - self.assertEqual([], storage_mock['subscriptions']) - mock_get_accounts.assert_called_with(mock.ANY) - self.assertEqual(mock_remove_account.call_count, 4) + self.assertEqual(2, len(storage_mock['subscriptions'])) + # action + profile.logout_all() - # 4. Log out from CLI, when already logged out from MSAL - profile._set_subscriptions(consolidated + consolidated2) - mock_get_accounts.reset_mock() - mock_remove_account.reset_mock() - mock_get_accounts.return_value = [] - profile.logout_all(clear_credential=True) - self.assertEqual([], storage_mock['subscriptions']) - mock_get_accounts.assert_called_with(mock.ANY) - mock_remove_account.assert_not_called() - - # 5. Not logged in to CLI or MSAL - profile._set_subscriptions([]) - mock_get_accounts.reset_mock() - mock_remove_account.reset_mock() - mock_get_accounts.return_value = [] - profile.logout_all(clear_credential=True) + # verify self.assertEqual([], storage_mock['subscriptions']) - mock_get_accounts.assert_called_with(mock.ANY) - mock_remove_account.assert_not_called() + logout_all_users_mock.assert_called_once() @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) @@ -1772,31 +1678,6 @@ def __init__(self, tenant_id, display_name="DISPLAY_NAME"): self.additional_properties = {'displayName': display_name} -class MSRestAzureAuthStub: - def __init__(self, *args, **kwargs): - self._token = { - 'token_type': 'Bearer', - 'access_token': TestProfile.test_msi_access_token - } - self.set_token_invoked_count = 0 - self.token_read_count = 0 - self.client_id = kwargs.get('client_id') - self.object_id = kwargs.get('object_id') - self.msi_res_id = kwargs.get('msi_res_id') - - def set_token(self): - self.set_token_invoked_count += 1 - - @property - def token(self): - self.token_read_count += 1 - return self._token - - @token.setter - def token(self, value): - self._token = value - - class TestUtils(unittest.TestCase): def test_detect_adfs_authority(self): # Public cloud diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 6cef7d393a8..7366b97ddfd 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -61,8 +61,7 @@ def show_subscription(cmd, subscription=None, show_auth_for_sdk=None): return profile.get_subscription(subscription) -def get_access_token(cmd, subscription=None, resource=None, scopes=None, resource_type=None, tenant=None, - epoch_expires_on=False): +def get_access_token(cmd, subscription=None, resource=None, scopes=None, resource_type=None, tenant=None): """ get AAD token to access to a specified resource. Use 'az cloud show' command for other Azure resources @@ -73,7 +72,7 @@ def get_access_token(cmd, subscription=None, resource=None, scopes=None, resourc profile = Profile(cli_ctx=cmd.cli_ctx) creds, subscription, tenant = profile.get_raw_token(subscription=subscription, resource=resource, scopes=scopes, - tenant=tenant, epoch_expires_on=epoch_expires_on) + tenant=tenant) token_entry = creds[2] # MSIAuthentication's token entry has `expires_on`, while ADAL's token entry has `expiresOn` From 9e3852e5b8c1a00c63ae9be74c0047634598ed60 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 7 Sep 2021 13:37:41 +0800 Subject: [PATCH 44/69] Fix managed identity tests --- src/azure-cli-core/azure/cli/core/_profile.py | 66 +- .../azure/cli/core/auth/identity.py | 2 +- .../azure/cli/core/auth/tests/test.json.json | 7 + .../cli/core/auth/tests/test_identity.py | 169 ++++- .../azure/cli/core/tests/test_profile.py | 664 +++++++----------- 5 files changed, 460 insertions(+), 448 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/auth/tests/test.json.json diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index f59e6787d97..1d8f53e37e1 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -172,41 +172,35 @@ def login(self, if user_identity: username = user_identity['username'] - # List tenants and find subscriptions by calling ARM - if find_subscriptions: - subscription_finder = SubscriptionFinder(self.cli_ctx) - - # Create credentials - if user_identity: - credential = identity.get_user_credential(username) - else: - credential = identity.get_service_principal_credential(username) - - if tenant: - subscriptions = subscription_finder.find_using_specific_tenant(tenant, credential) - else: - subscriptions = subscription_finder.find_using_common_tenant(username, credential) + subscription_finder = SubscriptionFinder(self.cli_ctx) - if not subscriptions and not allow_no_subscriptions: - if username: - msg = "No subscriptions found for {}.".format(username) - else: - # Don't show username if bare 'az login' is used - msg = "No subscriptions found." - raise CLIError(msg) + # Create credentials + if user_identity: + credential = identity.get_user_credential(username) + else: + credential = identity.get_service_principal_credential(username) - if allow_no_subscriptions: - t_list = [s.tenant_id for s in subscriptions] - bare_tenants = [t for t in subscription_finder.tenants if t not in t_list] - profile = Profile(cli_ctx=self.cli_ctx) - tenant_accounts = profile._build_tenant_level_accounts(bare_tenants) # pylint: disable=protected-access - subscriptions.extend(tenant_accounts) - if not subscriptions: - return [] + if tenant: + subscriptions = subscription_finder.find_using_specific_tenant(tenant, credential) else: - # Build a tenant account - bare_tenant = tenant or user_identity['tenantId'] - subscriptions = self._build_tenant_level_accounts([bare_tenant]) + subscriptions = subscription_finder.find_using_common_tenant(username, credential) + + if not subscriptions and not allow_no_subscriptions: + if username: + msg = "No subscriptions found for {}.".format(username) + else: + # Don't show username if bare 'az login' is used + msg = "No subscriptions found." + raise CLIError(msg) + + if allow_no_subscriptions: + t_list = [s.tenant_id for s in subscriptions] + bare_tenants = [t for t in subscription_finder.tenants if t not in t_list] + profile = Profile(cli_ctx=self.cli_ctx) + tenant_accounts = profile._build_tenant_level_accounts(bare_tenants) # pylint: disable=protected-access + subscriptions.extend(tenant_accounts) + if not subscriptions: + return [] consolidated = self._normalize_properties(username, subscriptions, is_service_principal, bool(use_cert_sn_issuer)) @@ -423,7 +417,7 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No str(tenant if tenant else account[_TENANT_ID])) def _normalize_properties(self, user, subscriptions, is_service_principal, cert_sn_issuer_auth=None, - user_assigned_identity_id=None, managed_identity_info=None): + user_assigned_identity_id=None): import sys consolidated = [] for s in subscriptions: @@ -443,16 +437,13 @@ def _normalize_properties(self, user, subscriptions, is_service_principal, cert_ if subscription_dict[_SUBSCRIPTION_NAME] != _TENANT_LEVEL_ACCOUNT_NAME: _transform_subscription_for_multiapi(s, subscription_dict) - if cert_sn_issuer_auth: - subscription_dict[_USER_ENTITY][_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH] = True + consolidated.append(subscription_dict) - # This will be deprecated and client_id will be the only persisted ID if cert_sn_issuer_auth: consolidated[-1][_USER_ENTITY][_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH] = True if user_assigned_identity_id: consolidated[-1][_USER_ENTITY][_ASSIGNED_IDENTITY_INFO] = user_assigned_identity_id - consolidated.append(subscription_dict) return consolidated def _build_tenant_level_accounts(self, tenants): @@ -846,7 +837,6 @@ def find_using_common_tenant(self, username, credential=None): return all_subscriptions def find_using_specific_tenant(self, tenant, credential): - from azure.cli.core.auth import CredentialAdaptor client = self._create_subscription_client(credential) subscriptions = client.subscriptions.list() all_subscriptions = [] diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index badb1a706e8..e7e3bcd5617 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -341,7 +341,7 @@ def _load_persistence(self): except PersistenceNotFound: pass except Exception as ex: - raise CLIError("Failed to load token files. If you have a repro, please log an issue at " + raise CLIError("Failed to load token files. If you can reproduce, please log an issue at " "https://github.com/Azure/azure-cli/issues. At the same time, you can clean " "up by running 'az account clear' and then 'az login'. (Inner Error: {})".format(ex)) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test.json.json b/src/azure-cli-core/azure/cli/core/auth/tests/test.json.json new file mode 100644 index 00000000000..9056bcca24b --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test.json.json @@ -0,0 +1,7 @@ +[ + { + "servicePrincipalId": "myapp", + "servicePrincipalTenant": "mytenant", + "secret": "Secret" + } +] \ No newline at end of file diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index b3325b50114..e6180fff30d 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -8,6 +8,7 @@ import unittest from unittest import mock +import msal_extensions.persistence from azure.cli.core.auth.identity import Identity, ServicePrincipalAuth, MsalSecretStore from knack.util import CLIError from msal_extensions import FilePersistence @@ -57,36 +58,32 @@ class TestMsalSecretStore(unittest.TestCase): @mock.patch('azure.cli.core.auth.persistence.build_persistence', autospec=True) def test_load_service_principal_secret(self, build_persistence_mock): + persistence = MemoryPersistence() + build_persistence_mock.return_value = persistence + test_sp = { 'servicePrincipalId': 'myapp', 'servicePrincipalTenant': 'mytenant', 'secret': 'Secret' } - test_file = os.path.join(os.path.dirname(__file__), "test.json") - with open(test_file, 'w') as f: - json.dump([test_sp], f) + secret_store = MsalSecretStore(None) + persistence._content = [test_sp] - build_persistence_mock.return_value = FilePersistence(test_file) - secret_store = MsalSecretStore(test_file) entry = secret_store.load_credential("myapp", "mytenant") self.assertEqual(entry['secret'], "Secret") - try: - os.remove(test_file) - except: - pass - @mock.patch('azure.cli.core.auth.persistence.build_persistence', autospec=True) def test_save_service_principal_secret(self, build_persistence_mock): + test_file = os.path.join(os.path.dirname(__file__), "test.json") + build_persistence_mock.return_value = FilePersistence(test_file) + test_sp = { 'servicePrincipalId': 'myapp', 'servicePrincipalTenant': 'mytenant', 'secret': 'Secret' } - test_file = os.path.join(os.path.dirname(__file__), "test.json") - build_persistence_mock.return_value = FilePersistence(test_file) secret_store = MsalSecretStore(test_file) secret_store.save_credential(test_sp) @@ -99,6 +96,154 @@ def test_save_service_principal_secret(self, build_persistence_mock): except: pass + @mock.patch('azure.cli.core.auth.persistence.build_persistence', autospec=True) + def test_credscache_add_new_sp_creds(self, build_persistence_mock): + test_sp = { + "servicePrincipalId": "myapp", + "servicePrincipalTenant": "mytenant", + "secret": "Secret" + } + test_sp2 = { + "servicePrincipalId": "myapp2", + "servicePrincipalTenant": "mytenant2", + "secret": "Secret2" + } + mock_open_for_write1.return_value = None + mock_open_for_write2.return_value = None + mock_open_for_write3.return_value = None + mock_read_file1.return_value = json.dumps([test_sp]) + mock_read_file2.return_value = json.dumps([test_sp]) + mock_read_file3.return_value = json.dumps([test_sp]) + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() + + # action + creds_cache.save_credential(test_sp2) + + # assert + self.assertEqual(creds_cache._service_principal_creds, [test_sp, test_sp2]) + + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) + @mock.patch('msal_extensions.FilePersistence.save', autospec=True) + def test_credscache_add_preexisting_sp_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, + mock_read_file1, mock_read_file2, mock_read_file3): + test_sp = { + "servicePrincipalId": "myapp", + "servicePrincipalTenant": "mytenant", + "accessToken": "Secret" + } + mock_open_for_write1.return_value = None + mock_open_for_write2.return_value = None + mock_open_for_write3.return_value = None + mock_read_file1.return_value = json.dumps([test_sp]) + mock_read_file2.return_value = json.dumps([test_sp]) + mock_read_file3.return_value = json.dumps([test_sp]) + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() + + # action + creds_cache.save_credential(test_sp) + + # assert + self.assertEqual(creds_cache._service_principal_creds, [test_sp]) + + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) + @mock.patch('msal_extensions.FilePersistence.save', autospec=True) + def test_credscache_add_preexisting_sp_new_secret(self, mock_open_for_write1, mock_open_for_write2, + mock_open_for_write3, mock_read_file1, + mock_read_file2, mock_read_file3): + test_sp = { + "servicePrincipalId": "myapp", + "servicePrincipalTenant": "mytenant", + "accessToken": "Secret" + } + mock_open_for_write1.return_value = None + mock_open_for_write2.return_value = None + mock_open_for_write3.return_value = None + mock_read_file1.return_value = json.dumps([test_sp]) + mock_read_file2.return_value = json.dumps([test_sp]) + mock_read_file3.return_value = json.dumps([test_sp]) + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() + new_creds = test_sp.copy() + new_creds['accessToken'] = 'Secret2' + # action + creds_cache.save_credential(new_creds) + + # assert + self.assertEqual(creds_cache._service_principal_creds, [new_creds]) + + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) + @mock.patch('msal_extensions.FilePersistence.save', autospec=True) + def test_credscache_remove_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, + mock_read_file1, mock_read_file2, mock_read_file3): + test_sp = { + "servicePrincipalId": "myapp", + "servicePrincipalTenant": "mytenant", + "accessToken": "Secret" + } + mock_open_for_write1.return_value = None + mock_open_for_write2.return_value = None + mock_open_for_write3.return_value = None + mock_read_file1.return_value = json.dumps([test_sp]) + mock_read_file2.return_value = json.dumps([test_sp]) + mock_read_file3.return_value = json.dumps([test_sp]) + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() + + # action logout a service principal + creds_cache.remove_credential('myapp') + + # assert + self.assertEqual(creds_cache._service_principal_creds, []) + + @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) + @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) + @mock.patch('msal_extensions.FilePersistence.load', autospec=True) + def test_credscache_good_error_on_file_corruption(self, mock_read_file1, mock_read_file2, mock_read_file3): + mock_read_file1.side_effect = ValueError('a bad error for you') + mock_read_file2.side_effect = ValueError('a bad error for you') + mock_read_file3.side_effect = ValueError('a bad error for you') + + from azure.cli.core._identity import MsalSecretStore + creds_cache = MsalSecretStore() + + # assert + with self.assertRaises(CLIError) as context: + creds_cache._load_persistence() + + self.assertTrue(re.findall(r'bad error for you', str(context.exception))) + + +class MemoryPersistence(msal_extensions.persistence.BasePersistence): + + def __init__(self): + self._content = None + + def save(self, content): + self._content = content + + def load(self): + return self._content + + def time_last_modified(self): + pass + + def get_location(self): + pass + if __name__ == '__main__': unittest.main() diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index 201b82d01c5..5fbefa9c682 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -298,70 +298,95 @@ def test_login_with_auth_code(self, can_launch_browser_mock, login_with_auth_cod get_user_credential_mock.assert_called() self.assertEqual(self.subscription1_output, subs) - @mock.patch('azure.identity.UsernamePasswordCredential.authenticate', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_login_with_username_password_for_tenant(self, app_mock, authenticate_mock): - authenticate_mock.return_value = self.authentication_record + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_device_code', autospec=True) + def test_login_with_device_code(self, login_with_device_code_mock, get_user_credential_mock, + create_subscription_client_mock): + user_identity_mock = { + 'username': self.user1, + 'tenantId': self.tenant_id + } + login_with_device_code_mock.return_value = user_identity_mock + cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.side_effect = ValueError("'tenants.list' should not occur") - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + mock_subscription_client = mock.MagicMock() + mock_subscription_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - subs = profile.login(False, '1234', 'my-secret', False, self.tenant_id, use_device_code=False, - allow_no_subscriptions=False, subscription_finder=finder) + profile = Profile(cli_ctx=cli, storage=storage_mock) + subs = profile.login(True, None, None, False, None, use_device_code=True, allow_no_subscriptions=False) # assert self.assertEqual(self.subscription1_output, subs) - @mock.patch('azure.identity.DeviceCodeCredential.authenticate', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_login_with_device_code(self, app_mock, authenticate_mock): - authenticate_mock.return_value = self.authentication_record + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_device_code', autospec=True) + def test_login_with_device_code_for_tenant(self, login_with_device_code_mock, get_user_credential_mock, + create_subscription_client_mock): + user_identity_mock = { + 'username': self.user1, + 'tenantId': self.tenant_id + } + login_with_device_code_mock.return_value = user_identity_mock + cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + mock_subscription_client = mock.MagicMock() + mock_subscription_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - subs = profile.login(True, None, None, False, None, use_device_code=True, - allow_no_subscriptions=False, subscription_finder=finder) + profile = Profile(cli_ctx=cli, storage=storage_mock) + subs = profile.login(True, None, None, False, self.tenant_id, use_device_code=True, + allow_no_subscriptions=False) # assert self.assertEqual(self.subscription1_output, subs) - @mock.patch('azure.identity.DeviceCodeCredential.authenticate', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_login_with_device_code_for_tenant(self, app_mock, authenticate_mock): - authenticate_mock.return_value = self.authentication_record + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_username_password', autospec=True) + def test_login_with_username_password_for_tenant(self, login_with_username_password_mock, get_user_credential_mock, + create_subscription_client_mock): + user_identity_mock = { + 'username': self.user1, + 'tenantId': self.tenant_id + } + login_with_username_password_mock.return_value = user_identity_mock + cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.tenants.list.side_effect = ValueError("'tenants.list' should not occur") - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + mock_subscription_client = mock.MagicMock() + mock_subscription_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - subs = profile.login(True, None, None, False, self.tenant_id, use_device_code=True, - allow_no_subscriptions=False, subscription_finder=finder) + profile = Profile(cli_ctx=cli, storage=storage_mock) + subs = profile.login(False, '1234', 'my-secret', False, self.tenant_id, use_device_code=False, + allow_no_subscriptions=False) - # assert self.assertEqual(self.subscription1_output, subs) - def test_login_with_service_principal_secret(self): + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_service_principal_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_service_principal', autospec=True) + def test_login_with_service_principal(self, login_with_service_principal_mock, + get_service_principal_credential_mock, + create_subscription_client_mock): cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + mock_subscription_client = mock.MagicMock() + mock_subscription_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) subs = profile.login(False, 'my app', 'my secret', True, self.tenant_id, use_device_code=True, - allow_no_subscriptions=False, subscription_finder=finder) + allow_no_subscriptions=False) output = [{'environmentName': 'AzureCloud', 'homeTenantId': 'microsoft.com', 'id': '1', @@ -374,33 +399,6 @@ def test_login_with_service_principal_secret(self): 'user': { 'name': 'my app', 'type': 'servicePrincipal'}}] - # assert - self.assertEqual(output, subs) - - def test_login_with_service_principal_cert(self): - cli = DummyCli() - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) - curr_dir = os.path.dirname(os.path.realpath(__file__)) - test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - subs = profile.login(False, 'my app', test_cert_file, True, self.tenant_id, use_device_code=True, - allow_no_subscriptions=False, subscription_finder=finder) - output = [{'environmentName': 'AzureCloud', - 'homeTenantId': 'microsoft.com', - 'id': '1', - 'isDefault': True, - 'managedByTenants': [{'tenantId': '00000003-0000-0000-0000-000000000000'}, - {'tenantId': '00000004-0000-0000-0000-000000000000'}], - 'name': 'foo account', - 'state': 'Enabled', - 'tenantId': 'microsoft.com', - 'user': { - 'name': 'my app', - 'type': 'servicePrincipal'}}] - # assert self.assertEqual(output, subs) @unittest.skip("Not supported by Azure Identity.") @@ -431,6 +429,208 @@ def test_login_with_service_principal_cert_sn_issuer(self, get_token_mock): # assert self.assertEqual(output, subs) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.adal_authentication.MSIAuthenticationWrapper', autospec=True) + def test_login_in_cloud_shell(self, msi_auth_mock, create_subscription_client_mock): + msi_auth_mock.return_value = MSRestAzureAuthStub() + + cli = DummyCli() + mock_subscription_client = mock.MagicMock() + mock_subscription_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client + + profile = Profile(cli_ctx=cli, storage={'subscriptions': None}) + + subscriptions = profile.login_in_cloud_shell() + + # Check correct token is used + assert create_subscription_client_mock.call_args[0][1].token['access_token'] == TestProfile.test_msi_access_token + + self.assertEqual(len(subscriptions), 1) + s = subscriptions[0] + self.assertEqual(s['user']['name'], 'admin3@AzureSDKTeam.onmicrosoft.com') + self.assertEqual(s['tenantId'], '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a') + self.assertEqual(s['user']['cloudShellID'], True) + self.assertEqual(s['user']['type'], 'user') + self.assertEqual(s['name'], self.display_name1) + self.assertEqual(s['id'], self.id1.split('/')[-1]) + + @mock.patch('requests.get', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + def test_find_subscriptions_in_vm_with_msi_system_assigned(self, create_subscription_client_mock, mock_get): + mock_subscription_client = mock.MagicMock() + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client + + cli = DummyCli() + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock) + + test_token_entry = { + 'token_type': 'Bearer', + 'access_token': TestProfile.test_msi_access_token + } + encoded_test_token = json.dumps(test_token_entry).encode() + good_response = mock.MagicMock() + good_response.status_code = 200 + good_response.content = encoded_test_token + mock_get.return_value = good_response + + subscriptions = profile.login_with_managed_identity() + + # assert + self.assertEqual(len(subscriptions), 1) + s = subscriptions[0] + self.assertEqual(s['user']['name'], 'systemAssignedIdentity') + self.assertEqual(s['user']['type'], 'servicePrincipal') + self.assertEqual(s['user']['assignedIdentityInfo'], 'MSI') + self.assertEqual(s['name'], self.display_name1) + self.assertEqual(s['id'], self.id1.split('/')[-1]) + self.assertEqual(s['tenantId'], '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a') + + @mock.patch('requests.get', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + def test_find_subscriptions_in_vm_with_msi_no_subscriptions(self, create_subscription_client_mock, mock_get): + mock_subscription_client = mock.MagicMock() + mock_subscription_client.subscriptions.list.return_value = [] + create_subscription_client_mock.return_value = mock_subscription_client + + cli = DummyCli() + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock) + + test_token_entry = { + 'token_type': 'Bearer', + 'access_token': TestProfile.test_msi_access_token + } + encoded_test_token = json.dumps(test_token_entry).encode() + good_response = mock.MagicMock() + good_response.status_code = 200 + good_response.content = encoded_test_token + mock_get.return_value = good_response + + subscriptions = profile.login_with_managed_identity(allow_no_subscriptions=True) + + # assert + self.assertEqual(len(subscriptions), 1) + s = subscriptions[0] + + self.assertEqual(s['name'], 'N/A(tenant level account)') + self.assertEqual(s['id'], self.test_msi_tenant) + self.assertEqual(s['tenantId'], self.test_msi_tenant) + + self.assertEqual(s['user']['name'], 'systemAssignedIdentity') + self.assertEqual(s['user']['type'], 'servicePrincipal') + self.assertEqual(s['user']['assignedIdentityInfo'], 'MSI') + + @mock.patch('requests.get', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + def test_find_subscriptions_in_vm_with_msi_user_assigned_with_client_id(self, create_subscription_client_mock, mock_get): + mock_subscription_client = mock.MagicMock() + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client + + cli = DummyCli() + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock) + + test_token_entry = { + 'token_type': 'Bearer', + 'access_token': TestProfile.test_msi_access_token + } + test_client_id = '54826b22-38d6-4fb2-bad9-b7b93a3e9999' + encoded_test_token = json.dumps(test_token_entry).encode() + good_response = mock.MagicMock() + good_response.status_code = 200 + good_response.content = encoded_test_token + mock_get.return_value = good_response + + subscriptions = profile.login_with_managed_identity(identity_id=test_client_id) + + self.assertEqual(len(subscriptions), 1) + s = subscriptions[0] + self.assertEqual(s['name'], self.display_name1) + self.assertEqual(s['id'], self.id1.split('/')[-1]) + self.assertEqual(s['tenantId'], '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a') + + self.assertEqual(s['user']['name'], 'userAssignedIdentity') + self.assertEqual(s['user']['type'], 'servicePrincipal') + self.assertEqual(s['user']['assignedIdentityInfo'], 'MSIClient-{}'.format(test_client_id)) + + @mock.patch('azure.cli.core.auth.adal_authentication.MSIAuthenticationWrapper', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + def test_find_subscriptions_in_vm_with_msi_user_assigned_with_object_id(self, create_subscription_client_mock, + mock_msi_auth): + mock_subscription_client = mock.MagicMock() + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client + + from azure.cli.core.azclierror import AzureResponseError + class AuthStub: + def __init__(self, **kwargs): + self.token = None + self.client_id = kwargs.get('client_id') + self.object_id = kwargs.get('object_id') + # since msrestazure 0.4.34, set_token in init + self.set_token() + + def set_token(self): + # here we will reject the 1st sniffing of trying with client_id and then acccept the 2nd + if self.object_id: + self.token = { + 'token_type': 'Bearer', + 'access_token': TestProfile.test_msi_access_token + } + else: + raise AzureResponseError('Failed to connect to MSI. Please make sure MSI is configured correctly.\n' + 'Get Token request returned http error: 400, reason: Bad Request') + + profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}) + + mock_msi_auth.side_effect = AuthStub + test_object_id = '54826b22-38d6-4fb2-bad9-b7b93a3e9999' + + subscriptions = profile.login_with_managed_identity(identity_id=test_object_id) + + s = subscriptions[0] + self.assertEqual(s['user']['name'], 'userAssignedIdentity') + self.assertEqual(s['user']['type'], 'servicePrincipal') + self.assertEqual(s['user']['assignedIdentityInfo'], 'MSIObject-{}'.format(test_object_id)) + + @mock.patch('requests.get', autospec=True) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + def test_find_subscriptions_in_vm_with_msi_user_assigned_with_res_id(self, create_subscription_client_mock, + mock_get): + + mock_subscription_client = mock.MagicMock() + mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_subscription_client + + cli = DummyCli() + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock) + + test_token_entry = { + 'token_type': 'Bearer', + 'access_token': TestProfile.test_msi_access_token + } + test_res_id = ('/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590/resourcegroups/g1/' + 'providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1') + + encoded_test_token = json.dumps(test_token_entry).encode() + good_response = mock.MagicMock() + good_response.status_code = 200 + good_response.content = encoded_test_token + mock_get.return_value = good_response + + subscriptions = profile.login_with_managed_identity(identity_id=test_res_id) + + s = subscriptions[0] + self.assertEqual(s['user']['name'], 'userAssignedIdentity') + self.assertEqual(s['user']['type'], 'servicePrincipal') + self.assertEqual(subscriptions[0]['user']['assignedIdentityInfo'], 'MSIResource-{}'.format(test_res_id)) + def test_normalize(self): cli = DummyCli() storage_mock = {'subscriptions': None} @@ -1145,200 +1345,6 @@ def test_logout_all(self, logout_all_users_mock): self.assertEqual([], storage_mock['subscriptions']) logout_all_users_mock.assert_called_once() - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_cloud_console(self, mock_subscription_finder, get_token_mock): - class SubscriptionFinderStub: - def find_using_specific_tenant(self, tenant, credential): - # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') - return [TestProfile.subscription1] - - mock_subscription_finder.return_value = SubscriptionFinderStub() - - from azure.core.credentials import AccessToken - import time - get_token_mock.return_value = AccessToken(TestProfile.test_msi_access_token, - int(self.token_entry1['expiresIn'] + time.time())) - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, - async_persist=False) - - # action - subscriptions = profile.login_in_cloud_shell() - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'admin3@AzureSDKTeam.onmicrosoft.com') - self.assertEqual(s['user']['cloudShellID'], True) - self.assertEqual(s['user']['type'], 'user') - self.assertEqual(s['name'], self.display_name1) - self.assertEqual(s['id'], self.id1.split('/')[-1]) - - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_vm_with_msi_system_assigned(self, mock_subscription_finder, get_token_mock): - class SubscriptionFinderStub: - def find_using_specific_tenant(self, tenant, credential): - # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') - return [TestProfile.subscription1] - - mock_subscription_finder.return_value = SubscriptionFinderStub() - - from azure.core.credentials import AccessToken - import time - get_token_mock.return_value = AccessToken(TestProfile.test_msi_access_token, - int(self.token_entry1['expiresIn'] + time.time())) - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, async_persist=False) - - subscriptions = profile.login_with_managed_identity() - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'systemAssignedIdentity') - self.assertEqual(s['user']['type'], 'servicePrincipal') - self.assertEqual(s['user']['assignedIdentityInfo'], 'MSI') - self.assertEqual(s['name'], self.display_name1) - self.assertEqual(s['id'], self.id1.split('/')[-1]) - self.assertEqual(s['tenantId'], 'microsoft.com') - - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_vm_with_msi_no_subscriptions(self, mock_subscription_finder, get_token_mock): - class SubscriptionFinderStub: - def find_using_specific_tenant(self, tenant, credential): - # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') - return [] - - mock_subscription_finder.return_value = SubscriptionFinderStub() - - from azure.core.credentials import AccessToken - import time - get_token_mock.return_value = AccessToken(TestProfile.test_msi_access_token, - int(self.token_entry1['expiresIn'] + time.time())) - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, async_persist=False) - - subscriptions = profile.login_with_managed_identity(allow_no_subscriptions=True) - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'systemAssignedIdentity') - self.assertEqual(s['user']['type'], 'servicePrincipal') - self.assertEqual(s['user']['assignedIdentityInfo'], 'MSI') - self.assertEqual(s['name'], 'N/A(tenant level account)') - self.assertEqual(s['id'], self.test_msi_tenant) - self.assertEqual(s['tenantId'], self.test_msi_tenant) - - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_vm_with_msi_user_assigned_with_client_id(self, mock_subscription_finder, get_token_mock): - class SubscriptionFinderStub: - def find_using_specific_tenant(self, tenant, credential): - # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') - return [TestProfile.subscription1] - - mock_subscription_finder.return_value = SubscriptionFinderStub() - - from azure.core.credentials import AccessToken - import time - - get_token_mock.return_value = AccessToken(TestProfile.test_user_msi_access_token, - int(self.token_entry1['expiresIn'] + time.time())) - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, - use_global_creds_cache=False, async_persist=False) - - test_client_id = '62ac49e6-0438-412c-bdf5-484e7d452936' - - subscriptions = profile.login_with_managed_identity(identity_id=test_client_id) - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'userAssignedIdentity') - self.assertEqual(s['user']['type'], 'servicePrincipal') - self.assertEqual(s['user']['clientId'], test_client_id) - self.assertEqual(s['name'], self.display_name1) - self.assertEqual(s['id'], self.id1.split('/')[-1]) - self.assertEqual(s['tenantId'], 'microsoft.com') - - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_vm_with_msi_user_assigned_with_object_id(self, mock_subscription_finder, get_token_mock): - class SubscriptionFinderStub: - def find_using_specific_tenant(self, tenant, credential): - # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') - return [TestProfile.subscription1] - - mock_subscription_finder.return_value = SubscriptionFinderStub() - - from azure.core.credentials import AccessToken - import time - - get_token_mock.return_value = AccessToken(TestProfile.test_user_msi_access_token, - int(self.token_entry1['expiresIn'] + time.time())) - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, - use_global_creds_cache=False, async_persist=False) - - test_object_id = 'd834c66f-3af8-40b7-b463-ebdce7f3a827' - - subscriptions = profile.login_with_managed_identity(identity_id=test_object_id) - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'userAssignedIdentity') - self.assertEqual(s['user']['type'], 'servicePrincipal') - self.assertEqual(s['user']['objectId'], test_object_id) - self.assertEqual(s['name'], self.display_name1) - self.assertEqual(s['id'], self.id1.split('/')[-1]) - self.assertEqual(s['tenantId'], 'microsoft.com') - - @mock.patch('azure.identity.ManagedIdentityCredential.get_token', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder', autospec=True) - def test_find_subscriptions_in_vm_with_msi_user_assigned_with_res_id(self, mock_subscription_finder, get_token_mock): - class SubscriptionFinderStub: - def find_using_specific_tenant(self, tenant, credential): - # make sure the tenant and token args match 'TestProfile.test_msi_access_token' - if tenant != '54826b22-38d6-4fb2-bad9-b7b93a3e9c5a': - raise AssertionError('find_using_specific_tenant was not invoked with expected tenant or token') - return [TestProfile.subscription1] - - mock_subscription_finder.return_value = SubscriptionFinderStub() - - from azure.core.credentials import AccessToken - import time - - get_token_mock.return_value = AccessToken(TestProfile.test_user_msi_access_token, - int(self.token_entry1['expiresIn'] + time.time())) - profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, - use_global_creds_cache=False, async_persist=False) - - test_resource_id = ('/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590/resourcegroups/qianwens/providers/' - 'Microsoft.ManagedIdentity/userAssignedIdentities/qianwenidentity') - - subscriptions = profile.login_with_managed_identity(identity_id=test_resource_id) - - # assert - self.assertEqual(len(subscriptions), 1) - s = subscriptions[0] - self.assertEqual(s['user']['name'], 'userAssignedIdentity') - self.assertEqual(s['user']['type'], 'servicePrincipal') - self.assertEqual(s['user']['resourceId'], test_resource_id) - self.assertEqual(s['name'], self.display_name1) - self.assertEqual(s['id'], self.id1.split('/')[-1]) - self.assertEqual(s['tenantId'], 'microsoft.com') - @unittest.skip("todo: wait for identity support") @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) def test_find_subscriptions_thru_username_password_adfs(self, get_token_mock): @@ -1450,142 +1456,6 @@ def test_refresh_accounts_with_nothing(self, app_mock, get_token_mock): result = storage_mock['subscriptions'] self.assertEqual(0, len(result)) - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) - @mock.patch('msal_extensions.FilePersistence.save', autospec=True) - def test_credscache_add_new_sp_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, - mock_read_file1, mock_read_file2, mock_read_file3): - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - test_sp2 = { - "servicePrincipalId": "myapp2", - "servicePrincipalTenant": "mytenant2", - "accessToken": "Secret2" - } - mock_open_for_write1.return_value = None - mock_open_for_write2.return_value = None - mock_open_for_write3.return_value = None - mock_read_file1.return_value = json.dumps([test_sp]) - mock_read_file2.return_value = json.dumps([test_sp]) - mock_read_file3.return_value = json.dumps([test_sp]) - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() - - # action - creds_cache.save_credential(test_sp2) - - # assert - self.assertEqual(creds_cache._service_principal_creds, [test_sp, test_sp2]) - - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) - @mock.patch('msal_extensions.FilePersistence.save', autospec=True) - def test_credscache_add_preexisting_sp_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, - mock_read_file1, mock_read_file2, mock_read_file3): - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_open_for_write1.return_value = None - mock_open_for_write2.return_value = None - mock_open_for_write3.return_value = None - mock_read_file1.return_value = json.dumps([test_sp]) - mock_read_file2.return_value = json.dumps([test_sp]) - mock_read_file3.return_value = json.dumps([test_sp]) - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() - - # action - creds_cache.save_credential(test_sp) - - # assert - self.assertEqual(creds_cache._service_principal_creds, [test_sp]) - - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) - @mock.patch('msal_extensions.FilePersistence.save', autospec=True) - def test_credscache_add_preexisting_sp_new_secret(self, mock_open_for_write1, mock_open_for_write2, - mock_open_for_write3, mock_read_file1, - mock_read_file2, mock_read_file3): - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_open_for_write1.return_value = None - mock_open_for_write2.return_value = None - mock_open_for_write3.return_value = None - mock_read_file1.return_value = json.dumps([test_sp]) - mock_read_file2.return_value = json.dumps([test_sp]) - mock_read_file3.return_value = json.dumps([test_sp]) - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() - new_creds = test_sp.copy() - new_creds['accessToken'] = 'Secret2' - # action - creds_cache.save_credential(new_creds) - - # assert - self.assertEqual(creds_cache._service_principal_creds, [new_creds]) - - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) - @mock.patch('msal_extensions.FilePersistence.save', autospec=True) - def test_credscache_remove_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, - mock_read_file1, mock_read_file2, mock_read_file3): - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_open_for_write1.return_value = None - mock_open_for_write2.return_value = None - mock_open_for_write3.return_value = None - mock_read_file1.return_value = json.dumps([test_sp]) - mock_read_file2.return_value = json.dumps([test_sp]) - mock_read_file3.return_value = json.dumps([test_sp]) - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() - - # action logout a service principal - creds_cache.remove_credential('myapp') - - # assert - self.assertEqual(creds_cache._service_principal_creds, []) - - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - def test_credscache_good_error_on_file_corruption(self, mock_read_file1, mock_read_file2, mock_read_file3): - mock_read_file1.side_effect = ValueError('a bad error for you') - mock_read_file2.side_effect = ValueError('a bad error for you') - mock_read_file3.side_effect = ValueError('a bad error for you') - - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() - - # assert - with self.assertRaises(CLIError) as context: - creds_cache._load_persistence() - - self.assertTrue(re.findall(r'bad error for you', str(context.exception))) - @unittest.skip("todo: wait for identity support") @mock.patch('adal.AuthenticationContext', autospec=True) @mock.patch('azure.cli.core._profile._get_authorization_code', autospec=True) From 67f6eb30f63fbc1e45b99cccce6e948f24ddd45d Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 7 Sep 2021 16:22:19 +0800 Subject: [PATCH 45/69] Fix UserCredentialMock --- src/azure-cli-core/azure/cli/core/_profile.py | 70 +--- .../azure/cli/core/auth/__init__.py | 2 +- .../azure/cli/core/auth/identity.py | 30 +- .../azure/cli/core/auth/persistence.py | 32 +- .../cli/core/auth/tests/test_identity.py | 182 +++------ .../azure/cli/core/auth/tests/test_util.py | 16 +- .../azure/cli/core/auth/util.py | 27 +- src/azure-cli-core/azure/cli/core/cloud.py | 2 +- .../azure/cli/core/tests/test_profile.py | 358 ++++++------------ .../azure/cli/testsdk/patches.py | 40 +- 10 files changed, 221 insertions(+), 538 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 1d8f53e37e1..5e0dbb58b68 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -16,8 +16,9 @@ from azure.cli.core._session import ACCOUNT from azure.cli.core.util import in_cloud_console from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription -from azure.cli.core.auth import (Identity, MsalSecretStore, AZURE_CLI_CLIENT_ID, +from azure.cli.core.auth import (Identity, ServicePrincipalStore, AZURE_CLI_CLIENT_ID, resource_to_scopes, can_launch_browser) +from azure.cli.core.azclierror import AuthenticationError logger = get_logger(__name__) @@ -615,11 +616,11 @@ def _create_credential(self, account, tenant_id=None, client_id=None): raise NotImplementedError - def refresh_accounts(self, subscription_finder=None): + def refresh_accounts(self): subscriptions = self.load_cached_subscriptions() to_refresh = subscriptions - subscription_finder = subscription_finder or SubscriptionFinder(self.cli_ctx) + subscription_finder = SubscriptionFinder(self.cli_ctx) refreshed_list = set() result = [] for s in to_refresh: @@ -636,8 +637,7 @@ def refresh_accounts(self, subscription_finder=None): subscriptions = subscription_finder.find_using_specific_tenant(tenant, identity_credential) else: # pylint: disable=protected-access - subscriptions = subscription_finder. \ - find_using_common_tenant(user_name, identity_credential) # pylint: disable=protected-access + subscriptions = subscription_finder.find_using_common_tenant(user_name, identity_credential) except Exception as ex: # pylint: disable=broad-except logger.warning("Refreshing for '%s' failed with an error '%s'. The existing accounts were not " "modified. You can run 'az login' later to explicitly refresh them", user_name, ex) @@ -658,52 +658,6 @@ def refresh_accounts(self, subscription_finder=None): self._set_subscriptions(result, merge=False) - def get_sp_auth_info(self, subscription_id=None, name=None, password=None, cert_file=None): - # TODO: Use MSAL - from collections import OrderedDict - account = self.get_subscription(subscription_id) - - # is the credential created through command like 'create-for-rbac'? - result = OrderedDict() - if name and (password or cert_file): - result['clientId'] = name - if password: - result['clientSecret'] = password - else: - result['clientCertificate'] = cert_file - result['subscriptionId'] = subscription_id or account[_SUBSCRIPTION_ID] - else: # has logged in through cli - user_type = account[_USER_ENTITY].get(_USER_TYPE) - if user_type == _SERVICE_PRINCIPAL: - result['clientId'] = account[_USER_ENTITY][_USER_NAME] - msal_cache = MsalSecretStore(True) - secret, certificate_file = msal_cache.load_credential( - account[_USER_ENTITY][_USER_NAME], account[_TENANT_ID]) - if secret: - result['clientSecret'] = secret - else: - # we can output 'clientCertificateThumbprint' if asked - result['clientCertificate'] = certificate_file - result['subscriptionId'] = account[_SUBSCRIPTION_ID] - else: - raise CLIError('SDK Auth file is only applicable when authenticated using a service principal') - - result[_TENANT_ID] = account[_TENANT_ID] - endpoint_mappings = OrderedDict() # use OrderedDict to control the output sequence - endpoint_mappings['active_directory'] = 'activeDirectoryEndpointUrl' - endpoint_mappings['resource_manager'] = 'resourceManagerEndpointUrl' - endpoint_mappings['active_directory_graph_resource_id'] = 'activeDirectoryGraphResourceId' - endpoint_mappings['sql_management'] = 'sqlManagementEndpointUrl' - endpoint_mappings['gallery'] = 'galleryEndpointUrl' - endpoint_mappings['management'] = 'managementEndpointUrl' - from azure.cli.core.cloud import CloudEndpointNotSetException - for e in endpoint_mappings: - try: - result[endpoint_mappings[e]] = getattr(get_active_cloud(self.cli_ctx).endpoints, e) - except CloudEndpointNotSetException: - result[endpoint_mappings[e]] = None - return result - def get_installation_id(self): installation_id = self._storage.get(_INSTALLATION_ID) if not installation_id: @@ -784,14 +738,16 @@ def find_using_common_tenant(self, username, credential=None): identity = Identity(self.authority, tenant_id, allow_unencrypted=self.cli_ctx.config .getboolean('core', 'allow_fallback_to_plaintext', fallback=True)) + + specific_tenant_credential = identity.get_user_credential(username) + try: - specific_tenant_credential = identity.get_user_credential(username) - # TODO: handle MSAL exceptions - except adal.AdalError as ex: + subscriptions = self.find_using_specific_tenant(tenant_id, specific_tenant_credential) + except AuthenticationError as ex: # because user creds went through the 'common' tenant, the error here must be # tenant specific, like the account was disabled. For such errors, we will continue # with other tenants. - msg = (getattr(ex, 'error_response', None) or {}).get('error_description') or '' + msg = ex.error_msg if 'AADSTS50076' in msg: # The tenant requires MFA and can't be accessed with home tenant's refresh token mfa_tenants.append(t) @@ -799,10 +755,6 @@ def find_using_common_tenant(self, username, credential=None): logger.warning("Failed to authenticate '%s' due to error '%s'", t, ex) continue - subscriptions = self.find_using_specific_tenant( - tenant_id, - specific_tenant_credential) - if not subscriptions: empty_tenants.append(t) diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py index 6497ffb70da..9cb29339ea7 100644 --- a/src/azure-cli-core/azure/cli/core/auth/__init__.py +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -4,6 +4,6 @@ # -------------------------------------------------------------------------------------------- from .credential_adaptor import CredentialAdaptor -from .identity import Identity, MsalSecretStore, AZURE_CLI_CLIENT_ID +from .identity import Identity, ServicePrincipalStore, AZURE_CLI_CLIENT_ID from .util import resource_to_scopes, aad_error_handler, can_launch_browser, \ decode_access_token diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index e7e3bcd5617..7ee1e88ef98 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -59,7 +59,7 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): self._msal_app_instance = None # Store for Service principal credential persistence - self._msal_secret_store = MsalSecretStore(self._secret_file, fallback_to_plaintext=self._fallback_to_plaintext) + self._msal_secret_store = ServicePrincipalStore(self._secret_file, fallback_to_plaintext=self._fallback_to_plaintext) self._msal_app_kwargs = { "authority": self.msal_authority, "token_cache": self._load_msal_cache(), @@ -252,13 +252,14 @@ def get_entry_to_persist(self): return entry -class MsalSecretStore: +class ServicePrincipalStore: """Caches secrets in MSAL custom secret store for Service Principal authentication. """ - def __init__(self, secret_file, fallback_to_plaintext=True): + def __init__(self, secret_file=None, fallback_to_plaintext=True): + from .persistence import load_secret_store + self._secret_store = load_secret_store(secret_file, fallback_to_plaintext) self._secret_file = secret_file - self._lock_file = self._secret_file + '.lock' self._service_principal_creds = [] self._fallback_to_plaintext = fallback_to_plaintext @@ -300,7 +301,6 @@ def save_credential(self, sp_entry): if state_changed: self._save_persistence() - self._serialize_secrets() def remove_credential(self, sp_id): self._load_persistence() @@ -324,26 +324,10 @@ def remove_all_credentials(self): pass def _save_persistence(self): - from .persistence import build_persistence - persistence = build_persistence(self._secret_file) - from msal_extensions import CrossPlatLock - with CrossPlatLock(self._lock_file): - persistence.save(json.dumps(self._service_principal_creds)) + self._secret_store.save(self._service_principal_creds) def _load_persistence(self): - from .persistence import build_persistence - persistence = build_persistence(self._secret_file) - from msal_extensions import CrossPlatLock - from msal_extensions.persistence import PersistenceNotFound - with CrossPlatLock(self._lock_file): - try: - self._service_principal_creds = json.loads(persistence.load()) - except PersistenceNotFound: - pass - except Exception as ex: - raise CLIError("Failed to load token files. If you can reproduce, please log an issue at " - "https://github.com/Azure/azure-cli/issues. At the same time, you can clean " - "up by running 'az account clear' and then 'az login'. (Inner Error: {})".format(ex)) + self._service_principal_creds = self._secret_store.load() def _serialize_secrets(self): # ONLY FOR DEBUGGING PURPOSE. DO NOT USE IN PRODUCTION CODE. diff --git a/src/azure-cli-core/azure/cli/core/auth/persistence.py b/src/azure-cli-core/azure/cli/core/auth/persistence.py index 1263229da29..fe0da23d72f 100644 --- a/src/azure-cli-core/azure/cli/core/auth/persistence.py +++ b/src/azure-cli-core/azure/cli/core/auth/persistence.py @@ -6,11 +6,15 @@ # This file is modified from # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/dev/sample/token_cache_sample.py +import json import logging import sys from msal_extensions import (FilePersistenceWithDataProtection, KeychainPersistence, LibsecretPersistence, - FilePersistence, PersistedTokenCache) + FilePersistence, PersistedTokenCache, CrossPlatLock) +from msal_extensions.persistence import PersistenceNotFound + +from knack.util import CLIError def load_persisted_token_cache(location, fallback_to_plaintext): @@ -18,6 +22,11 @@ def load_persisted_token_cache(location, fallback_to_plaintext): return PersistedTokenCache(persistence) +def load_secret_store(location, fallback_to_plaintext): + persistence = build_persistence(location, fallback_to_plaintext) + return SecretStore(persistence) + + def build_persistence(location, fallback_to_plaintext=False): """Build a suitable persistence instance based your current OS""" if sys.platform.startswith('win'): @@ -42,3 +51,24 @@ def build_persistence(location, fallback_to_plaintext=False): raise logging.exception("Encryption unavailable. Opting in to plain text.") return FilePersistence(location) + + +class SecretStore: + def __init__(self, persistence): + self._lock_file = persistence.get_location() + ".lockfile" + self._persistence = persistence + + def save(self, content): + with CrossPlatLock(self._lock_file): + self._persistence.save(json.dumps(content)) + + def load(self): + with CrossPlatLock(self._lock_file): + try: + return json.loads(self._persistence.load()) + except PersistenceNotFound: + return [] + except Exception as ex: + raise CLIError("Failed to load token files. If you can reproduce, please log an issue at " + "https://github.com/Azure/azure-cli/issues. At the same time, you can clean " + "up by running 'az account clear' and then 'az login'. (Inner Error: {})".format(ex)) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index e6180fff30d..c36e997a338 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -3,15 +3,12 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import json import os import unittest from unittest import mock -import msal_extensions.persistence -from azure.cli.core.auth.identity import Identity, ServicePrincipalAuth, MsalSecretStore +from azure.cli.core.auth.identity import Identity, ServicePrincipalAuth, ServicePrincipalStore from knack.util import CLIError -from msal_extensions import FilePersistence class TestIdentity(unittest.TestCase): @@ -56,10 +53,10 @@ def test_service_principal_auth_client_cert(self): class TestMsalSecretStore(unittest.TestCase): - @mock.patch('azure.cli.core.auth.persistence.build_persistence', autospec=True) - def test_load_service_principal_secret(self, build_persistence_mock): - persistence = MemoryPersistence() - build_persistence_mock.return_value = persistence + @mock.patch('azure.cli.core.auth.persistence.load_secret_store') + def test_load_credential(self, load_secret_store_mock): + store = MemoryStore() + load_secret_store_mock.return_value = store test_sp = { 'servicePrincipalId': 'myapp', @@ -67,16 +64,16 @@ def test_load_service_principal_secret(self, build_persistence_mock): 'secret': 'Secret' } - secret_store = MsalSecretStore(None) - persistence._content = [test_sp] + secret_store = ServicePrincipalStore(None) + store._content = [test_sp] entry = secret_store.load_credential("myapp", "mytenant") self.assertEqual(entry['secret'], "Secret") - @mock.patch('azure.cli.core.auth.persistence.build_persistence', autospec=True) - def test_save_service_principal_secret(self, build_persistence_mock): - test_file = os.path.join(os.path.dirname(__file__), "test.json") - build_persistence_mock.return_value = FilePersistence(test_file) + @mock.patch('azure.cli.core.auth.persistence.load_secret_store') + def test_save_credential(self, load_secret_store_mock): + store = MemoryStore() + load_secret_store_mock.return_value = store test_sp = { 'servicePrincipalId': 'myapp', @@ -84,20 +81,16 @@ def test_save_service_principal_secret(self, build_persistence_mock): 'secret': 'Secret' } - secret_store = MsalSecretStore(test_file) + secret_store = ServicePrincipalStore(None) secret_store.save_credential(test_sp) - with open(test_file, 'r') as f: - result = json.load(f) - assert result[0] == test_sp + assert store._content == [test_sp] - try: - os.remove(test_file) - except: - pass + @mock.patch('azure.cli.core.auth.persistence.load_secret_store') + def test_save_credential_add_new(self, load_secret_store_mock): + store = MemoryStore() + load_secret_store_mock.return_value = store - @mock.patch('azure.cli.core.auth.persistence.build_persistence', autospec=True) - def test_credscache_add_new_sp_creds(self, build_persistence_mock): test_sp = { "servicePrincipalId": "myapp", "servicePrincipalTenant": "mytenant", @@ -108,129 +101,52 @@ def test_credscache_add_new_sp_creds(self, build_persistence_mock): "servicePrincipalTenant": "mytenant2", "secret": "Secret2" } - mock_open_for_write1.return_value = None - mock_open_for_write2.return_value = None - mock_open_for_write3.return_value = None - mock_read_file1.return_value = json.dumps([test_sp]) - mock_read_file2.return_value = json.dumps([test_sp]) - mock_read_file3.return_value = json.dumps([test_sp]) - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() - - # action - creds_cache.save_credential(test_sp2) - - # assert - self.assertEqual(creds_cache._service_principal_creds, [test_sp, test_sp2]) - - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) - @mock.patch('msal_extensions.FilePersistence.save', autospec=True) - def test_credscache_add_preexisting_sp_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, - mock_read_file1, mock_read_file2, mock_read_file3): - test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "accessToken": "Secret" - } - mock_open_for_write1.return_value = None - mock_open_for_write2.return_value = None - mock_open_for_write3.return_value = None - mock_read_file1.return_value = json.dumps([test_sp]) - mock_read_file2.return_value = json.dumps([test_sp]) - mock_read_file3.return_value = json.dumps([test_sp]) - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() - - # action - creds_cache.save_credential(test_sp) - - # assert - self.assertEqual(creds_cache._service_principal_creds, [test_sp]) - - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) - @mock.patch('msal_extensions.FilePersistence.save', autospec=True) - def test_credscache_add_preexisting_sp_new_secret(self, mock_open_for_write1, mock_open_for_write2, - mock_open_for_write3, mock_read_file1, - mock_read_file2, mock_read_file3): + + store._content = [test_sp] + secret_store = ServicePrincipalStore(None) + secret_store.save_credential(test_sp2) + assert store._content == [test_sp, test_sp2] + + @mock.patch('azure.cli.core.auth.persistence.load_secret_store') + def test_save_credential_update_existing(self, load_secret_store_mock): + store = MemoryStore() + load_secret_store_mock.return_value = store + test_sp = { "servicePrincipalId": "myapp", "servicePrincipalTenant": "mytenant", "accessToken": "Secret" } - mock_open_for_write1.return_value = None - mock_open_for_write2.return_value = None - mock_open_for_write3.return_value = None - mock_read_file1.return_value = json.dumps([test_sp]) - mock_read_file2.return_value = json.dumps([test_sp]) - mock_read_file3.return_value = json.dumps([test_sp]) - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() + + store._content = [test_sp] new_creds = test_sp.copy() new_creds['accessToken'] = 'Secret2' - # action - creds_cache.save_credential(new_creds) - - # assert - self.assertEqual(creds_cache._service_principal_creds, [new_creds]) - - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.save', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.save', autospec=True) - @mock.patch('msal_extensions.FilePersistence.save', autospec=True) - def test_credscache_remove_creds(self, mock_open_for_write1, mock_open_for_write2, mock_open_for_write3, - mock_read_file1, mock_read_file2, mock_read_file3): + + secret_store = ServicePrincipalStore(None) + secret_store.save_credential(new_creds) + assert store._content == [new_creds] + + @mock.patch('azure.cli.core.auth.persistence.load_secret_store') + def test_remove_credential(self, load_secret_store_mock): + store = MemoryStore() + load_secret_store_mock.return_value = store + test_sp = { "servicePrincipalId": "myapp", "servicePrincipalTenant": "mytenant", "accessToken": "Secret" } - mock_open_for_write1.return_value = None - mock_open_for_write2.return_value = None - mock_open_for_write3.return_value = None - mock_read_file1.return_value = json.dumps([test_sp]) - mock_read_file2.return_value = json.dumps([test_sp]) - mock_read_file3.return_value = json.dumps([test_sp]) - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() - - # action logout a service principal - creds_cache.remove_credential('myapp') - - # assert - self.assertEqual(creds_cache._service_principal_creds, []) - - @mock.patch('msal_extensions.FilePersistenceWithDataProtection.load', autospec=True) - @mock.patch('msal_extensions.LibsecretPersistence.load', autospec=True) - @mock.patch('msal_extensions.FilePersistence.load', autospec=True) - def test_credscache_good_error_on_file_corruption(self, mock_read_file1, mock_read_file2, mock_read_file3): - mock_read_file1.side_effect = ValueError('a bad error for you') - mock_read_file2.side_effect = ValueError('a bad error for you') - mock_read_file3.side_effect = ValueError('a bad error for you') - from azure.cli.core._identity import MsalSecretStore - creds_cache = MsalSecretStore() + store._content = [test_sp] + secret_store = ServicePrincipalStore(None) + secret_store.remove_credential('myapp') + assert store._content == [] - # assert - with self.assertRaises(CLIError) as context: - creds_cache._load_persistence() - self.assertTrue(re.findall(r'bad error for you', str(context.exception))) - - -class MemoryPersistence(msal_extensions.persistence.BasePersistence): +class MemoryStore: def __init__(self): - self._content = None + self._content = [] def save(self, content): self._content = content @@ -238,12 +154,6 @@ def save(self, content): def load(self): return self._content - def time_last_modified(self): - pass - - def get_location(self): - pass - if __name__ == '__main__': unittest.main() diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py index 23c019ab7d2..2a553da4341 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py @@ -6,25 +6,11 @@ # pylint: disable=protected-access import unittest -from ..util import _extract_claims, scopes_to_resource, resource_to_scopes, _generate_login_command +from ..util import scopes_to_resource, resource_to_scopes, _generate_login_command class TestUtil(unittest.TestCase): - def test_extract_claims(self): - challenge = 'Bearer ' \ - 'authorization_uri="https://login.windows.net/54826b22-38d6-4fb2-bad9-b7b93a3e9c5a", ' \ - 'error="invalid_token", ' \ - 'error_description="User session has been revoked", ' \ - 'claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxODgyNjE0OSJ9fX0="' - expected = 'eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxODgyNjE0OSJ9fX0=' - result = _extract_claims(challenge) - assert expected == result - - # Multiple www-authenticate headers - result = _extract_claims(', '.join((challenge, challenge))) - assert result is None - def test_scopes_to_resource(self): # scopes as a list self.assertEqual(scopes_to_resource(['https://management.core.windows.net//.default']), diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index f47776bc6c3..e427f3a8254 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -17,7 +17,7 @@ def aad_error_handler(error, **kwargs): login_message = _generate_login_message(**kwargs) from azure.cli.core.azclierror import AuthenticationError - raise AuthenticationError(msg, recommendation=login_message) + raise AuthenticationError(msg, recommendation=login_message, msal_result=error) def _generate_login_command(scopes=None, claims=None): @@ -167,28 +167,3 @@ def decode_claims(claims: str): pass return claims - - -def handle_response_401_track1(response): - """Generate recommendation when ARM returns 401 to Track 1 SDK.""" - challenge = response.response.headers.get('WWW-Authenticate') - claims = _extract_claims(challenge) - - recommendation = ( - "The access token has expired or been revoked by Continuous Access Evaluation. " - "Silent re-authentication will be attempted in the future.\n{}") - login_message = _generate_login_message(claims=claims) - return recommendation.format(login_message) - - -def _extract_claims(challenge): - # Copied from azure.mgmt.core.policies._authentication._parse_claims_challenge - from azure.mgmt.core.policies._authentication import _parse_challenges - parsed_challenges = _parse_challenges(challenge) - if len(parsed_challenges) != 1 or "claims" not in parsed_challenges[0].parameters: - # no or multiple challenges, or no claims directive - return None - - encoded_claims = parsed_challenges[0].parameters["claims"] - padding_needed = -len(encoded_claims) % 4 - return encoded_claims + "=" * padding_needed diff --git a/src/azure-cli-core/azure/cli/core/cloud.py b/src/azure-cli-core/azure/cli/core/cloud.py index d61ea7c0be1..4a555a4fdf2 100644 --- a/src/azure-cli-core/azure/cli/core/cloud.py +++ b/src/azure-cli-core/azure/cli/core/cloud.py @@ -298,7 +298,7 @@ def from_json(cls, json_str): 'AzureCloud', endpoints=CloudEndpoints( management='https://management.core.windows.net/', - resource_manager='https://eastus2euap.management.azure.com/', + resource_manager='https://management.azure.com/', sql_management='https://management.core.windows.net:8443/', batch_resource_id='https://batch.core.windows.net/', gallery='https://gallery.azure.com/', diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index 5fbefa9c682..abf36741b70 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -34,12 +34,6 @@ BEARER = 'Bearer' -class PublicClientApplicationMock(mock.MagicMock): - - def get_accounts(self, username): - return [account for account in TestProfile.msal_accounts if account['username'] == username] - - class MockCredential: def __init__(self, *args, **kwargs): @@ -631,6 +625,61 @@ def test_find_subscriptions_in_vm_with_msi_user_assigned_with_res_id(self, creat self.assertEqual(s['user']['type'], 'servicePrincipal') self.assertEqual(subscriptions[0]['user']['assignedIdentityInfo'], 'MSIResource-{}'.format(test_res_id)) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_auth_code', autospec=True) + @mock.patch('azure.cli.core._profile.can_launch_browser', autospec=True, return_value=True) + def test_login_no_subscription(self, can_launch_browser_mock, + login_with_auth_code_mock, get_user_credential_mock, + create_subscription_client_mock): + user_identity_mock = { + 'username': self.user1, + 'tenantId': self.tenant_id + } + login_with_auth_code_mock.return_value = user_identity_mock + + cli = DummyCli() + mock_subscription_client = mock.MagicMock() + mock_subscription_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_subscription_client.subscriptions.list.return_value = [] + create_subscription_client_mock.return_value = mock_subscription_client + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock) + subs = profile.login(True, None, None, False, None, use_device_code=False, allow_no_subscriptions=True) + + self.assertEqual(1, len(subs)) + self.assertEqual(subs[0]['id'], self.tenant_id) + self.assertEqual(subs[0]['state'], 'Enabled') + self.assertEqual(subs[0]['tenantId'], self.tenant_id) + self.assertEqual(subs[0]['name'], 'N/A(tenant level account)') + self.assertTrue(profile.is_tenant_level_account()) + + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_auth_code', autospec=True) + @mock.patch('azure.cli.core._profile.can_launch_browser', autospec=True, return_value=True) + def test_login_no_tenant(self, can_launch_browser_mock, + login_with_auth_code_mock, get_user_credential_mock, + create_subscription_client_mock): + user_identity_mock = { + 'username': self.user1, + 'tenantId': self.tenant_id + } + login_with_auth_code_mock.return_value = user_identity_mock + + cli = DummyCli() + mock_subscription_client = mock.MagicMock() + mock_subscription_client.tenants.list.return_value = [] + mock_subscription_client.subscriptions.list.return_value = [] + create_subscription_client_mock.return_value = mock_subscription_client + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock) + subs = profile.login(True, None, None, False, None, use_device_code=False, allow_no_subscriptions=True) + + assert subs == [] + def test_normalize(self): cli = DummyCli() storage_mock = {'subscriptions': None} @@ -783,19 +832,6 @@ def test_get_subscription(self): self.assertEqual(sub_id, profile.get_subscription(subscription=sub_id)['id']) self.assertRaises(CLIError, profile.get_subscription, "random_id") - def test_get_auth_info_fail_on_user_account(self): - cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock) - - consolidated = profile._normalize_properties(self.user1, - [self.subscription1], - False) - profile._set_subscriptions(consolidated) - - # testing dump of existing logged in account - self.assertRaises(CLIError, profile.get_sp_auth_info) - @mock.patch('azure.cli.core.profiles.get_api_version', autospec=True) def test_subscription_finder_constructor(self, get_api_mock): cli = DummyCli() @@ -805,183 +841,17 @@ def test_subscription_finder_constructor(self, get_api_mock): result = finder._create_subscription_client(mock.MagicMock()) self.assertEqual(result._client._base_url, 'http://foo_arm') - @mock.patch('adal.AuthenticationContext', autospec=True) - def test_get_auth_info_for_logged_in_service_principal(self, mock_auth_context): - cli = DummyCli() - mock_auth_context.acquire_token_with_client_credentials.return_value = self.token_entry1 - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) - - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - profile._management_resource_uri = 'https://management.core.windows.net/' - profile.login(False, '1234', 'my-secret', True, self.tenant_id, use_device_code=False, - allow_no_subscriptions=False, subscription_finder=finder) - # action - extended_info = profile.get_sp_auth_info() - # assert - self.assertEqual(self.id1.split('/')[-1], extended_info['subscriptionId']) - self.assertEqual('1234', extended_info['clientId']) - self.assertEqual('my-secret', extended_info['clientSecret']) - self.assertEqual('https://login.microsoftonline.com', extended_info['activeDirectoryEndpointUrl']) - self.assertEqual('https://management.azure.com/', extended_info['resourceManagerEndpointUrl']) - - def test_get_auth_info_for_newly_created_service_principal(self): - cli = DummyCli() - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) - profile._set_subscriptions(consolidated) - # action - extended_info = profile.get_sp_auth_info(name='1234', cert_file='/tmp/123.pem') - # assert - self.assertEqual(self.id1.split('/')[-1], extended_info['subscriptionId']) - self.assertEqual(self.tenant_id, extended_info['tenantId']) - self.assertEqual('1234', extended_info['clientId']) - self.assertEqual('/tmp/123.pem', extended_info['clientCertificate']) - self.assertIsNone(extended_info.get('clientSecret', None)) - self.assertEqual('https://login.microsoftonline.com', extended_info['activeDirectoryEndpointUrl']) - self.assertEqual('https://management.azure.com/', extended_info['resourceManagerEndpointUrl']) - - @mock.patch('azure.cli.core.auth.identity.Identity.get_service_principal_credential', autospec=True) - @mock.patch('azure.cli.core.auth.identity.Identity.login_with_service_principal', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) - def test_create_account_without_subscriptions_thru_service_principal(self, create_subscription_client_mock, - login_with_service_principal_mock, - get_service_principal_credential_mock): - cli = DummyCli() - mock_subscription_client = mock.MagicMock() - mock_subscription_client.subscriptions.list.return_value = [] - create_subscription_client_mock.return_value = mock_subscription_client - - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock) - profile._management_resource_uri = 'https://management.core.windows.net/' - - # action - result = profile.login(False, - '1234', - 'my-secret', - True, - self.tenant_id, - use_device_code=False, - allow_no_subscriptions=True) - # assert - self.assertEqual(1, len(result)) - self.assertEqual(result[0]['id'], self.tenant_id) - self.assertEqual(result[0]['state'], 'Enabled') - self.assertEqual(result[0]['tenantId'], self.tenant_id) - self.assertEqual(result[0]['name'], 'N/A(tenant level account)') - self.assertTrue(profile.is_tenant_level_account()) - - @mock.patch('azure.cli.core.auth.identity.Identity.get_service_principal_credential', autospec=True) - @mock.patch('azure.cli.core.auth.identity.Identity.login_with_service_principal', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) - def test_create_account_with_subscriptions_allow_no_subscriptions_thru_service_principal( - self, create_subscription_client_mock, login_with_service_principal_mock, - get_service_principal_credential_mock): - """test subscription is returned even with --allow-no-subscriptions. """ - cli = DummyCli() - mock_subscription_client = mock.MagicMock() - mock_subscription_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - create_subscription_client_mock.return_value = mock_subscription_client - - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock) - - result = profile.login(False, - '1234', - 'my-secret', - True, - self.tenant_id, - use_device_code=False, - allow_no_subscriptions=True) - - self.assertEqual(1, len(result)) - self.assertEqual(result[0]['id'], self.id1.split('/')[-1]) - self.assertEqual(result[0]['state'], 'Enabled') - self.assertEqual(result[0]['tenantId'], self.tenant_id) - self.assertEqual(result[0]['name'], self.display_name1) - self.assertFalse(profile.is_tenant_level_account()) - - @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) - @mock.patch('azure.cli.core.auth.identity.Identity.login_with_username_password', autospec=True) - @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) - def test_create_account_without_subscriptions_thru_common_tenant(self, create_subscription_client_mock, - login_with_username_password_mock, - get_user_credential_mock): - - cli = DummyCli() - tenant_object = TenantIdDescription() - tenant_object.id = "foo-bar" - tenant_object.tenant_id = self.tenant_id - - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [] - mock_arm_client.tenants.list.return_value = [tenant_object] - create_subscription_client_mock.return_value = mock_arm_client - - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock) - - # action - result = profile.login(False, - '1234', - 'my-secret', - False, - None, - use_device_code=False, - allow_no_subscriptions=True) - - # assert - self.assertEqual(1, len(result)) - self.assertEqual(result[0]['id'], self.tenant_id) - self.assertEqual(result[0]['state'], 'Enabled') - self.assertEqual(result[0]['tenantId'], self.tenant_id) - self.assertEqual(result[0]['name'], 'N/A(tenant level account)') - - @mock.patch('azure.cli.core._identity.Identity.login_with_username_password', autospec=True) - def test_create_account_without_subscriptions_without_tenant(self, login_with_username_password): - cli = DummyCli() - from azure.identity import UsernamePasswordCredential - auth_profile = self.authentication_record - credential = UsernamePasswordCredential(self.client_id, '1234', 'my-secret') - login_with_username_password.return_value = [credential, auth_profile] - - mock_arm_client = mock.MagicMock() - mock_arm_client.subscriptions.list.return_value = [] - mock_arm_client.tenants.list.return_value = [] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) - storage_mock = {'subscriptions': []} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) - - # action - result = profile.login(False, - '1234', - 'my-secret', - False, - None, - use_device_code=False, - allow_no_subscriptions=True, - subscription_finder=finder) - - # assert - self.assertTrue(0 == len(result)) - def test_get_current_account_user(self): cli = DummyCli() storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, [self.subscription1], False) profile._set_subscriptions(consolidated) - # action user = profile.get_current_account_user() - # verify self.assertEqual(user, self.user1) @mock.patch('azure.cli.core.auth.identity.UserCredential', MockCredential) @@ -1380,21 +1250,24 @@ def test_acquire_token(self, resource, username, password, client_id): # assert self.assertEqual([self.subscription1], subs) - @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_refresh_accounts_one_user_account(self, app_mock, get_token_mock): + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + def test_refresh_accounts_one_user_account(self, get_user_credential_mock, create_subscription_client_mock): + mock_arm_client = mock.MagicMock() + mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] + mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] + create_subscription_client_mock.return_value = mock_arm_client + cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + storage_mock = {'subscriptions': []} + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False, None, None) profile._set_subscriptions(consolidated) - get_token_mock.return_value = self.access_token - mock_arm_client = mock.MagicMock() + mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] mock_arm_client.subscriptions.list.return_value = deepcopy([self.subscription1_raw, self.subscription2_raw]) - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) - # action - profile.refresh_accounts(finder) + + profile.refresh_accounts() # assert result = storage_mock['subscriptions'] @@ -1403,32 +1276,28 @@ def test_refresh_accounts_one_user_account(self, app_mock, get_token_mock): self.assertEqual(self.id2.split('/')[-1], result[1]['id']) self.assertTrue(result[0]['isDefault']) - @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) - @mock.patch('azure.identity.ClientSecretCredential.get_token', autospec=True) - @mock.patch('azure.cli.core._identity.MsalSecretStore.retrieve_secret_of_service_principal', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_refresh_accounts_one_user_account_one_sp_account(self, app_mock, retrieve_secret_of_service_principal, - get_token1, get_token2): + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_service_principal_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + def test_refresh_accounts_one_user_account_one_sp_account(self, get_user_credential_mock, + get_service_principal_credential_mock, + create_subscription_client_mock): cli = DummyCli() - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + storage_mock = {'subscriptions': []} + profile = Profile(cli_ctx=cli, storage=storage_mock) sp_subscription1 = SubscriptionStub('sp-sub/3', 'foo-subname', self.state1, 'footenant.onmicrosoft.com') consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False, None, None) consolidated += profile._normalize_properties('http://foo', [sp_subscription1], True) profile._set_subscriptions(consolidated) - retrieve_secret_of_service_principal.return_value = 'fake', 'fake' - get_token1.return_value = self.access_token - get_token2.return_value = self.access_token + mock_arm_client = mock.MagicMock() mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] mock_arm_client.subscriptions.list.side_effect = deepcopy( [[self.subscription1], [self.subscription2, sp_subscription1]]) - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) + create_subscription_client_mock.return_value = mock_arm_client - # action - profile.refresh_accounts(finder) + profile.refresh_accounts() - # assert result = storage_mock['subscriptions'] self.assertEqual(3, len(result)) self.assertEqual(self.id1.split('/')[-1], result[0]['id']) @@ -1436,67 +1305,66 @@ def test_refresh_accounts_one_user_account_one_sp_account(self, app_mock, retrie self.assertEqual('3', result[2]['id']) self.assertTrue(result[0]['isDefault']) - @mock.patch('azure.identity.UsernamePasswordCredential.get_token', autospec=True) - @mock.patch('msal.PublicClientApplication', new_callable=PublicClientApplicationMock) - def test_refresh_accounts_with_nothing(self, app_mock, get_token_mock): + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + def test_refresh_accounts_with_nothing(self, get_user_credential_mock, create_subscription_client_mock): cli = DummyCli() - get_token_mock.return_value = self.access_token - storage_mock = {'subscriptions': None} - profile = Profile(cli_ctx=cli, storage=storage_mock, use_global_creds_cache=False, async_persist=False) + storage_mock = {'subscriptions': []} + profile = Profile(cli_ctx=cli, storage=storage_mock) consolidated = profile._normalize_properties(self.user1, deepcopy([self.subscription1]), False, None, None) profile._set_subscriptions(consolidated) + mock_arm_client = mock.MagicMock() mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id)] mock_arm_client.subscriptions.list.return_value = [] - finder = SubscriptionFinder(cli, lambda _: mock_arm_client) - # action - profile.refresh_accounts(finder) + create_subscription_client_mock.return_value = mock_arm_client + + profile.refresh_accounts() # assert result = storage_mock['subscriptions'] self.assertEqual(0, len(result)) - @unittest.skip("todo: wait for identity support") - @mock.patch('adal.AuthenticationContext', autospec=True) - @mock.patch('azure.cli.core._profile._get_authorization_code', autospec=True) - def test_find_using_common_tenant_mfa_warning(self, _get_authorization_code_mock, mock_auth_context): + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + def test_login_common_tenant_mfa_warning(self, get_user_credential_mock, create_subscription_client_mock): # Assume 2 tenants. Home tenant tenant1 doesn't require MFA, but tenant2 does - # todo: @jiashuo - import adal cli = DummyCli() mock_arm_client = mock.MagicMock() tenant2_mfa_id = 'tenant2-0000-0000-0000-000000000000' mock_arm_client.tenants.list.return_value = [TenantStub(self.tenant_id), TenantStub(tenant2_mfa_id)] - mock_arm_client.subscriptions.list.return_value = [deepcopy(self.subscription1_raw)] - token_cache = adal.TokenCache() - finder = SubscriptionFinder(cli, lambda _, _1, _2: mock_auth_context, token_cache, lambda _: mock_arm_client) + create_subscription_client_mock.return_value = mock_arm_client - adal_error_mfa = adal.AdalError(error_msg="", error_response={ + finder = SubscriptionFinder(cli) + + from azure.cli.core.azclierror import AuthenticationError + error_description = ("AADSTS50076: Due to a configuration change made by your administrator, " + "or because you moved to a new location, you must use multi-factor " + "authentication to access '797f4846-ba00-4fd7-ba43-dac1f8f63013'.\n" + "Trace ID: 00000000-0000-0000-0000-000000000000\n" + "Correlation ID: 00000000-0000-0000-0000-000000000000\n" + "Timestamp: 2020-03-10 04:42:59Z") + msal_result = { 'error': 'interaction_required', - 'error_description': "AADSTS50076: Due to a configuration change made by your administrator, " - "or because you moved to a new location, you must use multi-factor " - "authentication to access '797f4846-ba00-4fd7-ba43-dac1f8f63013'.\n" - "Trace ID: 00000000-0000-0000-0000-000000000000\n" - "Correlation ID: 00000000-0000-0000-0000-000000000000\n" - "Timestamp: 2020-03-10 04:42:59Z", - 'error_codes': [50076], + 'error_description': error_description, + 'error_codes':[50076], 'timestamp': '2020-03-10 04:42:59Z', 'trace_id': '00000000-0000-0000-0000-000000000000', 'correlation_id': '00000000-0000-0000-0000-000000000000', 'error_uri': 'https://login.microsoftonline.com/error?code=50076', - 'suberror': 'basic_action'}) + 'suberror': 'basic_action' + } - # adal_error_mfa are raised on the second call - mock_auth_context.acquire_token.side_effect = [self.token_entry1, adal_error_mfa] + err = AuthenticationError(error_description, recommendation=None, msal_result=msal_result) - # action - all_subscriptions = finder.find_using_common_tenant(access_token="token1", - resource='https://management.core.windows.net/') + # MFA error raised on the second call + mock_arm_client.subscriptions.list.side_effect = [[deepcopy(self.subscription1_raw)], err] + + credential = mock.MagicMock() + all_subscriptions = finder.find_using_common_tenant(self.user1, credential) - # assert # subscriptions are correctly returned self.assertEqual(all_subscriptions, [self.subscription1]) - self.assertEqual(mock_auth_context.acquire_token.call_count, 2) # With pytest, use -o log_cli=True to manually check the log diff --git a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py index 58617d8ae3c..9d6579ebfc5 100644 --- a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py +++ b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py @@ -52,17 +52,6 @@ def _handle_load_cached_subscription(*args, **kwargs): # pylint: disable=unused "name": MOCKED_USER_NAME, "type": "user" } - }, - { - "id": AUX_SUBSCRIPTION, - "state": "Enabled", - "name": "Azure CLI Tests with TTL = 2 Days", - "tenantId": AUX_TENANT, - "isDefault": False, - "user": { - "name": MOCKED_USER_NAME, - "type": "user" - } } ] @@ -73,32 +62,21 @@ def _handle_load_cached_subscription(*args, **kwargs): # pylint: disable=unused def patch_retrieve_token_for_user(unit_test): - class PublicClientApplicationMock: + class UserCredentialMock: def __init__(self, *args, **kwargs): pass - def get_accounts(self, username): - return [{ - 'home_account_id': '182c0000-0000-0000-0000-000000000000.54820000-0000-0000-0000-000000000000', - 'environment': 'login.microsoftonline.com', - 'realm': 'organizations', - 'local_account_id': '182c0000-0000-0000-0000-000000000000', - 'username': MOCKED_USER_NAME, - 'authority_type': 'MSSTS' - }] - - def _mock_get_token(*args, **kwargs): # pylint: disable=unused-argument - from azure.core.credentials import AccessToken - import time - fake_raw_token = 'top-secret-token-for-you' - now = int(time.time()) - # Mock sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py:230 - return AccessToken(fake_raw_token, now + 3600) + def get_token(*args, **kwargs): # pylint: disable=unused-argument + from azure.core.credentials import AccessToken + import time + fake_raw_token = 'top-secret-token-for-you' + now = int(time.time()) + # Mock sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py:230 + return AccessToken(fake_raw_token, now + 3600) # Creating a PublicClientApplication will trigger an HTTP request to validate the tenant. Patch it! - mock_in_unit_test(unit_test, 'msal.PublicClientApplication', PublicClientApplicationMock) - mock_in_unit_test(unit_test, 'azure.identity.InteractiveBrowserCredential.get_token', _mock_get_token) + mock_in_unit_test(unit_test, 'azure.cli.core.auth.identity.UserCredential', UserCredentialMock) def patch_long_run_operation_delay(unit_test): From 7eb11e4219c87467c51b8227ce6db129d460fdce Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 7 Sep 2021 17:55:49 +0800 Subject: [PATCH 46/69] Refine --- src/azure-cli-core/azure/cli/core/_profile.py | 17 ++--- .../azure/cli/core/auth/__init__.py | 5 -- src/azure-cli-core/setup.py | 3 +- .../cli/command_modules/configure/_consts.py | 2 - .../cli/command_modules/configure/custom.py | 5 +- .../cli/command_modules/profile/custom.py | 10 +-- .../profile/tests/latest/test_auth_e2e.py | 66 +------------------ .../tests/latest/test_profile_custom.py | 43 +++--------- src/azure-cli/requirements.opt.py3.Linux.txt | 2 - src/azure-cli/requirements.opt.py3.Trusty.txt | 2 - src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- src/azure-cli/setup.py | 1 + 14 files changed, 24 insertions(+), 138 deletions(-) delete mode 100644 src/azure-cli/requirements.opt.py3.Linux.txt delete mode 100644 src/azure-cli/requirements.opt.py3.Trusty.txt diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 5e0dbb58b68..ff701b4866c 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -3,22 +3,19 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import collections - import os import os.path -import re from copy import deepcopy from enum import Enum -from knack.log import get_logger -from knack.util import CLIError from azure.cli.core._session import ACCOUNT -from azure.cli.core.util import in_cloud_console -from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription -from azure.cli.core.auth import (Identity, ServicePrincipalStore, AZURE_CLI_CLIENT_ID, - resource_to_scopes, can_launch_browser) +from azure.cli.core.auth.identity import Identity, AZURE_CLI_CLIENT_ID +from azure.cli.core.auth.util import resource_to_scopes, can_launch_browser from azure.cli.core.azclierror import AuthenticationError +from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription +from azure.cli.core.util import in_cloud_console +from knack.log import get_logger +from knack.util import CLIError logger = get_logger(__name__) @@ -400,7 +397,6 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No else: credential = self._create_credential(account, tenant) token = credential.get_token(*scopes) - import datetime # BREAKING CHANGE # expires_on = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") @@ -419,7 +415,6 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No def _normalize_properties(self, user, subscriptions, is_service_principal, cert_sn_issuer_auth=None, user_assigned_identity_id=None): - import sys consolidated = [] for s in subscriptions: subscription_dict = { diff --git a/src/azure-cli-core/azure/cli/core/auth/__init__.py b/src/azure-cli-core/azure/cli/core/auth/__init__.py index 9cb29339ea7..34913fb394d 100644 --- a/src/azure-cli-core/azure/cli/core/auth/__init__.py +++ b/src/azure-cli-core/azure/cli/core/auth/__init__.py @@ -2,8 +2,3 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- - -from .credential_adaptor import CredentialAdaptor -from .identity import Identity, ServicePrincipalStore, AZURE_CLI_CLIENT_ID -from .util import resource_to_scopes, aad_error_handler, can_launch_browser, \ - decode_access_token diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index a756dab723a..fb55fbb15eb 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -47,7 +47,7 @@ 'argcomplete~=1.8', 'azure-cli-telemetry==1.0.6.*', 'azure-common~=1.1', - 'azure-core==1.17.0', + 'azure-core==1.18.0', 'azure-mgmt-core==1.3.0b3', 'cryptography>=3.2,<3.4', 'humanfriendly>=4.7,<10.0', @@ -59,7 +59,6 @@ 'PyJWT>=2.1.0', 'pyopenssl>=17.1.0', # https://github.com/pyca/pyopenssl/pull/612 'requests[socks]~=2.25.1', - 'six~=1.12', 'urllib3[secure]>=1.26.5', ] diff --git a/src/azure-cli/azure/cli/command_modules/configure/_consts.py b/src/azure-cli/azure/cli/command_modules/configure/_consts.py index e23c53fa0e6..04ca4af91bc 100644 --- a/src/azure-cli/azure/cli/command_modules/configure/_consts.py +++ b/src/azure-cli/azure/cli/command_modules/configure/_consts.py @@ -49,5 +49,3 @@ MSG_PROMPT_FILE_LOGGING = '\nWould you like to enable logging to file?' MSG_PROMPT_CACHE_TTL = '\nCLI object cache time-to-live (TTL) in minutes [Default: {}]: '.format(DEFAULT_CACHE_TTL) - -MSG_PROMPT_ALLOW_PLAINTEXT = '\nWould you like to allow fallback to plaintext if encrypt credential fail?' diff --git a/src/azure-cli/azure/cli/command_modules/configure/custom.py b/src/azure-cli/azure/cli/command_modules/configure/custom.py index 8fd43744978..c201119492c 100644 --- a/src/azure-cli/azure/cli/command_modules/configure/custom.py +++ b/src/azure-cli/azure/cli/command_modules/configure/custom.py @@ -26,8 +26,7 @@ MSG_PROMPT_FILE_LOGGING, MSG_PROMPT_CACHE_TTL, WARNING_CLOUD_FORBID_TELEMETRY, - DEFAULT_CACHE_TTL, - MSG_PROMPT_ALLOW_PLAINTEXT) + DEFAULT_CACHE_TTL) from azure.cli.command_modules.configure._utils import get_default_from_config answers = {} @@ -135,14 +134,12 @@ def _handle_global_configuration(config, cloud_forbid_telemetry): except ValueError: logger.error('TTL must be a positive integer') cache_ttl = None - allow_fallback_to_plaintext = prompt_y_n(MSG_PROMPT_ALLOW_PLAINTEXT, default='y') # save the global config config.set_value('core', 'output', OUTPUT_LIST[output_index]['name']) config.set_value('core', 'collect_telemetry', 'yes' if allow_telemetry else 'no') config.set_value('core', 'cache_ttl', cache_ttl) config.set_value('logging', 'enable_log_file', 'yes' if enable_file_logging else 'no') - config.set_value('core', 'allow_fallback_to_plaintext', 'yes' if allow_fallback_to_plaintext else 'no') # pylint: disable=inconsistent-return-statements diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 7366b97ddfd..8abc9361024 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -74,18 +74,10 @@ def get_access_token(cmd, subscription=None, resource=None, scopes=None, resourc creds, subscription, tenant = profile.get_raw_token(subscription=subscription, resource=resource, scopes=scopes, tenant=tenant) - token_entry = creds[2] - # MSIAuthentication's token entry has `expires_on`, while ADAL's token entry has `expiresOn` - # Unify to ISO `expiresOn`, like "2020-06-30 06:14:41" - if 'expires_on' in token_entry: - # https://docs.python.org/3.8/library/datetime.html#strftime-and-strptime-format-codes - token_entry['expiresOn'] = _fromtimestamp(int(token_entry['expires_on']))\ - .strftime("%Y-%m-%d %H:%M:%S.%f") - result = { 'tokenType': creds[0], 'accessToken': creds[1], - 'expiresOn': creds[2].get('expiresOn', 'N/A'), + 'expiresOn': creds[2].get('expiresOn', None), 'tenant': tenant } if subscription: diff --git a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py index 6653e0256b2..efb135d7779 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py +++ b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_auth_e2e.py @@ -3,79 +3,15 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from time import sleep - +from azure.cli.core.auth.util import decode_access_token from azure.cli.core.azclierror import AuthenticationError from azure.cli.testsdk import LiveScenarioTest -from azure.cli.core.auth.util import decode_access_token -from msrestazure.azure_exceptions import CloudError ARM_URL = "https://eastus2euap.management.azure.com/" # ARM canary ARM_MAX_RETRY = 30 ARM_RETRY_INTERVAL = 10 -class CAEScenarioTest(LiveScenarioTest): - - def setUp(self): - super().setUp() - # Clear MSAL cache to avoid unexpected tokens from cache - self.cmd('az account clear') - - def _retry_until_error(self, cmd): - remaining_reties = ARM_MAX_RETRY - while remaining_reties > 0: - remaining_reties -= 1 - sleep(ARM_RETRY_INTERVAL) - self.cmd(cmd) - raise AssertionError("Retry chance exhausted.") - - def test_client_capabilities(self): - self.cmd('login') - - # Verify the access token has CAE enabled - result = self.cmd('account get-access-token').get_output_in_json() - access_token = result['accessToken'] - decoded = decode_access_token(access_token) - self.assertEqual(decoded['xms_cc'], ['CP1']) # xms_cc: extension microsoft client capabilities - self.assertEqual(decoded['xms_ssm'], '1') # xms_ssm: extension microsoft smart session management - - def test_revoke_session(self): - track2_cmd = "storage account list" - track1_cmd = "group list" - - self.test_client_capabilities() - - # Test access token is working - self.cmd(track2_cmd) - self.cmd(track1_cmd) - - self._revoke_sign_in_sessions() - - # CAE is currently only available in canary endpoint - # with mock.patch.object(self.cli_ctx.cloud.endpoints, "resource_manager", ARM_URL): - - # Keep trying until failure - - # Track 2 - with self.assertRaises(AuthenticationError) as cm: - self._retry_until_error(track2_cmd) - - assert 'AADSTS50173' in cm.exception.error_msg - assert 'az login --claims' in cm.exception.recommendations[0] - - # Track 1 - with self.assertRaises(CloudError) as cm: - self._retry_until_error(track1_cmd) - - self.assertEqual(cm.exception.status_code, 401) - self.assertIsNotNone(cm.exception.response.headers["WWW-Authenticate"]) - - def _revoke_sign_in_sessions(self): - # Manually revoke sign in sessions - self.cmd('rest -m POST -u https://graph.microsoft.com/v1.0/me/revokeSignInSessions') - - class ConditionalAccessScenarioTest(LiveScenarioTest): def setUp(self): diff --git a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py index 62de1ccef6a..7dd85ee40f5 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py @@ -35,10 +35,8 @@ def test_get_raw_token(self, get_raw_token_mock): cmd = mock.MagicMock() cmd.cli_ctx = DummyCli() - # arrange - get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': '2100-01-01'}], 'sub123', 'tenant123') + get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': 1593497681}], 'sub123', 'tenant123') - # action result = get_access_token(cmd) # assert @@ -46,7 +44,7 @@ def test_get_raw_token(self, get_raw_token_mock): expected_result = { 'tokenType': 'bearer', 'accessToken': 'token123', - 'expiresOn': '2100-01-01', + 'expiresOn': 1593497681, 'subscription': 'sub123', 'tenant': 'tenant123' } @@ -55,49 +53,28 @@ def test_get_raw_token(self, get_raw_token_mock): # assert it takes customized resource, subscription resource = 'https://graph.microsoft.com/' subscription_id = '00000001-0000-0000-0000-000000000000' - get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': '2100-01-01'}], subscription_id, + get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': 1593497681}], subscription_id, 'tenant123') result = get_access_token(cmd, subscription=subscription_id, resource=resource) get_raw_token_mock.assert_called_with(mock.ANY, resource, None, subscription_id, None) - expected_result = { - 'tokenType': 'bearer', - 'accessToken': 'token123', - 'expiresOn': '2100-01-01', - 'subscription': subscription_id, - 'tenant': 'tenant123' - } - self.assertEqual(result, expected_result) + + # assert it takes customized scopes + get_access_token(cmd, scopes='https://graph.microsoft.com/.default') + get_raw_token_mock.assert_called_with(mock.ANY, None, scopes='https://graph.microsoft.com/.default', + subscription=None, tenant=None) # test get token with tenant tenant_id = '00000000-0000-0000-0000-000000000000' - get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': '2100-01-01'}], None, tenant_id) + get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': 1593497681}], None, tenant_id) result = get_access_token(cmd, tenant=tenant_id) - get_raw_token_mock.assert_called_with(mock.ANY, None, None, None, tenant_id) expected_result = { 'tokenType': 'bearer', 'accessToken': 'token123', - 'expiresOn': '2100-01-01', # subscription shouldn't be present + 'expiresOn': 1593497681, 'tenant': tenant_id } self.assertEqual(result, expected_result) - - # test get token with Managed Identity. - # This test can only pass on a system that uses UTC as the time zone. Change your system's time zone - # before running this test. - get_raw_token_mock.return_value = (['bearer', 'token123', {'expires_on': '1593497681'}], None, tenant_id) - result = get_access_token(cmd, tenant=tenant_id) get_raw_token_mock.assert_called_with(mock.ANY, None, None, None, tenant_id) - expected_result = { - 'tokenType': 'bearer', - 'accessToken': 'token123', - 'expiresOn': '2020-06-30 06:14:41.000000', - 'tenant': tenant_id - } - self.assertEqual(result, expected_result) - - get_access_token(cmd, scopes='https://graph.microsoft.com/.default') - get_raw_token_mock.assert_called_with(mock.ANY, None, scopes='https://graph.microsoft.com/.default', - subscription=None, tenant=None) @mock.patch('azure.cli.command_modules.profile.custom.Profile', autospec=True) def test_get_login(self, profile_mock): diff --git a/src/azure-cli/requirements.opt.py3.Linux.txt b/src/azure-cli/requirements.opt.py3.Linux.txt deleted file mode 100644 index f5f65b31e84..00000000000 --- a/src/azure-cli/requirements.opt.py3.Linux.txt +++ /dev/null @@ -1,2 +0,0 @@ -pycairo==1.19.1 -PyGObject==3.36.1 diff --git a/src/azure-cli/requirements.opt.py3.Trusty.txt b/src/azure-cli/requirements.opt.py3.Trusty.txt deleted file mode 100644 index 319c82eead7..00000000000 --- a/src/azure-cli/requirements.opt.py3.Trusty.txt +++ /dev/null @@ -1,2 +0,0 @@ -pycairo==1.19.1 -PyGObject==3.12.0 diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index d14a339073a..a9cafe9ea41 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -9,7 +9,7 @@ azure-cli-core==2.28.0 azure-cli-telemetry==1.0.6 azure-cli==2.28.0 azure-common==1.1.22 -azure-core==1.17.0 +azure-core==1.18.0 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 6c1239b38ed..c948aae222e 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -9,7 +9,7 @@ azure-cli-core==2.28.0 azure-cli-telemetry==1.0.6 azure-cli==2.28.0 azure-common==1.1.22 -azure-core==1.17.0 +azure-core==1.18.0 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index c330c9c157d..24b80564631 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -9,7 +9,7 @@ azure-cli-core==2.28.0 azure-cli-telemetry==1.0.6 azure-cli==2.28.0 azure-common==1.1.22 -azure-core==1.17.0 +azure-core==1.18.0 azure-cosmos==3.2.0 azure-datalake-store==0.0.49 azure-functions-devops-build==0.0.22 diff --git a/src/azure-cli/setup.py b/src/azure-cli/setup.py index e2df1623096..c60746f1e9a 100644 --- a/src/azure-cli/setup.py +++ b/src/azure-cli/setup.py @@ -145,6 +145,7 @@ 'pytz==2019.1', 'scp~=0.13.2', 'semver==2.13.0', + 'six~=1.12', 'sshtunnel~=0.1.4', 'websocket-client~=0.56.0', 'xmltodict~=0.12' From 727442bed96ad1260a854ba9e04368a11315fbb2 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Thu, 9 Sep 2021 15:06:06 +0800 Subject: [PATCH 47/69] style --- src/azure-cli-core/azure/cli/core/_profile.py | 7 +- .../cli/core/auth/adal_authentication.py | 6 +- .../azure/cli/core/auth/credential_adaptor.py | 22 +----- .../azure/cli/core/auth/identity.py | 14 ++-- .../azure/cli/core/auth/persistence.py | 4 +- .../azure/cli/core/auth/util.py | 33 +-------- .../azure/cli/core/azclierror.py | 7 +- src/azure-cli-core/setup.py | 1 - .../azure/cli/command_modules/acs/custom.py | 13 +--- .../cli/command_modules/configure/custom.py | 48 ------------- .../keyvault/_client_factory.py | 29 ++------ .../cli/command_modules/profile/custom.py | 72 ++++--------------- src/azure-cli/requirements.py3.Darwin.txt | 1 - src/azure-cli/requirements.py3.Linux.txt | 1 - src/azure-cli/requirements.py3.windows.txt | 1 - 15 files changed, 38 insertions(+), 221 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index ff701b4866c..5111a6b2d5b 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -354,7 +354,7 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N external_credentials = [] for external_tenant in external_tenants: external_credentials.append(self._create_credential(account, external_tenant, client_id=client_id)) - from azure.cli.core.auth import CredentialAdaptor + from azure.cli.core.auth.credential_adaptor import CredentialAdaptor cred = CredentialAdaptor(credential, external_credentials=external_credentials, resource=resource) @@ -398,9 +398,6 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No credential = self._create_credential(account, tenant) token = credential.get_token(*scopes) - # BREAKING CHANGE - # expires_on = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") - token_entry = { 'accessToken': token.token, 'expiresOn': token.expires_on @@ -805,7 +802,7 @@ def _create_subscription_client(self, credential): .format(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS, self.cli_ctx.cloud.profile)) api_version = get_api_version(self.cli_ctx, ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS) client_kwargs = _prepare_mgmt_client_kwargs_track2(self.cli_ctx, credential) - # TODO: Support CAE + client = client_type(credential, api_version=api_version, base_url=self.cli_ctx.cloud.endpoints.resource_manager, **client_kwargs) diff --git a/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py index 227da740f6b..aa41e99a1bd 100644 --- a/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py @@ -4,12 +4,10 @@ # -------------------------------------------------------------------------------------------- import requests - -from msrestazure.azure_active_directory import MSIAuthentication -from azure.core.credentials import AccessToken from azure.cli.core.auth.util import try_scopes_to_resource - +from azure.core.credentials import AccessToken from knack.log import get_logger +from msrestazure.azure_active_directory import MSIAuthentication logger = get_logger(__name__) diff --git a/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py b/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py index 7b71d30c149..dc2fd6a3b0e 100644 --- a/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py +++ b/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py @@ -3,17 +3,11 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import json -from typing import Tuple, List - import requests -from azure.cli.core.util import in_cloud_console -from azure.core.credentials import AccessToken -from azure.identity import CredentialUnavailableError, AuthenticationRequiredError from knack.log import get_logger from knack.util import CLIError -from .util import resource_to_scopes, aad_error_handler +from .util import resource_to_scopes logger = get_logger(__name__) @@ -39,21 +33,9 @@ def _get_token(self, scopes=None, **kwargs): if self._external_credentials: external_tenant_tokens = [cred.get_token(*scopes) for cred in self._external_credentials] return token, external_tenant_tokens - except CLIError as err: - if in_cloud_console(): - CredentialAdaptor._log_hostname() - raise err - except AuthenticationRequiredError as err: - err_dict = json.loads(err.response.text()) - aad_error_handler(err_dict, scopes=err.scopes, claims=err.claims) - except CredentialUnavailableError as err: - err_dict = json.loads(err.response.text()) - aad_error_handler(err_dict) except requests.exceptions.SSLError as err: - from .util import SSLERROR_TEMPLATE + from azure.cli.core.util import SSLERROR_TEMPLATE raise CLIError(SSLERROR_TEMPLATE.format(str(err))) - except requests.exceptions.ConnectionError as err: - raise CLIError('Please ensure you have network connection. Error detail: ' + str(err)) def signed_session(self, session=None): logger.debug("CredentialAdaptor.get_token") diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 7ee1e88ef98..9bade182532 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -12,7 +12,7 @@ from knack.util import CLIError from .msal_authentication import UserCredential, ServicePrincipalCredential -from .util import aad_error_handler, resource_to_scopes, check_result +from .util import aad_error_handler, check_result AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' @@ -59,11 +59,11 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): self._msal_app_instance = None # Store for Service principal credential persistence - self._msal_secret_store = ServicePrincipalStore(self._secret_file, fallback_to_plaintext=self._fallback_to_plaintext) + self._msal_secret_store = ServicePrincipalStore(self._secret_file, + fallback_to_plaintext=self._fallback_to_plaintext) self._msal_app_kwargs = { "authority": self.msal_authority, - "token_cache": self._load_msal_cache(), - "client_capabilities": ["CP1"] + "token_cache": self._load_msal_cache() } # TODO: Allow disabling SSL verification @@ -172,7 +172,7 @@ def logout_service_principal(self, sp): # remove service principal secrets self._msal_secret_store.remove_credential(sp) - def logout_all_service_principal(self, sp): + def logout_all_service_principal(self): # remove service principal secrets self._msal_secret_store.remove_all_credentials() @@ -183,14 +183,14 @@ def get_user(self, user=None): def get_user_credential(self, username): return UserCredential(self.client_id, username, **self._msal_app_kwargs) - def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): + def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): # pylint: disable=unused-argument entry = self._msal_secret_store.load_credential(client_id, self.tenant_id) # TODO: support use_cert_sn_issuer in CertificateCredential sp_auth = ServicePrincipalAuth.build_from_entry(entry) return ServicePrincipalCredential(sp_auth, **self._msal_app_kwargs) def get_managed_identity_credential(self, client_id=None): - raise NotImplemented + raise NotImplementedError def serialize_token_cache(self, path=None): path = path or os.path.join(get_config_dir(), "msal.cache.snapshot.json") diff --git a/src/azure-cli-core/azure/cli/core/auth/persistence.py b/src/azure-cli-core/azure/cli/core/auth/persistence.py index fe0da23d72f..f9a9ca589d8 100644 --- a/src/azure-cli-core/azure/cli/core/auth/persistence.py +++ b/src/azure-cli-core/azure/cli/core/auth/persistence.py @@ -44,8 +44,8 @@ def build_persistence(location, fallback_to_plaintext=False): # a remote ssh session being active simultaneously. location, schema_name="my_schema_name", - attributes={"my_attr1": "foo", "my_attr2": "bar"}, - ) + attributes={"my_attr1": "foo", "my_attr2": "bar"} + ) except: # pylint: disable=bare-except if not fallback_to_plaintext: raise diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index e427f3a8254..faf90c3f35d 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -20,16 +20,11 @@ def aad_error_handler(error, **kwargs): raise AuthenticationError(msg, recommendation=login_message, msal_result=error) -def _generate_login_command(scopes=None, claims=None): +def _generate_login_command(scopes=None): login_command = ['az login'] - # Rejected by Continuous Access Evaluation, then by Conditional Access - if claims: - login_command.append('--claims {}'.format(encode_claims(claims))) - return 'az logout\n' + ' '.join(login_command) - # Rejected by Conditional Access policy, like MFA - elif scopes: + if scopes: login_command.append('--scope {}'.format(' '.join(scopes))) return ' '.join(login_command) @@ -143,27 +138,3 @@ def decode_access_token(access_token): # Access token consists of headers.claims.signature. Decode the claim part decoded_str = decode_part(access_token.split('.')[1]) return json.loads(decoded_str) - - -def encode_claims(claims: str): - import base64 - try: - base64.urlsafe_b64decode(claims) - is_base64 = True - except ValueError: - is_base64 = False - - if not is_base64: - claims = base64.urlsafe_b64encode(claims.encode()).decode() - - return claims - - -def decode_claims(claims: str): - import base64 - try: - claims = base64.urlsafe_b64decode(claims).decode() - except ValueError: - pass - - return claims diff --git a/src/azure-cli-core/azure/cli/core/azclierror.py b/src/azure-cli-core/azure/cli/core/azclierror.py index 4002682352e..82775cf4cef 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -170,12 +170,7 @@ class BadRequestError(UserFault): class UnauthorizedError(UserFault): """ Unauthorized request: 401 error """ - - def __init__(self, error_msg, recommendation=None, original_error=None): - - from azure.cli.core.auth.util import handle_response_401_track1 - super().__init__(error_msg, recommendation=handle_response_401_track1(original_error), - original_error=original_error) + pass class ForbiddenError(UserFault): diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index fb55fbb15eb..fc469a1696c 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -43,7 +43,6 @@ ] DEPENDENCIES = [ - 'adal~=1.2.7', 'argcomplete~=1.8', 'azure-cli-telemetry==1.0.6.*', 'azure-common~=1.1', diff --git a/src/azure-cli/azure/cli/command_modules/acs/custom.py b/src/azure-cli/azure/cli/command_modules/acs/custom.py index 89ae3c4e695..1a2027055c3 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/custom.py +++ b/src/azure-cli/azure/cli/command_modules/acs/custom.py @@ -3306,18 +3306,7 @@ def _get_command_context(command_files): def _get_dataplane_aad_token(cli_ctx, serverAppId): # this function is mostly copied from keyvault cli - import adal - try: - return Profile(cli_ctx=cli_ctx).get_raw_token(resource=serverAppId)[0][2].get('accessToken') - except adal.AdalError as err: - # pylint: disable=no-member - if (hasattr(err, 'error_response') and - ('error_description' in err.error_response) and - ('AADSTS70008:' in err.error_response['error_description'])): - raise CLIError( - "Credentials have expired due to inactivity. Please run 'az login'") - raise CLIError(err) - + return Profile(cli_ctx=cli_ctx).get_raw_token(resource=serverAppId)[0][2].get('accessToken') DEV_SPACES_EXTENSION_NAME = 'dev-spaces' DEV_SPACES_EXTENSION_MODULE = 'azext_dev_spaces.custom' diff --git a/src/azure-cli/azure/cli/command_modules/configure/custom.py b/src/azure-cli/azure/cli/command_modules/configure/custom.py index c201119492c..a669d3857ce 100644 --- a/src/azure-cli/azure/cli/command_modules/configure/custom.py +++ b/src/azure-cli/azure/cli/command_modules/configure/custom.py @@ -48,54 +48,6 @@ def _print_cur_configuration(file_config): print('\n'.join(['{} = {}'.format(ev, os.environ[ev]) for ev in env_vars])) -def _config_env_public_azure(cli_ctx, _): - from adal.adal_error import AdalError - from azure.cli.core.commands.client_factory import get_mgmt_service_client - from azure.cli.core._profile import Profile - from azure.cli.core.profiles import ResourceType - # Determine if user logged in - - try: - list(get_mgmt_service_client(cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES).resources.list()) - except CLIError: - # Not logged in - login_successful = False - while not login_successful: - method_index = prompt_choice_list(MSG_PROMPT_LOGIN, LOGIN_METHOD_LIST) - answers['login_index'] = method_index - answers['login_options'] = str(LOGIN_METHOD_LIST) - profile = Profile(cli_ctx=cli_ctx) - interactive = False - username = None - password = None - service_principal = None - tenant = None - if method_index == 0: # device auth - interactive = True - elif method_index == 1: # username and password - username = prompt('Username: ') - password = prompt_pass(msg='Password: ') - elif method_index == 2: # service principal with secret - service_principal = True - username = prompt('Service principal: ') - tenant = prompt('Tenant: ') - password = prompt_pass(msg='Client secret: ') - elif method_index == 3: # skip - return - try: - profile.login( - interactive, - username, - password, - service_principal, - tenant) - login_successful = True - logger.warning('Login successful!') - except AdalError as err: - logger.error('Login error!') - logger.error(err) - - def _handle_global_configuration(config, cloud_forbid_telemetry): # print location of global configuration print(MSG_GLOBAL_SETTINGS_LOCATION.format(config.config_path)) diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py b/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py index 15d19b6e820..3ae57e67a12 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py @@ -136,18 +136,8 @@ def keyvault_data_plane_factory(cli_ctx, *_): version = str(get_api_version(cli_ctx, ResourceType.DATA_KEYVAULT)) def get_token(server, resource, scope): # pylint: disable=unused-argument - import adal - try: - return Profile(cli_ctx=cli_ctx).get_raw_token(resource=resource, - subscription=cli_ctx.data.get('subscription_id'))[0] - except adal.AdalError as err: - # pylint: disable=no-member - if (hasattr(err, 'error_response') and - ('error_description' in err.error_response) and - ('AADSTS70008:' in err.error_response['error_description'])): - raise CLIError( - "Credentials have expired due to inactivity. Please run 'az login'") - raise CLIError(err) + return Profile(cli_ctx=cli_ctx).get_raw_token(resource=resource, + subscription=cli_ctx.data.get('subscription_id'))[0] client = KeyVaultClient(KeyVaultAuthentication(get_token), api_version=version) @@ -173,19 +163,8 @@ def keyvault_private_data_plane_factory_v7_2_preview(cli_ctx, _): version = str(get_api_version(cli_ctx, ResourceType.DATA_PRIVATE_KEYVAULT)) def get_token(server, resource, scope): # pylint: disable=unused-argument - import adal - try: - return Profile(cli_ctx=cli_ctx).get_raw_token(resource=resource, - subscription=cli_ctx.data.get('subscription_id'))[0] - except adal.AdalError as err: - # pylint: disable=no-member - if (hasattr(err, 'error_response') and - ('error_description' in err.error_response) and - ('AADSTS70008:' in err.error_response['error_description'])): - raise CLIError( - "Credentials have expired due to inactivity. Please run 'az login'") - raise CLIError(err) - + return Profile(cli_ctx=cli_ctx).get_raw_token(resource=resource, + subscription=cli_ctx.data.get('subscription_id'))[0] client = KeyVaultClient(KeyVaultAuthentication(get_token), api_version=version) # HACK, work around the fact that KeyVault library does't take confiuration object on constructor diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 8abc9361024..537201c2139 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -99,7 +99,7 @@ def account_clear(cmd, clear_credential=True): if in_cloud_console(): logger.warning(_CLOUD_CONSOLE_LOGOUT_WARNING) profile = Profile(cli_ctx=cmd.cli_ctx) - profile.logout_all(clear_credential) + profile.logout_all() # pylint: disable=inconsistent-return-statements, too-many-branches @@ -107,7 +107,6 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None identity=False, use_device_code=False, use_cert_sn_issuer=None, tenant_access=False, environment=False, scopes=None, claims_challenge=None): """Log in to access Azure subscriptions""" - from adal.adal_error import AdalError import requests # quick argument usage check @@ -146,50 +145,25 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None else: interactive = True - if environment: - return profile.login_with_environment_credential(find_subscriptions=not tenant_access) - - try: - subscriptions = profile.login( - interactive, - username, - password, - service_principal, - tenant, - scopes=scopes, - use_device_code=use_device_code, - allow_no_subscriptions=allow_no_subscriptions, - use_cert_sn_issuer=use_cert_sn_issuer, - find_subscriptions=not tenant_access, - claims_challenge=claims_challenge) - except AdalError as err: - # try polish unfriendly server errors - if username: - msg = str(err) - suggestion = "For cross-check, try 'az login' to authenticate through browser." - if ('ID3242:' in msg) or ('Server returned an unknown AccountType' in msg): - raise CLIError("The user name might be invalid. " + suggestion) - if 'Server returned error in RSTR - ErrorCode' in msg: - raise CLIError("Logging in through command line is not supported. " + suggestion) - if 'wstrust' in msg: - raise CLIError("Authentication failed due to error of '" + msg + "' " - "This typically happens when attempting a Microsoft account, which requires " - "interactive login. Please invoke 'az login' to cross check. " - # pylint: disable=line-too-long - "More details are available at https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication") - raise CLIError(err) - except requests.exceptions.SSLError as err: - from azure.cli.core.util import SSLERROR_TEMPLATE - raise CLIError(SSLERROR_TEMPLATE + " Error detail: {}".format(str(err))) - except requests.exceptions.ConnectionError as err: - raise CLIError('Please ensure you have network connection. Error detail: ' + str(err)) + subscriptions = profile.login( + interactive, + username, + password, + service_principal, + tenant, + scopes=scopes, + use_device_code=use_device_code, + allow_no_subscriptions=allow_no_subscriptions, + use_cert_sn_issuer=use_cert_sn_issuer, + find_subscriptions=not tenant_access, + claims_challenge=claims_challenge) all_subscriptions = list(subscriptions) for sub in all_subscriptions: sub['cloudName'] = sub.pop('environmentName', None) return all_subscriptions -def logout(cmd, username=None, clear_credential=True): +def logout(cmd, username=None): """Log out to remove access to Azure subscriptions""" if in_cloud_console(): logger.warning(_CLOUD_CONSOLE_LOGOUT_WARNING) @@ -197,7 +171,7 @@ def logout(cmd, username=None, clear_credential=True): profile = Profile(cli_ctx=cmd.cli_ctx) if not username: username = profile.get_current_account_user() - profile.logout(username, clear_credential) + profile.logout(username) def list_locations(cmd): @@ -205,12 +179,6 @@ def list_locations(cmd): return get_subscription_locations(cmd.cli_ctx) -def export_msal_cache(cmd, path=None): # pylint: disable=unused-argument - from azure.cli.core.auth import Identity - identity = Identity() - identity.serialize_token_cache(path) - - def check_cli(cmd): from azure.cli.core.file_util import ( create_invoker_and_load_cmds_and_args, get_all_help) @@ -241,13 +209,3 @@ def check_cli(cmd): print('CLI self-test completed: OK') else: raise CLIError(exceptions) - - -def _fromtimestamp(t): - # datetime.datetime can't be patched: - # TypeError: can't set attributes of built-in/extension type 'datetime.datetime' - # So we wrap datetime.datetime.fromtimestamp with this function. - # https://docs.python.org/3/library/unittest.mock-examples.html#partial-mocking - # https://williambert.online/2011/07/how-to-unit-testing-in-django-with-mocking-and-patching/ - from datetime import datetime - return datetime.fromtimestamp(t) diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index a9cafe9ea41..f3153afca25 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -1,4 +1,3 @@ -adal==1.2.7 antlr4-python3-runtime==4.7.2 applicationinsights==0.11.9 argcomplete==1.11.1 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index c948aae222e..108db2b5645 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -1,4 +1,3 @@ -adal==1.2.7 antlr4-python3-runtime==4.7.2 applicationinsights==0.11.9 argcomplete==1.11.1 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 24b80564631..ff77780657a 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -1,4 +1,3 @@ -adal==1.2.7 antlr4-python3-runtime==4.7.2 applicationinsights==0.11.7 argcomplete==1.11.1 From 3a39ff62102fff5f786d23b450f7c862bd77b21a Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Thu, 9 Sep 2021 16:57:29 +0800 Subject: [PATCH 48/69] show warning for fallback_to_plaintext --- src/azure-cli-core/azure/cli/core/_profile.py | 1 + .../azure/cli/core/auth/msal_authentication.py | 6 ++---- src/azure-cli-core/azure/cli/core/auth/persistence.py | 6 ++++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 5111a6b2d5b..f43c55c8713 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -308,6 +308,7 @@ def logout_all(self): identity = Identity(self._authority) identity.logout_all_users() + identity.logout_all_service_principal() def get_login_credentials(self, resource=None, client_id=None, subscription_id=None, aux_subscriptions=None, aux_tenants=None): diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index 2456086ecb6..bd458a0e3dd 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -45,10 +45,8 @@ def get_token(self, *scopes, **kwargs): # scopes = ['https://pas.windows.net/CheckMyAccess/Linux/.default'] logger.debug("UserCredential.get_token: scopes=%r, kwargs=%r", scopes, kwargs) - claims = kwargs.pop('claims', None) - result = self.acquire_token_silent_with_error(list(scopes), self.account, claims_challenge=claims, - **kwargs) - check_result(result, scopes=scopes, claims=claims) + result = self.acquire_token_silent_with_error(list(scopes), self.account, **kwargs) + check_result(result, scopes=scopes) return _build_sdk_access_token(result) diff --git a/src/azure-cli-core/azure/cli/core/auth/persistence.py b/src/azure-cli-core/azure/cli/core/auth/persistence.py index f9a9ca589d8..cd9a61cd443 100644 --- a/src/azure-cli-core/azure/cli/core/auth/persistence.py +++ b/src/azure-cli-core/azure/cli/core/auth/persistence.py @@ -7,7 +7,6 @@ # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/dev/sample/token_cache_sample.py import json -import logging import sys from msal_extensions import (FilePersistenceWithDataProtection, KeychainPersistence, LibsecretPersistence, @@ -15,6 +14,9 @@ from msal_extensions.persistence import PersistenceNotFound from knack.util import CLIError +from knack.log import get_logger + +logger = get_logger(__name__) def load_persisted_token_cache(location, fallback_to_plaintext): @@ -49,7 +51,7 @@ def build_persistence(location, fallback_to_plaintext=False): except: # pylint: disable=bare-except if not fallback_to_plaintext: raise - logging.exception("Encryption unavailable. Opting in to plain text.") + logger.exception("Encryption unavailable. Opting in to plain text.") return FilePersistence(location) From 84e5e972f25634cda51a4406d6193e8de3830859 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Thu, 23 Sep 2021 14:38:22 +0800 Subject: [PATCH 49/69] Force plaintext --- src/azure-cli-core/azure/cli/core/_profile.py | 22 +++---- .../azure/cli/core/auth/identity.py | 59 ++++++++----------- .../azure/cli/core/auth/persistence.py | 39 +++++------- .../cli/command_modules/profile/custom.py | 6 +- 4 files changed, 51 insertions(+), 75 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index f43c55c8713..b8dbae3623b 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -5,6 +5,7 @@ import os import os.path +import sys from copy import deepcopy from enum import Enum @@ -38,7 +39,6 @@ _CLOUD_SHELL_ID = 'cloudShellID' _SUBSCRIPTIONS = 'subscriptions' _INSTALLATION_ID = 'installationId' -_USE_MSAL_TOKEN_CACHE = 'useMsalTokenCache' _ENVIRONMENT_NAME = 'environmentName' _STATE = 'state' _USER_TYPE = 'type' @@ -123,6 +123,13 @@ def __init__(self, cli_ctx=None, storage=None): self._authority = self.cli_ctx.cloud.endpoints.active_directory self._arm_scope = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) + if sys.platform.startswith('win32'): + token_encryption_fallback = True + else: + token_encryption_fallback = False + Identity.token_encryption = self.cli_ctx.config.getboolean('core', 'token_encryption', + fallback=token_encryption_fallback) + # pylint: disable=too-many-branches,too-many-statements,too-many-locals def login(self, interactive, @@ -135,7 +142,6 @@ def login(self, use_device_code=False, allow_no_subscriptions=False, use_cert_sn_issuer=None, - find_subscriptions=True, **kwargs): if not scopes: @@ -144,10 +150,7 @@ def login(self, # For ADFS, auth_tenant is 'adfs' # https://github.com/Azure/azure-sdk-for-python/blob/661cd524e88f480c14220ed1f86de06aaff9a977/sdk/identity/azure-identity/CHANGELOG.md#L19 authority, auth_tenant = _detect_adfs_authority(self.cli_ctx.cloud.endpoints.active_directory, tenant) - identity = Identity(authority=authority, tenant_id=auth_tenant, - client_id=client_id, - allow_unencrypted=self.cli_ctx.config - .getboolean('core', 'allow_fallback_to_plaintext', fallback=True)) + identity = Identity(authority=authority, tenant_id=auth_tenant, client_id=client_id) user_identity = None if interactive: @@ -320,14 +323,8 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N :param aux_subscriptions: :param aux_tenants: """ - # Check if the token has been migrated to MSAL by checking "useMsalTokenCache": true - # If not yet, do it now. resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id - use_msal = self._storage.get(_USE_MSAL_TOKEN_CACHE) - if not use_msal: - self._storage[_USE_MSAL_TOKEN_CACHE] = True - if aux_tenants and aux_subscriptions: raise CLIError("Please specify only one of aux_subscriptions and aux_tenants, not both") @@ -505,7 +502,6 @@ def _match_account(account, subscription_id, secondary_key_name, secondary_key_v set_cloud_subscription(self.cli_ctx, active_cloud.name, default_sub_id) self._storage[_SUBSCRIPTIONS] = subscriptions - self._storage[_USE_MSAL_TOKEN_CACHE] = True @staticmethod def _pick_working_subscription(subscriptions): diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 9bade182532..e94edd1a182 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -40,6 +40,8 @@ class Identity: # pylint: disable=too-many-instance-attributes CLOUD_SHELL_IDENTITY_UNIQUE_NAME = "unique_name" + token_encryption = True + def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): """ @@ -53,14 +55,12 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): self.msal_authority = "{}/{}".format(self.authority, self.tenant_id) self.client_id = client_id or AZURE_CLI_CLIENT_ID - self._token_cache_file = os.path.join(get_config_dir(), "tokenCache.bin") - self._secret_file = os.path.join(get_config_dir(), "secrets.bin") - self._fallback_to_plaintext = kwargs.pop('fallback_to_plaintext', True) + self._token_cache_file = os.path.join(get_config_dir(), "tokenCache") + self._secret_file = os.path.join(get_config_dir(), "secrets") self._msal_app_instance = None # Store for Service principal credential persistence - self._msal_secret_store = ServicePrincipalStore(self._secret_file, - fallback_to_plaintext=self._fallback_to_plaintext) + self._msal_secret_store = ServicePrincipalStore(self._secret_file, self.token_encryption) self._msal_app_kwargs = { "authority": self.msal_authority, "token_cache": self._load_msal_cache() @@ -96,7 +96,7 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): def _load_msal_cache(self): from .persistence import load_persisted_token_cache # Store for user token persistence - cache = load_persisted_token_cache(self._token_cache_file, self._fallback_to_plaintext) + cache = load_persisted_token_cache(self._token_cache_file, self.token_encryption) cache._reload_if_necessary() # pylint: disable=protected-access return cache @@ -256,19 +256,18 @@ class ServicePrincipalStore: """Caches secrets in MSAL custom secret store for Service Principal authentication. """ - def __init__(self, secret_file=None, fallback_to_plaintext=True): + def __init__(self, secret_file, encrypt): from .persistence import load_secret_store - self._secret_store = load_secret_store(secret_file, fallback_to_plaintext) + self._secret_store = load_secret_store(secret_file, encrypt) self._secret_file = secret_file - self._service_principal_creds = [] - self._fallback_to_plaintext = fallback_to_plaintext + self._entries = [] def load_credential(self, sp_id, tenant): self._load_persistence() - matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] + matched = [x for x in self._entries if sp_id == x[_SERVICE_PRINCIPAL_ID]] if not matched: raise CLIError("Could not retrieve credential from local cache for service principal {}. " - "Please run 'az login' for this service principal." + "Please run `az login` for this service principal." .format(sp_id)) matched_with_tenant = [x for x in matched if tenant == x[_SERVICE_PRINCIPAL_TENANT]] if matched_with_tenant: @@ -283,36 +282,26 @@ def load_credential(self, sp_id, tenant): def save_credential(self, sp_entry): self._load_persistence() - matched = [x for x in self._service_principal_creds - if sp_entry[_SERVICE_PRINCIPAL_ID] == x[_SERVICE_PRINCIPAL_ID] and - sp_entry[_SERVICE_PRINCIPAL_TENANT] == x[_SERVICE_PRINCIPAL_TENANT]] - state_changed = False - if matched: - # pylint: disable=line-too-long - if (sp_entry.get(_ACCESS_TOKEN, None) != matched[0].get(_ACCESS_TOKEN, None) or - sp_entry.get(_SERVICE_PRINCIPAL_CERT_FILE, None) != matched[0].get(_SERVICE_PRINCIPAL_CERT_FILE, - None)): - self._service_principal_creds.remove(matched[0]) - self._service_principal_creds.append(sp_entry) - state_changed = True - else: - self._service_principal_creds.append(sp_entry) - state_changed = True - if state_changed: - self._save_persistence() + self._entries = [ + x for x in self._entries + if not (sp_entry[_SERVICE_PRINCIPAL_ID] == x[_SERVICE_PRINCIPAL_ID] and + sp_entry[_SERVICE_PRINCIPAL_TENANT] == x[_SERVICE_PRINCIPAL_TENANT])] + + self._entries.append(sp_entry) + self._save_persistence() def remove_credential(self, sp_id): self._load_persistence() state_changed = False # clear service principal creds - matched = [x for x in self._service_principal_creds + matched = [x for x in self._entries if x[_SERVICE_PRINCIPAL_ID] == sp_id] if matched: state_changed = True - self._service_principal_creds = [x for x in self._service_principal_creds - if x not in matched] + self._entries = [x for x in self._entries + if x not in matched] if state_changed: self._save_persistence() @@ -324,16 +313,16 @@ def remove_all_credentials(self): pass def _save_persistence(self): - self._secret_store.save(self._service_principal_creds) + self._secret_store.save(self._entries) def _load_persistence(self): - self._service_principal_creds = self._secret_store.load() + self._entries = self._secret_store.load() def _serialize_secrets(self): # ONLY FOR DEBUGGING PURPOSE. DO NOT USE IN PRODUCTION CODE. logger.warning("Secrets are serialized as plain text and saved to `msalSecrets.cache.json`.") with open(self._secret_file + ".json", "w") as fd: - fd.write(json.dumps(self._service_principal_creds, indent=4)) + fd.write(json.dumps(self._entries, indent=4)) def _read_response_templates(): diff --git a/src/azure-cli-core/azure/cli/core/auth/persistence.py b/src/azure-cli-core/azure/cli/core/auth/persistence.py index cd9a61cd443..1620abf892f 100644 --- a/src/azure-cli-core/azure/cli/core/auth/persistence.py +++ b/src/azure-cli-core/azure/cli/core/auth/persistence.py @@ -19,40 +19,33 @@ logger = get_logger(__name__) -def load_persisted_token_cache(location, fallback_to_plaintext): - persistence = build_persistence(location, fallback_to_plaintext) +def load_persisted_token_cache(location, encrypt): + persistence = build_persistence(location, encrypt) return PersistedTokenCache(persistence) -def load_secret_store(location, fallback_to_plaintext): - persistence = build_persistence(location, fallback_to_plaintext) +def load_secret_store(location, encrypt): + persistence = build_persistence(location, encrypt) return SecretStore(persistence) -def build_persistence(location, fallback_to_plaintext=False): +def build_persistence(location, encrypt): """Build a suitable persistence instance based your current OS""" - if sys.platform.startswith('win'): - return FilePersistenceWithDataProtection(location) - if sys.platform.startswith('darwin'): - return KeychainPersistence(location, "my_service_name", "my_account_name") - if sys.platform.startswith('linux'): - try: + if encrypt: + location += '.bin' + if sys.platform.startswith('win'): + return FilePersistenceWithDataProtection(location) + if sys.platform.startswith('darwin'): + return KeychainPersistence(location, "my_service_name", "my_account_name") + if sys.platform.startswith('linux'): return LibsecretPersistence( - # By using same location as the fall back option below, - # this would override the unencrypted data stored by the - # fall back option. It is probably OK, or even desirable - # (in order to aggressively wipe out plain-text persisted data), - # unless there would frequently be a desktop session and - # a remote ssh session being active simultaneously. location, schema_name="my_schema_name", attributes={"my_attr1": "foo", "my_attr2": "bar"} ) - except: # pylint: disable=bare-except - if not fallback_to_plaintext: - raise - logger.exception("Encryption unavailable. Opting in to plain text.") - return FilePersistence(location) + else: + location += '.json' + return FilePersistence(location) class SecretStore: @@ -62,7 +55,7 @@ def __init__(self, persistence): def save(self, content): with CrossPlatLock(self._lock_file): - self._persistence.save(json.dumps(content)) + self._persistence.save(json.dumps(content, indent=4)) def load(self): with CrossPlatLock(self._lock_file): diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 537201c2139..e494a737dca 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -94,7 +94,7 @@ def set_active_subscription(cmd, subscription): profile.set_active_subscription(subscription) -def account_clear(cmd, clear_credential=True): +def account_clear(cmd): """Clear all stored subscriptions. To clear individual, use 'logout'""" if in_cloud_console(): logger.warning(_CLOUD_CONSOLE_LOGOUT_WARNING) @@ -104,10 +104,9 @@ def account_clear(cmd, clear_credential=True): # pylint: disable=inconsistent-return-statements, too-many-branches def login(cmd, username=None, password=None, service_principal=None, tenant=None, allow_no_subscriptions=False, - identity=False, use_device_code=False, use_cert_sn_issuer=None, tenant_access=False, environment=False, + identity=False, use_device_code=False, use_cert_sn_issuer=None, environment=False, scopes=None, claims_challenge=None): """Log in to access Azure subscriptions""" - import requests # quick argument usage check if any([password, service_principal, tenant]) and identity: @@ -155,7 +154,6 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None use_device_code=use_device_code, allow_no_subscriptions=allow_no_subscriptions, use_cert_sn_issuer=use_cert_sn_issuer, - find_subscriptions=not tenant_access, claims_challenge=claims_challenge) all_subscriptions = list(subscriptions) for sub in all_subscriptions: From cf56dcf0de4574a4bdba883d29937337ef0095dd Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Thu, 23 Sep 2021 17:31:46 +0800 Subject: [PATCH 50/69] Federated token and SNI --- src/azure-cli-core/azure/cli/core/_profile.py | 9 +- .../azure/cli/core/auth/identity.py | 100 +++++++++--------- .../cli/core/auth/msal_authentication.py | 17 ++- .../cli/command_modules/profile/__init__.py | 1 + .../cli/command_modules/profile/custom.py | 17 +-- 5 files changed, 82 insertions(+), 62 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index b8dbae3623b..d105857db5c 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -143,7 +143,14 @@ def login(self, allow_no_subscriptions=False, use_cert_sn_issuer=None, **kwargs): - + """ + For service principal credential, specify `password` as a dict like below. Only one key can exist: + { + 'secret': 'my_secret', + 'certificate': '/path/to/cert.pem', + 'federated_token': 'my_token' + } + """ if not scopes: scopes = self._arm_scope diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index e94edd1a182..2cd4194ab7f 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -16,12 +16,13 @@ AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' -_SERVICE_PRINCIPAL_ID = 'servicePrincipalId' -_SERVICE_PRINCIPAL_TENANT = 'servicePrincipalTenant' -_ACCESS_TOKEN = 'accessToken' -_SERVICE_PRINCIPAL_SECRET = 'secret' -_SERVICE_PRINCIPAL_CERT_FILE = 'certificateFile' -_SERVICE_PRINCIPAL_CERT_THUMBPRINT = 'thumbprint' +# Service principal entry properties +_CLIENT_ID = 'client_id' +_TENANT_ID = 'tenant_id' +_SECRET = 'secret' +_CERTIFICATE = 'certificate' +_FEDERATED_TOKEN = 'federated_token' +_USE_CERT_SN_ISSUER = 'use_cert_sn_issuer' logger = get_logger(__name__) @@ -142,9 +143,8 @@ def login_with_username_password(self, username, password, scopes=None, **kwargs result = self.msal_app.acquire_token_by_username_password(username, password, scopes, **kwargs) return check_result(result) - def login_with_service_principal(self, client_id, secret_or_certificate, use_cert_sn_issuer=None, scopes=None): - sp_auth = ServicePrincipalAuth(self.tenant_id, client_id, - secret_or_certificate, use_cert_sn_issuer=use_cert_sn_issuer) + def login_with_service_principal(self, client_id, credential, scopes=None): + sp_auth = ServicePrincipalAuth.build_from_credential(self.tenant_id, client_id, credential) cred = ServicePrincipalCredential(sp_auth, **self._msal_app_kwargs) result = cred.acquire_token_for_client(scopes) check_result(result) @@ -185,8 +185,7 @@ def get_user_credential(self, username): def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): # pylint: disable=unused-argument entry = self._msal_secret_store.load_credential(client_id, self.tenant_id) - # TODO: support use_cert_sn_issuer in CertificateCredential - sp_auth = ServicePrincipalAuth.build_from_entry(entry) + sp_auth = ServicePrincipalAuth(entry) return ServicePrincipalCredential(sp_auth, **self._msal_app_kwargs) def get_managed_identity_credential(self, client_id=None): @@ -203,54 +202,57 @@ def serialize_token_cache(self, path=None): fd.write(cache.serialize()) -class ServicePrincipalAuth: # pylint: disable=too-few-public-methods +class ServicePrincipalAuth: - def __init__(self, tenant_id, client_id, password_arg_value, use_cert_sn_issuer=None): - if not password_arg_value: - raise CLIError('missing secret or certificate in order to ' - 'authenticate through a service principal') + def __init__(self, entry): + self.__dict__.update(entry) - self.client_id = client_id - self.tenant_id = tenant_id - - if os.path.isfile(password_arg_value): - certificate_file = password_arg_value + if _CERTIFICATE in entry: from OpenSSL.crypto import load_certificate, FILETYPE_PEM, Error - self.certificate_file = certificate_file self.public_certificate = None try: - with open(certificate_file, 'r') as file_reader: - self.cert_file_string = file_reader.read() - cert = load_certificate(FILETYPE_PEM, self.cert_file_string) + with open(self.certificate, 'r') as file_reader: + self.certificate_string = file_reader.read() + cert = load_certificate(FILETYPE_PEM, self.certificate_string) self.thumbprint = cert.digest("sha1").decode().replace(':', '') - if use_cert_sn_issuer: + if entry.get(_USE_CERT_SN_ISSUER): # low-tech but safe parsing based on # https://github.com/libressl-portable/openbsd/blob/master/src/lib/libcrypto/pem/pem.h - match = re.search(r'-+BEGIN CERTIFICATE.+-+(?P[^-]+)-+END CERTIFICATE.+-+', - self.cert_file_string, re.I) - self.public_certificate = match.group('public').strip() + match = re.search(r'-----BEGIN CERTIFICATE-----(?P[^-]+)-----END CERTIFICATE-----', + self.certificate_string, re.I) + self.public_certificate = match.group() except (UnicodeDecodeError, Error) as ex: raise CLIError('Invalid certificate, please use a valid PEM file. Error detail: {}'.format(ex)) - else: - self.secret = password_arg_value @classmethod - def build_from_entry(cls, entry): - return ServicePrincipalAuth(entry.get(_SERVICE_PRINCIPAL_TENANT), - entry.get(_SERVICE_PRINCIPAL_ID), - entry.get(_SERVICE_PRINCIPAL_SECRET) or entry.get(_SERVICE_PRINCIPAL_CERT_FILE)) - - def get_entry_to_persist(self): + def build_from_credential(cls, tenant_id, client_id, credential): entry = { - _SERVICE_PRINCIPAL_ID: self.client_id, - _SERVICE_PRINCIPAL_TENANT: self.tenant_id, + _CLIENT_ID: client_id, + _TENANT_ID: tenant_id } - if hasattr(self, 'secret'): - entry[_SERVICE_PRINCIPAL_SECRET] = self.secret - else: - entry[_SERVICE_PRINCIPAL_CERT_FILE] = self.certificate_file + entry.update(credential) + return ServicePrincipalAuth(entry) + + @classmethod + def build_credential(cls, secret_or_certificate=None, federated_token=None, use_cert_sn_issuer=None): + """Build credential from user input. + """ + entry = {} + if secret_or_certificate: + if os.path.isfile(secret_or_certificate): + entry[_CERTIFICATE] = secret_or_certificate + if use_cert_sn_issuer: + entry[_USE_CERT_SN_ISSUER] = use_cert_sn_issuer + else: + entry[_SECRET] = secret_or_certificate + elif federated_token: + entry[_FEDERATED_TOKEN] = federated_token return entry + def get_entry_to_persist(self): + persisted_keys = [_CLIENT_ID, _TENANT_ID, _SECRET, _CERTIFICATE, _USE_CERT_SN_ISSUER, _FEDERATED_TOKEN] + return {k: v for k, v in self.__dict__.items() if k in persisted_keys} + class ServicePrincipalStore: """Caches secrets in MSAL custom secret store for Service Principal authentication. @@ -264,18 +266,18 @@ def __init__(self, secret_file, encrypt): def load_credential(self, sp_id, tenant): self._load_persistence() - matched = [x for x in self._entries if sp_id == x[_SERVICE_PRINCIPAL_ID]] + matched = [x for x in self._entries if sp_id == x[_CLIENT_ID]] if not matched: raise CLIError("Could not retrieve credential from local cache for service principal {}. " "Please run `az login` for this service principal." .format(sp_id)) - matched_with_tenant = [x for x in matched if tenant == x[_SERVICE_PRINCIPAL_TENANT]] + matched_with_tenant = [x for x in matched if tenant == x[_TENANT_ID]] if matched_with_tenant: cred = matched_with_tenant[0] else: logger.warning("Could not retrieve credential from local cache for service principal %s under tenant %s. " "Trying credential under tenant %s, assuming that is an app credential.", - sp_id, tenant, matched[0][_SERVICE_PRINCIPAL_TENANT]) + sp_id, tenant, matched[0][_TENANT_ID]) cred = matched[0] return cred @@ -285,8 +287,8 @@ def save_credential(self, sp_entry): self._entries = [ x for x in self._entries - if not (sp_entry[_SERVICE_PRINCIPAL_ID] == x[_SERVICE_PRINCIPAL_ID] and - sp_entry[_SERVICE_PRINCIPAL_TENANT] == x[_SERVICE_PRINCIPAL_TENANT])] + if not (sp_entry[_CLIENT_ID] == x[_CLIENT_ID] and + sp_entry[_TENANT_ID] == x[_TENANT_ID])] self._entries.append(sp_entry) self._save_persistence() @@ -297,7 +299,7 @@ def remove_credential(self, sp_id): # clear service principal creds matched = [x for x in self._entries - if x[_SERVICE_PRINCIPAL_ID] == sp_id] + if x[_CLIENT_ID] == sp_id] if matched: state_changed = True self._entries = [x for x in self._entries diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index bd458a0e3dd..35b56d946c8 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -54,11 +54,20 @@ class ServicePrincipalCredential(ConfidentialClientApplication): def __init__(self, service_principal_auth, **kwargs): - if hasattr(service_principal_auth, 'secret'): + client_credential = None + if getattr(service_principal_auth, 'secret', None): client_credential = service_principal_auth.secret - else: - client_credential = {"private_key": service_principal_auth.cert_file_string, - "thumbprint": service_principal_auth.thumbprint} + + elif getattr(service_principal_auth, 'certificate', None): + client_credential = { + "private_key": service_principal_auth.certificate_string, + "thumbprint": service_principal_auth.thumbprint + } + if getattr(service_principal_auth, 'public_certificate', None): + client_credential['public_certificate'] = service_principal_auth.public_certificate + + elif getattr(service_principal_auth, 'federated_token', None): + client_credential = {"client_assertion": service_principal_auth.federated_token} super().__init__(service_principal_auth.client_id, client_credential=client_credential, **kwargs) diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index c45aaee8d21..f480f231c9e 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -58,6 +58,7 @@ def load_arguments(self, command): help="Use CLI's old authentication flow based on device code. CLI will also use this if it can't launch a browser in your behalf, e.g. in remote SSH or Cloud Shell") c.argument('use_cert_sn_issuer', action='store_true', help='used with a service principal configured with Subject Name and Issuer Authentication in order to support automatic certificate rolls') c.argument('scopes', options_list=['--scope'], nargs='+', help='Used in the /authorize request. It can cover only one static resource.') + c.argument('federated_token', help='Federated token that can be used for OIDC token exchange.') with self.argument_context('logout') as c: c.argument('username', help='account user, if missing, logout the current active account') diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index e494a737dca..ce5404cac80 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -3,6 +3,8 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import os + from knack.log import get_logger from knack.prompting import prompt_pass, NoTTYException from knack.util import CLIError @@ -105,7 +107,7 @@ def account_clear(cmd): # pylint: disable=inconsistent-return-statements, too-many-branches def login(cmd, username=None, password=None, service_principal=None, tenant=None, allow_no_subscriptions=False, identity=False, use_device_code=False, use_cert_sn_issuer=None, environment=False, - scopes=None, claims_challenge=None): + scopes=None, federated_token=None): """Log in to access Azure subscriptions""" # quick argument usage check @@ -120,10 +122,6 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None if service_principal and not username: raise CLIError('usage error: --service-principal --username NAME --password SECRET --tenant TENANT') - if claims_challenge: - from azure.cli.core.auth.util import decode_claims - claims_challenge = decode_claims(claims_challenge) - interactive = False profile = Profile(cli_ctx=cmd.cli_ctx) @@ -136,7 +134,7 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None logger.warning(_CLOUD_CONSOLE_LOGIN_WARNING) if username: - if not password: + if not (password or federated_token): try: password = prompt_pass('Password: ') except NoTTYException: @@ -144,6 +142,10 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None else: interactive = True + if service_principal: + from azure.cli.core.auth.identity import ServicePrincipalAuth + password = ServicePrincipalAuth.build_credential(password, federated_token, use_cert_sn_issuer) + subscriptions = profile.login( interactive, username, @@ -153,8 +155,7 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None scopes=scopes, use_device_code=use_device_code, allow_no_subscriptions=allow_no_subscriptions, - use_cert_sn_issuer=use_cert_sn_issuer, - claims_challenge=claims_challenge) + use_cert_sn_issuer=use_cert_sn_issuer) all_subscriptions = list(subscriptions) for sub in all_subscriptions: sub['cloudName'] = sub.pop('environmentName', None) From a294dd9aa9e64e09ffb262e60356987ed9522bf7 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 24 Sep 2021 13:40:46 +0800 Subject: [PATCH 51/69] remove unused code --- src/azure-cli-core/azure/cli/core/_profile.py | 9 +-- .../azure/cli/core/auth/identity.py | 73 +++++++------------ 2 files changed, 29 insertions(+), 53 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index d105857db5c..1fdab3557f8 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -123,10 +123,7 @@ def __init__(self, cli_ctx=None, storage=None): self._authority = self.cli_ctx.cloud.endpoints.active_directory self._arm_scope = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) - if sys.platform.startswith('win32'): - token_encryption_fallback = True - else: - token_encryption_fallback = False + token_encryption_fallback = sys.platform.startswith('win32') Identity.token_encryption = self.cli_ctx.config.getboolean('core', 'token_encryption', fallback=token_encryption_fallback) @@ -731,9 +728,7 @@ def find_using_common_tenant(self, username, credential=None): logger.info("Finding subscriptions under tenant %s", t.tenant_id_name) - identity = Identity(self.authority, tenant_id, - allow_unencrypted=self.cli_ctx.config - .getboolean('core', 'allow_fallback_to_plaintext', fallback=True)) + identity = Identity(self.authority, tenant_id) specific_tenant_credential = identity.get_user_credential(username) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 2cd4194ab7f..89eae00f119 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -28,27 +28,25 @@ class Identity: # pylint: disable=too-many-instance-attributes - """Class to interact with Azure Identity. + """Class to manage identities: + - user + - service principal + TODO: - managed identity """ - MANAGED_IDENTITY_TENANT_ID = "tenant_id" - MANAGED_IDENTITY_CLIENT_ID = "client_id" - MANAGED_IDENTITY_OBJECT_ID = "object_id" - MANAGED_IDENTITY_RESOURCE_ID = "resource_id" - MANAGED_IDENTITY_SYSTEM_ASSIGNED = 'systemAssignedIdentity' - MANAGED_IDENTITY_USER_ASSIGNED = 'userAssignedIdentity' - MANAGED_IDENTITY_TYPE = 'type' - MANAGED_IDENTITY_ID_TYPE = "id_type" - - CLOUD_SHELL_IDENTITY_UNIQUE_NAME = "unique_name" - + # Whether token and secrets should be encrypted. Set it to False to disable token encryption. token_encryption = True - def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): + # HTTP cache for MSAL's tenant discovery, retry-after error cache, etc. + # It must follow singleton pattern. Otherwise, a new dbm.dumb http_cache can read out-of-sync dat and dir. + # https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/407 + http_cache = None + + def __init__(self, authority=None, tenant_id=None, client_id=None): """ - :param authority: - :param tenant_id: - :param client_id::param kwargs: + :param authority: AAD endpoint, like https://login.microsoftonline.com/ + :param tenant_id: Tenant GUID, like 00000000-0000-0000-0000-000000000000 + :param client_id: Client ID of the CLI application. """ self.authority = authority self.tenant_id = tenant_id or "organizations" @@ -56,44 +54,27 @@ def __init__(self, authority=None, tenant_id=None, client_id=None, **kwargs): self.msal_authority = "{}/{}".format(self.authority, self.tenant_id) self.client_id = client_id or AZURE_CLI_CLIENT_ID - self._token_cache_file = os.path.join(get_config_dir(), "tokenCache") - self._secret_file = os.path.join(get_config_dir(), "secrets") + config_dir = get_config_dir() + self._token_cache_file = os.path.join(config_dir, "tokenCache") + self._secret_file = os.path.join(config_dir, "secrets") + self._http_cache_file = os.path.join(config_dir, "httpCache") + + # Prepare HTTP cache. + if not Identity.http_cache: + import atexit + import shelve + Identity.http_cache = persisted_http_cache = shelve.open(self._http_cache_file) + atexit.register(persisted_http_cache.close) self._msal_app_instance = None # Store for Service principal credential persistence self._msal_secret_store = ServicePrincipalStore(self._secret_file, self.token_encryption) self._msal_app_kwargs = { "authority": self.msal_authority, - "token_cache": self._load_msal_cache() + "token_cache": self._load_msal_cache(), + "http_cache": Identity.http_cache } - # TODO: Allow disabling SSL verification - # The underlying requests lib of MSAL has been patched with Azure Core by MsalTransportAdapter - # connection_verify will be received by azure.core.configuration.ConnectionConfiguration - # However, MSAL defaults verify to True, thus overriding ConnectionConfiguration - # Still not work yet - from azure.cli.core._debug import change_ssl_cert_verification_track2 - self._credential_kwargs = {} - self._credential_kwargs.update(change_ssl_cert_verification_track2()) - - # Turn on NetworkTraceLoggingPolicy to show DEBUG logs. - # WARNING: This argument is only for development purpose. It will make credentials be printed to - # - console log, when --debug is specified - # - file log, when logging.enable_log_file is enabled, even without --debug - # Credentials include and are not limited to: - # - Authorization code - # - Device code - # - Refresh token - # - Access token - # - Service principal secret - # - Service principal certificate - self._credential_kwargs['logging_enable'] = True - - # Make MSAL remove existing accounts on successful login. - # self._credential_kwargs['remove_existing_account'] = True - # from azure.cli.core._msal_patch import patch_token_cache_add - # patch_token_cache_add(self.msal_app.remove_account) - def _load_msal_cache(self): from .persistence import load_persisted_token_cache # Store for user token persistence From 882e70c8d09e306e4720becbd3a64a56772ba606 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 24 Sep 2021 14:28:12 +0800 Subject: [PATCH 52/69] fix auth tests --- src/azure-cli-core/azure/cli/core/_profile.py | 3 +- .../azure/cli/core/auth/identity.py | 14 ++-- .../cli/core/auth/tests/test_identity.py | 68 ++++++++++++------- .../azure/cli/core/auth/tests/test_util.py | 12 ---- 4 files changed, 52 insertions(+), 45 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 1fdab3557f8..a76d60b18cd 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -604,8 +604,7 @@ def _create_credential(self, account, tenant_id=None, client_id=None): # Service Principal if user_type == _SERVICE_PRINCIPAL: - use_cert_sn_issuer = account[_USER_ENTITY].get(_SERVICE_PRINCIPAL_CERT_SN_ISSUER_AUTH) - return identity.get_service_principal_credential(username_or_sp_id, use_cert_sn_issuer) + return identity.get_service_principal_credential(username_or_sp_id) raise NotImplementedError diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 89eae00f119..1bc8b8cb38b 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -61,10 +61,7 @@ def __init__(self, authority=None, tenant_id=None, client_id=None): # Prepare HTTP cache. if not Identity.http_cache: - import atexit - import shelve - Identity.http_cache = persisted_http_cache = shelve.open(self._http_cache_file) - atexit.register(persisted_http_cache.close) + Identity.http_cache = self._load_http_cache() self._msal_app_instance = None # Store for Service principal credential persistence @@ -82,6 +79,13 @@ def _load_msal_cache(self): cache._reload_if_necessary() # pylint: disable=protected-access return cache + def _load_http_cache(self): + import atexit + import shelve + http_cache = persisted_http_cache = shelve.open(self._http_cache_file) + atexit.register(persisted_http_cache.close) + return http_cache + def _build_persistent_msal_app(self): # Initialize _msal_app for logout, token migration which Azure Identity doesn't support from msal import PublicClientApplication @@ -164,7 +168,7 @@ def get_user(self, user=None): def get_user_credential(self, username): return UserCredential(self.client_id, username, **self._msal_app_kwargs) - def get_service_principal_credential(self, client_id, use_cert_sn_issuer=False): # pylint: disable=unused-argument + def get_service_principal_credential(self, client_id): entry = self._msal_secret_store.load_credential(client_id, self.tenant_id) sp_auth = ServicePrincipalAuth(entry) return ServicePrincipalCredential(sp_auth, **self._msal_app_kwargs) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index c36e997a338..a389bfe3320 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -20,36 +20,52 @@ def test_login_with_service_principal_certificate_cert_err(self): test_cert_file = os.path.join(current_dir, 'err_sp_cert.pem') with self.assertRaisesRegex(CLIError, "Invalid certificate"): - identity.login_with_service_principal("00000000-0000-0000-0000-000000000000", test_cert_file) + identity.login_with_service_principal("00000000-0000-0000-0000-000000000000", + {"certificate": test_cert_file}) class TestServicePrincipalAuth(unittest.TestCase): def test_service_principal_auth_client_secret(self): - sp_auth = ServicePrincipalAuth('tenant1', 'sp_id1', 'verySecret!') + sp_auth = ServicePrincipalAuth.build_from_credential('tenant1', 'sp_id1', {'secret': "test_secret"}) result = sp_auth.get_entry_to_persist() assert result == { - 'servicePrincipalId': 'sp_id1', - 'servicePrincipalTenant': 'tenant1', - 'secret': 'verySecret!' + 'client_id': 'sp_id1', + 'tenant_id': 'tenant1', + 'secret': 'test_secret' } def test_service_principal_auth_client_cert(self): curr_dir = os.path.dirname(os.path.realpath(__file__)) test_cert_file = os.path.join(curr_dir, 'sp_cert.pem') - sp_auth = ServicePrincipalAuth('tenant1', 'sp_id1', test_cert_file) + sp_auth = ServicePrincipalAuth.build_from_credential('tenant1', 'sp_id1', {'certificate': test_cert_file}) result = sp_auth.get_entry_to_persist() # To compute the thumb print: # openssl x509 -in sp_cert.pem -noout -fingerprint assert sp_auth.thumbprint == 'F06A53848BBE714A4290D69D335279C1D01073FD' assert result == { - 'servicePrincipalId': 'sp_id1', - 'servicePrincipalTenant': 'tenant1', - 'certificateFile': test_cert_file + 'client_id': 'sp_id1', + 'tenant_id': 'tenant1', + 'certificate': test_cert_file } + def test_build_credential(self): + # secret + cred = ServicePrincipalAuth.build_credential("test_secret") + assert cred == {"secret": "test_secret"} + + # certificate + current_dir = os.path.dirname(os.path.realpath(__file__)) + test_cert_file = os.path.join(current_dir, 'sp_cert.pem') + cred = ServicePrincipalAuth.build_credential(test_cert_file) + assert cred.get('certificate').endswith('sp_cert.pem') + + # federated token + cred = ServicePrincipalAuth.build_credential(federated_token="test_token") + assert cred == {"federated_token": "test_token"} + class TestMsalSecretStore(unittest.TestCase): @@ -59,12 +75,12 @@ def test_load_credential(self, load_secret_store_mock): load_secret_store_mock.return_value = store test_sp = { - 'servicePrincipalId': 'myapp', - 'servicePrincipalTenant': 'mytenant', + 'client_id': 'myapp', + 'tenant_id': 'mytenant', 'secret': 'Secret' } - secret_store = ServicePrincipalStore(None) + secret_store = ServicePrincipalStore(None, None) store._content = [test_sp] entry = secret_store.load_credential("myapp", "mytenant") @@ -76,12 +92,12 @@ def test_save_credential(self, load_secret_store_mock): load_secret_store_mock.return_value = store test_sp = { - 'servicePrincipalId': 'myapp', - 'servicePrincipalTenant': 'mytenant', + 'client_id': 'myapp', + 'tenant_id': 'mytenant', 'secret': 'Secret' } - secret_store = ServicePrincipalStore(None) + secret_store = ServicePrincipalStore(None, None) secret_store.save_credential(test_sp) assert store._content == [test_sp] @@ -92,18 +108,18 @@ def test_save_credential_add_new(self, load_secret_store_mock): load_secret_store_mock.return_value = store test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", + "client_id": "myapp", + "tenant_id": "mytenant", "secret": "Secret" } test_sp2 = { - "servicePrincipalId": "myapp2", - "servicePrincipalTenant": "mytenant2", + "client_id": "myapp2", + "tenant_id": "mytenant2", "secret": "Secret2" } store._content = [test_sp] - secret_store = ServicePrincipalStore(None) + secret_store = ServicePrincipalStore(None, None) secret_store.save_credential(test_sp2) assert store._content == [test_sp, test_sp2] @@ -113,8 +129,8 @@ def test_save_credential_update_existing(self, load_secret_store_mock): load_secret_store_mock.return_value = store test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", + "client_id": "myapp", + "tenant_id": "mytenant", "accessToken": "Secret" } @@ -122,7 +138,7 @@ def test_save_credential_update_existing(self, load_secret_store_mock): new_creds = test_sp.copy() new_creds['accessToken'] = 'Secret2' - secret_store = ServicePrincipalStore(None) + secret_store = ServicePrincipalStore(None, None) secret_store.save_credential(new_creds) assert store._content == [new_creds] @@ -132,13 +148,13 @@ def test_remove_credential(self, load_secret_store_mock): load_secret_store_mock.return_value = store test_sp = { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", + "client_id": "myapp", + "tenant_id": "mytenant", "accessToken": "Secret" } store._content = [test_sp] - secret_store = ServicePrincipalStore(None) + secret_store = ServicePrincipalStore(None, None) secret_store.remove_credential('myapp') assert store._content == [] diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py index 2a553da4341..9021ac2111e 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py @@ -54,18 +54,6 @@ def test_generate_login_command(self): # No parameter is given assert _generate_login_command() == 'az login' - base64_claims = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxNzE3MjE1NiJ9fX0=" - json_claims = '{"access_token":{"nbf":{"essential":true, "value":"1617172156"}}}' - expect = 'az logout\naz login --claims eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYxNzE3MjE1NiJ9fX0=' - - # Base64 string is preserved - actual = _generate_login_command(claims=base64_claims) - assert actual == expect - - # JSON string is converted to base64 - actual = _generate_login_command(claims=json_claims) - assert actual == expect - # scopes actual = _generate_login_command(scopes=["https://management.core.windows.net//.default"]) assert actual == 'az login --scope https://management.core.windows.net//.default' From d972e693106cef94adfeb1a3d7a9c67f5250f6e6 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 24 Sep 2021 15:01:25 +0800 Subject: [PATCH 53/69] turn off encryption by default --- .../azure/cli/core/auth/identity.py | 15 ++++++++++----- .../azure/cli/core/tests/test_util.py | 3 ++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 1bc8b8cb38b..3173e01da08 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -33,8 +33,8 @@ class Identity: # pylint: disable=too-many-instance-attributes - service principal TODO: - managed identity """ - # Whether token and secrets should be encrypted. Set it to False to disable token encryption. - token_encryption = True + # Whether token and secrets should be encrypted. Change its value to turn on/off token encryption. + token_encryption = False # HTTP cache for MSAL's tenant discovery, retry-after error cache, etc. # It must follow singleton pattern. Otherwise, a new dbm.dumb http_cache can read out-of-sync dat and dir. @@ -60,8 +60,8 @@ def __init__(self, authority=None, tenant_id=None, client_id=None): self._http_cache_file = os.path.join(config_dir, "httpCache") # Prepare HTTP cache. - if not Identity.http_cache: - Identity.http_cache = self._load_http_cache() + # if not Identity.http_cache: + # Identity.http_cache = self._load_http_cache() self._msal_app_instance = None # Store for Service principal credential persistence @@ -220,7 +220,12 @@ def build_from_credential(cls, tenant_id, client_id, credential): @classmethod def build_credential(cls, secret_or_certificate=None, federated_token=None, use_cert_sn_issuer=None): - """Build credential from user input. + """Build credential from user input. The credential looks like below, but only one key can exist. + { + "secret": "xxx", + "certificate": "/path/to/cert.pem", + "federated_token": "xxx" + } """ entry = {} if secret_or_certificate: diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 66aedfe2b8d..3a345e9cded 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -155,7 +155,8 @@ def test_open_page_in_browser(self, subprocess_open_mock, webbrowser_open_mock): platform = sys.platform.lower() open_page_in_browser('http://foo') if is_wsl(): - subprocess_open_mock.assert_called_once_with(['powershell.exe', '-Command', 'Start-Process "http://foo"']) + subprocess_open_mock.assert_called_once_with(['powershell.exe', '-NoProfile', '-Command', + 'Start-Process "http://foo"']) elif platform == 'darwin': subprocess_open_mock.assert_called_once_with(['open', 'http://foo']) else: From c075e7a51a5d49336ffae60ae8bdca3cd5ddc76e Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 24 Sep 2021 15:10:44 +0800 Subject: [PATCH 54/69] Fix webapp tests --- src/azure-cli-testsdk/azure/cli/testsdk/patches.py | 1 - .../latest/test_app_service_environment_commands_thru_mock.py | 3 +-- .../tests/latest/test_functionapp_commands_thru_mock.py | 3 +-- .../appservice/tests/latest/test_webapp_commands_thru_mock.py | 3 +-- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py index 9d6579ebfc5..b369350b2cd 100644 --- a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py +++ b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py @@ -7,7 +7,6 @@ from azure_devtools.scenario_tests.const import MOCKED_SUBSCRIPTION_ID, MOCKED_TENANT_ID from .exceptions import CliExecutionError -from .constants import AUX_SUBSCRIPTION, AUX_TENANT MOCKED_USER_NAME = 'example@example.com' diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py index 286e3141388..98ead035f6b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py @@ -14,7 +14,6 @@ from azure.mgmt.web import WebSiteManagementClient from azure.mgmt.web.models import HostingEnvironmentProfile from azure.mgmt.network.models import (Subnet, RouteTable, Route, NetworkSecurityGroup, SecurityRule, Delegation) -from azure.cli.core.auth import CredentialAdaptor from azure.cli.command_modules.appservice.appservice_environment import (show_appserviceenvironment, list_appserviceenvironments, @@ -30,7 +29,7 @@ def setUp(self): self.mock_logger = mock.MagicMock() self.mock_cmd = mock.MagicMock() self.mock_cmd.cli_ctx = mock.MagicMock() - self.client = WebSiteManagementClient(CredentialAdaptor(lambda: ('bearer', 'secretToken')), '123455678') + self.client = WebSiteManagementClient(mock.MagicMock(), '123455678') @mock.patch('azure.cli.command_modules.appservice.appservice_environment._get_ase_client_factory', autospec=True) def test_app_service_environment_show(self, ase_client_factory_mock): diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py index 5ae03927862..d3895939513 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py @@ -7,7 +7,6 @@ import os from azure.mgmt.web import WebSiteManagementClient -from azure.cli.core.auth import CredentialAdaptor from knack.util import CLIError from azure.cli.command_modules.appservice.custom import ( enable_zip_deploy_functionapp, @@ -34,7 +33,7 @@ def _get_test_cmd(): class TestFunctionappMocked(unittest.TestCase): def setUp(self): - self.client = WebSiteManagementClient(CredentialAdaptor(lambda: ('bearer', 'secretToken')), '123455678') + self.client = WebSiteManagementClient(mock.MagicMock(), '123455678') @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) @mock.patch('azure.cli.command_modules.appservice.custom.parse_resource_id') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index cb2df818f85..9ec7ff9781e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -8,7 +8,6 @@ from msrestazure.azure_exceptions import CloudError from azure.mgmt.web import WebSiteManagementClient -from azure.cli.core.auth import CredentialAdaptor from knack.util import CLIError from azure.cli.command_modules.appservice.custom import (set_deployment_user, update_git_token, add_hostname, @@ -46,7 +45,7 @@ def _get_test_cmd(): class TestWebappMocked(unittest.TestCase): def setUp(self): - self.client = WebSiteManagementClient(CredentialAdaptor(lambda: ('bearer', 'secretToken')), '123455678') + self.client = WebSiteManagementClient(mock.MagicMock(), '123455678') @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) def test_set_deployment_user_creds(self, client_factory_mock): From 63ba90ae819d391a757ea628663d289741bf0c04 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 24 Sep 2021 15:45:00 +0800 Subject: [PATCH 55/69] Fix linter --- .../cli/command_modules/configure/_consts.py | 8 -------- .../cli/command_modules/configure/custom.py | 5 ++--- .../cli/command_modules/profile/__init__.py | 3 ++- .../azure/cli/command_modules/profile/_help.py | 17 ----------------- .../azure/cli/command_modules/profile/custom.py | 7 +------ 5 files changed, 5 insertions(+), 35 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/configure/_consts.py b/src/azure-cli/azure/cli/command_modules/configure/_consts.py index 04ca4af91bc..aea5fed11a1 100644 --- a/src/azure-cli/azure/cli/command_modules/configure/_consts.py +++ b/src/azure-cli/azure/cli/command_modules/configure/_consts.py @@ -16,13 +16,6 @@ {'name': 'none', 'desc': 'No output, except for errors and warnings.'} ] -LOGIN_METHOD_LIST = [ - 'Device code authentication, we will provide a code you enter into a web page and log into', - "Username and password (MFA enforced accounts or MSA accounts such as live-id not supported)", - 'Service Principal with secret', - 'Skip this step (login is available with the \'az login\' command)' -] - MSG_INTRO = '\nWelcome to the Azure CLI! This command will guide you through logging in and ' \ 'setting some default values.\n' MSG_CLOSING = '\nYou\'re all set! Here are some commands to try:\n' \ @@ -40,7 +33,6 @@ MSG_PROMPT_MANAGE_GLOBAL = '\nDo you wish to change your settings?' MSG_PROMPT_GLOBAL_OUTPUT = '\nWhat default output format would you like?' -MSG_PROMPT_LOGIN = '\nHow would you like to log in to access your subscriptions?' MSG_PROMPT_TELEMETRY = '\nMicrosoft would like to collect anonymous Azure CLI usage data to ' \ 'improve our CLI. Participation is voluntary and when you choose to ' \ 'participate, your device automatically sends information to Microsoft ' \ diff --git a/src/azure-cli/azure/cli/command_modules/configure/custom.py b/src/azure-cli/azure/cli/command_modules/configure/custom.py index a669d3857ce..b6a9b6aa38f 100644 --- a/src/azure-cli/azure/cli/command_modules/configure/custom.py +++ b/src/azure-cli/azure/cli/command_modules/configure/custom.py @@ -8,12 +8,12 @@ import configparser from knack.log import get_logger -from knack.prompting import prompt, prompt_y_n, prompt_choice_list, prompt_pass, NoTTYException +from knack.prompting import prompt, prompt_y_n, prompt_choice_list, NoTTYException from knack.util import CLIError from azure.cli.core.util import ConfiguredDefaultSetter -from azure.cli.command_modules.configure._consts import (OUTPUT_LIST, LOGIN_METHOD_LIST, +from azure.cli.command_modules.configure._consts import (OUTPUT_LIST, MSG_INTRO, MSG_CLOSING, MSG_GLOBAL_SETTINGS_LOCATION, @@ -21,7 +21,6 @@ MSG_HEADING_ENV_VARS, MSG_PROMPT_MANAGE_GLOBAL, MSG_PROMPT_GLOBAL_OUTPUT, - MSG_PROMPT_LOGIN, MSG_PROMPT_TELEMETRY, MSG_PROMPT_FILE_LOGGING, MSG_PROMPT_CACHE_TTL, diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index f480f231c9e..2ad96848941 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -78,7 +78,8 @@ def load_arguments(self, command): with self.argument_context('account get-access-token') as c: c.argument('resource_type', get_enum_type(cloud_resource_types), options_list=['--resource-type'], arg_group='', help='Type of well-known resource.') - c.argument('scopes', options_list=['--scope'], nargs='*', arg_group='MSAL', help='Space-separated AAD scopes in AAD v2.0.') + c.argument('resource', options_list=['--resource'], help='Azure resource endpoints in AAD v1.0.') + c.argument('scopes', options_list=['--scope'], nargs='*', help='Space-separated AAD scopes in AAD v2.0. Default to Azure Resource Manager.') c.argument('tenant', options_list=['--tenant', '-t'], help='Tenant ID for which the token is acquired. Only available for user and service principal account, not for MSI or Cloud Shell account') diff --git a/src/azure-cli/azure/cli/command_modules/profile/_help.py b/src/azure-cli/azure/cli/command_modules/profile/_help.py index dbd20caa9e1..779fe627797 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/_help.py +++ b/src/azure-cli/azure/cli/command_modules/profile/_help.py @@ -96,23 +96,6 @@ az account get-access-token --resource-type ms-graph """ -helps['account export-msal-cache'] = """ -type: command -short-summary: Export MSAL cache in plain text. -long-summary: > - By default export to '~/.azure/msal.cache.snapshot.json'. - The exported cache is unencrypted. - It contains login information of all logged-in users. Make sure you protect it safely. - - You can mount the exported MSAL cache to a container at '~/.IdentityService/msal.cache', so that Azure CLI - inside the container can automatically authenticate. -examples: - - name: Export MSAL cache to the default path. - text: az account export-msal-cache - - name: Export MSAL cache to a custom path. - text: az account export-msal-cache --path ~/msal_cache.json -""" - helps['self-test'] = """ type: command short-summary: Runs a self-test of the CLI. diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index ce5404cac80..3f3b493c241 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -3,8 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import os - from knack.log import get_logger from knack.prompting import prompt_pass, NoTTYException from knack.util import CLIError @@ -106,8 +104,7 @@ def account_clear(cmd): # pylint: disable=inconsistent-return-statements, too-many-branches def login(cmd, username=None, password=None, service_principal=None, tenant=None, allow_no_subscriptions=False, - identity=False, use_device_code=False, use_cert_sn_issuer=None, environment=False, - scopes=None, federated_token=None): + identity=False, use_device_code=False, use_cert_sn_issuer=None, scopes=None, federated_token=None): """Log in to access Azure subscriptions""" # quick argument usage check @@ -115,8 +112,6 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None raise CLIError("usage error: '--identity' is not applicable with other arguments") if any([password, service_principal, username, identity]) and use_device_code: raise CLIError("usage error: '--use-device-code' is not applicable with other arguments") - if any([password, service_principal, username, identity, use_device_code]) and environment: - raise CLIError("usage error: '--environment' is not applicable with other arguments") if use_cert_sn_issuer and not service_principal: raise CLIError("usage error: '--use-sn-issuer' is only applicable with a service principal") if service_principal and not username: From be7d7b4698cf7108d22bdfd13fc7c420d4c5572f Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 24 Sep 2021 16:10:43 +0800 Subject: [PATCH 56/69] azure-mgmt-core==1.2.1 --- src/azure-cli-core/azure/cli/core/commands/client_factory.py | 4 ++-- src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/client_factory.py b/src/azure-cli-core/azure/cli/core/commands/client_factory.py index 39582f80915..81720e6a108 100644 --- a/src/azure-cli-core/azure/cli/core/commands/client_factory.py +++ b/src/azure-cli-core/azure/cli/core/commands/client_factory.py @@ -168,11 +168,11 @@ def _prepare_mgmt_client_kwargs_track2(cli_ctx, cred): client_kwargs = _prepare_client_kwargs_track2(cli_ctx) # Enable CAE support in mgmt SDK - from azure.mgmt.core.policies import ARMChallengeAuthenticationPolicy + from azure.core.pipeline.policies import BearerTokenCredentialPolicy # Track 2 SDK maintains `scopes` and passes `scopes` to get_token. scopes = resource_to_scopes(cli_ctx.cloud.endpoints.active_directory_resource_id) - policy = ARMChallengeAuthenticationPolicy(cred, *scopes) + policy = BearerTokenCredentialPolicy(cred, *scopes) client_kwargs['credential_scopes'] = scopes client_kwargs['authentication_policy'] = policy diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 027b02cd622..9cbb64ddf15 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -34,7 +34,7 @@ azure-mgmt-consumption==2.0.0 azure-mgmt-containerinstance==8.0.0 azure-mgmt-containerregistry==8.1.0 azure-mgmt-containerservice==16.1.0 -azure-mgmt-core==1.3.0b3 +azure-mgmt-core==1.2.1 azure-mgmt-cosmosdb==6.4.0 azure-mgmt-databoxedge==1.0.0 azure-mgmt-datalake-analytics==0.2.1 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 53682ecc4da..e2a0ea7d3dc 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -34,7 +34,7 @@ azure-mgmt-consumption==2.0.0 azure-mgmt-containerinstance==8.0.0 azure-mgmt-containerregistry==8.1.0 azure-mgmt-containerservice==16.1.0 -azure-mgmt-core==1.3.0b3 +azure-mgmt-core==1.2.1 azure-mgmt-cosmosdb==6.4.0 azure-mgmt-databoxedge==1.0.0 azure-mgmt-datalake-analytics==0.2.1 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 0d55abaec68..edc1b7cd55c 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -34,7 +34,7 @@ azure-mgmt-consumption==2.0.0 azure-mgmt-containerinstance==8.0.0 azure-mgmt-containerregistry==8.1.0 azure-mgmt-containerservice==16.1.0 -azure-mgmt-core==1.3.0b3 +azure-mgmt-core==1.2.1 azure-mgmt-cosmosdb==6.4.0 azure-mgmt-databoxedge==1.0.0 azure-mgmt-datalake-analytics==0.2.1 From f23a322e0daa3a1ee5183836237a97fccb679381 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 24 Sep 2021 17:59:45 +0800 Subject: [PATCH 57/69] Disable http_cache --- src/azure-cli-core/azure/cli/core/auth/identity.py | 7 +++---- .../auth/{auth_landing_pages => landing_pages}/error.html | 0 .../{auth_landing_pages => landing_pages}/success.html | 0 src/azure-cli-core/setup.py | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) rename src/azure-cli-core/azure/cli/core/auth/{auth_landing_pages => landing_pages}/error.html (100%) rename src/azure-cli-core/azure/cli/core/auth/{auth_landing_pages => landing_pages}/success.html (100%) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 3173e01da08..030f70ea339 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -68,8 +68,7 @@ def __init__(self, authority=None, tenant_id=None, client_id=None): self._msal_secret_store = ServicePrincipalStore(self._secret_file, self.token_encryption) self._msal_app_kwargs = { "authority": self.msal_authority, - "token_cache": self._load_msal_cache(), - "http_cache": Identity.http_cache + "token_cache": self._load_msal_cache() } def _load_msal_cache(self): @@ -319,11 +318,11 @@ def _serialize_secrets(self): def _read_response_templates(): """Read from success.html and error.html to strings and pass them to MSAL. """ - success_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'auth_landing_pages', 'success.html') + success_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'landing_pages', 'success.html') with open(success_file) as f: success_template = f.read() - error_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'auth_landing_pages', 'error.html') + error_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'landing_pages', 'error.html') with open(error_file) as f: error_template = f.read() diff --git a/src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/error.html b/src/azure-cli-core/azure/cli/core/auth/landing_pages/error.html similarity index 100% rename from src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/error.html rename to src/azure-cli-core/azure/cli/core/auth/landing_pages/error.html diff --git a/src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/success.html b/src/azure-cli-core/azure/cli/core/auth/landing_pages/success.html similarity index 100% rename from src/azure-cli-core/azure/cli/core/auth/auth_landing_pages/success.html rename to src/azure-cli-core/azure/cli/core/auth/landing_pages/success.html diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index dc6e75458e5..f535fecb9d3 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -82,5 +82,5 @@ packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests", "azure", "azure.cli"]), install_requires=DEPENDENCIES, python_requires='>=3.6.0', - package_data={'azure.cli.core': ['auth_landing_pages/*.html']} + package_data={'azure.cli.core': ['auth/landing_pages/*.html']} ) From e3d24225a7b5d31c3562880861e314c1255c4e8b Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 28 Sep 2021 13:40:11 +0800 Subject: [PATCH 58/69] Add back expiresOn --- scripts/ci/credscan/CredScanSuppressions.json | 2 +- src/azure-cli-core/azure/cli/core/_profile.py | 27 +++++++++---------- .../azure/cli/core/auth/identity.py | 3 --- .../cli/core/auth/msal_authentication.py | 2 +- .../cli/core/auth/tests/test_identity.py | 16 +++++------ .../azure/cli/core/auth/util.py | 9 ------- .../azure/cli/core/tests/test_profile.py | 8 +++--- .../cli/command_modules/profile/custom.py | 1 + 8 files changed, 27 insertions(+), 41 deletions(-) diff --git a/scripts/ci/credscan/CredScanSuppressions.json b/scripts/ci/credscan/CredScanSuppressions.json index d21f94a5d22..db7061b4a2e 100644 --- a/scripts/ci/credscan/CredScanSuppressions.json +++ b/scripts/ci/credscan/CredScanSuppressions.json @@ -412,7 +412,7 @@ "_justification": "[AppService] Test certs" }, { - "file": "src\\azure-cli-core\\azure\\cli\\core\\tests\\sp_cert.pem", + "file": "src\\azure-cli-core\\azure\\cli\\core\\auth\\tests\\sp_cert.pem", "_justification": "[Core] Test certs" }, { diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index a76d60b18cd..e3aeed54789 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -11,10 +11,10 @@ from azure.cli.core._session import ACCOUNT from azure.cli.core.auth.identity import Identity, AZURE_CLI_CLIENT_ID -from azure.cli.core.auth.util import resource_to_scopes, can_launch_browser +from azure.cli.core.auth.util import resource_to_scopes from azure.cli.core.azclierror import AuthenticationError from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription -from azure.cli.core.util import in_cloud_console +from azure.cli.core.util import in_cloud_console, can_launch_browser from knack.log import get_logger from knack.util import CLIError @@ -162,16 +162,14 @@ def login(self, logger.info('No web browser is available. Fall back to device code.') use_device_code = True - if not use_device_code: - user_identity = identity.login_with_auth_code(scopes=scopes, **kwargs) - else: + if use_device_code: user_identity = identity.login_with_device_code(scopes=scopes, **kwargs) + else: + user_identity = identity.login_with_auth_code(scopes=scopes, **kwargs) else: if not is_service_principal: user_identity = identity.login_with_username_password(username, password, scopes=scopes, **kwargs) else: - if not tenant: - raise CLIError('Please supply tenant using "--tenant"') identity.login_with_service_principal(username, password, scopes=scopes) if user_identity: @@ -191,18 +189,13 @@ def login(self, subscriptions = subscription_finder.find_using_common_tenant(username, credential) if not subscriptions and not allow_no_subscriptions: - if username: - msg = "No subscriptions found for {}.".format(username) - else: - # Don't show username if bare 'az login' is used - msg = "No subscriptions found." - raise CLIError(msg) + raise CLIError("No subscriptions found for {}.".format(username)) if allow_no_subscriptions: t_list = [s.tenant_id for s in subscriptions] bare_tenants = [t for t in subscription_finder.tenants if t not in t_list] profile = Profile(cli_ctx=self.cli_ctx) - tenant_accounts = profile._build_tenant_level_accounts(bare_tenants) # pylint: disable=protected-access + tenant_accounts = profile._build_tenant_level_accounts(bare_tenants) subscriptions.extend(tenant_accounts) if not subscriptions: return [] @@ -400,9 +393,13 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No credential = self._create_credential(account, tenant) token = credential.get_token(*scopes) + import datetime + expiresOn = datetime.datetime.fromtimestamp(token.expires_on).strftime("%Y-%m-%d %H:%M:%S.%f") + token_entry = { 'accessToken': token.token, - 'expiresOn': token.expires_on + 'expires_on': token.expires_on, + 'expiresOn': expiresOn } # (tokenType, accessToken, tokenEntry) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 030f70ea339..88c45a0439c 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -109,9 +109,6 @@ def login_with_auth_code(self, scopes=None, **kwargs): result = self.msal_app.acquire_token_interactive( scopes, prompt='select_account', success_template=success_template, error_template=error_template, **kwargs) - - if not result or 'error' in result: - aad_error_handler(result) return check_result(result) def login_with_device_code(self, scopes=None, **kwargs): diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index 35b56d946c8..f6ecdaa3926 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -86,4 +86,4 @@ def _build_sdk_access_token(token_entry): import time request_time = int(time.time()) - return AccessToken(token_entry["access_token"], request_time + int(token_entry["expires_in"])) + return AccessToken(token_entry["access_token"], request_time + token_entry["expires_in"]) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index a389bfe3320..a9027ae35b2 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -77,14 +77,14 @@ def test_load_credential(self, load_secret_store_mock): test_sp = { 'client_id': 'myapp', 'tenant_id': 'mytenant', - 'secret': 'Secret' + 'secret': 'test_secret' } secret_store = ServicePrincipalStore(None, None) store._content = [test_sp] entry = secret_store.load_credential("myapp", "mytenant") - self.assertEqual(entry['secret'], "Secret") + self.assertEqual(entry['secret'], "test_secret") @mock.patch('azure.cli.core.auth.persistence.load_secret_store') def test_save_credential(self, load_secret_store_mock): @@ -94,7 +94,7 @@ def test_save_credential(self, load_secret_store_mock): test_sp = { 'client_id': 'myapp', 'tenant_id': 'mytenant', - 'secret': 'Secret' + 'secret': 'test_secret' } secret_store = ServicePrincipalStore(None, None) @@ -110,12 +110,12 @@ def test_save_credential_add_new(self, load_secret_store_mock): test_sp = { "client_id": "myapp", "tenant_id": "mytenant", - "secret": "Secret" + "secret": "test_secret" } test_sp2 = { "client_id": "myapp2", "tenant_id": "mytenant2", - "secret": "Secret2" + "secret": "test_secret2" } store._content = [test_sp] @@ -131,12 +131,12 @@ def test_save_credential_update_existing(self, load_secret_store_mock): test_sp = { "client_id": "myapp", "tenant_id": "mytenant", - "accessToken": "Secret" + "accessToken": "test_secret" } store._content = [test_sp] new_creds = test_sp.copy() - new_creds['accessToken'] = 'Secret2' + new_creds['accessToken'] = 'test_secret' secret_store = ServicePrincipalStore(None, None) secret_store.save_credential(new_creds) @@ -150,7 +150,7 @@ def test_remove_credential(self, load_secret_store_mock): test_sp = { "client_id": "myapp", "tenant_id": "mytenant", - "accessToken": "Secret" + "accessToken": "test_secret" } store._content = [test_sp] diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index faf90c3f35d..2b92236c4ca 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -121,15 +121,6 @@ def check_result(result, **kwargs): return None -def can_launch_browser(): - import webbrowser - try: - webbrowser.get() - return True - except webbrowser.Error: - return False - - def decode_access_token(access_token): # Decode the access token. We can do the same with https://jwt.ms from msal.oauth2cli.oidc import decode_part diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index abf36741b70..299fc97c2dc 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -1060,7 +1060,7 @@ def test_get_raw_token(self): self.assertEqual(creds[0], 'Bearer') self.assertEqual(creds[1], MOCK_ACCESS_TOKEN) - self.assertEqual(creds[2]['expiresOn'], MOCK_EXPIRES_ON) + self.assertEqual(creds[2]['expires_on'], MOCK_EXPIRES_ON) # subscription should be set self.assertEqual(sub, self.subscription1.subscription_id) @@ -1071,7 +1071,7 @@ def test_get_raw_token(self): self.assertEqual(creds[0], 'Bearer') self.assertEqual(creds[1], MOCK_ACCESS_TOKEN) - self.assertEqual(creds[2]['expiresOn'], MOCK_EXPIRES_ON) + self.assertEqual(creds[2]['expires_on'], MOCK_EXPIRES_ON) # subscription shouldn't be set self.assertIsNone(sub) @@ -1095,7 +1095,7 @@ def test_get_raw_token_for_sp(self, get_service_principal_credential_mock): self.assertEqual(creds[0], BEARER) self.assertEqual(creds[1], MOCK_ACCESS_TOKEN) # the last in the tuple is the whole token entry which has several fields - self.assertEqual(creds[2]['expiresOn'], MOCK_EXPIRES_ON) + self.assertEqual(creds[2]['expires_on'], MOCK_EXPIRES_ON) # subscription should be set self.assertEqual(sub, self.subscription1.subscription_id) @@ -1106,7 +1106,7 @@ def test_get_raw_token_for_sp(self, get_service_principal_credential_mock): self.assertEqual(creds[0], BEARER) self.assertEqual(creds[1], MOCK_ACCESS_TOKEN) - self.assertEqual(creds[2]['expiresOn'], MOCK_EXPIRES_ON) + self.assertEqual(creds[2]['expires_on'], MOCK_EXPIRES_ON) # subscription shouldn't be set self.assertIsNone(sub) diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 3f3b493c241..e02c3a01e98 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -77,6 +77,7 @@ def get_access_token(cmd, subscription=None, resource=None, scopes=None, resourc result = { 'tokenType': creds[0], 'accessToken': creds[1], + 'expires_on': creds[2].get('expires_on', None), 'expiresOn': creds[2].get('expiresOn', None), 'tenant': tenant } From 3a47f6b2be7b79bd50c9d0570bb62f9cd30b0df0 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 28 Sep 2021 13:51:10 +0800 Subject: [PATCH 59/69] linter --- scripts/ci/credscan/CredScanSuppressions.json | 4 ++ src/azure-cli-core/azure/cli/core/_profile.py | 4 +- .../azure/cli/core/auth/identity.py | 38 +++++++++++-------- .../cli/core/auth/tests/test_identity.py | 31 ++++++++------- .../azure/cli/core/auth/util.py | 7 +++- .../azure/cli/command_modules/acs/custom.py | 1 + 6 files changed, 52 insertions(+), 33 deletions(-) diff --git a/scripts/ci/credscan/CredScanSuppressions.json b/scripts/ci/credscan/CredScanSuppressions.json index db7061b4a2e..6dc082be08e 100644 --- a/scripts/ci/credscan/CredScanSuppressions.json +++ b/scripts/ci/credscan/CredScanSuppressions.json @@ -415,6 +415,10 @@ "file": "src\\azure-cli-core\\azure\\cli\\core\\auth\\tests\\sp_cert.pem", "_justification": "[Core] Test certs" }, + { + "placeholder": "test_secret", + "_justification": "[Core] Test secret" + }, { "placeholder": "0abf356884d74b4aacbd7b1ebd3da0f7", "_justification": "[AMS] hard code accessToken in test_ams_live_event_scenarios.py" diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index e3aeed54789..9b8644c6f83 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -123,6 +123,7 @@ def __init__(self, cli_ctx=None, storage=None): self._authority = self.cli_ctx.cloud.endpoints.active_directory self._arm_scope = resource_to_scopes(self.cli_ctx.cloud.endpoints.active_directory_resource_id) + # Only enable token cache encryption for Windows (for now) token_encryption_fallback = sys.platform.startswith('win32') Identity.token_encryption = self.cli_ctx.config.getboolean('core', 'token_encryption', fallback=token_encryption_fallback) @@ -194,8 +195,7 @@ def login(self, if allow_no_subscriptions: t_list = [s.tenant_id for s in subscriptions] bare_tenants = [t for t in subscription_finder.tenants if t not in t_list] - profile = Profile(cli_ctx=self.cli_ctx) - tenant_accounts = profile._build_tenant_level_accounts(bare_tenants) + tenant_accounts = self._build_tenant_level_accounts(bare_tenants) subscriptions.extend(tenant_accounts) if not subscriptions: return [] diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 88c45a0439c..282e22e7b1c 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -12,7 +12,7 @@ from knack.util import CLIError from .msal_authentication import UserCredential, ServicePrincipalCredential -from .util import aad_error_handler, check_result +from .util import check_result AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' @@ -31,7 +31,7 @@ class Identity: # pylint: disable=too-many-instance-attributes """Class to manage identities: - user - service principal - TODO: - managed identity + - TODO: managed identity """ # Whether token and secrets should be encrypted. Change its value to turn on/off token encryption. token_encryption = False @@ -125,12 +125,24 @@ def login_with_username_password(self, username, password, scopes=None, **kwargs return check_result(result) def login_with_service_principal(self, client_id, credential, scopes=None): + """ + 'credential' is a dict like below. Only one key can exist: + { + 'secret': 'my_secret', + 'certificate': '/path/to/cert.pem', + 'federated_token': 'my_token' + } + """ sp_auth = ServicePrincipalAuth.build_from_credential(self.tenant_id, client_id, credential) + + # This cred means SDK credential object cred = ServicePrincipalCredential(sp_auth, **self._msal_app_kwargs) result = cred.acquire_token_for_client(scopes) check_result(result) + + # Only persist the service principal after a successful login entry = sp_auth.get_entry_to_persist() - self._msal_secret_store.save_credential(entry) + self._msal_secret_store.save_entry(entry) def login_with_managed_identity(self, scopes, identity_id=None): # pylint: disable=too-many-statements raise NotImplementedError @@ -151,11 +163,11 @@ def logout_all_users(self): def logout_service_principal(self, sp): # remove service principal secrets - self._msal_secret_store.remove_credential(sp) + self._msal_secret_store.remove_entry(sp) def logout_all_service_principal(self): # remove service principal secrets - self._msal_secret_store.remove_all_credentials() + self._msal_secret_store.remove_all_entries() def get_user(self, user=None): accounts = self.msal_app.get_accounts(user) if user else self.msal_app.get_accounts() @@ -165,7 +177,7 @@ def get_user_credential(self, username): return UserCredential(self.client_id, username, **self._msal_app_kwargs) def get_service_principal_credential(self, client_id): - entry = self._msal_secret_store.load_credential(client_id, self.tenant_id) + entry = self._msal_secret_store.load_entry(client_id, self.tenant_id) sp_auth = ServicePrincipalAuth(entry) return ServicePrincipalCredential(sp_auth, **self._msal_app_kwargs) @@ -250,7 +262,7 @@ def __init__(self, secret_file, encrypt): self._secret_file = secret_file self._entries = [] - def load_credential(self, sp_id, tenant): + def load_entry(self, sp_id, tenant): self._load_persistence() matched = [x for x in self._entries if sp_id == x[_CLIENT_ID]] if not matched: @@ -268,7 +280,7 @@ def load_credential(self, sp_id, tenant): return cred - def save_credential(self, sp_entry): + def save_entry(self, sp_entry): self._load_persistence() self._entries = [ @@ -279,7 +291,7 @@ def save_credential(self, sp_entry): self._entries.append(sp_entry) self._save_persistence() - def remove_credential(self, sp_id): + def remove_entry(self, sp_id): self._load_persistence() state_changed = False @@ -294,7 +306,7 @@ def remove_credential(self, sp_id): if state_changed: self._save_persistence() - def remove_all_credentials(self): + def remove_all_entries(self): try: os.remove(self._secret_file) except FileNotFoundError: @@ -306,12 +318,6 @@ def _save_persistence(self): def _load_persistence(self): self._entries = self._secret_store.load() - def _serialize_secrets(self): - # ONLY FOR DEBUGGING PURPOSE. DO NOT USE IN PRODUCTION CODE. - logger.warning("Secrets are serialized as plain text and saved to `msalSecrets.cache.json`.") - with open(self._secret_file + ".json", "w") as fd: - fd.write(json.dumps(self._entries, indent=4)) - def _read_response_templates(): """Read from success.html and error.html to strings and pass them to MSAL. """ diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index a9027ae35b2..7383fb2413f 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -60,7 +60,10 @@ def test_build_credential(self): current_dir = os.path.dirname(os.path.realpath(__file__)) test_cert_file = os.path.join(current_dir, 'sp_cert.pem') cred = ServicePrincipalAuth.build_credential(test_cert_file) - assert cred.get('certificate').endswith('sp_cert.pem') + assert cred == {'certificate': test_cert_file} + + cred = ServicePrincipalAuth.build_credential(test_cert_file, use_cert_sn_issuer=True) + assert cred == {'certificate': test_cert_file, 'use_cert_sn_issuer': True} # federated token cred = ServicePrincipalAuth.build_credential(federated_token="test_token") @@ -70,7 +73,7 @@ def test_build_credential(self): class TestMsalSecretStore(unittest.TestCase): @mock.patch('azure.cli.core.auth.persistence.load_secret_store') - def test_load_credential(self, load_secret_store_mock): + def test_load_entry(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store @@ -83,11 +86,11 @@ def test_load_credential(self, load_secret_store_mock): secret_store = ServicePrincipalStore(None, None) store._content = [test_sp] - entry = secret_store.load_credential("myapp", "mytenant") + entry = secret_store.load_entry("myapp", "mytenant") self.assertEqual(entry['secret'], "test_secret") @mock.patch('azure.cli.core.auth.persistence.load_secret_store') - def test_save_credential(self, load_secret_store_mock): + def test_save_entry(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store @@ -98,12 +101,12 @@ def test_save_credential(self, load_secret_store_mock): } secret_store = ServicePrincipalStore(None, None) - secret_store.save_credential(test_sp) + secret_store.save_entry(test_sp) assert store._content == [test_sp] @mock.patch('azure.cli.core.auth.persistence.load_secret_store') - def test_save_credential_add_new(self, load_secret_store_mock): + def test_save_entry_add_new(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store @@ -120,42 +123,42 @@ def test_save_credential_add_new(self, load_secret_store_mock): store._content = [test_sp] secret_store = ServicePrincipalStore(None, None) - secret_store.save_credential(test_sp2) + secret_store.save_entry(test_sp2) assert store._content == [test_sp, test_sp2] @mock.patch('azure.cli.core.auth.persistence.load_secret_store') - def test_save_credential_update_existing(self, load_secret_store_mock): + def test_save_entry_update_existing(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store test_sp = { "client_id": "myapp", "tenant_id": "mytenant", - "accessToken": "test_secret" + "secret": "test_secret" } store._content = [test_sp] new_creds = test_sp.copy() - new_creds['accessToken'] = 'test_secret' + new_creds['secret'] = 'test_secret' secret_store = ServicePrincipalStore(None, None) - secret_store.save_credential(new_creds) + secret_store.save_entry(new_creds) assert store._content == [new_creds] @mock.patch('azure.cli.core.auth.persistence.load_secret_store') - def test_remove_credential(self, load_secret_store_mock): + def test_remove_entry(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store test_sp = { "client_id": "myapp", "tenant_id": "mytenant", - "accessToken": "test_secret" + "secret": "test_secret" } store._content = [test_sp] secret_store = ServicePrincipalStore(None, None) - secret_store.remove_credential('myapp') + secret_store.remove_entry('myapp') assert store._content == [] diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index 2b92236c4ca..7b11c36d282 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -101,6 +101,11 @@ def try_scopes_to_resource(scopes): def check_result(result, **kwargs): + """ + 1. Check if the MSAL result contains a valid access token. + 2. If there is error, handle the error and show re-login message. + 3. For user login, return the username and tenant_id in a dict. + """ from azure.cli.core.azclierror import AuthenticationError if not result: @@ -115,7 +120,7 @@ def check_result(result, **kwargs): return { # AAD returns "preferred_username", ADFS returns "upn" 'username': idt.get("preferred_username") or idt["upn"], - 'tenantId': idt['tid'] + 'tenant_id': idt['tid'] } return None diff --git a/src/azure-cli/azure/cli/command_modules/acs/custom.py b/src/azure-cli/azure/cli/command_modules/acs/custom.py index e908509e757..feef62a188a 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/custom.py +++ b/src/azure-cli/azure/cli/command_modules/acs/custom.py @@ -3320,6 +3320,7 @@ def _get_dataplane_aad_token(cli_ctx, serverAppId): # this function is mostly copied from keyvault cli return Profile(cli_ctx=cli_ctx).get_raw_token(resource=serverAppId)[0][2].get('accessToken') + DEV_SPACES_EXTENSION_NAME = 'dev-spaces' DEV_SPACES_EXTENSION_MODULE = 'azext_dev_spaces.custom' From 0c3bf9ee8265386ce09ce4748c82bf2e82715c51 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 28 Sep 2021 15:14:56 +0800 Subject: [PATCH 60/69] federated_token -> client_assertion --- src/azure-cli-core/azure/cli/core/_profile.py | 2 +- src/azure-cli-core/azure/cli/core/auth/identity.py | 14 +++++++------- .../azure/cli/core/auth/msal_authentication.py | 4 ++-- .../azure/cli/core/auth/tests/test_identity.py | 6 +++--- .../azure/cli/command_modules/profile/__init__.py | 2 +- .../azure/cli/command_modules/profile/custom.py | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 9b8644c6f83..def7d8fef34 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -146,7 +146,7 @@ def login(self, { 'secret': 'my_secret', 'certificate': '/path/to/cert.pem', - 'federated_token': 'my_token' + 'client_assertion': 'my_token' } """ if not scopes: diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 282e22e7b1c..e91e3304520 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -21,7 +21,7 @@ _TENANT_ID = 'tenant_id' _SECRET = 'secret' _CERTIFICATE = 'certificate' -_FEDERATED_TOKEN = 'federated_token' +_CLIENT_ASSERTION = 'client_assertion' _USE_CERT_SN_ISSUER = 'use_cert_sn_issuer' logger = get_logger(__name__) @@ -130,7 +130,7 @@ def login_with_service_principal(self, client_id, credential, scopes=None): { 'secret': 'my_secret', 'certificate': '/path/to/cert.pem', - 'federated_token': 'my_token' + 'client_assertion': 'my_token' } """ sp_auth = ServicePrincipalAuth.build_from_credential(self.tenant_id, client_id, credential) @@ -227,12 +227,12 @@ def build_from_credential(cls, tenant_id, client_id, credential): return ServicePrincipalAuth(entry) @classmethod - def build_credential(cls, secret_or_certificate=None, federated_token=None, use_cert_sn_issuer=None): + def build_credential(cls, secret_or_certificate=None, client_assertion=None, use_cert_sn_issuer=None): """Build credential from user input. The credential looks like below, but only one key can exist. { "secret": "xxx", "certificate": "/path/to/cert.pem", - "federated_token": "xxx" + "client_assertion": "xxx" } """ entry = {} @@ -243,12 +243,12 @@ def build_credential(cls, secret_or_certificate=None, federated_token=None, use_ entry[_USE_CERT_SN_ISSUER] = use_cert_sn_issuer else: entry[_SECRET] = secret_or_certificate - elif federated_token: - entry[_FEDERATED_TOKEN] = federated_token + elif client_assertion: + entry[_CLIENT_ASSERTION] = client_assertion return entry def get_entry_to_persist(self): - persisted_keys = [_CLIENT_ID, _TENANT_ID, _SECRET, _CERTIFICATE, _USE_CERT_SN_ISSUER, _FEDERATED_TOKEN] + persisted_keys = [_CLIENT_ID, _TENANT_ID, _SECRET, _CERTIFICATE, _USE_CERT_SN_ISSUER, _CLIENT_ASSERTION] return {k: v for k, v in self.__dict__.items() if k in persisted_keys} diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index f6ecdaa3926..a830b2c9b05 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -66,8 +66,8 @@ def __init__(self, service_principal_auth, **kwargs): if getattr(service_principal_auth, 'public_certificate', None): client_credential['public_certificate'] = service_principal_auth.public_certificate - elif getattr(service_principal_auth, 'federated_token', None): - client_credential = {"client_assertion": service_principal_auth.federated_token} + elif getattr(service_principal_auth, 'client_assertion', None): + client_credential = {"client_assertion": service_principal_auth.client_assertion} super().__init__(service_principal_auth.client_id, client_credential=client_credential, **kwargs) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index 7383fb2413f..28a166b7b54 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -65,9 +65,9 @@ def test_build_credential(self): cred = ServicePrincipalAuth.build_credential(test_cert_file, use_cert_sn_issuer=True) assert cred == {'certificate': test_cert_file, 'use_cert_sn_issuer': True} - # federated token - cred = ServicePrincipalAuth.build_credential(federated_token="test_token") - assert cred == {"federated_token": "test_token"} + # client assertion + cred = ServicePrincipalAuth.build_credential(client_assertion="test_jwt") + assert cred == {"client_assertion": "test_jwt"} class TestMsalSecretStore(unittest.TestCase): diff --git a/src/azure-cli/azure/cli/command_modules/profile/__init__.py b/src/azure-cli/azure/cli/command_modules/profile/__init__.py index 2ad96848941..7e0dc3218cc 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/profile/__init__.py @@ -58,7 +58,7 @@ def load_arguments(self, command): help="Use CLI's old authentication flow based on device code. CLI will also use this if it can't launch a browser in your behalf, e.g. in remote SSH or Cloud Shell") c.argument('use_cert_sn_issuer', action='store_true', help='used with a service principal configured with Subject Name and Issuer Authentication in order to support automatic certificate rolls') c.argument('scopes', options_list=['--scope'], nargs='+', help='Used in the /authorize request. It can cover only one static resource.') - c.argument('federated_token', help='Federated token that can be used for OIDC token exchange.') + c.argument('client_assertion', options_list=['--federated-token'], help='Federated token that can be used for OIDC token exchange.') with self.argument_context('logout') as c: c.argument('username', help='account user, if missing, logout the current active account') diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index e02c3a01e98..2f5229a7fb7 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -105,7 +105,7 @@ def account_clear(cmd): # pylint: disable=inconsistent-return-statements, too-many-branches def login(cmd, username=None, password=None, service_principal=None, tenant=None, allow_no_subscriptions=False, - identity=False, use_device_code=False, use_cert_sn_issuer=None, scopes=None, federated_token=None): + identity=False, use_device_code=False, use_cert_sn_issuer=None, scopes=None, client_assertion=None): """Log in to access Azure subscriptions""" # quick argument usage check @@ -130,7 +130,7 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None logger.warning(_CLOUD_CONSOLE_LOGIN_WARNING) if username: - if not (password or federated_token): + if not (password or client_assertion): try: password = prompt_pass('Password: ') except NoTTYException: @@ -140,7 +140,7 @@ def login(cmd, username=None, password=None, service_principal=None, tenant=None if service_principal: from azure.cli.core.auth.identity import ServicePrincipalAuth - password = ServicePrincipalAuth.build_credential(password, federated_token, use_cert_sn_issuer) + password = ServicePrincipalAuth.build_credential(password, client_assertion, use_cert_sn_issuer) subscriptions = profile.login( interactive, From 65718c405ae0f3f11fa4cabff3b93648d023e306 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 28 Sep 2021 15:45:13 +0800 Subject: [PATCH 61/69] Use MSAL names --- .../azure/cli/core/auth/identity.py | 25 +++---- .../cli/core/auth/msal_authentication.py | 37 +++++++--- .../azure/cli/core/auth/tests/test.json.json | 7 -- .../cli/core/auth/tests/test_identity.py | 71 +++++++------------ 4 files changed, 62 insertions(+), 78 deletions(-) delete mode 100644 src/azure-cli-core/azure/cli/core/auth/tests/test.json.json diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index e91e3304520..a5abe300efc 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -14,15 +14,12 @@ from .msal_authentication import UserCredential, ServicePrincipalCredential from .util import check_result +# Service principal entry properties +from .msal_authentication import _CLIENT_ID, _TENANT, _CLIENT_SECRET, _CERTIFICATE, _CLIENT_ASSERTION,\ + _USE_CERT_SN_ISSUER + AZURE_CLI_CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' -# Service principal entry properties -_CLIENT_ID = 'client_id' -_TENANT_ID = 'tenant_id' -_SECRET = 'secret' -_CERTIFICATE = 'certificate' -_CLIENT_ASSERTION = 'client_assertion' -_USE_CERT_SN_ISSUER = 'use_cert_sn_issuer' logger = get_logger(__name__) @@ -220,8 +217,8 @@ def __init__(self, entry): @classmethod def build_from_credential(cls, tenant_id, client_id, credential): entry = { - _CLIENT_ID: client_id, - _TENANT_ID: tenant_id + _TENANT: tenant_id, + _CLIENT_ID: client_id } entry.update(credential) return ServicePrincipalAuth(entry) @@ -242,13 +239,13 @@ def build_credential(cls, secret_or_certificate=None, client_assertion=None, use if use_cert_sn_issuer: entry[_USE_CERT_SN_ISSUER] = use_cert_sn_issuer else: - entry[_SECRET] = secret_or_certificate + entry[_CLIENT_SECRET] = secret_or_certificate elif client_assertion: entry[_CLIENT_ASSERTION] = client_assertion return entry def get_entry_to_persist(self): - persisted_keys = [_CLIENT_ID, _TENANT_ID, _SECRET, _CERTIFICATE, _USE_CERT_SN_ISSUER, _CLIENT_ASSERTION] + persisted_keys = [_CLIENT_ID, _TENANT, _CLIENT_SECRET, _CERTIFICATE, _USE_CERT_SN_ISSUER, _CLIENT_ASSERTION] return {k: v for k, v in self.__dict__.items() if k in persisted_keys} @@ -269,13 +266,13 @@ def load_entry(self, sp_id, tenant): raise CLIError("Could not retrieve credential from local cache for service principal {}. " "Please run `az login` for this service principal." .format(sp_id)) - matched_with_tenant = [x for x in matched if tenant == x[_TENANT_ID]] + matched_with_tenant = [x for x in matched if tenant == x[_TENANT]] if matched_with_tenant: cred = matched_with_tenant[0] else: logger.warning("Could not retrieve credential from local cache for service principal %s under tenant %s. " "Trying credential under tenant %s, assuming that is an app credential.", - sp_id, tenant, matched[0][_TENANT_ID]) + sp_id, tenant, matched[0][_TENANT]) cred = matched[0] return cred @@ -286,7 +283,7 @@ def save_entry(self, sp_entry): self._entries = [ x for x in self._entries if not (sp_entry[_CLIENT_ID] == x[_CLIENT_ID] and - sp_entry[_TENANT_ID] == x[_TENANT_ID])] + sp_entry[_TENANT] == x[_TENANT])] self._entries.append(sp_entry) self._save_persistence() diff --git a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py index a830b2c9b05..799aabfc568 100644 --- a/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/msal_authentication.py @@ -17,6 +17,15 @@ from .util import check_result +# OAuth 2.0 client credentials flow parameter +# https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow +_TENANT = 'tenant' +_CLIENT_ID = 'client_id' +_CLIENT_SECRET = 'client_secret' +_CERTIFICATE = 'certificate' +_CLIENT_ASSERTION = 'client_assertion' +_USE_CERT_SN_ISSUER = 'use_cert_sn_issuer' + logger = get_logger(__name__) @@ -55,19 +64,27 @@ class ServicePrincipalCredential(ConfidentialClientApplication): def __init__(self, service_principal_auth, **kwargs): client_credential = None - if getattr(service_principal_auth, 'secret', None): - client_credential = service_principal_auth.secret - elif getattr(service_principal_auth, 'certificate', None): + # client_secret + client_secret = getattr(service_principal_auth, _CLIENT_SECRET, None) + if client_secret: + client_credential = client_secret + + # certificate + certificate = getattr(service_principal_auth, _CERTIFICATE, None) + if certificate: client_credential = { - "private_key": service_principal_auth.certificate_string, - "thumbprint": service_principal_auth.thumbprint + "private_key": getattr(service_principal_auth, 'certificate_string'), + "thumbprint": getattr(service_principal_auth, 'thumbprint') } - if getattr(service_principal_auth, 'public_certificate', None): - client_credential['public_certificate'] = service_principal_auth.public_certificate - - elif getattr(service_principal_auth, 'client_assertion', None): - client_credential = {"client_assertion": service_principal_auth.client_assertion} + public_certificate = getattr(service_principal_auth, 'public_certificate', None) + if public_certificate: + client_credential['public_certificate'] = public_certificate + + # client_assertion + client_assertion = getattr(service_principal_auth, _CLIENT_ASSERTION, None) + if client_assertion: + client_credential = {'client_assertion': client_assertion} super().__init__(service_principal_auth.client_id, client_credential=client_credential, **kwargs) diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test.json.json b/src/azure-cli-core/azure/cli/core/auth/tests/test.json.json deleted file mode 100644 index 9056bcca24b..00000000000 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test.json.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "servicePrincipalId": "myapp", - "servicePrincipalTenant": "mytenant", - "secret": "Secret" - } -] \ No newline at end of file diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index 28a166b7b54..e38fd8d9eb5 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -27,13 +27,13 @@ def test_login_with_service_principal_certificate_cert_err(self): class TestServicePrincipalAuth(unittest.TestCase): def test_service_principal_auth_client_secret(self): - sp_auth = ServicePrincipalAuth.build_from_credential('tenant1', 'sp_id1', {'secret': "test_secret"}) + sp_auth = ServicePrincipalAuth.build_from_credential('tenant1', 'sp_id1', {'client_secret': "test_secret"}) result = sp_auth.get_entry_to_persist() assert result == { 'client_id': 'sp_id1', - 'tenant_id': 'tenant1', - 'secret': 'test_secret' + 'tenant': 'tenant1', + 'client_secret': 'test_secret' } def test_service_principal_auth_client_cert(self): @@ -47,14 +47,14 @@ def test_service_principal_auth_client_cert(self): assert sp_auth.thumbprint == 'F06A53848BBE714A4290D69D335279C1D01073FD' assert result == { 'client_id': 'sp_id1', - 'tenant_id': 'tenant1', + 'tenant': 'tenant1', 'certificate': test_cert_file } def test_build_credential(self): # secret cred = ServicePrincipalAuth.build_credential("test_secret") - assert cred == {"secret": "test_secret"} + assert cred == {"client_secret": "test_secret"} # certificate current_dir = os.path.dirname(os.path.realpath(__file__)) @@ -72,74 +72,57 @@ def test_build_credential(self): class TestMsalSecretStore(unittest.TestCase): + test_sp = { + 'client_id': 'myapp', + 'tenant': 'mytenant', + 'client_secret': 'test_secret' + } + @mock.patch('azure.cli.core.auth.persistence.load_secret_store') def test_load_entry(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store - test_sp = { - 'client_id': 'myapp', - 'tenant_id': 'mytenant', - 'secret': 'test_secret' - } - secret_store = ServicePrincipalStore(None, None) - store._content = [test_sp] + store._content = [self.test_sp] entry = secret_store.load_entry("myapp", "mytenant") - self.assertEqual(entry['secret'], "test_secret") + self.assertEqual(entry['client_secret'], "test_secret") @mock.patch('azure.cli.core.auth.persistence.load_secret_store') def test_save_entry(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store - test_sp = { - 'client_id': 'myapp', - 'tenant_id': 'mytenant', - 'secret': 'test_secret' - } - secret_store = ServicePrincipalStore(None, None) - secret_store.save_entry(test_sp) + secret_store.save_entry(self.test_sp) - assert store._content == [test_sp] + assert store._content == [self.test_sp] @mock.patch('azure.cli.core.auth.persistence.load_secret_store') def test_save_entry_add_new(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store - test_sp = { - "client_id": "myapp", - "tenant_id": "mytenant", - "secret": "test_secret" - } test_sp2 = { - "client_id": "myapp2", - "tenant_id": "mytenant2", - "secret": "test_secret2" + 'client_id': "myapp2", + 'tenant': "mytenant2", + 'client_secret': "test_secret2" } - store._content = [test_sp] + store._content = [self.test_sp] secret_store = ServicePrincipalStore(None, None) secret_store.save_entry(test_sp2) - assert store._content == [test_sp, test_sp2] + assert store._content == [self.test_sp, test_sp2] @mock.patch('azure.cli.core.auth.persistence.load_secret_store') def test_save_entry_update_existing(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store - test_sp = { - "client_id": "myapp", - "tenant_id": "mytenant", - "secret": "test_secret" - } - - store._content = [test_sp] - new_creds = test_sp.copy() - new_creds['secret'] = 'test_secret' + store._content = [self.test_sp] + new_creds = self.test_sp.copy() + new_creds['client_secret'] = 'test_secret' secret_store = ServicePrincipalStore(None, None) secret_store.save_entry(new_creds) @@ -150,13 +133,7 @@ def test_remove_entry(self, load_secret_store_mock): store = MemoryStore() load_secret_store_mock.return_value = store - test_sp = { - "client_id": "myapp", - "tenant_id": "mytenant", - "secret": "test_secret" - } - - store._content = [test_sp] + store._content = [self.test_sp] secret_store = ServicePrincipalStore(None, None) secret_store.remove_entry('myapp') assert store._content == [] From d7b432871657a79f0dc6b0ac97d86757c81db3e4 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 28 Sep 2021 17:40:23 +0800 Subject: [PATCH 62/69] Fix tests for expiresOn --- .../azure/cli/command_modules/profile/custom.py | 2 +- .../profile/tests/latest/test_profile_custom.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 2f5229a7fb7..bafbdf24471 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -77,7 +77,7 @@ def get_access_token(cmd, subscription=None, resource=None, scopes=None, resourc result = { 'tokenType': creds[0], 'accessToken': creds[1], - 'expires_on': creds[2].get('expires_on', None), + # 'expires_on': creds[2].get('expires_on', None), 'expiresOn': creds[2].get('expiresOn', None), 'tenant': tenant } diff --git a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py index 7dd85ee40f5..168a121474e 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/tests/latest/test_profile_custom.py @@ -35,7 +35,7 @@ def test_get_raw_token(self, get_raw_token_mock): cmd = mock.MagicMock() cmd.cli_ctx = DummyCli() - get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': 1593497681}], 'sub123', 'tenant123') + get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': '2100-01-01'}], 'sub123', 'tenant123') result = get_access_token(cmd) @@ -44,7 +44,7 @@ def test_get_raw_token(self, get_raw_token_mock): expected_result = { 'tokenType': 'bearer', 'accessToken': 'token123', - 'expiresOn': 1593497681, + 'expiresOn': '2100-01-01', 'subscription': 'sub123', 'tenant': 'tenant123' } @@ -53,7 +53,7 @@ def test_get_raw_token(self, get_raw_token_mock): # assert it takes customized resource, subscription resource = 'https://graph.microsoft.com/' subscription_id = '00000001-0000-0000-0000-000000000000' - get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': 1593497681}], subscription_id, + get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': '2100-01-01'}], subscription_id, 'tenant123') result = get_access_token(cmd, subscription=subscription_id, resource=resource) get_raw_token_mock.assert_called_with(mock.ANY, resource, None, subscription_id, None) @@ -65,12 +65,12 @@ def test_get_raw_token(self, get_raw_token_mock): # test get token with tenant tenant_id = '00000000-0000-0000-0000-000000000000' - get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': 1593497681}], None, tenant_id) + get_raw_token_mock.return_value = (['bearer', 'token123', {'expiresOn': '2100-01-01'}], None, tenant_id) result = get_access_token(cmd, tenant=tenant_id) expected_result = { 'tokenType': 'bearer', 'accessToken': 'token123', - 'expiresOn': 1593497681, + 'expiresOn': '2100-01-01', 'tenant': tenant_id } self.assertEqual(result, expected_result) From dca385b68da5ead70dee4d8e1d5f5ed83a3955b5 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 8 Oct 2021 14:18:01 +0800 Subject: [PATCH 63/69] Bump msal to 1.15.0 --- src/azure-cli-core/setup.py | 2 +- src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 91fe6cfacd7..3186c424644 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -51,7 +51,7 @@ 'humanfriendly>=4.7,<10.0', 'jmespath', 'knack~=0.8.2', - 'msal>=1.14.0,<2.0.0', + 'msal>=1.15.0,<2.0.0', 'paramiko>=2.0.8,<3.0.0', 'pkginfo>=1.5.0.1', 'PyJWT>=2.1.0', diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 5c50f466632..a0b0c8ef936 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -108,7 +108,7 @@ Jinja2==2.11.3 jmespath==0.9.5 knack==0.8.2 MarkupSafe==1.1.1 -msal==1.14.0 +msal==1.15.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index e3d4e2e2791..b66872ad4d1 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -109,7 +109,7 @@ Jinja2==2.11.3 jmespath==0.9.5 knack==0.8.2 MarkupSafe==1.1.1 -msal==1.14.0 +msal==1.15.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 100925e6de5..f561b5e1f20 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -107,7 +107,7 @@ Jinja2==2.11.3 jmespath==0.9.5 knack==0.8.2 MarkupSafe==1.1.1 -msal==1.14.0 +msal==1.15.0 msrest==0.6.21 msrestazure==0.6.3 oauthlib==3.0.1 From 3a15e40e61ada144479051b963ef278a207d3ed2 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 11 Oct 2021 10:27:32 +0800 Subject: [PATCH 64/69] Add http cache --- .../azure/cli/core/auth/identity.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index a5abe300efc..1f24a4d9f36 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -54,9 +54,10 @@ def __init__(self, authority=None, tenant_id=None, client_id=None): config_dir = get_config_dir() self._token_cache_file = os.path.join(config_dir, "tokenCache") self._secret_file = os.path.join(config_dir, "secrets") - self._http_cache_file = os.path.join(config_dir, "httpCache") + self._http_cache_file = os.path.join(config_dir, "msalHttpCache") # Prepare HTTP cache. + # https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/407 # if not Identity.http_cache: # Identity.http_cache = self._load_http_cache() @@ -66,6 +67,7 @@ def __init__(self, authority=None, tenant_id=None, client_id=None): self._msal_app_kwargs = { "authority": self.msal_authority, "token_cache": self._load_msal_cache() + # "http_cache": Identity.http_cache } def _load_msal_cache(self): @@ -77,10 +79,20 @@ def _load_msal_cache(self): def _load_http_cache(self): import atexit - import shelve - http_cache = persisted_http_cache = shelve.open(self._http_cache_file) - atexit.register(persisted_http_cache.close) - return http_cache + import pickle + + try: + with open(self._http_cache_file, 'rb') as f: + persisted_http_cache = pickle.load(f) # Take a snapshot + except: # pylint: disable=bare-except + persisted_http_cache = {} # Ignore a non-exist or corrupted http_cache + atexit.register(lambda: pickle.dump( + # When exit, flush it back to the file. + # If 2 processes write at the same time, the cache will be corrupted, + # but that is fine. Subsequent runs would reach eventual consistency. + persisted_http_cache, open(self._http_cache_file, 'wb'))) + + return persisted_http_cache def _build_persistent_msal_app(self): # Initialize _msal_app for logout, token migration which Azure Identity doesn't support From 2c9784881956005742fc3261d913712c5bc65925 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 11 Oct 2021 11:18:52 +0800 Subject: [PATCH 65/69] Refactor --- src/azure-cli-core/azure/cli/core/_profile.py | 3 +- .../cli/core/auth/adal_authentication.py | 5 +- .../azure/cli/core/auth/credential_adaptor.py | 57 ++++++------------- .../azure/cli/core/auth/identity.py | 31 ++++------ .../azure/cli/core/auth/util.py | 21 ++++--- .../azure/cli/core/commands/client_factory.py | 15 ----- 6 files changed, 47 insertions(+), 85 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index def7d8fef34..f86e3a98e26 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -173,6 +173,7 @@ def login(self, else: identity.login_with_service_principal(username, password, scopes=scopes) + # We have finished login. Let's find all subscriptions. if user_identity: username = user_identity['username'] @@ -351,7 +352,7 @@ def get_login_credentials(self, resource=None, client_id=None, subscription_id=N external_credentials.append(self._create_credential(account, external_tenant, client_id=client_id)) from azure.cli.core.auth.credential_adaptor import CredentialAdaptor cred = CredentialAdaptor(credential, - external_credentials=external_credentials, + auxiliary_credentials=external_credentials, resource=resource) else: # managed identity diff --git a/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py b/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py index aa41e99a1bd..8b8252679e7 100644 --- a/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py +++ b/src/azure-cli-core/azure/cli/core/auth/adal_authentication.py @@ -4,11 +4,12 @@ # -------------------------------------------------------------------------------------------- import requests -from azure.cli.core.auth.util import try_scopes_to_resource from azure.core.credentials import AccessToken from knack.log import get_logger from msrestazure.azure_active_directory import MSIAuthentication +from .util import _normalize_scopes, scopes_to_resource + logger = get_logger(__name__) @@ -16,7 +17,7 @@ class MSIAuthenticationWrapper(MSIAuthentication): # This method is exposed for Azure Core. Add *scopes, **kwargs to fit azure.core requirement def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument logger.debug("MSIAuthenticationWrapper.get_token invoked by Track 2 SDK with scopes=%s", scopes) - resource = try_scopes_to_resource(scopes) + resource = scopes_to_resource(_normalize_scopes(scopes)) if resource: # If available, use resource provided by SDK self.resource = resource diff --git a/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py b/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py index dc2fd6a3b0e..01ab8637d39 100644 --- a/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py +++ b/src/azure-cli-core/azure/cli/core/auth/credential_adaptor.py @@ -7,21 +7,27 @@ from knack.log import get_logger from knack.util import CLIError -from .util import resource_to_scopes +from .util import resource_to_scopes, _normalize_scopes logger = get_logger(__name__) class CredentialAdaptor: - """Adaptor to both - - Track 1: msrest.authentication.Authentication, which exposes signed_session - - Track 2: azure.core.credentials.TokenCredential, which exposes get_token - """ + def __init__(self, credential, resource=None, auxiliary_credentials=None): + """ + Adaptor to both + - Track 1: msrest.authentication.Authentication, which exposes signed_session + - Track 2: azure.core.credentials.TokenCredential, which exposes get_token + + :param credential: Main credential from .msal_authentication + :param resource: AAD resource for Track 1 only + :param auxiliary_credentials: Credentials from .msal_authentication for cross tenant authentication. + Details about cross tenant authentication: + https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/authenticate-multi-tenant + """ - def __init__(self, credential, resource=None, external_credentials=None): self._credential = credential - # _external_credentials and _resource are only needed in Track1 SDK - self._external_credentials = external_credentials + self._auxiliary_credentials = auxiliary_credentials self._resource = resource def _get_token(self, scopes=None, **kwargs): @@ -30,8 +36,8 @@ def _get_token(self, scopes=None, **kwargs): scopes = scopes or resource_to_scopes(self._resource) try: token = self._credential.get_token(*scopes, **kwargs) - if self._external_credentials: - external_tenant_tokens = [cred.get_token(*scopes) for cred in self._external_credentials] + if self._auxiliary_credentials: + external_tenant_tokens = [cred.get_token(*scopes) for cred in self._auxiliary_credentials] return token, external_tenant_tokens except requests.exceptions.SSLError as err: from azure.cli.core.util import SSLERROR_TEMPLATE @@ -55,33 +61,6 @@ def get_token(self, *scopes, **kwargs): return token def get_auxiliary_tokens(self, *scopes, **kwargs): - if self._external_credentials: - return [cred.get_token(*scopes, **kwargs) for cred in self._external_credentials] + if self._auxiliary_credentials: + return [cred.get_token(*scopes, **kwargs) for cred in self._auxiliary_credentials] return None - - @staticmethod - def _log_hostname(): - import socket - logger.warning("A Cloud Shell credential problem occurred. When you report the issue with the error " - "below, please mention the hostname '%s'", socket.gethostname()) - - -def _normalize_scopes(scopes): - """Normalize scopes to workaround some SDK issues.""" - - # Track 2 SDKs generated before https://github.com/Azure/autorest.python/pull/239 don't maintain - # credential_scopes and call `get_token` with empty scopes. - # As a workaround, return None so that the CLI-managed resource is used. - if not scopes: - logger.debug("No scope is provided by the SDK, use the CLI-managed resource.") - return None - - # Track 2 SDKs generated before https://github.com/Azure/autorest.python/pull/745 extend default - # credential_scopes with custom credential_scopes. Instead, credential_scopes should be replaced by - # custom credential_scopes. https://github.com/Azure/azure-sdk-for-python/issues/12947 - # As a workaround, remove the first one if there are multiple scopes provided. - if len(scopes) > 1: - logger.debug("Multiple scopes are provided by the SDK, discarding the first one: %s", scopes[0]) - return scopes[1:] - - return scopes diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 1f24a4d9f36..40452cbc29f 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -74,7 +74,6 @@ def _load_msal_cache(self): from .persistence import load_persisted_token_cache # Store for user token persistence cache = load_persisted_token_cache(self._token_cache_file, self.token_encryption) - cache._reload_if_necessary() # pylint: disable=protected-access return cache def _load_http_cache(self): @@ -95,7 +94,7 @@ def _load_http_cache(self): return persisted_http_cache def _build_persistent_msal_app(self): - # Initialize _msal_app for logout, token migration which Azure Identity doesn't support + # Initialize _msal_app for login and logout from msal import PublicClientApplication msal_app = PublicClientApplication(self.client_id, **self._msal_app_kwargs) return msal_app @@ -136,11 +135,11 @@ def login_with_username_password(self, username, password, scopes=None, **kwargs def login_with_service_principal(self, client_id, credential, scopes=None): """ 'credential' is a dict like below. Only one key can exist: - { - 'secret': 'my_secret', - 'certificate': '/path/to/cert.pem', - 'client_assertion': 'my_token' - } + { + 'secret': 'my_secret', + 'certificate': '/path/to/cert.pem', + 'client_assertion': 'my_federated_token' + } """ sp_auth = ServicePrincipalAuth.build_from_credential(self.tenant_id, client_id, credential) @@ -193,16 +192,6 @@ def get_service_principal_credential(self, client_id): def get_managed_identity_credential(self, client_id=None): raise NotImplementedError - def serialize_token_cache(self, path=None): - path = path or os.path.join(get_config_dir(), "msal.cache.snapshot.json") - path = os.path.expanduser(path) - logger.warning("Token cache is exported to '%s'. The exported cache is unencrypted. " - "It contains login information of all logged-in users. Make sure you protect it safely.", path) - - cache = self._load_msal_cache() - with open(path, "w") as fd: - fd.write(cache.serialize()) - class ServicePrincipalAuth: @@ -239,9 +228,9 @@ def build_from_credential(cls, tenant_id, client_id, credential): def build_credential(cls, secret_or_certificate=None, client_assertion=None, use_cert_sn_issuer=None): """Build credential from user input. The credential looks like below, but only one key can exist. { - "secret": "xxx", - "certificate": "/path/to/cert.pem", - "client_assertion": "xxx" + 'secret': 'my_secret', + 'certificate': '/path/to/cert.pem', + 'client_assertion': 'my_federated_token' } """ entry = {} @@ -262,7 +251,7 @@ def get_entry_to_persist(self): class ServicePrincipalStore: - """Caches secrets in MSAL custom secret store for Service Principal authentication. + """Save secrets in MSAL custom secret store for Service Principal authentication. """ def __init__(self, secret_file, encrypt): diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index 7b11c36d282..1490bcd424e 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -4,6 +4,7 @@ # -------------------------------------------------------------------------------------------- from knack.log import get_logger +from azure.cli.core.util import in_cloud_console logger = get_logger(__name__) @@ -13,6 +14,12 @@ def aad_error_handler(error, **kwargs): # https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes # Search for an error code at https://login.microsoftonline.com/error + + if in_cloud_console(): + import socket + logger.warning("A Cloud Shell credential problem occurred. When you report the issue with the error " + "below, please mention the hostname '%s'", socket.gethostname()) + msg = error.get('error_description') login_message = _generate_login_message(**kwargs) @@ -67,10 +74,11 @@ def scopes_to_resource(scopes): :return: The ADAL resource :rtype: str """ - scope = scopes[0] + if not scopes: + return None + scope = scopes[0] suffixes = ['/.default', '/user_impersonation'] - for s in suffixes: if scope.endswith(s): return scope[:-len(s)] @@ -78,8 +86,8 @@ def scopes_to_resource(scopes): return scope -def try_scopes_to_resource(scopes): - """Wrap scopes_to_resource to workaround some SDK issues.""" +def _normalize_scopes(scopes): + """Normalize scopes to workaround some SDK issues.""" # Track 2 SDKs generated before https://github.com/Azure/autorest.python/pull/239 don't maintain # credential_scopes and call `get_token` with empty scopes. @@ -94,10 +102,9 @@ def try_scopes_to_resource(scopes): # As a workaround, remove the first one if there are multiple scopes provided. if len(scopes) > 1: logger.debug("Multiple scopes are provided by the SDK, discarding the first one: %s", scopes[0]) - return scopes_to_resource(scopes[1:]) + return scopes[1:] - # Exactly only one scope is provided - return scopes_to_resource(scopes) + return scopes def check_result(result, **kwargs): diff --git a/src/azure-cli-core/azure/cli/core/commands/client_factory.py b/src/azure-cli-core/azure/cli/core/commands/client_factory.py index 81720e6a108..962cb485f67 100644 --- a/src/azure-cli-core/azure/cli/core/commands/client_factory.py +++ b/src/azure-cli-core/azure/cli/core/commands/client_factory.py @@ -202,21 +202,6 @@ def _get_mgmt_service_client(cli_ctx, aux_subscriptions=None, aux_tenants=None, **kwargs): - """ - - :param cli_ctx: - :param client_type: - :param subscription_bound: - :param subscription_id: - :param api_version: - :param base_url_bound: - :param resource: For track 1 SDK which uses msrest and ADAL. It will be passed to get_login_credentials. - :param sdk_profile: - :param aux_subscriptions: - :param aux_tenants: - :param kwargs: - :return: - """ from azure.cli.core._profile import Profile logger.debug('Getting management service client client_type=%s', client_type.__name__) From f65940e9f06737dab6fd23c4364b40433d688cfd Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 11 Oct 2021 14:02:05 +0800 Subject: [PATCH 66/69] Revert unnecessary changes --- azure-cli.pyproj | 2 +- azure-cli2017.pyproj | 2 +- src/azure-cli-core/azure/cli/core/_debug.py | 4 ---- src/azure-cli-core/azure/cli/core/_help.py | 2 +- src/azure-cli-core/azure/cli/core/_session.py | 15 +++++++++++---- .../azure/cli/core/auth/tests/test_util.py | 17 ++++++++++++++++- src/azure-cli-core/azure/cli/core/auth/util.py | 2 +- src/azure-cli-core/azure/cli/core/azclierror.py | 10 +++------- src/azure-cli-core/azure/cli/core/azlogging.py | 13 ++++--------- .../azure/cli/core/commands/client_factory.py | 2 +- .../azure/cli/core/profiles/_shared.py | 3 +-- .../azure/cli/core/tests/test_profile.py | 2 +- .../azure/cli/core/tests/test_util.py | 3 +-- src/azure-cli-core/azure/cli/core/util.py | 4 ++-- .../azure/cli/testsdk/patches.py | 2 -- 15 files changed, 44 insertions(+), 39 deletions(-) diff --git a/azure-cli.pyproj b/azure-cli.pyproj index 74ff88b8c90..45452f0409a 100644 --- a/azure-cli.pyproj +++ b/azure-cli.pyproj @@ -23,7 +23,7 @@ 10.0 - + diff --git a/azure-cli2017.pyproj b/azure-cli2017.pyproj index d1388169933..830ca4fdd4a 100644 --- a/azure-cli2017.pyproj +++ b/azure-cli2017.pyproj @@ -23,7 +23,7 @@ 10.0 - + diff --git a/src/azure-cli-core/azure/cli/core/_debug.py b/src/azure-cli-core/azure/cli/core/_debug.py index b873b5694dc..b8028791ce9 100644 --- a/src/azure-cli-core/azure/cli/core/_debug.py +++ b/src/azure-cli-core/azure/cli/core/_debug.py @@ -45,7 +45,3 @@ def change_ssl_cert_verification_track2(): logger.debug("Using CA bundle file at '%s'.", ca_bundle_file) client_kwargs['connection_verify'] = ca_bundle_file return client_kwargs - - -def msal_connection_verify(): - return not should_disable_connection_verify() diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index b25f91b2614..534956a1916 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -39,7 +39,7 @@ /_/ \_\/___|\__,_|_| \___| -Welcome to Azure CLI v3 beta with MSAL support! +Welcome to the cool new Azure CLI! Use `az --version` to display the current version. Here are the base commands: diff --git a/src/azure-cli-core/azure/cli/core/_session.py b/src/azure-cli-core/azure/cli/core/_session.py index 75441073f75..52f83bb79d2 100644 --- a/src/azure-cli-core/azure/cli/core/_session.py +++ b/src/azure-cli-core/azure/cli/core/_session.py @@ -13,8 +13,15 @@ except ImportError: import collections +from codecs import open as codecs_open + from knack.log import get_logger +try: + t_JSONDecodeError = json.JSONDecodeError +except AttributeError: # in Python 2.7 + t_JSONDecodeError = ValueError + class Session(collections.MutableMapping): """ @@ -38,14 +45,14 @@ def load(self, filename, max_age=0): st = os.stat(self.filename) if st.st_mtime + max_age < time.time(): self.save() - with open(self.filename, 'r', encoding=self._encoding) as f: + with codecs_open(self.filename, 'r', encoding=self._encoding) as f: self.data = json.load(f) - except (OSError, IOError, json.JSONDecodeError) as load_exception: + except (OSError, IOError, t_JSONDecodeError) as load_exception: # OSError / IOError should imply file not found issues which are expected on fresh runs (e.g. on build # agents or new systems). A parse error indicates invalid/bad data in the file. We do not wish to warn # on missing files since we expect that, but do if the data isn't parsing as expected. log_level = logging.INFO - if isinstance(load_exception, json.JSONDecodeError): + if isinstance(load_exception, t_JSONDecodeError): log_level = logging.WARNING get_logger(__name__).log(log_level, @@ -55,7 +62,7 @@ def load(self, filename, max_age=0): def save(self): if self.filename: - with open(self.filename, 'w', encoding=self._encoding) as f: + with codecs_open(self.filename, 'w', encoding=self._encoding) as f: json.dump(self.data, f) def save_with_retry(self, retries=5): diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py index 9021ac2111e..f5db382d736 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_util.py @@ -6,7 +6,7 @@ # pylint: disable=protected-access import unittest -from ..util import scopes_to_resource, resource_to_scopes, _generate_login_command +from ..util import scopes_to_resource, resource_to_scopes, _normalize_scopes, _generate_login_command class TestUtil(unittest.TestCase): @@ -50,6 +50,21 @@ def test_resource_to_scopes(self): self.assertEqual(resource_to_scopes('https://managedhsm.azure.com'), ['https://managedhsm.azure.com/.default']) + def test_normalize_scopes(self): + # Test no scopes + self.assertIsNone(_normalize_scopes(())) + self.assertIsNone(_normalize_scopes([])) + self.assertIsNone(_normalize_scopes(None)) + + # Test multiple scopes, with the first one discarded + scopes = _normalize_scopes(("https://management.core.windows.net//.default", + "https://management.core.chinacloudapi.cn//.default")) + self.assertEqual(list(scopes), ["https://management.core.chinacloudapi.cn//.default"]) + + # Test single scopes (the correct usage) + scopes = _normalize_scopes(("https://management.core.chinacloudapi.cn//.default",)) + self.assertEqual(list(scopes), ["https://management.core.chinacloudapi.cn//.default"]) + def test_generate_login_command(self): # No parameter is given assert _generate_login_command() == 'az login' diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index 1490bcd424e..3611ea2409d 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -24,7 +24,7 @@ def aad_error_handler(error, **kwargs): login_message = _generate_login_message(**kwargs) from azure.cli.core.azclierror import AuthenticationError - raise AuthenticationError(msg, recommendation=login_message, msal_result=error) + raise AuthenticationError(msg, recommendation=login_message) def _generate_login_command(scopes=None): diff --git a/src/azure-cli-core/azure/cli/core/azclierror.py b/src/azure-cli-core/azure/cli/core/azclierror.py index 82775cf4cef..e3b58946c2f 100644 --- a/src/azure-cli-core/azure/cli/core/azclierror.py +++ b/src/azure-cli-core/azure/cli/core/azclierror.py @@ -25,10 +25,9 @@ class AzCLIError(CLIError): """ Base class for all the AzureCLI defined error classes. DO NOT raise this error class in your codes. """ - def __init__(self, error_msg, recommendation=None, original_error=None): + def __init__(self, error_msg, recommendation=None): # error message self.error_msg = error_msg - self.original_error = original_error # manual recommendations provided based on developers' knowledge self.recommendations = [] @@ -265,10 +264,7 @@ class RecommendationError(ClientError): pass -class AuthenticationError(AzCLIError): - """ Raised when authentication fails. """ - def __init__(self, error_msg, recommendation=None, msal_result=None): - super().__init__(error_msg, recommendation) - self.msal_result = msal_result +class AuthenticationError(ServiceError): + """ Raised when AAD authentication fails. """ # endregion diff --git a/src/azure-cli-core/azure/cli/core/azlogging.py b/src/azure-cli-core/azure/cli/core/azlogging.py index e4e24bc85ce..acb5add203a 100644 --- a/src/azure-cli-core/azure/cli/core/azlogging.py +++ b/src/azure-cli-core/azure/cli/core/azlogging.py @@ -51,18 +51,13 @@ def __init__(self, name, cli_ctx=None): def configure(self, args): super(AzCliLogging, self).configure(args) - if self.log_level: - # When invoked by pytest, configure() is skipped and log_level will not be set. - from knack.log import CliLogLevel - + from knack.log import CliLogLevel + if self.log_level == CliLogLevel.DEBUG: # As azure.core.pipeline.policies.http_logging_policy is a redacted version of - # azure.core.pipeline.policies._universal, always disable it + # azure.core.pipeline.policies._universal, disable azure.core.pipeline.policies.http_logging_policy + # when debug log is shown. logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.CRITICAL) - if self.log_level <= CliLogLevel.WARNING: - # Disable warnings from Azure Identity - logging.getLogger("azure.identity").setLevel(logging.CRITICAL) - def get_command_log_dir(self): return self.command_log_dir diff --git a/src/azure-cli-core/azure/cli/core/commands/client_factory.py b/src/azure-cli-core/azure/cli/core/commands/client_factory.py index 962cb485f67..4f13bc14916 100644 --- a/src/azure-cli-core/azure/cli/core/commands/client_factory.py +++ b/src/azure-cli-core/azure/cli/core/commands/client_factory.py @@ -117,7 +117,7 @@ def configure_common_settings(cli_ctx, client): def _prepare_client_kwargs_track2(cli_ctx): - """Prepare kwargs for Track 2 data and mgmt SDK clients.""" + """Prepare kwargs for Track 2 SDK client.""" client_kwargs = {} # Prepare connection_verify to change SSL verification behavior, used by ConnectionConfiguration diff --git a/src/azure-cli-core/azure/cli/core/profiles/_shared.py b/src/azure-cli-core/azure/cli/core/profiles/_shared.py index 30762d0b8da..84f2ae9d2fc 100644 --- a/src/azure-cli-core/azure/cli/core/profiles/_shared.py +++ b/src/azure-cli-core/azure/cli/core/profiles/_shared.py @@ -583,8 +583,7 @@ def supported_resource_type(api_profile, resource_type): return False -def _get_attr(sdk_path, mod_attr_path, checked=False): - """If `checked` is True, None is returned in case of import failure.""" +def _get_attr(sdk_path, mod_attr_path, checked=True): try: attr_mod, attr_path = mod_attr_path.split('#') \ if '#' in mod_attr_path else (mod_attr_path, '') diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index 299fc97c2dc..3b5b5abdd01 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -1355,7 +1355,7 @@ def test_login_common_tenant_mfa_warning(self, get_user_credential_mock, create_ 'suberror': 'basic_action' } - err = AuthenticationError(error_description, recommendation=None, msal_result=msal_result) + err = AuthenticationError(error_description, recommendation=None) # MFA error raised on the second call mock_arm_client.subscriptions.list.side_effect = [[deepcopy(self.subscription1_raw)], err] diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 3a345e9cded..66aedfe2b8d 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -155,8 +155,7 @@ def test_open_page_in_browser(self, subprocess_open_mock, webbrowser_open_mock): platform = sys.platform.lower() open_page_in_browser('http://foo') if is_wsl(): - subprocess_open_mock.assert_called_once_with(['powershell.exe', '-NoProfile', '-Command', - 'Start-Process "http://foo"']) + subprocess_open_mock.assert_called_once_with(['powershell.exe', '-Command', 'Start-Process "http://foo"']) elif platform == 'darwin': subprocess_open_mock.assert_called_once_with(['open', 'http://foo']) else: diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index a708525ed2b..524c45cf85e 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -87,7 +87,7 @@ def handle_exception(ex): # pylint: disable=too-many-locals, too-many-statement error_msg = extract_common_error_message(ex) status_code = str(getattr(ex, 'status_code', 'Unknown Code')) AzCLIErrorType = get_error_type_by_status_code(status_code) - az_error = AzCLIErrorType(error_msg, original_error=ex) + az_error = AzCLIErrorType(error_msg) elif isinstance(ex, ValidationError): az_error = azclierror.ValidationError(error_msg) @@ -100,7 +100,7 @@ def handle_exception(ex): # pylint: disable=too-many-locals, too-many-statement if extract_common_error_message(ex): error_msg = extract_common_error_message(ex) AzCLIErrorType = get_error_type_by_azure_error(ex) - az_error = AzCLIErrorType(error_msg, original_error=ex) + az_error = AzCLIErrorType(error_msg) elif isinstance(ex, AzureException): if is_azure_connection_error(error_msg): diff --git a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py index b369350b2cd..0d331179309 100644 --- a/src/azure-cli-testsdk/azure/cli/testsdk/patches.py +++ b/src/azure-cli-testsdk/azure/cli/testsdk/patches.py @@ -71,10 +71,8 @@ def get_token(*args, **kwargs): # pylint: disable=unused-argument import time fake_raw_token = 'top-secret-token-for-you' now = int(time.time()) - # Mock sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py:230 return AccessToken(fake_raw_token, now + 3600) - # Creating a PublicClientApplication will trigger an HTTP request to validate the tenant. Patch it! mock_in_unit_test(unit_test, 'azure.cli.core.auth.identity.UserCredential', UserCredentialMock) From 86cb6455ba6ca1782ae7c4f174b21a44745911db Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 11 Oct 2021 15:19:48 +0800 Subject: [PATCH 67/69] style --- src/azure-cli-core/azure/cli/core/auth/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/auth/util.py b/src/azure-cli-core/azure/cli/core/auth/util.py index 3611ea2409d..5d79080b907 100644 --- a/src/azure-cli-core/azure/cli/core/auth/util.py +++ b/src/azure-cli-core/azure/cli/core/auth/util.py @@ -4,7 +4,6 @@ # -------------------------------------------------------------------------------------------- from knack.log import get_logger -from azure.cli.core.util import in_cloud_console logger = get_logger(__name__) @@ -15,6 +14,7 @@ def aad_error_handler(error, **kwargs): # https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes # Search for an error code at https://login.microsoftonline.com/error + from azure.cli.core.util import in_cloud_console if in_cloud_console(): import socket logger.warning("A Cloud Shell credential problem occurred. When you report the issue with the error " From ed110ad9aec4d6cd8756f334ef32a14ca59c520b Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Tue, 12 Oct 2021 17:28:37 +0800 Subject: [PATCH 68/69] revert --- src/azure-cli/azure/cli/command_modules/configure/custom.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/configure/custom.py b/src/azure-cli/azure/cli/command_modules/configure/custom.py index b6a9b6aa38f..a9f203d411a 100644 --- a/src/azure-cli/azure/cli/command_modules/configure/custom.py +++ b/src/azure-cli/azure/cli/command_modules/configure/custom.py @@ -85,7 +85,6 @@ def _handle_global_configuration(config, cloud_forbid_telemetry): except ValueError: logger.error('TTL must be a positive integer') cache_ttl = None - # save the global config config.set_value('core', 'output', OUTPUT_LIST[output_index]['name']) config.set_value('core', 'collect_telemetry', 'yes' if allow_telemetry else 'no') From 2fc24e8c3691bf65e9736bc053d8ba8c3d8aaeba Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Wed, 13 Oct 2021 14:12:31 +0800 Subject: [PATCH 69/69] rename cache files --- src/azure-cli-core/azure/cli/core/_profile.py | 7 +------ .../azure/cli/core/auth/identity.py | 15 +++++---------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index f86e3a98e26..fd948429a67 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -142,12 +142,7 @@ def login(self, use_cert_sn_issuer=None, **kwargs): """ - For service principal credential, specify `password` as a dict like below. Only one key can exist: - { - 'secret': 'my_secret', - 'certificate': '/path/to/cert.pem', - 'client_assertion': 'my_token' - } + For service principal, `password` is a dict returned by ServicePrincipalAuth.build_credential """ if not scopes: scopes = self._arm_scope diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 40452cbc29f..37e065b0334 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -52,9 +52,9 @@ def __init__(self, authority=None, tenant_id=None, client_id=None): self.client_id = client_id or AZURE_CLI_CLIENT_ID config_dir = get_config_dir() - self._token_cache_file = os.path.join(config_dir, "tokenCache") - self._secret_file = os.path.join(config_dir, "secrets") - self._http_cache_file = os.path.join(config_dir, "msalHttpCache") + self._token_cache_file = os.path.join(config_dir, "msal_token_cache") + self._secret_file = os.path.join(config_dir, "service_principal_entries") + self._http_cache_file = os.path.join(config_dir, "msal_http_cache") # Prepare HTTP cache. # https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/407 @@ -134,12 +134,7 @@ def login_with_username_password(self, username, password, scopes=None, **kwargs def login_with_service_principal(self, client_id, credential, scopes=None): """ - 'credential' is a dict like below. Only one key can exist: - { - 'secret': 'my_secret', - 'certificate': '/path/to/cert.pem', - 'client_assertion': 'my_federated_token' - } + `credential` is a dict returned by ServicePrincipalAuth.build_credential """ sp_auth = ServicePrincipalAuth.build_from_credential(self.tenant_id, client_id, credential) @@ -228,7 +223,7 @@ def build_from_credential(cls, tenant_id, client_id, credential): def build_credential(cls, secret_or_certificate=None, client_assertion=None, use_cert_sn_issuer=None): """Build credential from user input. The credential looks like below, but only one key can exist. { - 'secret': 'my_secret', + 'client_secret': 'my_secret', 'certificate': '/path/to/cert.pem', 'client_assertion': 'my_federated_token' }