diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index 3223ae7d87139..bd3a14d3805d8 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -1,6 +1,7 @@ # Release History ## 1.4.0b6 (Unreleased) +- Upgraded minimum `msal` version to 1.3.0 - The async `AzureCliCredential` correctly invokes `/bin/sh` ([#12048](https://github.com/Azure/azure-sdk-for-python/issues/12048)) @@ -18,14 +19,14 @@ identity by its client ID, continue using the `client_id` argument. To specify an identity by any other ID, use the `identity_config` argument, for example: `ManagedIdentityCredential(identity_config={"object_id": ".."})` - ([#10989](https://github.com/Azure/azure-sdk-for-python/issues/10989)) + ([#10989](https://github.com/Azure/azure-sdk-for-python/issues/10989)) - `CertificateCredential` and `ClientSecretCredential` can optionally store access tokens they acquire in a persistent cache. To enable this, construct the credential with `enable_persistent_cache=True`. On Linux, the persistent cache requires libsecret and `pygobject`. If these are unavailable or unusable (e.g. in an SSH session), loading the persistent cache will raise an error. You may optionally configure the credential to fall back to an - unencrypted cache by constructing it with keyword argument + unencrypted cache by constructing it with keyword argument `allow_unencrypted_cache=True`. ([#11347](https://github.com/Azure/azure-sdk-for-python/issues/11347)) - `AzureCliCredential` raises `CredentialUnavailableError` when no user is @@ -66,7 +67,7 @@ ## 1.4.0b3 (2020-05-04) - `EnvironmentCredential` correctly initializes `UsernamePasswordCredential` -with the value of `AZURE_TENANT_ID` +with the value of `AZURE_TENANT_ID` ([#11127](https://github.com/Azure/azure-sdk-for-python/pull/11127)) - Values for the constructor keyword argument `authority` and `AZURE_AUTHORITY_HOST` may optionally specify an "https" scheme. For example, @@ -86,7 +87,7 @@ with the value of `AZURE_TENANT_ID` - `enable_persistent_cache=True` configures these credentials to use a persistent cache on supported platforms (in this release, Windows only). By default they cache in memory only. -- Now `DefaultAzureCredential` can authenticate with the identity signed in to +- Now `DefaultAzureCredential` can authenticate with the identity signed in to Visual Studio Code's Azure extension. ([#10472](https://github.com/Azure/azure-sdk-for-python/issues/10472)) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/user_password.py b/sdk/identity/azure-identity/azure/identity/_credentials/user_password.py index 2a5edf281a5dd..d2a7a80c342a5 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/user_password.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/user_password.py @@ -55,7 +55,6 @@ def __init__(self, client_id, username, password, **kwargs): def _request_token(self, *scopes, **kwargs): # type: (*str, **Any) -> dict app = self._get_app() - with self._adapter: - return app.acquire_token_by_username_password( - username=self._username, password=self._password, scopes=list(scopes) - ) + return app.acquire_token_by_username_password( + username=self._username, password=self._password, scopes=list(scopes) + ) diff --git a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py index 40a11461b5596..4d4f3f5989a75 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py @@ -37,8 +37,7 @@ def get_default_authority(): from .certificate_credential_base import CertificateCredentialBase from .client_secret_credential_base import ClientSecretCredentialBase from .exception_wrapper import wrap_exceptions -from .msal_credentials import ConfidentialClientCredential, InteractiveCredential, PublicClientCredential -from .msal_transport_adapter import MsalTransportAdapter, MsalTransportResponse +from .msal_credentials import InteractiveCredential, PublicClientCredential def _scopes_to_resource(*scopes): @@ -62,11 +61,8 @@ def _scopes_to_resource(*scopes): "AadClientCertificate", "CertificateCredentialBase", "ClientSecretCredentialBase", - "ConfidentialClientCredential", "get_default_authority", "InteractiveCredential", - "MsalTransportAdapter", - "MsalTransportResponse", "normalize_authority", "PublicClientCredential", "wrap_exceptions", diff --git a/sdk/identity/azure-identity/azure/identity/_internal/msal_client.py b/sdk/identity/azure-identity/azure/identity/_internal/msal_client.py new file mode 100644 index 0000000000000..6072e2af617f6 --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_internal/msal_client.py @@ -0,0 +1,137 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import six + +from azure.core.configuration import Configuration +from azure.core.exceptions import ClientAuthenticationError +from azure.core.pipeline import Pipeline +from azure.core.pipeline.policies import ( + ContentDecodePolicy, + DistributedTracingPolicy, + HttpLoggingPolicy, + NetworkTraceLoggingPolicy, + ProxyPolicy, + RetryPolicy, + UserAgentPolicy, +) +from azure.core.pipeline.transport import HttpRequest, RequestsTransport + +from .user_agent import USER_AGENT + +try: + from typing import TYPE_CHECKING +except ImportError: + TYPE_CHECKING = False + +if TYPE_CHECKING: + # pylint:disable=unused-import,ungrouped-imports + from typing import Any, Dict, List, Optional, Union + from azure.core.pipeline import PipelineResponse + from azure.core.pipeline.policies import HTTPPolicy, SansIOHTTPPolicy + from azure.core.pipeline.transport import HttpTransport + + PolicyList = List[Union[HTTPPolicy, SansIOHTTPPolicy]] + RequestData = Union[Dict[str, str], str] + + +class MsalResponse(object): + """Wraps HttpResponse according to msal.oauth2cli.http""" + + def __init__(self, response): + # type: (PipelineResponse) -> None + self._response = response + + @property + def status_code(self): + # type: () -> int + return self._response.http_response.status_code + + @property + def text(self): + # type: () -> str + return self._response.http_response.text(encoding="utf-8") + + def raise_for_status(self): + if self.status_code < 400: + return + + if ContentDecodePolicy.CONTEXT_NAME in self._response.context: + content = self._response.context[ContentDecodePolicy.CONTEXT_NAME] + if "error" in content or "error_description" in content: + message = "Authentication failed: {}".format(content.get("error_description") or content.get("error")) + else: + for secret in ("access_token", "refresh_token"): + if secret in content: + content[secret] = "***" + message = 'Unexpected response from Azure Active Directory: "{}"'.format(content) + else: + message = "Unexpected response from Azure Active Directory" + + raise ClientAuthenticationError(message=message, response=self._response.http_response) + + +class MsalClient(object): + """Wraps Pipeline according to msal.oauth2cli.http""" + + def __init__(self, **kwargs): # pylint:disable=missing-client-constructor-parameter-credential + # type: (**Any) -> None + self._pipeline = _build_pipeline(**kwargs) + + def post(self, url, params=None, data=None, headers=None, **kwargs): # pylint:disable=unused-argument + # type: (str, Optional[Dict[str, str]], RequestData, Optional[Dict[str, str]], **Any) -> MsalResponse + request = HttpRequest("POST", url, headers=headers) + if params: + request.format_parameters(params) + if data: + if isinstance(data, dict): + request.headers["Content-Type"] = "application/x-www-form-urlencoded" + request.set_formdata_body(data) + elif isinstance(data, six.text_type): + body_bytes = six.ensure_binary(data) + request.set_bytes_body(body_bytes) + else: + raise ValueError('expected "data" to be text or a dict') + + response = self._pipeline.run(request) + return MsalResponse(response) + + def get(self, url, params=None, headers=None, **kwargs): # pylint:disable=unused-argument + # type: (str, Optional[Dict[str, str]], Optional[Dict[str, str]], **Any) -> MsalResponse + request = HttpRequest("GET", url, headers=headers) + if params: + request.format_parameters(params) + response = self._pipeline.run(request) + return MsalResponse(response) + + +def _create_config(**kwargs): + # type: (Any) -> Configuration + config = Configuration(**kwargs) + config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) + config.retry_policy = RetryPolicy(**kwargs) + config.proxy_policy = ProxyPolicy(**kwargs) + config.user_agent_policy = UserAgentPolicy(base_user_agent=USER_AGENT, **kwargs) + return config + + +def _build_pipeline(config=None, policies=None, transport=None, **kwargs): + # type: (Optional[Configuration], Optional[PolicyList], Optional[HttpTransport], **Any) -> Pipeline + config = config or _create_config(**kwargs) + + if policies is None: # [] is a valid policy list + policies = [ + ContentDecodePolicy(), + config.user_agent_policy, + config.proxy_policy, + config.retry_policy, + config.logging_policy, + DistributedTracingPolicy(**kwargs), + HttpLoggingPolicy(**kwargs), + ] + + if not transport: + transport = RequestsTransport(**kwargs) + + return Pipeline(transport=transport, policies=policies) diff --git a/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py b/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py index b408d37d69ac8..826ed9ef50d20 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py @@ -2,9 +2,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -"""Credentials wrapping MSAL applications and delegating token acquisition and caching to them. -This entails monkeypatching MSAL's OAuth client with an adapter substituting an azure-core pipeline for Requests. -""" import abc import base64 import json @@ -17,7 +14,7 @@ from azure.core.exceptions import ClientAuthenticationError from .exception_wrapper import wrap_exceptions -from .msal_transport_adapter import MsalTransportAdapter +from .msal_client import MsalClient from .persistent_cache import load_user_cache from .._constants import KnownAuthorities from .._exceptions import AuthenticationRequiredError, CredentialUnavailableError @@ -102,7 +99,7 @@ def __init__(self, client_id, client_credential=None, **kwargs): else: self._cache = msal.TokenCache() - self._adapter = kwargs.pop("msal_adapter", None) or MsalTransportAdapter(**kwargs) + self._client = MsalClient(**kwargs) # postpone creating the wrapped application because its initializer uses the network self._msal_app = None # type: Optional[msal.ClientApplication] @@ -119,53 +116,17 @@ def _get_app(self): def _create_app(self, cls): # type: (Type[msal.ClientApplication]) -> msal.ClientApplication - """Creates an MSAL application, patching msal.authority to use an azure-core pipeline during tenant discovery""" - - # MSAL application initializers use msal.authority to send AAD tenant discovery requests - with self._adapter: - # MSAL's "authority" is a URL e.g. https://login.microsoftonline.com/common - app = cls( - client_id=self._client_id, - client_credential=self._client_credential, - authority="{}/{}".format(self._authority, self._tenant_id), - token_cache=self._cache, - ) - - # monkeypatch the app to replace requests.Session with MsalTransportAdapter - app.client.session.close() - app.client.session = self._adapter + app = cls( + client_id=self._client_id, + client_credential=self._client_credential, + authority="{}/{}".format(self._authority, self._tenant_id), + token_cache=self._cache, + http_client=self._client, + ) return app -class ConfidentialClientCredential(MsalCredential): - """Wraps an MSAL ConfidentialClientApplication with the TokenCredential API""" - - @wrap_exceptions - def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument - # type: (*str, **Any) -> AccessToken - - # MSAL requires scopes be a list - scopes = list(scopes) # type: ignore - now = int(time.time()) - - # First try to get a cached access token or if a refresh token is cached, redeem it for an access token. - # Failing that, acquire a new token. - app = self._get_app() - result = app.acquire_token_silent(scopes, account=None) or app.acquire_token_for_client(scopes) - - if "access_token" not in result: - raise ClientAuthenticationError(message="authentication failed: {}".format(result.get("error_description"))) - - return AccessToken(result["access_token"], now + int(result["expires_in"])) - - def _get_app(self): - # type: () -> msal.ConfidentialClientApplication - if not self._msal_app: - self._msal_app = self._create_app(msal.ConfidentialClientApplication) - return self._msal_app - - class PublicClientCredential(MsalCredential): """Wraps an MSAL PublicClientApplication with the TokenCredential API""" diff --git a/sdk/identity/azure-identity/azure/identity/_internal/msal_transport_adapter.py b/sdk/identity/azure-identity/azure/identity/_internal/msal_transport_adapter.py deleted file mode 100644 index d4bd1b3338ce6..0000000000000 --- a/sdk/identity/azure-identity/azure/identity/_internal/msal_transport_adapter.py +++ /dev/null @@ -1,134 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -"""Adapter to substitute an azure-core pipeline for Requests in MSAL application token acquisition methods.""" - -import json - -from azure.core.configuration import Configuration -from azure.core.exceptions import ClientAuthenticationError -from azure.core.pipeline import Pipeline -from azure.core.pipeline.policies import ( - ContentDecodePolicy, - DistributedTracingPolicy, - HttpLoggingPolicy, - NetworkTraceLoggingPolicy, - ProxyPolicy, - RetryPolicy, - UserAgentPolicy, -) -from azure.core.pipeline.transport import HttpRequest, RequestsTransport - -from .user_agent import USER_AGENT - -try: - from unittest import mock -except ImportError: # python < 3.3 - import mock # type: ignore - -try: - from typing import TYPE_CHECKING -except ImportError: - TYPE_CHECKING = False - -if TYPE_CHECKING: - # pylint:disable=unused-import,ungrouped-imports - from typing import Any, Dict, Mapping, Optional - from azure.core.pipeline import PipelineResponse - - -class MsalTransportResponse: - """Wraps an azure-core PipelineResponse with the shape of requests.Response""" - - def __init__(self, pipeline_response): - # type: (PipelineResponse) -> None - self._response = pipeline_response.http_response - self.status_code = self._response.status_code - self.text = self._response.text() - - def json(self, **kwargs): - # type: (Any) -> Mapping[str, Any] - return json.loads(self.text, **kwargs) - - def raise_for_status(self): - # type: () -> None - if self.status_code >= 400: - raise ClientAuthenticationError("authentication failed", self._response) - - -class MsalTransportAdapter(object): - """Wraps an azure-core pipeline with the shape of ``requests.Session``. - - Used as a context manager, patches msal.authority to intercept calls to requests. - """ - - def __init__(self, **kwargs): - # type: (Any) -> None - super(MsalTransportAdapter, self).__init__() - self._patch = mock.patch("msal.authority.requests", self) - self._pipeline = self._build_pipeline(**kwargs) - - def __enter__(self): - self._patch.__enter__() - return self - - def __exit__(self, *args): - self._patch.__exit__(*args) - - @staticmethod - def _create_config(**kwargs): - # type: (Any) -> Configuration - config = Configuration(**kwargs) - config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) - config.retry_policy = RetryPolicy(**kwargs) - config.proxy_policy = ProxyPolicy(**kwargs) - config.user_agent_policy = UserAgentPolicy(base_user_agent=USER_AGENT, **kwargs) - return config - - def _build_pipeline(self, config=None, policies=None, transport=None, **kwargs): - config = config or self._create_config(**kwargs) - policies = policies or [ - ContentDecodePolicy(), - config.user_agent_policy, - config.proxy_policy, - config.retry_policy, - config.logging_policy, - DistributedTracingPolicy(**kwargs), - HttpLoggingPolicy(**kwargs), - ] - if not transport: - transport = RequestsTransport(**kwargs) - return Pipeline(transport=transport, policies=policies) - - def get(self, url, headers=None, params=None, timeout=None, verify=None, **kwargs): - # type: (str, Optional[Mapping[str, str]], Optional[Dict[str, str]], float, bool, Any) -> MsalTransportResponse - request = HttpRequest("GET", url, headers=headers) - if params: - request.format_parameters(params) - response = self._pipeline.run( - request, stream=False, connection_timeout=timeout, connection_verify=verify, **kwargs - ) - return MsalTransportResponse(response) - - def post( - self, - url, # type: str - data=None, # type: Optional[Mapping[str, str]] - headers=None, # type: Optional[Mapping[str, str]] - params=None, # type: Optional[Dict[str, str]] - timeout=None, # type: float - verify=None, # type: bool - **kwargs # type: Any - ): - # type: (...) -> MsalTransportResponse - request = HttpRequest("POST", url, headers=headers) - if params: - request.format_parameters(params) - if data: - request.headers["Content-Type"] = "application/x-www-form-urlencoded" - request.set_formdata_body(data) - response = self._pipeline.run( - request, stream=False, connection_timeout=timeout, connection_verify=verify, **kwargs - ) - return MsalTransportResponse(response) diff --git a/sdk/identity/azure-identity/azure/identity/aio/_internal/__init__.py b/sdk/identity/azure-identity/azure/identity/aio/_internal/__init__.py index 097b56a96c854..82f17fb260f89 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_internal/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_internal/__init__.py @@ -4,6 +4,5 @@ # ------------------------------------ from .aad_client import AadClient from .exception_wrapper import wrap_exceptions -from .msal_transport_adapter import MsalTransportAdapter -__all__ = ["AadClient", "MsalTransportAdapter", "wrap_exceptions"] +__all__ = ["AadClient", "wrap_exceptions"] diff --git a/sdk/identity/azure-identity/azure/identity/aio/_internal/msal_transport_adapter.py b/sdk/identity/azure-identity/azure/identity/aio/_internal/msal_transport_adapter.py deleted file mode 100644 index 1f4d12e151cc1..0000000000000 --- a/sdk/identity/azure-identity/azure/identity/aio/_internal/msal_transport_adapter.py +++ /dev/null @@ -1,121 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -"""Adapter to substitute an async azure-core pipeline for Requests in MSAL application token acquisition methods.""" - -import asyncio -from typing import TYPE_CHECKING - -from azure.core.configuration import Configuration -from azure.core.pipeline import AsyncPipeline -from azure.core.pipeline.policies import ( - AsyncRetryPolicy, - DistributedTracingPolicy, - HttpLoggingPolicy, - NetworkTraceLoggingPolicy, - ProxyPolicy, - UserAgentPolicy, -) -from azure.core.pipeline.transport import AioHttpTransport, HttpRequest - -from ..._internal import MsalTransportResponse -from ..._internal.user_agent import USER_AGENT - -if TYPE_CHECKING: - # pylint:disable=unused-import - from typing import Any, Dict, Iterable, Optional - from azure.core.pipeline.policies import AsyncHTTPPolicy - from azure.core.pipeline.transport import AsyncHttpTransport - - -class MsalTransportAdapter: - """Wraps an async azure-core pipeline with the shape of a (synchronous) Requests Session""" - - def __init__( - self, - config: "Optional[Configuration]" = None, - policies: "Optional[Iterable[AsyncHTTPPolicy]]" = None, - transport: "Optional[AsyncHttpTransport]" = None, - **kwargs: "Any" - ) -> None: - - config = config or self._create_config(**kwargs) - policies = policies or [ - config.user_agent_policy, - config.proxy_policy, - config.retry_policy, - config.logging_policy, - DistributedTracingPolicy(**kwargs), - HttpLoggingPolicy(**kwargs), - ] - self._transport = transport or AioHttpTransport(configuration=config) - self._pipeline = AsyncPipeline(transport=self._transport, policies=policies) - - async def __aenter__(self): - await self._pipeline.__aenter__() - return self - - async def __aexit__(self, *args): - await self.close() - - async def close(self): - """Close the adapter's transport session.""" - await self._pipeline.__aexit__() - - def get( - self, - url: str, - loop: "asyncio.AbstractEventLoop", - headers: "Optional[Dict[str, str]]" = None, - params: "Optional[Dict[str, str]]" = None, - timeout: "Optional[float]" = None, - verify: "Optional[bool]" = None, - **kwargs: "Any" - ) -> MsalTransportResponse: - - request = HttpRequest("GET", url, headers=headers) - if params: - request.format_parameters(params) - - future = asyncio.run_coroutine_threadsafe( # type: ignore - self._pipeline.run(request, connection_timeout=timeout, connection_verify=verify, **kwargs), loop - ) - response = future.result(timeout=timeout) - - return MsalTransportResponse(response) - - def post( - self, - url: str, - loop: "asyncio.AbstractEventLoop", - data: "Any" = None, - headers: "Optional[Dict[str, str]]" = None, - params: "Optional[Dict[str, str]]" = None, - timeout: "Optional[float]" = None, - verify: "Optional[bool]" = None, - **kwargs: "Any" - ) -> MsalTransportResponse: - - request = HttpRequest("POST", url, headers=headers) - if params: - request.format_parameters(params) - if data: - request.headers["Content-Type"] = "application/x-www-form-urlencoded" - request.set_formdata_body(data) - - future = asyncio.run_coroutine_threadsafe( # type: ignore - self._pipeline.run(request, connection_timeout=timeout, connection_verify=verify, **kwargs), loop - ) - response = future.result(timeout=timeout) - - return MsalTransportResponse(response) - - @staticmethod - def _create_config(**kwargs: "Any") -> Configuration: - config = Configuration(**kwargs) - config.proxy_policy = ProxyPolicy(**kwargs) - config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) - config.retry_policy = AsyncRetryPolicy(**kwargs) - config.user_agent_policy = UserAgentPolicy(base_user_agent=USER_AGENT, **kwargs) - return config diff --git a/sdk/identity/azure-identity/setup.py b/sdk/identity/azure-identity/setup.py index d356f803e476d..95922e1a6d6a3 100644 --- a/sdk/identity/azure-identity/setup.py +++ b/sdk/identity/azure-identity/setup.py @@ -73,7 +73,7 @@ install_requires=[ "azure-core<2.0.0,>=1.0.0", "cryptography>=2.1.4", - "msal<2.0.0,>=1.1.0", + "msal<2.0.0,>=1.3.0", "msal-extensions~=0.2.2", "six>=1.6", ], diff --git a/sdk/identity/azure-identity/tests/test_live.py b/sdk/identity/azure-identity/tests/test_live.py index 23d0779d0dc34..20521b54a2c09 100644 --- a/sdk/identity/azure-identity/tests/test_live.py +++ b/sdk/identity/azure-identity/tests/test_live.py @@ -9,12 +9,10 @@ CertificateCredential, ClientSecretCredential, DeviceCodeCredential, - KnownAuthorities, InteractiveBrowserCredential, UsernamePasswordCredential, ) from azure.identity._constants import AZURE_CLI_CLIENT_ID -from azure.identity._internal import ConfidentialClientCredential ARM_SCOPE = "https://management.azure.com/.default" @@ -57,16 +55,6 @@ def test_default_credential(live_service_principal): get_token(credential) -def test_confidential_client_credential(live_service_principal): - credential = ConfidentialClientCredential( - client_id=live_service_principal["client_id"], - client_credential=live_service_principal["client_secret"], - authority=KnownAuthorities.AZURE_PUBLIC_CLOUD, - tenant_id=live_service_principal["tenant_id"], - ) - get_token(credential) - - def test_username_password_auth(live_user_details): credential = UsernamePasswordCredential( client_id=live_user_details["client_id"], diff --git a/shared_requirements.txt b/shared_requirements.txt index 70acf0e54fe9c..65f69e060da53 100644 --- a/shared_requirements.txt +++ b/shared_requirements.txt @@ -95,7 +95,7 @@ cryptography>=2.1.4 futures mock typing -msal<2.0.0,>=1.1.0 +msal<2.0.0,>=1.3.0 msal-extensions~=0.2.2 msrest>=0.5.0 msrestazure<2.0.0,>=0.4.32