From 9720536fe74aec3ff3df73400fc4ea1bb4106a93 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 15 May 2019 10:10:00 -0700 Subject: [PATCH 01/14] complete azure-identity prototype --- sdk/identity/azure-identity/README.md | 45 +++- .../azure-identity/azure/identity/__init__.py | 35 ++- .../{authn_client.py => _authn_client.py} | 44 ++-- .../azure-identity/azure/identity/_base.py | 50 ++++ .../azure/identity/aio/__init__.py | 16 +- .../aio/{authn_client.py => _authn_client.py} | 11 +- .../azure/identity/aio/credentials.py | 110 +++++++-- .../azure/identity/constants.py | 23 ++ .../azure/identity/credentials.py | 125 ++++++++-- .../azure/identity/exceptions.py | 11 +- .../azure-identity/dev_requirements.txt | 1 + sdk/identity/azure-identity/setup.py | 26 +-- sdk/identity/azure-identity/tests/conftest.py | 3 +- .../azure-identity/tests/private-key.pem | 35 +++ .../azure-identity/tests/test_credentials.py | 214 ++++++++++++++++++ .../tests/test_credentials_async.py | 213 +++++++++++++++++ .../azure-identity/tests/test_identity.py | 90 -------- .../tests/test_identity_async.py | 93 -------- shared_requirements.txt | 2 +- 19 files changed, 864 insertions(+), 283 deletions(-) rename sdk/identity/azure-identity/azure/identity/{authn_client.py => _authn_client.py} (77%) create mode 100644 sdk/identity/azure-identity/azure/identity/_base.py rename sdk/identity/azure-identity/azure/identity/aio/{authn_client.py => _authn_client.py} (89%) create mode 100644 sdk/identity/azure-identity/azure/identity/constants.py create mode 100644 sdk/identity/azure-identity/tests/private-key.pem create mode 100644 sdk/identity/azure-identity/tests/test_credentials.py create mode 100644 sdk/identity/azure-identity/tests/test_credentials_async.py delete mode 100644 sdk/identity/azure-identity/tests/test_identity.py delete mode 100644 sdk/identity/azure-identity/tests/test_identity_async.py diff --git a/sdk/identity/azure-identity/README.md b/sdk/identity/azure-identity/README.md index 690d317662cb..1132582d925e 100644 --- a/sdk/identity/azure-identity/README.md +++ b/sdk/identity/azure-identity/README.md @@ -5,16 +5,57 @@ # Key concepts # Examples -Shortest path to an access token: +Authenticating as a service principal: ```py +# using a client secret from azure.identity import ClientSecretCredential - credential = ClientSecretCredential(client_id, secret, tenant_id) # all credentials implement get_token token = credential.get_token(scopes=["https://vault.azure.net/.default"]) + +# using a certificate requires a thumbprint and PEM-encoded private key +from azure.identity import CertificateCredential +with open("private-key.pem") as f: + private_key = f.read() +credential = CertificateCredential(client_id, tenant_id, private_key, thumbprint) +``` + +Authenticating via environment variables: +```py +from azure.identity import EnvironmentCredential + +# will authenticate with client secret or certificate, +# depending on which environment variables are set +# (see constants.py for expected variable names) +credential = EnvironmentCredential() +token = credential.get_token(scopes=["https://vault.azure.net/.default"]) +``` + +Chaining together multiple credentials: +```py +from azure.identity import TokenCredentialChain + +# default credentials are environment then managed identity +credential_chain = TokenCredentialChain.default() + +scopes = ["https://vault.azure.net/.default"] +# the chain has a get_token method like all credentials +token = credential_chain.get_token(scopes) # try each credential in order, return the first token ``` +Authenticating from a service client: +```py +from azure.core.pipeline import Pipeline +from azure.core.pipeline.policies import BearerTokenCredentialPolicy + +credential_chain = TokenCredentialChain.default() +scopes = ["https://vault.azure.net/.default"] + +# BearerTokenCredentialPolicy gets tokens as necessary, adds appropriate auth headers to requests +policies = [BearerTokenCredentialPolicy(credential=credential_chain, scopes=scopes)] +pipeline = Pipeline(transport=some_transport, policies=policies) +``` # Troubleshooting # Next steps diff --git a/sdk/identity/azure-identity/azure/identity/__init__.py b/sdk/identity/azure-identity/azure/identity/__init__.py index 180c308436e4..f07942a4b7f8 100644 --- a/sdk/identity/azure-identity/azure/identity/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/__init__.py @@ -4,13 +4,40 @@ # license information. # -------------------------------------------------------------------------- from .exceptions import AuthenticationError -from .credentials import ClientSecretCredential, TokenCredentialChain +from .credentials import ( + CertificateCredential, + ClientSecretCredential, + EnvironmentCredential, + ManagedIdentityCredential, + TokenCredentialChain, +) -__all__ = ["AuthenticationError", "ClientSecretCredential", "TokenCredentialChain"] +__all__ = [ + "AuthenticationError", + "CertificateCredential", + "ClientSecretCredential", + "EnvironmentCredential", + "ManagedIdentityCredential", + "TokenCredentialChain", +] try: - from .aio import AsyncClientSecretCredential, AsyncTokenCredentialChain + from .aio import ( + AsyncCertificateCredential, + AsyncClientSecretCredential, + AsyncEnvironmentCredential, + AsyncManagedIdentityCredential, + AsyncTokenCredentialChain, + ) - __all__.extend(["AsyncClientSecretCredential", "AsyncTokenCredentialChain"]) + __all__.extend( + [ + "AsyncCertificateCredential", + "AsyncClientSecretCredential", + "AsyncEnvironmentCredential", + "AsyncManagedIdentityCredential", + "AsyncTokenCredentialChain", + ] + ) except SyntaxError: pass diff --git a/sdk/identity/azure-identity/azure/identity/authn_client.py b/sdk/identity/azure-identity/azure/identity/_authn_client.py similarity index 77% rename from sdk/identity/azure-identity/azure/identity/authn_client.py rename to sdk/identity/azure-identity/azure/identity/_authn_client.py index aca01dd10bdc..c162ae599299 100644 --- a/sdk/identity/azure-identity/azure/identity/authn_client.py +++ b/sdk/identity/azure-identity/azure/identity/_authn_client.py @@ -6,9 +6,9 @@ from time import time from azure.core import Configuration, HttpRequest -from azure.core.pipeline import Pipeline +from azure.core.pipeline import Pipeline, PipelineRequest from azure.core.pipeline.policies import ContentDecodePolicy, NetworkTraceLoggingPolicy, RetryPolicy -from azure.core.pipeline.transport import RequestsTransport +from azure.core.pipeline.transport import HttpTransport, RequestsTransport from msal import TokenCache from .exceptions import AuthenticationError @@ -18,16 +18,21 @@ except ImportError: TYPE_CHECKING = False if TYPE_CHECKING: - from typing import Any, Iterable, Mapping, Optional + from typing import Any, Dict, Iterable, Mapping, Optional + from azure.core.pipeline import PipelineResponse + from azure.core.pipeline.policies import HTTPPolicy -class _AuthnClientBase(object): +class AuthnClientBase(object): + """Sans I/O authentication client methods""" + def __init__(self, auth_url, **kwargs): + # type: (str, Mapping[str, Any]) -> None if not auth_url: raise ValueError("auth_url") - super(_AuthnClientBase, self).__init__(**kwargs) - self._cache = TokenCache() + super(AuthnClientBase, self).__init__() self._auth_url = auth_url + self._cache = TokenCache() def get_cached_token(self, scopes): # type: (Iterable[str]) -> Optional[str] @@ -38,16 +43,8 @@ def get_cached_token(self, scopes): return token["secret"] return None - def _prepare_request(self, method="POST", form_data=None, params=None): - request = HttpRequest(method, self._auth_url) - if form_data: - request.headers["Content-Type"] = "application/x-www-form-urlencoded" - request.set_formdata_body(form_data) - if params: - request.format_parameters(params) - return request - def _deserialize_and_cache_token(self, response, scopes): + # type: (PipelineResponse, Iterable[str]) -> str try: if "deserialized_data" in response.context: payload = response.context["deserialized_data"] @@ -61,11 +58,23 @@ def _deserialize_and_cache_token(self, response, scopes): except Exception as ex: raise AuthenticationError("Authentication failed: {}".format(str(ex))) + def _prepare_request(self, method="POST", form_data=None, params=None): + # type: (Optional[str], Optional[Mapping[str, str]], Optional[Dict[str, str]]) -> HttpRequest + request = HttpRequest(method, self._auth_url) + if form_data: + request.headers["Content-Type"] = "application/x-www-form-urlencoded" + request.set_formdata_body(form_data) + if params: + request.format_parameters(params) + return request + -class AuthnClient(_AuthnClientBase): +class AuthnClient(AuthnClientBase): + """Synchronous authentication client""" + def __init__(self, auth_url, config=None, policies=None, transport=None, **kwargs): + # type: (str, Optional[Configuration], Optional[Iterable[HTTPPolicy]], Optional[HttpTransport], Mapping[str, Any]) -> None config = config or self.create_config(**kwargs) - # TODO: ContentDecodePolicy doesn't accept kwargs policies = policies or [ContentDecodePolicy(), config.logging_policy, config.retry_policy] if not transport: transport = RequestsTransport(configuration=config) @@ -73,6 +82,7 @@ def __init__(self, auth_url, config=None, policies=None, transport=None, **kwarg super(AuthnClient, self).__init__(auth_url, **kwargs) def request_token(self, scopes, method="POST", form_data=None, params=None): + # type: (Iterable[str], Optional[str], Optional[Mapping[str, str]], Optional[Dict[str, str]]) -> str request = self._prepare_request(method, form_data, params) response = self._pipeline.run(request, stream=False) token = self._deserialize_and_cache_token(response, scopes) diff --git a/sdk/identity/azure-identity/azure/identity/_base.py b/sdk/identity/azure-identity/azure/identity/_base.py new file mode 100644 index 000000000000..ca9a202693de --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_base.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. +# -------------------------------------------------------------------------- +from msal.oauth2cli import JwtSigner + +from .constants import OAUTH_ENDPOINT + +try: + from typing import TYPE_CHECKING +except ImportError: + TYPE_CHECKING = False + +if TYPE_CHECKING: + # pylint:disable=unused-import + from typing import Any, Mapping + + +class ClientSecretCredentialBase(object): + def __init__(self, client_id, secret, tenant_id, **kwargs): + # type: (str, str, str, Mapping[str, Any]) -> None + if not client_id: + raise ValueError("client_id") + if not secret: + raise ValueError("secret") + if not tenant_id: + raise ValueError("tenant_id") + self._form_data = {"client_id": client_id, "client_secret": secret, "grant_type": "client_credentials"} + super(ClientSecretCredentialBase, self).__init__() + + +class CertificateCredentialBase(object): + def __init__(self, client_id, tenant_id, private_key, thumbprint, **kwargs): + # type: (str, str, str, str, Mapping[str, Any]) -> None + if not private_key: + raise ValueError("certificate credentials require private_key") + if not thumbprint: + raise ValueError("certificate credentials require thumbprint") + + super(CertificateCredentialBase, self).__init__() + auth_url = OAUTH_ENDPOINT.format(tenant_id) + signer = JwtSigner(private_key, "RS256", thumbprint) + client_assertion = signer.sign_assertion(audience=auth_url, issuer=client_id) + self._form_data = { + "client_assertion": client_assertion, + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_id": client_id, + "grant_type": "client_credentials", + } diff --git a/sdk/identity/azure-identity/azure/identity/aio/__init__.py b/sdk/identity/azure-identity/azure/identity/aio/__init__.py index 476dcc21e9dc..f4f8b6c0e22a 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/aio/__init__.py @@ -3,6 +3,18 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for # license information. # -------------------------------------------------------------------------- -from .credentials import AsyncClientSecretCredential, AsyncTokenCredentialChain +from .credentials import ( + AsyncCertificateCredential, + AsyncClientSecretCredential, + AsyncEnvironmentCredential, + AsyncManagedIdentityCredential, + AsyncTokenCredentialChain, +) -__all__ = ["AsyncClientSecretCredential", "AsyncTokenCredentialChain"] +__all__ = [ + "AsyncCertificateCredential", + "AsyncClientSecretCredential", + "AsyncEnvironmentCredential", + "AsyncManagedIdentityCredential", + "AsyncTokenCredentialChain", +] diff --git a/sdk/identity/azure-identity/azure/identity/aio/authn_client.py b/sdk/identity/azure-identity/azure/identity/aio/_authn_client.py similarity index 89% rename from sdk/identity/azure-identity/azure/identity/aio/authn_client.py rename to sdk/identity/azure-identity/azure/identity/aio/_authn_client.py index 6886b945642d..e3a49a52e344 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/authn_client.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_authn_client.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for # license information. # -------------------------------------------------------------------------- -from typing import Any, Iterable, Mapping, Optional +from typing import Any, Dict, Iterable, Mapping, Optional from azure.core import Configuration from azure.core.pipeline import AsyncPipeline @@ -11,10 +11,12 @@ from azure.core.pipeline.transport import AsyncHttpTransport from azure.core.pipeline.transport.requests_asyncio import AsyncioRequestsTransport -from ..authn_client import _AuthnClientBase +from .._authn_client import AuthnClientBase -class AsyncAuthnClient(_AuthnClientBase): +class AsyncAuthnClient(AuthnClientBase): + """Async authentication client""" + def __init__( self, auth_url: str, @@ -24,7 +26,6 @@ def __init__( **kwargs: Mapping[str, Any] ) -> None: config = config or self.create_config(**kwargs) - # TODO: ContentDecodePolicy doesn't accept kwargs policies = policies or [ContentDecodePolicy(), config.logging_policy, config.retry_policy] if not transport: transport = AsyncioRequestsTransport(configuration=config) @@ -36,7 +37,7 @@ async def request_token( scopes: Iterable[str], method: Optional[str] = "POST", form_data: Optional[Mapping[str, str]] = None, - params: Optional[Mapping[str, str]] = None, + params: Optional[Dict[str, str]] = None, ) -> str: request = self._prepare_request(method, form_data, params) response = await self._pipeline.run(request, stream=False) diff --git a/sdk/identity/azure-identity/azure/identity/aio/credentials.py b/sdk/identity/azure-identity/azure/identity/aio/credentials.py index 60474f476f95..cec10780be35 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/aio/credentials.py @@ -3,64 +3,130 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for # license information. # -------------------------------------------------------------------------- -from typing import Any, Dict, Iterable, Mapping, Optional +import os +from typing import Any, Iterable, Mapping, Optional, Union from azure.core import Configuration -from azure.core.pipeline.policies import HTTPPolicy +from azure.core.pipeline.policies import ContentDecodePolicy, HeadersPolicy, NetworkTraceLoggingPolicy, AsyncRetryPolicy -from .authn_client import AsyncAuthnClient +from ._authn_client import AsyncAuthnClient +from .._base import ClientSecretCredentialBase, CertificateCredentialBase +from ..constants import EnvironmentVariables, IMDS_ENDPOINT, OAUTH_ENDPOINT from ..credentials import TokenCredentialChain from ..exceptions import AuthenticationError - # pylint:disable=too-few-public-methods -# TODO: could share more code with sync -class _AsyncClientCredentialBase(object): - _OAUTH_ENDPOINT = "https://login.microsoftonline.com/{}/oauth2/v2.0/token" +class AsyncClientSecretCredential(ClientSecretCredentialBase): def __init__( self, client_id: str, + secret: str, tenant_id: str, config: Optional[Configuration] = None, - policies: Optional[Iterable[HTTPPolicy]] = None, **kwargs: Mapping[str, Any] ) -> None: - if not client_id: - raise ValueError("client_id") - if not tenant_id: - raise ValueError("tenant_id") - self._client = AsyncAuthnClient(self._OAUTH_ENDPOINT.format(tenant_id), config, policies, **kwargs) - self._form_data = {} # type: Dict[str, str] + super(AsyncClientSecretCredential, self).__init__(client_id, secret, tenant_id, **kwargs) + self._client = AsyncAuthnClient(OAUTH_ENDPOINT.format(tenant_id), config, **kwargs) async def get_token(self, scopes: Iterable[str]) -> str: - data = self._form_data.copy() - data["scope"] = " ".join(scopes) token = self._client.get_cached_token(scopes) if not token: + data = dict(self._form_data, scope=" ".join(scopes)) token = await self._client.request_token(scopes, form_data=data) return token # type: ignore -class AsyncClientSecretCredential(_AsyncClientCredentialBase): +class AsyncCertificateCredential(CertificateCredentialBase): def __init__( self, client_id: str, - secret: str, tenant_id: str, + private_key: str, + thumbprint: str, config: Optional[Configuration] = None, **kwargs: Mapping[str, Any] ) -> None: - if not secret: - raise ValueError("secret") - super(AsyncClientSecretCredential, self).__init__(client_id, tenant_id, config, **kwargs) - self._form_data = {"client_id": client_id, "client_secret": secret, "grant_type": "client_credentials"} + super(AsyncCertificateCredential, self).__init__(client_id, tenant_id, private_key, thumbprint, **kwargs) + self._client = AsyncAuthnClient(OAUTH_ENDPOINT.format(tenant_id), config, **kwargs) + + async def get_token(self, scopes: Iterable[str]) -> str: + token = self._client.get_cached_token(scopes) + if not token: + data = dict(self._form_data, scope=" ".join(scopes)) + token = await self._client.request_token(scopes, form_data=data) + return token # type: ignore + + +class AsyncEnvironmentCredential: + def __init__(self, **kwargs: Mapping[str, Any]) -> None: + self._credential = None # type: Optional[Union[AsyncCertificateCredential, AsyncClientSecretCredential]] + + if not any(v for v in EnvironmentVariables.CLIENT_SECRET_VARS if os.environ.get(v) is None): + self._credential = AsyncClientSecretCredential( + client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], + secret=os.environ[EnvironmentVariables.AZURE_CLIENT_SECRET], + tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], + **kwargs + ) + elif not any(v for v in EnvironmentVariables.CERT_VARS if os.environ.get(v) is None): + try: + with open(os.environ[EnvironmentVariables.AZURE_PRIVATE_KEY_FILE]) as private_key_file: + private_key = private_key_file.read() + except IOError: + return + + self._credential = AsyncCertificateCredential( + client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], + tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], + private_key=private_key, + thumbprint=os.environ[EnvironmentVariables.AZURE_THUMBPRINT], + **kwargs + ) + + async def get_token(self, scopes): + if not self._credential: + raise AuthenticationError("required environment variables not defined") + return await self._credential.get_token(scopes) + + +class AsyncManagedIdentityCredential: + def __init__(self, config: Optional[Configuration] = None, **kwargs: Mapping[str, Any]) -> None: + config = config or self.create_config(**kwargs) + policies = [config.header_policy, ContentDecodePolicy(), config.logging_policy, config.retry_policy] + self._client = AsyncAuthnClient(IMDS_ENDPOINT, config, policies) + + async def get_token(self, scopes: Iterable[str]) -> str: + scopes = list(scopes) + if len(scopes) != 1: + raise ValueError("Managed identity credential supports one scope per request") + token = self._client.get_cached_token(scopes) + if not token: + resource = scopes[0].rstrip("/.default") + token = await self._client.request_token( + scopes, method="GET", params={"api-version": "2018-02-01", "resource": resource} + ) + return token # type: ignore + + @staticmethod + def create_config(**kwargs: Mapping[str, Any]) -> Configuration: + config = Configuration(**kwargs) + config.header_policy = HeadersPolicy(base_headers={"Metadata": "true"}, **kwargs) + config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) + config.retry_policy = AsyncRetryPolicy( + retry_on_status_codes=[404, 429] + [x for x in range(500, 600)], **kwargs + ) + return config class AsyncTokenCredentialChain(TokenCredentialChain): """A sequence of token credentials""" + @classmethod + def default(cls): + return cls([AsyncEnvironmentCredential(), AsyncManagedIdentityCredential()]) + async def get_token(self, scopes: Iterable[str]) -> str: """Attempts to get a token from each credential, in order, returning the first token. If no token is acquired, raises an exception listing error messages. diff --git a/sdk/identity/azure-identity/azure/identity/constants.py b/sdk/identity/azure-identity/azure/identity/constants.py new file mode 100644 index 000000000000..ffa1b8dd739e --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/constants.py @@ -0,0 +1,23 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + + +class EnvironmentVariables: + # TODO: align cross-language + AZURE_CLIENT_ID = "AZURE_CLIENT_ID" + AZURE_CLIENT_SECRET = "AZURE_CLIENT_SECRET" + AZURE_TENANT_ID = "AZURE_TENANT_ID" + CLIENT_SECRET_VARS = (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID) + + AZURE_PRIVATE_KEY_FILE = "AZURE_PRIVATE_KEY_FILE" + AZURE_THUMBPRINT = "AZURE_THUMBPRINT" + CERT_VARS = (AZURE_CLIENT_ID, AZURE_PRIVATE_KEY_FILE, AZURE_TENANT_ID, AZURE_THUMBPRINT) + + +# https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http +IMDS_ENDPOINT = "http://169.254.169.254/metadata/identity/oauth2/token" + +OAUTH_ENDPOINT = "https://login.microsoftonline.com/{}/oauth2/v2.0/token" diff --git a/sdk/identity/azure-identity/azure/identity/credentials.py b/sdk/identity/azure-identity/azure/identity/credentials.py index 9b71bf3cd2dd..71553a7cbd25 100644 --- a/sdk/identity/azure-identity/azure/identity/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/credentials.py @@ -3,9 +3,14 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for # license information. # -------------------------------------------------------------------------- +import os + from azure.core import Configuration +from azure.core.pipeline.policies import ContentDecodePolicy, HeadersPolicy, NetworkTraceLoggingPolicy, RetryPolicy -from .authn_client import AuthnClient +from ._authn_client import AuthnClient +from ._base import ClientSecretCredentialBase, CertificateCredentialBase +from .constants import EnvironmentVariables, IMDS_ENDPOINT, OAUTH_ENDPOINT from .exceptions import AuthenticationError try: @@ -15,42 +20,112 @@ if TYPE_CHECKING: # pylint:disable=unused-import - from typing import Any, Dict, Iterable, Mapping, Optional - from azure.core.pipeline.policies import HTTPPolicy + from typing import Any, Iterable, List, Mapping, Optional, Union from azure.core.credentials import SupportsGetToken # pylint:disable=too-few-public-methods -class _ClientCredentialBase(object): - _OAUTH_ENDPOINT = "https://login.microsoftonline.com/{}/oauth2/v2.0/token" +class ClientSecretCredential(ClientSecretCredentialBase): + """Authenticates with a client secret""" - def __init__(self, client_id, tenant_id, config=None, policies=None, **kwargs): - # type: (str, str, Optional[Configuration], Optional[Iterable[HTTPPolicy]], Mapping[str, Any]) -> None - if not client_id: - raise ValueError("client_id") - if not tenant_id: - raise ValueError("tenant_id") - self._client = AuthnClient(self._OAUTH_ENDPOINT.format(tenant_id), config, policies, **kwargs) - self._form_data = {} # type: Dict[str, str] + def __init__(self, client_id, secret, tenant_id, config=None, **kwargs): + # type: (str, str, str, Optional[Configuration], Mapping[str, Any]) -> None + super(ClientSecretCredential, self).__init__(client_id, secret, tenant_id, **kwargs) + self._client = AuthnClient(OAUTH_ENDPOINT.format(tenant_id), config, **kwargs) def get_token(self, scopes): # type: (Iterable[str]) -> str - data = self._form_data.copy() - data["scope"] = " ".join(scopes) token = self._client.get_cached_token(scopes) if not token: - return self._client.request_token(scopes, form_data=data) + data = dict(self._form_data, scope=" ".join(scopes)) + token = self._client.request_token(scopes, form_data=data) return token -class ClientSecretCredential(_ClientCredentialBase): - def __init__(self, client_id, secret, tenant_id, config=None, **kwargs): - # type: (str, str, str, Optional[Configuration], Mapping[str, Any]) -> None - if not secret: - raise ValueError("secret") - super(ClientSecretCredential, self).__init__(client_id, tenant_id, config, **kwargs) - self._form_data = {"client_id": client_id, "client_secret": secret, "grant_type": "client_credentials"} +class CertificateCredential(CertificateCredentialBase): + """Authenticates with a certificate (thumbprint and PEM-encoded private key)""" + + def __init__(self, client_id, tenant_id, private_key, thumbprint, config=None, **kwargs): + # type: (str, str, str, str, Optional[Configuration], Mapping[str, Any]) -> None + self._client = AuthnClient(OAUTH_ENDPOINT.format(tenant_id), config, **kwargs) + super(CertificateCredential, self).__init__(client_id, tenant_id, private_key, thumbprint, **kwargs) + + def get_token(self, scopes): + # type: (Iterable[str]) -> str + token = self._client.get_cached_token(scopes) + if not token: + data = dict(self._form_data, scope=" ".join(scopes)) + token = self._client.request_token(scopes, form_data=data) + return token + + +class EnvironmentCredential: + """Authenticates with a secret or certificate (thumbprint and PEM-encoded private key)""" + + def __init__(self, **kwargs): + # type: (Mapping[str, Any]) -> None + self._credential = None # type: Optional[Union[CertificateCredential, ClientSecretCredential]] + + if not any(v for v in EnvironmentVariables.CLIENT_SECRET_VARS if os.environ.get(v) is None): + self._credential = ClientSecretCredential( + client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], + secret=os.environ[EnvironmentVariables.AZURE_CLIENT_SECRET], + tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], + **kwargs + ) + elif not any(v for v in EnvironmentVariables.CERT_VARS if os.environ.get(v) is None): + try: + with open(os.environ[EnvironmentVariables.AZURE_PRIVATE_KEY_FILE]) as private_key_file: + private_key = private_key_file.read() + except IOError: + return + + self._credential = CertificateCredential( + client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], + tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], + private_key=private_key, + thumbprint=os.environ[EnvironmentVariables.AZURE_THUMBPRINT], + **kwargs + ) + + def get_token(self, scopes): + # type: (Iterable[str]) -> str + if not self._credential: + raise AuthenticationError("required environment variables not defined") + return self._credential.get_token(scopes) + + +# TODO: support multiple identities? +class ManagedIdentityCredential: + """Authenticates with a managed identity""" + + def __init__(self, config=None, **kwargs): + # type: (Optional[Configuration], Mapping[str, Any]) -> None + config = config or self.create_config(**kwargs) + policies = [config.header_policy, ContentDecodePolicy(), config.logging_policy, config.retry_policy] + self._client = AuthnClient(IMDS_ENDPOINT, config, policies) + + @staticmethod + def create_config(**kwargs): + # type: (Mapping[str, str]) -> Configuration + config = Configuration(**kwargs) + config.header_policy = HeadersPolicy(base_headers={"Metadata": "true"}, **kwargs) + config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) + config.retry_policy = RetryPolicy(retry_on_status_codes=[404, 429] + [x for x in range(500, 600)], **kwargs) + return config + + def get_token(self, scopes): + # type: (List[str]) -> str + if len(scopes) != 1: + raise ValueError("Managed identity credential supports one scope per request") + token = self._client.get_cached_token(scopes) + if not token: + resource = scopes[0].rstrip("/.default") + token = self._client.request_token( + scopes, method="GET", params={"api-version": "2018-02-01", "resource": resource} + ) + return token class TokenCredentialChain: @@ -62,6 +137,10 @@ def __init__(self, credentials): raise ValueError("at least one credential is required") self._credentials = credentials + @classmethod + def default(cls): + return cls([EnvironmentCredential(), ManagedIdentityCredential()]) + def get_token(self, scopes): # type: (Iterable[str]) -> str """Attempts to get a token from each credential, in order, returning the first token. diff --git a/sdk/identity/azure-identity/azure/identity/exceptions.py b/sdk/identity/azure-identity/azure/identity/exceptions.py index f3edd0b45dee..672feb69be57 100644 --- a/sdk/identity/azure-identity/azure/identity/exceptions.py +++ b/sdk/identity/azure-identity/azure/identity/exceptions.py @@ -3,13 +3,8 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for # license information. # -------------------------------------------------------------------------- +from azure.core.exceptions import AzureError -# TODO: probably a better base for this in azure-core -class AuthenticationError(Exception): - def __init__(self, message): - # type: (str) -> None - self.message = message - - def __str__(self): - return self.message +class AuthenticationError(AzureError): + pass diff --git a/sdk/identity/azure-identity/dev_requirements.txt b/sdk/identity/azure-identity/dev_requirements.txt index e30ad303788a..4ac84a358c64 100644 --- a/sdk/identity/azure-identity/dev_requirements.txt +++ b/sdk/identity/azure-identity/dev_requirements.txt @@ -1 +1,2 @@ +-e ../../core/azure-core typing_extensions>=3.7.2 diff --git a/sdk/identity/azure-identity/setup.py b/sdk/identity/azure-identity/setup.py index dab4f6d8e050..8ac3d497e80c 100644 --- a/sdk/identity/azure-identity/setup.py +++ b/sdk/identity/azure-identity/setup.py @@ -5,7 +5,6 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- - import re import os.path from io import open @@ -37,27 +36,27 @@ # Version extraction inspired from 'requests' with open(os.path.join(package_folder_path, "version.py"), "r") as fd: - version = re.search(r'^VERSION\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE).group(1) + VERSION = re.search(r'^VERSION\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE).group(1) -if not version: +if not VERSION: raise RuntimeError("Cannot find version information") -# with open('README.rst', encoding='utf-8') as f: -# readme = f.read() +with open("README.md", encoding="utf-8") as f: + README = f.read() # with open('HISTORY.rst', encoding='utf-8') as f: # history = f.read() setup( name=PACKAGE_NAME, - version=version, + version=VERSION, description="Microsoft Azure {} Library for Python".format(PACKAGE_PPRINT_NAME), - # long_description=readme + '\n\n' + history, + long_description=README, license="MIT License", author="Microsoft Corporation", # author_email='', url="https://github.com/Azure/azure-sdk-for-python", classifiers=[ - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", @@ -76,10 +75,9 @@ "azure", ] ), - install_requires=["azure-core~=1.0.0", "msal>=0.3.1"], - extras_require={ - ":python_version<'3.0'": ["azure-nspkg"], - ":python_version<'3.4'": ["enum34>=1.0.4"], - ":python_version<'3.5'": ["typing"], - }, + install_requires=[ + # "azure-core~=1.0.0", TODO: commented until azure-core is published + "msal~=0.3.1" + ], + extras_require={":python_version<'3.0'": ["azure-nspkg"]}, ) diff --git a/sdk/identity/azure-identity/tests/conftest.py b/sdk/identity/azure-identity/tests/conftest.py index 01957ae3dd87..c79ec90326a3 100644 --- a/sdk/identity/azure-identity/tests/conftest.py +++ b/sdk/identity/azure-identity/tests/conftest.py @@ -6,6 +6,5 @@ import sys # Ignore collection of async tests for Python 2 -collect_ignore = [] if sys.version_info < (3, 5): - collect_ignore.append("test_identity_async.py") + collect_ignore_glob = ["*_async.py"] diff --git a/sdk/identity/azure-identity/tests/private-key.pem b/sdk/identity/azure-identity/tests/private-key.pem new file mode 100644 index 000000000000..636550d834dd --- /dev/null +++ b/sdk/identity/azure-identity/tests/private-key.pem @@ -0,0 +1,35 @@ +Bag Attributes + Microsoft Local Key set: + localKeyID: 01 00 00 00 + friendlyName: te-6c7ca2ba-5bec-4802-bde3-1755fb26d651 + Microsoft CSP Name: Microsoft Software Key Storage Provider +Key Attributes + X509v3 Key Usage: 90 +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZnnXDSPMKmbA+ +Lx+iZio78D0RS61lP5UfRQJU4NMnZhA1qEdVsvQfR+24eN4U34zXYsHMGP57NVRT +GYpy842oH6XAJ3uq59FgkO7NB6z1FXqnFbfAese+mssSlXtc91Ach4BpePk2/umO +eCZp/7l0pQPKMrawY8z7zogTnegAIwSDvVeGU0Zap4WPRMmMDAE38s7i0Dfxa1kh +9/TXvH/x6/+uNIyTIZ8PMH2uv9w/LNex4vh+Pw8jwo5XdVRXRessdMt2I3dVUtkw +AGHh1TA9Qkflji9tXjv3r2nRHqMLcqhPeIDJVkqUIqSiINQ61gy7CdkMxalp0MxE +6Mjo7eBpAgMBAAECggEAdv0XsvGeQnuKTFYD3A40pZVULrLMWoILjY90GOjdS7uY +vV4HsyooJTp1Ftqvw4YAQnyzLl+0NbYRJ2bdtsDJAdZcENcF3Yrnhv94Mw8xWMin +ydgsIsh/kw6cXsrxKwHnAdJtOj51Ncbn+YhkqKy0wLzBd7uG/Kd1G3HwIZnDkt6Q +E6J4G0u9atEX7pvV8/JBK3QxAZoOo1FkOO2IDk92z85G9+7GysDRssu6erzihX/b +/fG/DVx1vjeZz1jUC3rIbOhEmq1To0FdOlyRT79IW0RZWRNFAEaFE9sNYR4ZSsgZ +Bwznp1vwtwefPk31WJkGf+WRUERhG8vycOVOBErXCQKBgQD8fgO9JSESdOm8QIHt +kbqzA+IdyOL7lnK8SdWNiCqdX3e7ZokbYhlUvj6Hf3gGQUTHwTvGa2LIifrSo7jh +VvO7kIBRg7eMhK6mZMArEINFqEjlqnC5w+Js7hrhjMwPjwIzmcZ3R9GVWl1aq543 +Q8S+0aByxd7HKGG2DOtcdZxW1wKBgQDcpGtKslM3ql7hIMigrwg2vpB7OOj2P4Ja +cfY/DE4NJ+ZlAzPyDy/Hho7oSxb9ez0xxpOVz5/FpU0Ve1YfRU/41LAndHjudE3z +sxr7TjGcNS6Ao9790RCqtuE1LipaJ+pKvMS/4J94EM3jbpfZB0Dckmk0gz4lTvUM +5ZedRpravwKBgQDIfyRm6PnnFxGX3D2QMb1oc7f1YNTlZSV84MCEX9E/IFUKabSM +Gwz0XxF2NUFQ7jk4yfe2awWJKxASfdHMlmh605cho494NNAe7zgtujITeTtRrFNR +H/xH9ZdA7bYI0M21vfF8PHpvt88Ttd2wEs9Dm2BmYzuxOB7HGmE3DWl1BwKBgQCJ +1B+9wpWfYUrxoRQS5CPiZrpEbzF/mf6o1yW3Ds23BCS1FwIdBIWZQyIEU9vhrll0 +vZI19EPfKDp139zVneuuCdacXvKoKnkDce+56oetB7+r1jIXJcEeky0tllAYj3SZ +CUByiDO1wfGLT+uFRDWtU7xqdE2e6qrDSqyiL5fOawKBgDkkyj0ayZnxcMGgM5W6 +g1bwx5q3Kjc0FzGd8cmO5BLmxW8sCcQykOoZb42qmlGEJU0Q8ppiz82xmI7l+Sml +QdVTbroI9ECxwRFX97pu8gBR4rI1lyfCllI3Pm2Y6cvapki0eq+1jEReSclsyMK9 +mvgSQ07XbJjrqb6hJJo6iLmh +-----END PRIVATE KEY----- diff --git a/sdk/identity/azure-identity/tests/test_credentials.py b/sdk/identity/azure-identity/tests/test_credentials.py new file mode 100644 index 000000000000..b155d53b246d --- /dev/null +++ b/sdk/identity/azure-identity/tests/test_credentials.py @@ -0,0 +1,214 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import json +import os +import time +import uuid + +try: + from unittest.mock import Mock +except ImportError: # python < 3.3 + from mock import Mock + +import pytest +import requests +from azure.identity import ( + AuthenticationError, + ClientSecretCredential, + EnvironmentCredential, + TokenCredentialChain, + ManagedIdentityCredential, + CertificateCredential) +from azure.identity.constants import EnvironmentVariables + + +def test_client_secret_credential_cache(monkeypatch): + expired = "this token's expired" + now = time.time() + token_payload = { + "access_token": expired, + "expires_in": 0, + "ext_expires_in": 0, + "expires_on": now - 300, # expired 5 minutes ago + "not_before": now, + "token_type": "Bearer", + "resource": str(uuid.uuid1()), + } + + # monkeypatch requests so we can test pipeline configuration + mock_response = Mock(text=json.dumps(token_payload), headers={"content-type": "application/json"}, status_code=200) + mock_send = Mock(return_value=mock_response) + monkeypatch.setattr(requests.Session, "send", value=mock_send) + + credential = ClientSecretCredential("client_id", "secret", tenant_id=str(uuid.uuid1())) + scopes = ("https://foo.bar/.default", "https://bar.qux/.default") + token = credential.get_token(scopes) + assert token == expired + + token = credential.get_token(scopes) + assert token == expired + assert mock_send.call_count == 2 + + +def test_client_secret_environment_credential(monkeypatch): + client_id = "fake-client-id" + secret = "fake-client-secret" + tenant_id = "fake-tenant-id" + + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_SECRET, secret) + monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) + + success_message = "request passed validation" + + def validate_request(request, **kwargs): + assert tenant_id in request.url + assert request.data["client_id"] == client_id + assert request.data["client_secret"] == secret + # raising here makes mocking a transport response unnecessary + raise AuthenticationError(success_message) + + credential = EnvironmentCredential(transport=Mock(send=validate_request)) + with pytest.raises(AuthenticationError) as ex: + credential.get_token(("",)) + assert str(ex.value) == success_message + + +def test_cert_environment_credential(monkeypatch): + client_id = "fake-client-id" + private_key_file = os.path.join(os.path.dirname(__file__), "private-key.pem") + tenant_id = "fake-tenant-id" + thumbprint = "0ee111848510505f35155f0571067efa538ea036" + + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) + monkeypatch.setenv(EnvironmentVariables.AZURE_PRIVATE_KEY_FILE, private_key_file) + monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) + monkeypatch.setenv(EnvironmentVariables.AZURE_THUMBPRINT, thumbprint) + + success_message = "request passed validation" + + def validate_request(request, **kwargs): + assert tenant_id in request.url + assert request.data["client_id"] == client_id + # raising here makes mocking a transport response unnecessary + raise AuthenticationError(success_message) + + credential = EnvironmentCredential(transport=Mock(send=validate_request)) + with pytest.raises(AuthenticationError) as ex: + credential.get_token(("",)) + assert str(ex.value) == success_message + + +def test_credential_chain_error_message(): + def raise_authn_error(message): + raise AuthenticationError(message) + + first_error = "first_error" + first_credential = Mock(spec=ClientSecretCredential, get_token=lambda _: raise_authn_error(first_error)) + second_error = "second_error" + second_credential = Mock(name="second_credential", get_token=lambda _: raise_authn_error(second_error)) + + with pytest.raises(AuthenticationError) as ex: + TokenCredentialChain([first_credential, second_credential]).get_token(("scope",)) + + assert "ClientSecretCredential" in ex.value.message + assert first_error in ex.value.message + assert second_error in ex.value.message + + +def test_chain_attempts_all_credentials(): + def raise_authn_error(message="it didn't work"): + raise AuthenticationError(message) + + credentials = [ + Mock(get_token=Mock(wraps=raise_authn_error)), + Mock(get_token=Mock(wraps=raise_authn_error)), + Mock(get_token=Mock(return_value="token")), + ] + + TokenCredentialChain(credentials).get_token(("scope",)) + + for credential in credentials: + assert credential.get_token.call_count == 1 + + +def test_chain_returns_first_token(): + expected_token = Mock() + first_credential = Mock(get_token=lambda _: expected_token) + second_credential = Mock(get_token=Mock()) + + aggregate = TokenCredentialChain([first_credential, second_credential]) + credential = aggregate.get_token(("scope",)) + + assert credential is expected_token + assert second_credential.get_token.call_count == 0 + + +def test_msi_credential_cache(monkeypatch): + scope = "https://foo.bar" + expired = "this token's expired" + now = int(time.time()) + token_payload = { + "access_token": expired, + "refresh_token": "", + "expires_in": 0, + "expires_on": now - 300, # expired 5 minutes ago + "not_before": now, + "resource": scope, + "token_type": "Bearer", + } + + # monkeypatch requests so we can test pipeline configuration + mock_response = Mock(text=json.dumps(token_payload), headers={"content-type": "application/json"}, status_code=200) + mock_send = Mock(return_value=mock_response) + monkeypatch.setattr(requests.Session, "send", value=mock_send) + + credential = ManagedIdentityCredential() + token = credential.get_token((scope,)) + assert token == expired + assert mock_send.call_count == 1 + + # calling get_token again should provoke another HTTP request + good_for_an_hour = "this token's good for an hour" + token_payload["expires_on"] = int(time.time()) + 3600 + token_payload["expires_in"] = 3600 + token_payload["access_token"] = good_for_an_hour + mock_response.text = json.dumps(token_payload) + token = credential.get_token((scope,)) + assert token == good_for_an_hour + assert mock_send.call_count == 2 + + # get_token should return the cached token now + token = credential.get_token((scope,)) + assert token == good_for_an_hour + assert mock_send.call_count == 2 + + +def test_msi_credential_retries(monkeypatch): + # monkeypatch requests so we can test pipeline configuration + mock_response = Mock(headers={"Retry-After": "0"}, text=b"") + mock_send = Mock(return_value=mock_response) + monkeypatch.setattr(requests.Session, "send", value=mock_send) + + retry_total = 1 + credential = ManagedIdentityCredential(retry_total=retry_total) + + for status_code in (404, 429, 500): + mock_response.status_code = status_code + try: + credential.get_token(("",)) + except AuthenticationError: + pass + assert mock_send.call_count is 1 + retry_total + mock_send.reset_mock() + + +def test_cert_credential_constructor(): + client_id = "client_id" + tenant_id = "tenant_id" + private_key = "Bag Attributes\n Microsoft Local Key set: \n localKeyID: 01 00 00 00 \n friendlyName: te-6c7ca2ba-5bec-4802-bde3-1755fb26d651\n Microsoft CSP Name: Microsoft Software Key Storage Provider\nKey Attributes\n X509v3 Key Usage: 90 \n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZnnXDSPMKmbA+\nLx+iZio78D0RS61lP5UfRQJU4NMnZhA1qEdVsvQfR+24eN4U34zXYsHMGP57NVRT\nGYpy842oH6XAJ3uq59FgkO7NB6z1FXqnFbfAese+mssSlXtc91Ach4BpePk2/umO\neCZp/7l0pQPKMrawY8z7zogTnegAIwSDvVeGU0Zap4WPRMmMDAE38s7i0Dfxa1kh\n9/TXvH/x6/+uNIyTIZ8PMH2uv9w/LNex4vh+Pw8jwo5XdVRXRessdMt2I3dVUtkw\nAGHh1TA9Qkflji9tXjv3r2nRHqMLcqhPeIDJVkqUIqSiINQ61gy7CdkMxalp0MxE\n6Mjo7eBpAgMBAAECggEAdv0XsvGeQnuKTFYD3A40pZVULrLMWoILjY90GOjdS7uY\nvV4HsyooJTp1Ftqvw4YAQnyzLl+0NbYRJ2bdtsDJAdZcENcF3Yrnhv94Mw8xWMin\nydgsIsh/kw6cXsrxKwHnAdJtOj51Ncbn+YhkqKy0wLzBd7uG/Kd1G3HwIZnDkt6Q\nE6J4G0u9atEX7pvV8/JBK3QxAZoOo1FkOO2IDk92z85G9+7GysDRssu6erzihX/b\n/fG/DVx1vjeZz1jUC3rIbOhEmq1To0FdOlyRT79IW0RZWRNFAEaFE9sNYR4ZSsgZ\nBwznp1vwtwefPk31WJkGf+WRUERhG8vycOVOBErXCQKBgQD8fgO9JSESdOm8QIHt\nkbqzA+IdyOL7lnK8SdWNiCqdX3e7ZokbYhlUvj6Hf3gGQUTHwTvGa2LIifrSo7jh\nVvO7kIBRg7eMhK6mZMArEINFqEjlqnC5w+Js7hrhjMwPjwIzmcZ3R9GVWl1aq543\nQ8S+0aByxd7HKGG2DOtcdZxW1wKBgQDcpGtKslM3ql7hIMigrwg2vpB7OOj2P4Ja\ncfY/DE4NJ+ZlAzPyDy/Hho7oSxb9ez0xxpOVz5/FpU0Ve1YfRU/41LAndHjudE3z\nsxr7TjGcNS6Ao9790RCqtuE1LipaJ+pKvMS/4J94EM3jbpfZB0Dckmk0gz4lTvUM\n5ZedRpravwKBgQDIfyRm6PnnFxGX3D2QMb1oc7f1YNTlZSV84MCEX9E/IFUKabSM\nGwz0XxF2NUFQ7jk4yfe2awWJKxASfdHMlmh605cho494NNAe7zgtujITeTtRrFNR\nH/xH9ZdA7bYI0M21vfF8PHpvt88Ttd2wEs9Dm2BmYzuxOB7HGmE3DWl1BwKBgQCJ\n1B+9wpWfYUrxoRQS5CPiZrpEbzF/mf6o1yW3Ds23BCS1FwIdBIWZQyIEU9vhrll0\nvZI19EPfKDp139zVneuuCdacXvKoKnkDce+56oetB7+r1jIXJcEeky0tllAYj3SZ\nCUByiDO1wfGLT+uFRDWtU7xqdE2e6qrDSqyiL5fOawKBgDkkyj0ayZnxcMGgM5W6\ng1bwx5q3Kjc0FzGd8cmO5BLmxW8sCcQykOoZb42qmlGEJU0Q8ppiz82xmI7l+Sml\nQdVTbroI9ECxwRFX97pu8gBR4rI1lyfCllI3Pm2Y6cvapki0eq+1jEReSclsyMK9\nmvgSQ07XbJjrqb6hJJo6iLmh\n-----END PRIVATE KEY-----\n" + thumbprint = "0ee111848510505f35155f0571067efa538ea036" + CertificateCredential(client_id, tenant_id, private_key, thumbprint) diff --git a/sdk/identity/azure-identity/tests/test_credentials_async.py b/sdk/identity/azure-identity/tests/test_credentials_async.py new file mode 100644 index 000000000000..e9d57bc198e0 --- /dev/null +++ b/sdk/identity/azure-identity/tests/test_credentials_async.py @@ -0,0 +1,213 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import asyncio +import json +import os +import time +from unittest.mock import Mock +import uuid + +import pytest +import requests +from azure.identity import ( + AuthenticationError, + AsyncClientSecretCredential, + AsyncEnvironmentCredential, + AsyncTokenCredentialChain, + AsyncManagedIdentityCredential, +) +from azure.identity.constants import EnvironmentVariables + + +@pytest.mark.asyncio +async def test_client_secret_credential_cache(monkeypatch): + expired = "this token's expired" + now = time.time() + token_payload = { + "access_token": expired, + "expires_in": 0, + "ext_expires_in": 0, + "expires_on": now - 300, # expired 5 minutes ago + "not_before": now, + "token_type": "Bearer", + "resource": str(uuid.uuid1()), + } + + # monkeypatch requests so we can test pipeline configuration + mock_response = Mock(text=json.dumps(token_payload), headers={"content-type": "application/json"}, status_code=200) + mock_send = Mock(return_value=mock_response) + monkeypatch.setattr(requests.Session, "send", value=mock_send) + + credential = AsyncClientSecretCredential("client_id", "secret", tenant_id=str(uuid.uuid1())) + scopes = ("https://foo.bar/.default", "https://bar.qux/.default") + token = await credential.get_token(scopes) + assert token == expired + + token = await credential.get_token(scopes) + assert token == expired + assert mock_send.call_count == 2 + + +@pytest.mark.asyncio +async def test_cert_environment_credential(monkeypatch): + client_id = "fake-client-id" + private_key_file = os.path.join(os.path.dirname(__file__), "private-key.pem") + tenant_id = "fake-tenant-id" + thumbprint = "0ee111848510505f35155f0571067efa538ea036" + + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) + monkeypatch.setenv(EnvironmentVariables.AZURE_PRIVATE_KEY_FILE, private_key_file) + monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) + monkeypatch.setenv(EnvironmentVariables.AZURE_THUMBPRINT, thumbprint) + + success_message = "request passed validation" + + def validate_request(request, **kwargs): + assert tenant_id in request.url + assert request.data["client_id"] == client_id + # raising here makes mocking a transport response unnecessary + raise AuthenticationError(success_message) + + credential = AsyncEnvironmentCredential(transport=Mock(send=validate_request)) + with pytest.raises(AuthenticationError) as ex: + await credential.get_token(("",)) + assert str(ex.value) == success_message + + +@pytest.mark.asyncio +async def test_client_secret_environment_credential(monkeypatch): + client_id = "fake-client-id" + secret = "fake-client-secret" + tenant_id = "fake-tenant-id" + + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_SECRET, secret) + monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) + + success_message = "request passed validation" + + def validate_request(request, **kwargs): + assert tenant_id in request.url + assert request.data["client_id"] == client_id + assert request.data["client_secret"] == secret + # raising here makes mocking a transport response unnecessary + raise AuthenticationError(success_message) + + credential = AsyncEnvironmentCredential(transport=Mock(send=validate_request)) + with pytest.raises(AuthenticationError) as ex: + await credential.get_token(("",)) + assert str(ex.value) == success_message + + +@pytest.mark.asyncio +async def test_credential_chain_error_message(): + def raise_authn_error(message): + raise AuthenticationError(message) + + first_error = "first_error" + first_credential = Mock(spec=AsyncClientSecretCredential, get_token=lambda _: raise_authn_error(first_error)) + second_error = "second_error" + second_credential = Mock(name="second_credential", get_token=lambda _: raise_authn_error(second_error)) + + with pytest.raises(AuthenticationError) as ex: + await AsyncTokenCredentialChain([first_credential, second_credential]).get_token(("scope",)) + + assert "ClientSecretCredential" in ex.value.message + assert first_error in ex.value.message + assert second_error in ex.value.message + + +@pytest.mark.asyncio +async def test_chain_attempts_all_credentials(): + async def raise_authn_error(message="it didn't work"): + raise AuthenticationError(message) + + expected_token = "expected_token" + credentials = [ + Mock(get_token=Mock(wraps=raise_authn_error)), + Mock(get_token=Mock(wraps=raise_authn_error)), + Mock(get_token=asyncio.coroutine(lambda _: expected_token)), + ] + + token = await AsyncTokenCredentialChain(credentials).get_token(("scope",)) + assert token is expected_token + + for credential in credentials[:-1]: + assert credential.get_token.call_count == 1 + + +@pytest.mark.asyncio +async def test_chain_returns_first_token(): + expected_token = Mock() + first_credential = Mock(get_token=asyncio.coroutine(lambda _: expected_token)) + second_credential = Mock(get_token=Mock()) + + aggregate = AsyncTokenCredentialChain([first_credential, second_credential]) + credential = await aggregate.get_token(("scope",)) + + assert credential is expected_token + assert second_credential.get_token.call_count == 0 + + +@pytest.mark.asyncio +async def test_msi_credential_cache(monkeypatch): + scope = "https://foo.bar" + expired = "this token's expired" + now = int(time.time()) + token_payload = { + "access_token": expired, + "refresh_token": "", + "expires_in": 0, + "expires_on": now - 300, # expired 5 minutes ago + "not_before": now, + "resource": scope, + "token_type": "Bearer", + } + + # monkeypatch requests so we can test pipeline configuration + mock_response = Mock(text=json.dumps(token_payload), headers={"content-type": "application/json"}, status_code=200) + mock_send = Mock(return_value=mock_response) + monkeypatch.setattr(requests.Session, "send", value=mock_send) + + credential = AsyncManagedIdentityCredential() + token = await credential.get_token((scope,)) + assert token == expired + assert mock_send.call_count == 1 + + # calling get_token again should provoke another HTTP request + good_for_an_hour = "this token's good for an hour" + token_payload["expires_on"] = int(time.time()) + 3600 + token_payload["expires_in"] = 3600 + token_payload["access_token"] = good_for_an_hour + mock_response.text = json.dumps(token_payload) + token = await credential.get_token((scope,)) + assert token == good_for_an_hour + assert mock_send.call_count == 2 + + # get_token should return the cached token now + token = await credential.get_token((scope,)) + assert token == good_for_an_hour + assert mock_send.call_count == 2 + + +@pytest.mark.asyncio +async def test_msi_credential_retries(monkeypatch): + # monkeypatch requests so we can test pipeline configuration + mock_response = Mock(headers={"Retry-After": "0"}, text=b"") + mock_send = Mock(return_value=mock_response) + monkeypatch.setattr(requests.Session, "send", value=mock_send) + + retry_total = 1 + credential = AsyncManagedIdentityCredential(retry_total=retry_total) + + for status_code in (404, 429, 500): + mock_response.status_code = status_code + try: + await credential.get_token(("",)) + except AuthenticationError: + pass + assert mock_send.call_count is 1 + retry_total + mock_send.reset_mock() diff --git a/sdk/identity/azure-identity/tests/test_identity.py b/sdk/identity/azure-identity/tests/test_identity.py deleted file mode 100644 index a6ef08e2e1b8..000000000000 --- a/sdk/identity/azure-identity/tests/test_identity.py +++ /dev/null @@ -1,90 +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 json -import time -import uuid - -try: - from unittest.mock import Mock, MagicMock -except ImportError: # python < 3.3 - from mock import Mock - -import pytest -import requests -from azure.identity import AuthenticationError, ClientSecretCredential, TokenCredentialChain - - -def test_client_secret_credential_cache(monkeypatch): - expired = "this token's expired" - now = time.time() - token_payload = { - "access_token": expired, - "expires_in": 0, - "ext_expires_in": 0, - "expires_on": now - 300, # expired 5 minutes ago - "not_before": now, - "token_type": "Bearer", - "resource": str(uuid.uuid1()), - } - - # monkeypatch requests so we can test pipeline configuration - mock_response = Mock(text=json.dumps(token_payload), headers={"content-type": "application/json"}, status_code=200) - mock_send = Mock(return_value=mock_response) - monkeypatch.setattr(requests.Session, "send", value=mock_send) - - credential = ClientSecretCredential("client_id", "secret", tenant_id=str(uuid.uuid1())) - scopes = ("https://foo.bar/.default", "https://bar.qux/.default") - token = credential.get_token(scopes) - assert token == expired - - token = credential.get_token(scopes) - assert token == expired - assert mock_send.call_count == 2 - - -def test_credential_chain_error_message(): - def raise_authn_error(message): - raise AuthenticationError(message) - - first_error = "first_error" - first_credential = Mock(spec=ClientSecretCredential, get_token=lambda _: raise_authn_error(first_error)) - second_error = "second_error" - second_credential = Mock(name="second_credential", get_token=lambda _: raise_authn_error(second_error)) - - with pytest.raises(AuthenticationError) as ex: - TokenCredentialChain([first_credential, second_credential]).get_token(("scope",)) - - assert "ClientSecretCredential" in ex.value.message - assert first_error in ex.value.message - assert second_error in ex.value.message - - -def test_chain_attempts_all_credentials(): - def raise_authn_error(message="it didn't work"): - raise AuthenticationError(message) - - credentials = [ - Mock(get_token=Mock(wraps=raise_authn_error)), - Mock(get_token=Mock(wraps=raise_authn_error)), - Mock(get_token=Mock(return_value="token")), - ] - - TokenCredentialChain(credentials).get_token(("scope",)) - - for credential in credentials: - assert credential.get_token.call_count == 1 - - -def test_chain_returns_first_token(): - expected_token = Mock() - first_credential = Mock(get_token=lambda _: expected_token) - second_credential = Mock(get_token=Mock()) - - aggregate = TokenCredentialChain([first_credential, second_credential]) - credential = aggregate.get_token(("scope",)) - - assert credential is expected_token - assert second_credential.get_token.call_count == 0 diff --git a/sdk/identity/azure-identity/tests/test_identity_async.py b/sdk/identity/azure-identity/tests/test_identity_async.py deleted file mode 100644 index 2bf6b867fe29..000000000000 --- a/sdk/identity/azure-identity/tests/test_identity_async.py +++ /dev/null @@ -1,93 +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 asyncio -import json -import time -from unittest.mock import Mock -import uuid - -import pytest -import requests -from azure.identity import AuthenticationError, AsyncClientSecretCredential, AsyncTokenCredentialChain - - -@pytest.mark.asyncio -async def test_client_secret_credential_cache(monkeypatch): - expired = "this token's expired" - now = time.time() - token_payload = { - "access_token": expired, - "expires_in": 0, - "ext_expires_in": 0, - "expires_on": now - 300, # expired 5 minutes ago - "not_before": now, - "token_type": "Bearer", - "resource": str(uuid.uuid1()), - } - - # monkeypatch requests so we can test pipeline configuration - mock_response = Mock(text=json.dumps(token_payload), headers={"content-type": "application/json"}, status_code=200) - mock_send = Mock(return_value=mock_response) - monkeypatch.setattr(requests.Session, "send", value=mock_send) - - credential = AsyncClientSecretCredential("client_id", "secret", tenant_id=str(uuid.uuid1())) - scopes = ("https://foo.bar/.default", "https://bar.qux/.default") - token = await credential.get_token(scopes) - assert token == expired - - token = await credential.get_token(scopes) - assert token == expired - assert mock_send.call_count == 2 - - -@pytest.mark.asyncio -async def test_credential_chain_error_message(): - async def raise_authn_error(message): - raise AuthenticationError(message) - - first_error = "first_error" - first_credential = Mock(spec=AsyncClientSecretCredential, get_token=lambda _: raise_authn_error(first_error)) - second_error = "second_error" - second_credential = Mock(get_token=lambda _: raise_authn_error(second_error)) - - with pytest.raises(AuthenticationError) as ex: - await AsyncTokenCredentialChain([first_credential, second_credential]).get_token(("scope",)) - - assert "AsyncClientSecretCredential" in ex.value.message - assert first_error in ex.value.message - assert second_error in ex.value.message - - -@pytest.mark.asyncio -async def test_chain_attempts_all_credentials(): - async def raise_authn_error(message="it didn't work"): - raise AuthenticationError(message) - - expected_token = "expected_token" - credentials = [ - Mock(get_token=Mock(wraps=raise_authn_error)), - Mock(get_token=Mock(wraps=raise_authn_error)), - Mock(get_token=asyncio.coroutine(lambda _: expected_token)), - ] - - token = await AsyncTokenCredentialChain(credentials).get_token(("scope",)) - assert token is expected_token - - for credential in credentials[:-1]: - assert credential.get_token.call_count == 1 - - -@pytest.mark.asyncio -async def test_chain_returns_first_token(): - expected_token = Mock() - first_credential = Mock(get_token=asyncio.coroutine(lambda _: expected_token)) - second_credential = Mock(get_token=Mock()) - - aggregate = AsyncTokenCredentialChain([first_credential, second_credential]) - credential = await aggregate.get_token(("scope",)) - - assert credential is expected_token - assert second_credential.get_token.call_count == 0 diff --git a/shared_requirements.txt b/shared_requirements.txt index d5c48d3227ac..830c1d354648 100644 --- a/shared_requirements.txt +++ b/shared_requirements.txt @@ -87,7 +87,7 @@ azure-storage-queue~=1.3 cryptography>=2.1.4 futures typing -msal>=0.3.1 +msal~=0.3.1 msrest>=0.5.0 msrestazure<2.0.0,>=0.4.32 requests>=2.18.4 From 4531606c7e581d1ea3d2541951b42e176e02dfdd Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 30 May 2019 08:39:58 -0700 Subject: [PATCH 02/14] add long_description_content_type --- sdk/identity/azure-identity/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/identity/azure-identity/setup.py b/sdk/identity/azure-identity/setup.py index 8ac3d497e80c..a76a2f21c32b 100644 --- a/sdk/identity/azure-identity/setup.py +++ b/sdk/identity/azure-identity/setup.py @@ -51,6 +51,7 @@ version=VERSION, description="Microsoft Azure {} Library for Python".format(PACKAGE_PPRINT_NAME), long_description=README, + long_description_content_type="text/markdown", license="MIT License", author="Microsoft Corporation", # author_email='', From 0a80886aaa80a8a3f286cec5631bb0cb5ae7dac7 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 30 May 2019 08:57:47 -0700 Subject: [PATCH 03/14] use list constructor --- sdk/identity/azure-identity/azure/identity/_authn_client.py | 4 ++-- .../azure-identity/azure/identity/aio/_authn_client.py | 2 +- sdk/identity/azure-identity/azure/identity/aio/credentials.py | 2 +- sdk/identity/azure-identity/azure/identity/credentials.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/_authn_client.py b/sdk/identity/azure-identity/azure/identity/_authn_client.py index c162ae599299..12f7a37c49ef 100644 --- a/sdk/identity/azure-identity/azure/identity/_authn_client.py +++ b/sdk/identity/azure-identity/azure/identity/_authn_client.py @@ -71,7 +71,7 @@ def _prepare_request(self, method="POST", form_data=None, params=None): class AuthnClient(AuthnClientBase): """Synchronous authentication client""" - + def __init__(self, auth_url, config=None, policies=None, transport=None, **kwargs): # type: (str, Optional[Configuration], Optional[Iterable[HTTPPolicy]], Optional[HttpTransport], Mapping[str, Any]) -> None config = config or self.create_config(**kwargs) @@ -93,5 +93,5 @@ def create_config(**kwargs): # type: (Mapping[str, Any]) -> Configuration config = Configuration(**kwargs) config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) - config.retry_policy = RetryPolicy(retry_on_status_codes=[404, 429] + [x for x in range(500, 600)], **kwargs) + config.retry_policy = RetryPolicy(retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs) return config diff --git a/sdk/identity/azure-identity/azure/identity/aio/_authn_client.py b/sdk/identity/azure-identity/azure/identity/aio/_authn_client.py index e3a49a52e344..d42231dcfef5 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_authn_client.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_authn_client.py @@ -49,6 +49,6 @@ def create_config(**kwargs: Mapping[str, Any]) -> Configuration: config = Configuration(**kwargs) config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) config.retry_policy = AsyncRetryPolicy( - retry_on_status_codes=[404, 429] + [x for x in range(500, 600)], **kwargs + retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs ) return config diff --git a/sdk/identity/azure-identity/azure/identity/aio/credentials.py b/sdk/identity/azure-identity/azure/identity/aio/credentials.py index cec10780be35..a8b9f6e2a4ae 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/aio/credentials.py @@ -115,7 +115,7 @@ def create_config(**kwargs: Mapping[str, Any]) -> Configuration: config.header_policy = HeadersPolicy(base_headers={"Metadata": "true"}, **kwargs) config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) config.retry_policy = AsyncRetryPolicy( - retry_on_status_codes=[404, 429] + [x for x in range(500, 600)], **kwargs + retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs ) return config diff --git a/sdk/identity/azure-identity/azure/identity/credentials.py b/sdk/identity/azure-identity/azure/identity/credentials.py index 71553a7cbd25..2b7216719c6c 100644 --- a/sdk/identity/azure-identity/azure/identity/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/credentials.py @@ -112,7 +112,7 @@ def create_config(**kwargs): config = Configuration(**kwargs) config.header_policy = HeadersPolicy(base_headers={"Metadata": "true"}, **kwargs) config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) - config.retry_policy = RetryPolicy(retry_on_status_codes=[404, 429] + [x for x in range(500, 600)], **kwargs) + config.retry_policy = RetryPolicy(retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs) return config def get_token(self, scopes): From 3ff29b67bf816c1c56b69ad24c45df5978014973 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 30 May 2019 08:59:54 -0700 Subject: [PATCH 04/14] test_credentials -> test_identity --- .../tests/{test_credentials.py => test_identity.py} | 0 .../tests/{test_credentials_async.py => test_identity_async.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename sdk/identity/azure-identity/tests/{test_credentials.py => test_identity.py} (100%) rename sdk/identity/azure-identity/tests/{test_credentials_async.py => test_identity_async.py} (100%) diff --git a/sdk/identity/azure-identity/tests/test_credentials.py b/sdk/identity/azure-identity/tests/test_identity.py similarity index 100% rename from sdk/identity/azure-identity/tests/test_credentials.py rename to sdk/identity/azure-identity/tests/test_identity.py diff --git a/sdk/identity/azure-identity/tests/test_credentials_async.py b/sdk/identity/azure-identity/tests/test_identity_async.py similarity index 100% rename from sdk/identity/azure-identity/tests/test_credentials_async.py rename to sdk/identity/azure-identity/tests/test_identity_async.py From 2d27280f832afc0dda771a8bea55ec340d5ab611 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 30 May 2019 09:04:57 -0700 Subject: [PATCH 05/14] more descriptive error messages --- .../azure-identity/azure/identity/_authn_client.py | 2 +- sdk/identity/azure-identity/azure/identity/_base.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/_authn_client.py b/sdk/identity/azure-identity/azure/identity/_authn_client.py index 12f7a37c49ef..c7e81f8c45cd 100644 --- a/sdk/identity/azure-identity/azure/identity/_authn_client.py +++ b/sdk/identity/azure-identity/azure/identity/_authn_client.py @@ -29,7 +29,7 @@ class AuthnClientBase(object): def __init__(self, auth_url, **kwargs): # type: (str, Mapping[str, Any]) -> None if not auth_url: - raise ValueError("auth_url") + raise ValueError("auth_url should be the URL of an OAuth endpoint") super(AuthnClientBase, self).__init__() self._auth_url = auth_url self._cache = TokenCache() diff --git a/sdk/identity/azure-identity/azure/identity/_base.py b/sdk/identity/azure-identity/azure/identity/_base.py index ca9a202693de..9611e3697c54 100644 --- a/sdk/identity/azure-identity/azure/identity/_base.py +++ b/sdk/identity/azure-identity/azure/identity/_base.py @@ -21,11 +21,11 @@ class ClientSecretCredentialBase(object): def __init__(self, client_id, secret, tenant_id, **kwargs): # type: (str, str, str, Mapping[str, Any]) -> None if not client_id: - raise ValueError("client_id") + raise ValueError("client_id should be the id of an Azure Active Directory application") if not secret: - raise ValueError("secret") + raise ValueError("secret should be an Azure Active Directory application's client secret") if not tenant_id: - raise ValueError("tenant_id") + raise ValueError("tenant_id should be an Azure Active Directory tenant's id (also called its 'directory' id)") self._form_data = {"client_id": client_id, "client_secret": secret, "grant_type": "client_credentials"} super(ClientSecretCredentialBase, self).__init__() @@ -34,7 +34,7 @@ class CertificateCredentialBase(object): def __init__(self, client_id, tenant_id, private_key, thumbprint, **kwargs): # type: (str, str, str, str, Mapping[str, Any]) -> None if not private_key: - raise ValueError("certificate credentials require private_key") + raise ValueError("private_key should be a PEM-encoded private key") if not thumbprint: raise ValueError("certificate credentials require thumbprint") From 19ce84b6c93a3d2be5b24c386510c984d8219f4b Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 30 May 2019 15:34:44 -0700 Subject: [PATCH 06/14] let's call this 1.0 --- sdk/identity/azure-identity/azure/identity/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/azure/identity/version.py b/sdk/identity/azure-identity/azure/identity/version.py index 937c2e059114..238eafa90ebe 100644 --- a/sdk/identity/azure-identity/azure/identity/version.py +++ b/sdk/identity/azure-identity/azure/identity/version.py @@ -3,4 +3,4 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for # license information. # -------------------------------------------------------------------------- -VERSION = "0.0.1" +VERSION = "1.0.0" From 50d2bddf74734cc2e1d5a30c9f7bc73ea7aa6a1e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 30 May 2019 16:59:19 -0700 Subject: [PATCH 07/14] add tests and make EnvironmentCredential clearer --- .../azure/identity/aio/credentials.py | 15 ++++++++------- .../azure-identity/azure/identity/credentials.py | 9 ++++++--- .../azure-identity/tests/test_identity.py | 6 ++++++ .../azure-identity/tests/test_identity_async.py | 7 +++++++ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/aio/credentials.py b/sdk/identity/azure-identity/azure/identity/aio/credentials.py index a8b9f6e2a4ae..255db35d5dfb 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/aio/credentials.py @@ -63,14 +63,14 @@ class AsyncEnvironmentCredential: def __init__(self, **kwargs: Mapping[str, Any]) -> None: self._credential = None # type: Optional[Union[AsyncCertificateCredential, AsyncClientSecretCredential]] - if not any(v for v in EnvironmentVariables.CLIENT_SECRET_VARS if os.environ.get(v) is None): + if all(os.environ.get(v) is not None for v in EnvironmentVariables.CLIENT_SECRET_VARS): self._credential = AsyncClientSecretCredential( client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], secret=os.environ[EnvironmentVariables.AZURE_CLIENT_SECRET], tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], **kwargs ) - elif not any(v for v in EnvironmentVariables.CERT_VARS if os.environ.get(v) is None): + elif all(os.environ.get(v) is not None for v in EnvironmentVariables.CERT_VARS): try: with open(os.environ[EnvironmentVariables.AZURE_PRIVATE_KEY_FILE]) as private_key_file: private_key = private_key_file.read() @@ -85,9 +85,12 @@ def __init__(self, **kwargs: Mapping[str, Any]) -> None: **kwargs ) - async def get_token(self, scopes): + async def get_token(self, scopes: Iterable[str]) -> str: if not self._credential: - raise AuthenticationError("required environment variables not defined") + message = "Missing environment settings. To authenticate with a client secret, set {}. To authenticate with a certificate, set {}.".format( + ", ".join(EnvironmentVariables.CLIENT_SECRET_VARS), ", ".join(EnvironmentVariables.CERT_VARS) + ) + raise AuthenticationError(message) return await self._credential.get_token(scopes) @@ -114,9 +117,7 @@ def create_config(**kwargs: Mapping[str, Any]) -> Configuration: config = Configuration(**kwargs) config.header_policy = HeadersPolicy(base_headers={"Metadata": "true"}, **kwargs) config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) - config.retry_policy = AsyncRetryPolicy( - retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs - ) + config.retry_policy = AsyncRetryPolicy(retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs) return config diff --git a/sdk/identity/azure-identity/azure/identity/credentials.py b/sdk/identity/azure-identity/azure/identity/credentials.py index 2b7216719c6c..fc1a4c2312c3 100644 --- a/sdk/identity/azure-identity/azure/identity/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/credentials.py @@ -67,14 +67,14 @@ def __init__(self, **kwargs): # type: (Mapping[str, Any]) -> None self._credential = None # type: Optional[Union[CertificateCredential, ClientSecretCredential]] - if not any(v for v in EnvironmentVariables.CLIENT_SECRET_VARS if os.environ.get(v) is None): + if all(os.environ.get(v) is not None for v in EnvironmentVariables.CLIENT_SECRET_VARS): self._credential = ClientSecretCredential( client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], secret=os.environ[EnvironmentVariables.AZURE_CLIENT_SECRET], tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], **kwargs ) - elif not any(v for v in EnvironmentVariables.CERT_VARS if os.environ.get(v) is None): + elif all(os.environ.get(v) is not None for v in EnvironmentVariables.CERT_VARS): try: with open(os.environ[EnvironmentVariables.AZURE_PRIVATE_KEY_FILE]) as private_key_file: private_key = private_key_file.read() @@ -92,7 +92,10 @@ def __init__(self, **kwargs): def get_token(self, scopes): # type: (Iterable[str]) -> str if not self._credential: - raise AuthenticationError("required environment variables not defined") + message = "Missing environment settings. To authenticate with a client secret, set {}. To authenticate with a certificate, set {}.".format( + ", ".join(EnvironmentVariables.CLIENT_SECRET_VARS), ", ".join(EnvironmentVariables.CERT_VARS) + ) + raise AuthenticationError(message) return self._credential.get_token(scopes) diff --git a/sdk/identity/azure-identity/tests/test_identity.py b/sdk/identity/azure-identity/tests/test_identity.py index b155d53b246d..a04153c26e0b 100644 --- a/sdk/identity/azure-identity/tests/test_identity.py +++ b/sdk/identity/azure-identity/tests/test_identity.py @@ -93,6 +93,7 @@ def test_cert_environment_credential(monkeypatch): def validate_request(request, **kwargs): assert tenant_id in request.url assert request.data["client_id"] == client_id + assert request.data["grant_type"] == "client_credentials" # raising here makes mocking a transport response unnecessary raise AuthenticationError(success_message) @@ -102,6 +103,11 @@ def validate_request(request, **kwargs): assert str(ex.value) == success_message +def test_environment_credential_error(): + with pytest.raises(AuthenticationError): + EnvironmentCredential().get_token(("",)) + + def test_credential_chain_error_message(): def raise_authn_error(message): raise AuthenticationError(message) diff --git a/sdk/identity/azure-identity/tests/test_identity_async.py b/sdk/identity/azure-identity/tests/test_identity_async.py index e9d57bc198e0..891ede17ae07 100644 --- a/sdk/identity/azure-identity/tests/test_identity_async.py +++ b/sdk/identity/azure-identity/tests/test_identity_async.py @@ -68,6 +68,7 @@ async def test_cert_environment_credential(monkeypatch): def validate_request(request, **kwargs): assert tenant_id in request.url assert request.data["client_id"] == client_id + assert request.data["grant_type"] == "client_credentials" # raising here makes mocking a transport response unnecessary raise AuthenticationError(success_message) @@ -102,6 +103,12 @@ def validate_request(request, **kwargs): assert str(ex.value) == success_message +@pytest.mark.asyncio +async def test_environment_credential_error(): + with pytest.raises(AuthenticationError): + await AsyncEnvironmentCredential().get_token(("",)) + + @pytest.mark.asyncio async def test_credential_chain_error_message(): def raise_authn_error(message): From 71d6e4cb67a6e88a0c9919289e7edfeaab5a4cce Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 31 May 2019 16:19:05 -0700 Subject: [PATCH 08/14] remove thumbprint --- sdk/identity/azure-identity/azure/identity/_base.py | 8 +++----- .../azure/identity/aio/credentials.py | 4 +--- .../azure-identity/azure/identity/constants.py | 3 +-- .../azure-identity/azure/identity/credentials.py | 11 +++++------ sdk/identity/azure-identity/tests/test_identity.py | 13 ++----------- .../azure-identity/tests/test_identity_async.py | 2 -- 6 files changed, 12 insertions(+), 29 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/_base.py b/sdk/identity/azure-identity/azure/identity/_base.py index 9611e3697c54..2465f9d9b6fb 100644 --- a/sdk/identity/azure-identity/azure/identity/_base.py +++ b/sdk/identity/azure-identity/azure/identity/_base.py @@ -31,16 +31,14 @@ def __init__(self, client_id, secret, tenant_id, **kwargs): class CertificateCredentialBase(object): - def __init__(self, client_id, tenant_id, private_key, thumbprint, **kwargs): - # type: (str, str, str, str, Mapping[str, Any]) -> None + def __init__(self, client_id, tenant_id, private_key, **kwargs): + # type: (str, str, str, Mapping[str, Any]) -> None if not private_key: raise ValueError("private_key should be a PEM-encoded private key") - if not thumbprint: - raise ValueError("certificate credentials require thumbprint") super(CertificateCredentialBase, self).__init__() auth_url = OAUTH_ENDPOINT.format(tenant_id) - signer = JwtSigner(private_key, "RS256", thumbprint) + signer = JwtSigner(private_key, "RS256") client_assertion = signer.sign_assertion(audience=auth_url, issuer=client_id) self._form_data = { "client_assertion": client_assertion, diff --git a/sdk/identity/azure-identity/azure/identity/aio/credentials.py b/sdk/identity/azure-identity/azure/identity/aio/credentials.py index 255db35d5dfb..bc0865b6bbef 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/aio/credentials.py @@ -44,11 +44,10 @@ def __init__( client_id: str, tenant_id: str, private_key: str, - thumbprint: str, config: Optional[Configuration] = None, **kwargs: Mapping[str, Any] ) -> None: - super(AsyncCertificateCredential, self).__init__(client_id, tenant_id, private_key, thumbprint, **kwargs) + super(AsyncCertificateCredential, self).__init__(client_id, tenant_id, private_key, **kwargs) self._client = AsyncAuthnClient(OAUTH_ENDPOINT.format(tenant_id), config, **kwargs) async def get_token(self, scopes: Iterable[str]) -> str: @@ -81,7 +80,6 @@ def __init__(self, **kwargs: Mapping[str, Any]) -> None: client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], private_key=private_key, - thumbprint=os.environ[EnvironmentVariables.AZURE_THUMBPRINT], **kwargs ) diff --git a/sdk/identity/azure-identity/azure/identity/constants.py b/sdk/identity/azure-identity/azure/identity/constants.py index ffa1b8dd739e..d9551119b754 100644 --- a/sdk/identity/azure-identity/azure/identity/constants.py +++ b/sdk/identity/azure-identity/azure/identity/constants.py @@ -13,8 +13,7 @@ class EnvironmentVariables: CLIENT_SECRET_VARS = (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID) AZURE_PRIVATE_KEY_FILE = "AZURE_PRIVATE_KEY_FILE" - AZURE_THUMBPRINT = "AZURE_THUMBPRINT" - CERT_VARS = (AZURE_CLIENT_ID, AZURE_PRIVATE_KEY_FILE, AZURE_TENANT_ID, AZURE_THUMBPRINT) + CERT_VARS = (AZURE_CLIENT_ID, AZURE_PRIVATE_KEY_FILE, AZURE_TENANT_ID) # https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http diff --git a/sdk/identity/azure-identity/azure/identity/credentials.py b/sdk/identity/azure-identity/azure/identity/credentials.py index fc1a4c2312c3..417037ca122c 100644 --- a/sdk/identity/azure-identity/azure/identity/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/credentials.py @@ -44,12 +44,12 @@ def get_token(self, scopes): class CertificateCredential(CertificateCredentialBase): - """Authenticates with a certificate (thumbprint and PEM-encoded private key)""" + """Authenticates with a certificate""" - def __init__(self, client_id, tenant_id, private_key, thumbprint, config=None, **kwargs): - # type: (str, str, str, str, Optional[Configuration], Mapping[str, Any]) -> None + def __init__(self, client_id, tenant_id, private_key, config=None, **kwargs): + # type: (str, str, str, Optional[Configuration], Mapping[str, Any]) -> None self._client = AuthnClient(OAUTH_ENDPOINT.format(tenant_id), config, **kwargs) - super(CertificateCredential, self).__init__(client_id, tenant_id, private_key, thumbprint, **kwargs) + super(CertificateCredential, self).__init__(client_id, tenant_id, private_key, **kwargs) def get_token(self, scopes): # type: (Iterable[str]) -> str @@ -61,7 +61,7 @@ def get_token(self, scopes): class EnvironmentCredential: - """Authenticates with a secret or certificate (thumbprint and PEM-encoded private key)""" + """Authenticates with a secret or certificate using environment variable settings""" def __init__(self, **kwargs): # type: (Mapping[str, Any]) -> None @@ -85,7 +85,6 @@ def __init__(self, **kwargs): client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], private_key=private_key, - thumbprint=os.environ[EnvironmentVariables.AZURE_THUMBPRINT], **kwargs ) diff --git a/sdk/identity/azure-identity/tests/test_identity.py b/sdk/identity/azure-identity/tests/test_identity.py index a04153c26e0b..b60700dbd8e9 100644 --- a/sdk/identity/azure-identity/tests/test_identity.py +++ b/sdk/identity/azure-identity/tests/test_identity.py @@ -21,7 +21,8 @@ EnvironmentCredential, TokenCredentialChain, ManagedIdentityCredential, - CertificateCredential) + CertificateCredential, +) from azure.identity.constants import EnvironmentVariables @@ -81,12 +82,10 @@ def test_cert_environment_credential(monkeypatch): client_id = "fake-client-id" private_key_file = os.path.join(os.path.dirname(__file__), "private-key.pem") tenant_id = "fake-tenant-id" - thumbprint = "0ee111848510505f35155f0571067efa538ea036" monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) monkeypatch.setenv(EnvironmentVariables.AZURE_PRIVATE_KEY_FILE, private_key_file) monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) - monkeypatch.setenv(EnvironmentVariables.AZURE_THUMBPRINT, thumbprint) success_message = "request passed validation" @@ -210,11 +209,3 @@ def test_msi_credential_retries(monkeypatch): pass assert mock_send.call_count is 1 + retry_total mock_send.reset_mock() - - -def test_cert_credential_constructor(): - client_id = "client_id" - tenant_id = "tenant_id" - private_key = "Bag Attributes\n Microsoft Local Key set: \n localKeyID: 01 00 00 00 \n friendlyName: te-6c7ca2ba-5bec-4802-bde3-1755fb26d651\n Microsoft CSP Name: Microsoft Software Key Storage Provider\nKey Attributes\n X509v3 Key Usage: 90 \n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDZnnXDSPMKmbA+\nLx+iZio78D0RS61lP5UfRQJU4NMnZhA1qEdVsvQfR+24eN4U34zXYsHMGP57NVRT\nGYpy842oH6XAJ3uq59FgkO7NB6z1FXqnFbfAese+mssSlXtc91Ach4BpePk2/umO\neCZp/7l0pQPKMrawY8z7zogTnegAIwSDvVeGU0Zap4WPRMmMDAE38s7i0Dfxa1kh\n9/TXvH/x6/+uNIyTIZ8PMH2uv9w/LNex4vh+Pw8jwo5XdVRXRessdMt2I3dVUtkw\nAGHh1TA9Qkflji9tXjv3r2nRHqMLcqhPeIDJVkqUIqSiINQ61gy7CdkMxalp0MxE\n6Mjo7eBpAgMBAAECggEAdv0XsvGeQnuKTFYD3A40pZVULrLMWoILjY90GOjdS7uY\nvV4HsyooJTp1Ftqvw4YAQnyzLl+0NbYRJ2bdtsDJAdZcENcF3Yrnhv94Mw8xWMin\nydgsIsh/kw6cXsrxKwHnAdJtOj51Ncbn+YhkqKy0wLzBd7uG/Kd1G3HwIZnDkt6Q\nE6J4G0u9atEX7pvV8/JBK3QxAZoOo1FkOO2IDk92z85G9+7GysDRssu6erzihX/b\n/fG/DVx1vjeZz1jUC3rIbOhEmq1To0FdOlyRT79IW0RZWRNFAEaFE9sNYR4ZSsgZ\nBwznp1vwtwefPk31WJkGf+WRUERhG8vycOVOBErXCQKBgQD8fgO9JSESdOm8QIHt\nkbqzA+IdyOL7lnK8SdWNiCqdX3e7ZokbYhlUvj6Hf3gGQUTHwTvGa2LIifrSo7jh\nVvO7kIBRg7eMhK6mZMArEINFqEjlqnC5w+Js7hrhjMwPjwIzmcZ3R9GVWl1aq543\nQ8S+0aByxd7HKGG2DOtcdZxW1wKBgQDcpGtKslM3ql7hIMigrwg2vpB7OOj2P4Ja\ncfY/DE4NJ+ZlAzPyDy/Hho7oSxb9ez0xxpOVz5/FpU0Ve1YfRU/41LAndHjudE3z\nsxr7TjGcNS6Ao9790RCqtuE1LipaJ+pKvMS/4J94EM3jbpfZB0Dckmk0gz4lTvUM\n5ZedRpravwKBgQDIfyRm6PnnFxGX3D2QMb1oc7f1YNTlZSV84MCEX9E/IFUKabSM\nGwz0XxF2NUFQ7jk4yfe2awWJKxASfdHMlmh605cho494NNAe7zgtujITeTtRrFNR\nH/xH9ZdA7bYI0M21vfF8PHpvt88Ttd2wEs9Dm2BmYzuxOB7HGmE3DWl1BwKBgQCJ\n1B+9wpWfYUrxoRQS5CPiZrpEbzF/mf6o1yW3Ds23BCS1FwIdBIWZQyIEU9vhrll0\nvZI19EPfKDp139zVneuuCdacXvKoKnkDce+56oetB7+r1jIXJcEeky0tllAYj3SZ\nCUByiDO1wfGLT+uFRDWtU7xqdE2e6qrDSqyiL5fOawKBgDkkyj0ayZnxcMGgM5W6\ng1bwx5q3Kjc0FzGd8cmO5BLmxW8sCcQykOoZb42qmlGEJU0Q8ppiz82xmI7l+Sml\nQdVTbroI9ECxwRFX97pu8gBR4rI1lyfCllI3Pm2Y6cvapki0eq+1jEReSclsyMK9\nmvgSQ07XbJjrqb6hJJo6iLmh\n-----END PRIVATE KEY-----\n" - thumbprint = "0ee111848510505f35155f0571067efa538ea036" - CertificateCredential(client_id, tenant_id, private_key, thumbprint) diff --git a/sdk/identity/azure-identity/tests/test_identity_async.py b/sdk/identity/azure-identity/tests/test_identity_async.py index 891ede17ae07..d686a217d235 100644 --- a/sdk/identity/azure-identity/tests/test_identity_async.py +++ b/sdk/identity/azure-identity/tests/test_identity_async.py @@ -56,12 +56,10 @@ async def test_cert_environment_credential(monkeypatch): client_id = "fake-client-id" private_key_file = os.path.join(os.path.dirname(__file__), "private-key.pem") tenant_id = "fake-tenant-id" - thumbprint = "0ee111848510505f35155f0571067efa538ea036" monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) monkeypatch.setenv(EnvironmentVariables.AZURE_PRIVATE_KEY_FILE, private_key_file) monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) - monkeypatch.setenv(EnvironmentVariables.AZURE_THUMBPRINT, thumbprint) success_message = "request passed validation" From 46944fb44f15a1b89a5bcb72efbe85cb76e732d5 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 31 May 2019 16:20:41 -0700 Subject: [PATCH 09/14] AZURE_PRIVATE_KEY_FILE -> AZURE_CLIENT_CERTIFICATE_PATH --- .../azure-identity/azure/identity/aio/credentials.py | 2 +- sdk/identity/azure-identity/azure/identity/constants.py | 5 ++--- sdk/identity/azure-identity/azure/identity/credentials.py | 2 +- sdk/identity/azure-identity/tests/test_identity.py | 2 +- sdk/identity/azure-identity/tests/test_identity_async.py | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/aio/credentials.py b/sdk/identity/azure-identity/azure/identity/aio/credentials.py index bc0865b6bbef..a5c9a32fbf67 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/aio/credentials.py @@ -71,7 +71,7 @@ def __init__(self, **kwargs: Mapping[str, Any]) -> None: ) elif all(os.environ.get(v) is not None for v in EnvironmentVariables.CERT_VARS): try: - with open(os.environ[EnvironmentVariables.AZURE_PRIVATE_KEY_FILE]) as private_key_file: + with open(os.environ[EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH]) as private_key_file: private_key = private_key_file.read() except IOError: return diff --git a/sdk/identity/azure-identity/azure/identity/constants.py b/sdk/identity/azure-identity/azure/identity/constants.py index d9551119b754..d8e7477eebce 100644 --- a/sdk/identity/azure-identity/azure/identity/constants.py +++ b/sdk/identity/azure-identity/azure/identity/constants.py @@ -6,14 +6,13 @@ class EnvironmentVariables: - # TODO: align cross-language AZURE_CLIENT_ID = "AZURE_CLIENT_ID" AZURE_CLIENT_SECRET = "AZURE_CLIENT_SECRET" AZURE_TENANT_ID = "AZURE_TENANT_ID" CLIENT_SECRET_VARS = (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID) - AZURE_PRIVATE_KEY_FILE = "AZURE_PRIVATE_KEY_FILE" - CERT_VARS = (AZURE_CLIENT_ID, AZURE_PRIVATE_KEY_FILE, AZURE_TENANT_ID) + AZURE_CLIENT_CERTIFICATE_PATH = "AZURE_CLIENT_CERTIFICATE_PATH" + CERT_VARS = (AZURE_CLIENT_ID, AZURE_CLIENT_CERTIFICATE_PATH, AZURE_TENANT_ID) # https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http diff --git a/sdk/identity/azure-identity/azure/identity/credentials.py b/sdk/identity/azure-identity/azure/identity/credentials.py index 417037ca122c..55b88c3de563 100644 --- a/sdk/identity/azure-identity/azure/identity/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/credentials.py @@ -76,7 +76,7 @@ def __init__(self, **kwargs): ) elif all(os.environ.get(v) is not None for v in EnvironmentVariables.CERT_VARS): try: - with open(os.environ[EnvironmentVariables.AZURE_PRIVATE_KEY_FILE]) as private_key_file: + with open(os.environ[EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH]) as private_key_file: private_key = private_key_file.read() except IOError: return diff --git a/sdk/identity/azure-identity/tests/test_identity.py b/sdk/identity/azure-identity/tests/test_identity.py index b60700dbd8e9..48b21eb51649 100644 --- a/sdk/identity/azure-identity/tests/test_identity.py +++ b/sdk/identity/azure-identity/tests/test_identity.py @@ -84,7 +84,7 @@ def test_cert_environment_credential(monkeypatch): tenant_id = "fake-tenant-id" monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) - monkeypatch.setenv(EnvironmentVariables.AZURE_PRIVATE_KEY_FILE, private_key_file) + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH, private_key_file) monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) success_message = "request passed validation" diff --git a/sdk/identity/azure-identity/tests/test_identity_async.py b/sdk/identity/azure-identity/tests/test_identity_async.py index d686a217d235..661b239b53a9 100644 --- a/sdk/identity/azure-identity/tests/test_identity_async.py +++ b/sdk/identity/azure-identity/tests/test_identity_async.py @@ -58,7 +58,7 @@ async def test_cert_environment_credential(monkeypatch): tenant_id = "fake-tenant-id" monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) - monkeypatch.setenv(EnvironmentVariables.AZURE_PRIVATE_KEY_FILE, private_key_file) + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH, private_key_file) monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) success_message = "request passed validation" From 856cc226097f2219d17fc0c4c27a09fe92fd8131 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 31 May 2019 17:29:08 -0700 Subject: [PATCH 10/14] cert credential inits take path to cert file --- sdk/identity/azure-identity/azure/identity/_base.py | 10 +++++++--- .../azure-identity/azure/identity/aio/credentials.py | 12 +++--------- .../azure-identity/azure/identity/credentials.py | 12 +++--------- sdk/identity/azure-identity/tests/test_identity.py | 4 ++-- .../azure-identity/tests/test_identity_async.py | 4 ++-- 5 files changed, 17 insertions(+), 25 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/_base.py b/sdk/identity/azure-identity/azure/identity/_base.py index 2465f9d9b6fb..0d0e9d96040d 100644 --- a/sdk/identity/azure-identity/azure/identity/_base.py +++ b/sdk/identity/azure-identity/azure/identity/_base.py @@ -31,13 +31,17 @@ def __init__(self, client_id, secret, tenant_id, **kwargs): class CertificateCredentialBase(object): - def __init__(self, client_id, tenant_id, private_key, **kwargs): + def __init__(self, client_id, tenant_id, certificate_path, **kwargs): # type: (str, str, str, Mapping[str, Any]) -> None - if not private_key: - raise ValueError("private_key should be a PEM-encoded private key") + if not certificate_path: + # TODO: support PFX + raise ValueError("certificate_path must be the path to a PEM-encoded private key file") super(CertificateCredentialBase, self).__init__() auth_url = OAUTH_ENDPOINT.format(tenant_id) + + with open(certificate_path) as pem: + private_key = pem.read() signer = JwtSigner(private_key, "RS256") client_assertion = signer.sign_assertion(audience=auth_url, issuer=client_id) self._form_data = { diff --git a/sdk/identity/azure-identity/azure/identity/aio/credentials.py b/sdk/identity/azure-identity/azure/identity/aio/credentials.py index a5c9a32fbf67..fe8aaf4756ef 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/aio/credentials.py @@ -43,11 +43,11 @@ def __init__( self, client_id: str, tenant_id: str, - private_key: str, + certificate_path: str, config: Optional[Configuration] = None, **kwargs: Mapping[str, Any] ) -> None: - super(AsyncCertificateCredential, self).__init__(client_id, tenant_id, private_key, **kwargs) + super(AsyncCertificateCredential, self).__init__(client_id, tenant_id, certificate_path, **kwargs) self._client = AsyncAuthnClient(OAUTH_ENDPOINT.format(tenant_id), config, **kwargs) async def get_token(self, scopes: Iterable[str]) -> str: @@ -70,16 +70,10 @@ def __init__(self, **kwargs: Mapping[str, Any]) -> None: **kwargs ) elif all(os.environ.get(v) is not None for v in EnvironmentVariables.CERT_VARS): - try: - with open(os.environ[EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH]) as private_key_file: - private_key = private_key_file.read() - except IOError: - return - self._credential = AsyncCertificateCredential( client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], - private_key=private_key, + certificate_path=os.environ[EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH], **kwargs ) diff --git a/sdk/identity/azure-identity/azure/identity/credentials.py b/sdk/identity/azure-identity/azure/identity/credentials.py index 55b88c3de563..985f94b2b53e 100644 --- a/sdk/identity/azure-identity/azure/identity/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/credentials.py @@ -46,10 +46,10 @@ def get_token(self, scopes): class CertificateCredential(CertificateCredentialBase): """Authenticates with a certificate""" - def __init__(self, client_id, tenant_id, private_key, config=None, **kwargs): + def __init__(self, client_id, tenant_id, certificate_path, config=None, **kwargs): # type: (str, str, str, Optional[Configuration], Mapping[str, Any]) -> None self._client = AuthnClient(OAUTH_ENDPOINT.format(tenant_id), config, **kwargs) - super(CertificateCredential, self).__init__(client_id, tenant_id, private_key, **kwargs) + super(CertificateCredential, self).__init__(client_id, tenant_id, certificate_path, **kwargs) def get_token(self, scopes): # type: (Iterable[str]) -> str @@ -75,16 +75,10 @@ def __init__(self, **kwargs): **kwargs ) elif all(os.environ.get(v) is not None for v in EnvironmentVariables.CERT_VARS): - try: - with open(os.environ[EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH]) as private_key_file: - private_key = private_key_file.read() - except IOError: - return - self._credential = CertificateCredential( client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], tenant_id=os.environ[EnvironmentVariables.AZURE_TENANT_ID], - private_key=private_key, + certificate_path=os.environ[EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH], **kwargs ) diff --git a/sdk/identity/azure-identity/tests/test_identity.py b/sdk/identity/azure-identity/tests/test_identity.py index 48b21eb51649..6bd626141e1d 100644 --- a/sdk/identity/azure-identity/tests/test_identity.py +++ b/sdk/identity/azure-identity/tests/test_identity.py @@ -80,11 +80,11 @@ def validate_request(request, **kwargs): def test_cert_environment_credential(monkeypatch): client_id = "fake-client-id" - private_key_file = os.path.join(os.path.dirname(__file__), "private-key.pem") + pem_path = os.path.join(os.path.dirname(__file__), "private-key.pem") tenant_id = "fake-tenant-id" monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) - monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH, private_key_file) + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH, pem_path) monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) success_message = "request passed validation" diff --git a/sdk/identity/azure-identity/tests/test_identity_async.py b/sdk/identity/azure-identity/tests/test_identity_async.py index 661b239b53a9..8414d65e0416 100644 --- a/sdk/identity/azure-identity/tests/test_identity_async.py +++ b/sdk/identity/azure-identity/tests/test_identity_async.py @@ -54,11 +54,11 @@ async def test_client_secret_credential_cache(monkeypatch): @pytest.mark.asyncio async def test_cert_environment_credential(monkeypatch): client_id = "fake-client-id" - private_key_file = os.path.join(os.path.dirname(__file__), "private-key.pem") + pem_path = os.path.join(os.path.dirname(__file__), "private-key.pem") tenant_id = "fake-tenant-id" monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_ID, client_id) - monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH, private_key_file) + monkeypatch.setenv(EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH, pem_path) monkeypatch.setenv(EnvironmentVariables.AZURE_TENANT_ID, tenant_id) success_message = "request passed validation" From fd289df8bb717290e04b6e4284681b5f744916ba Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 3 Jun 2019 08:40:58 -0700 Subject: [PATCH 11/14] todo re the other clouds in our sky --- sdk/identity/azure-identity/azure/identity/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/identity/azure-identity/azure/identity/constants.py b/sdk/identity/azure-identity/azure/identity/constants.py index d8e7477eebce..0c29cc2559e8 100644 --- a/sdk/identity/azure-identity/azure/identity/constants.py +++ b/sdk/identity/azure-identity/azure/identity/constants.py @@ -18,4 +18,5 @@ class EnvironmentVariables: # https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http IMDS_ENDPOINT = "http://169.254.169.254/metadata/identity/oauth2/token" +# TODO: other clouds have other endpoints OAUTH_ENDPOINT = "https://login.microsoftonline.com/{}/oauth2/v2.0/token" From a3dde9eabc28b16d4f7bbea2149ab744cfb56dbe Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 3 Jun 2019 08:43:18 -0700 Subject: [PATCH 12/14] this release is minor --- sdk/identity/azure-identity/azure/identity/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/azure/identity/version.py b/sdk/identity/azure-identity/azure/identity/version.py index 238eafa90ebe..c78e131faf62 100644 --- a/sdk/identity/azure-identity/azure/identity/version.py +++ b/sdk/identity/azure-identity/azure/identity/version.py @@ -3,4 +3,4 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for # license information. # -------------------------------------------------------------------------- -VERSION = "1.0.0" +VERSION = "0.1.0" From 1d630a81ef2fd63313b56d4f9c8ca0df6f02a9a8 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 3 Jun 2019 08:44:13 -0700 Subject: [PATCH 13/14] broaden quotation --- sdk/identity/azure-identity/azure/identity/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/azure/identity/_base.py b/sdk/identity/azure-identity/azure/identity/_base.py index 0d0e9d96040d..a3221ae34c46 100644 --- a/sdk/identity/azure-identity/azure/identity/_base.py +++ b/sdk/identity/azure-identity/azure/identity/_base.py @@ -25,7 +25,7 @@ def __init__(self, client_id, secret, tenant_id, **kwargs): if not secret: raise ValueError("secret should be an Azure Active Directory application's client secret") if not tenant_id: - raise ValueError("tenant_id should be an Azure Active Directory tenant's id (also called its 'directory' id)") + raise ValueError("tenant_id should be an Azure Active Directory tenant's id (also called its 'directory id')") self._form_data = {"client_id": client_id, "client_secret": secret, "grant_type": "client_credentials"} super(ClientSecretCredentialBase, self).__init__() From da5ddd22dafc1908129b0ce771c40f34edc74b56 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 3 Jun 2019 08:59:20 -0700 Subject: [PATCH 14/14] follow IMDS retry guidance --- .../azure-identity/azure/identity/aio/credentials.py | 6 ++++-- sdk/identity/azure-identity/azure/identity/credentials.py | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/aio/credentials.py b/sdk/identity/azure-identity/azure/identity/aio/credentials.py index fe8aaf4756ef..cc4130efda7b 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/aio/credentials.py @@ -106,10 +106,12 @@ async def get_token(self, scopes: Iterable[str]) -> str: @staticmethod def create_config(**kwargs: Mapping[str, Any]) -> Configuration: - config = Configuration(**kwargs) + timeout = kwargs.pop("connection_timeout", 2) + config = Configuration(connection_timeout=timeout, **kwargs) config.header_policy = HeadersPolicy(base_headers={"Metadata": "true"}, **kwargs) config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) - config.retry_policy = AsyncRetryPolicy(retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs) + retries = kwargs.pop("retry_total", 5) + config.retry_policy = AsyncRetryPolicy(retry_total=retries, retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs) return config diff --git a/sdk/identity/azure-identity/azure/identity/credentials.py b/sdk/identity/azure-identity/azure/identity/credentials.py index 985f94b2b53e..d0293ad7cc7d 100644 --- a/sdk/identity/azure-identity/azure/identity/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/credentials.py @@ -92,7 +92,6 @@ def get_token(self, scopes): return self._credential.get_token(scopes) -# TODO: support multiple identities? class ManagedIdentityCredential: """Authenticates with a managed identity""" @@ -105,10 +104,12 @@ def __init__(self, config=None, **kwargs): @staticmethod def create_config(**kwargs): # type: (Mapping[str, str]) -> Configuration - config = Configuration(**kwargs) + timeout = kwargs.pop("connection_timeout", 2) + config = Configuration(connection_timeout=timeout, **kwargs) config.header_policy = HeadersPolicy(base_headers={"Metadata": "true"}, **kwargs) config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) - config.retry_policy = RetryPolicy(retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs) + retries = kwargs.pop("retry_total", 5) + config.retry_policy = RetryPolicy(retry_total=retries, retry_on_status_codes=[404, 429] + list(range(500, 600)), **kwargs) return config def get_token(self, scopes):