diff --git a/sdk/identity/azure-identity/azure/identity/__init__.py b/sdk/identity/azure-identity/azure/identity/__init__.py index 7648a37ffa19..dcd820532ff2 100644 --- a/sdk/identity/azure-identity/azure/identity/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/__init__.py @@ -4,7 +4,8 @@ # ------------------------------------ """Credentials for Azure SDK clients.""" -from ._exceptions import CredentialUnavailableError +from ._auth_profile import AuthProfile +from ._exceptions import AuthenticationRequiredError, CredentialUnavailableError from ._constants import KnownAuthorities from ._credentials import ( AuthorizationCodeCredential, @@ -22,6 +23,8 @@ __all__ = [ + "AuthenticationRequiredError", + "AuthProfile", "AuthorizationCodeCredential", "CertificateCredential", "ChainedTokenCredential", diff --git a/sdk/identity/azure-identity/azure/identity/_auth_profile.py b/sdk/identity/azure-identity/azure/identity/_auth_profile.py new file mode 100644 index 000000000000..12a5516a7699 --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_auth_profile.py @@ -0,0 +1,68 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + + +class AuthProfile(object): + """Public user information from an authentication. + + :param str environment: the Azure Active Directory instance which authenticated the user + :param str home_account_id: the user's Azure Active Directory object ID and home tenant ID + :param str tenant_id: the tenant which authenticated the user + :param str username: the user's username (usually an email address) + """ + + def __init__(self, environment, home_account_id, tenant_id, username, **kwargs): + # type: (str, str, str, str, **Any) -> None + self._additional_data = kwargs + self.environment = environment + self.home_account_id = home_account_id + self.tenant_id = tenant_id + self.username = username + + @property + def additional_data(self): + # type: () -> dict + """A dictionary of extra data deserialized alongside the profile""" + + return dict(self._additional_data) + + def __getitem__(self, key): + return getattr(self, key, None) or self._additional_data[key] + + @classmethod + def deserialize(cls, json_string): + # type: (str) -> AuthProfile + """Deserialize a profile from JSON""" + + deserialized = json.loads(json_string) + + return cls( + environment=deserialized.pop("environment"), + home_account_id=deserialized.pop("home_account_id"), + tenant_id=deserialized.pop("tenant_id"), + username=deserialized.pop("username"), + **deserialized + ) + + def serialize(self, **kwargs): + # type: (**Any) -> str + """Serialize the profile and any keyword arguments to JSON""" + + profile = dict( + { + "environment": self.environment, + "home_account_id": self.home_account_id, + "tenant_id": self.tenant_id, + "username": self.username, + }, + **kwargs + ) + + return json.dumps(profile) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/browser.py b/sdk/identity/azure-identity/azure/identity/_credentials/browser.py index b01abef7d714..e13d719ab372 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/browser.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/browser.py @@ -10,9 +10,10 @@ from azure.core.credentials import AccessToken from azure.core.exceptions import ClientAuthenticationError -from .. import CredentialUnavailableError +from .. import AuthenticationRequiredError, CredentialUnavailableError from .._constants import AZURE_CLI_CLIENT_ID -from .._internal import AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions +from .._internal import ARM_SCOPE, AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions +from .._internal.msal_credentials import _build_auth_profile try: from typing import TYPE_CHECKING @@ -21,7 +22,8 @@ if TYPE_CHECKING: # pylint:disable=unused-import - from typing import Any, List, Mapping + from typing import Any, List, Mapping, Tuple + from .. import AuthProfile class InteractiveBrowserCredential(PublicClientCredential): @@ -38,6 +40,11 @@ class InteractiveBrowserCredential(PublicClientCredential): authenticate work or school accounts. :keyword str client_id: Client ID of the Azure Active Directory application users will sign in to. If unspecified, the Azure CLI's ID will be used. + :keyword ~azure.identity.AuthProfile profile: a user profile from a prior authentication. If provided, keyword + arguments ``authority`` and ``tenant_id`` will be ignored because the profile contains this information. + :keyword bool silent_auth_only: authenticate only silently (without user interaction). False by default. If True, + :func:`~get_token` will raise :class:`~azure.identity.AuthenticationRequiredError` when it cannot + authenticate silently. :keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes). """ @@ -48,7 +55,6 @@ def __init__(self, **kwargs): client_id = kwargs.pop("client_id", AZURE_CLI_CLIENT_ID) super(InteractiveBrowserCredential, self).__init__(client_id=client_id, **kwargs) - @wrap_exceptions def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument # type: (*str, **Any) -> AccessToken """Request an access token for `scopes`. @@ -65,26 +71,57 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` attribute gives a reason. Any error response from Azure Active Directory is available as the error's ``response`` attribute. + :raises ~azure.identity.AuthenticationRequiredError: the credential is configured to authenticate only silently + (without user interaction), and was unable to do so. """ if not scopes: raise ValueError("'get_token' requires at least one scope") - return self._get_token_from_cache(scopes, **kwargs) or self._get_token_by_auth_code(scopes, **kwargs) + token = self._acquire_token_silent(*scopes, **kwargs) + if not token: + if self._silent_auth_only: + raise AuthenticationRequiredError() - def _get_token_from_cache(self, scopes, **kwargs): - """if the user has already signed in, we can redeem a refresh token for a new access token""" - app = self._get_app() - accounts = app.get_accounts() - if accounts: # => user has already authenticated - # MSAL asserts scopes is a list - scopes = list(scopes) # type: ignore now = int(time.time()) - token = app.acquire_token_silent(scopes, account=accounts[0], **kwargs) - if token and "access_token" in token and "expires_in" in token: - return AccessToken(token["access_token"], now + int(token["expires_in"])) - return None + response = self._get_token_by_auth_code(*scopes, **kwargs) + + # update profile because the user may have authenticated a different identity + self._profile = _build_auth_profile(response) + + token = AccessToken(response["access_token"], now + int(response["expires_in"])) + + return token + + @classmethod + def authenticate(cls, client_id, **kwargs): + # type: (str, **Any) -> Tuple[InteractiveBrowserCredential, AuthProfile] + """Authenticate a user. Returns a credential ready to get tokens for that user, and a user profile. + + This method will open a browser to a login page and listen on localhost for a request indicating authentication + has completed. + + Accepts the same keyword arguments as :class:`~InteractiveBrowserCredential` + + :param str client_id: Client ID of the Azure Active Directory application the user will sign in to + :rtype: ~azure.identity.InteractiveBrowserCredential, ~azure.identity.AuthProfile + :raises ~azure.identity.CredentialUnavailableError: the credential is unable to start an HTTP server on + localhost, or is unable to open a browser + :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` + attribute gives a reason. Any error response from Azure Active Directory is available as the error's + ``response`` attribute. + """ + # pylint:disable=protected-access + scope = kwargs.pop("scope", None) or ARM_SCOPE + + credential = cls(client_id=client_id, **kwargs) + response = credential._get_token_by_auth_code(scope) + profile = _build_auth_profile(response) + credential._profile = profile - def _get_token_by_auth_code(self, scopes, **kwargs): + return credential, profile + + @wrap_exceptions + def _get_token_by_auth_code(self, *scopes, **kwargs): # start an HTTP server on localhost to receive the redirect for port in range(8400, 9000): try: @@ -118,13 +155,12 @@ def _get_token_by_auth_code(self, scopes, **kwargs): # redeem the authorization code for a token code = self._parse_response(request_state, response) - now = int(time.time()) result = app.acquire_token_by_authorization_code(code, scopes=scopes, redirect_uri=redirect_uri, **kwargs) if "access_token" not in result: raise ClientAuthenticationError(message="Authentication failed: {}".format(result.get("error_description"))) - return AccessToken(result["access_token"], now + int(result["expires_in"])) + return result @staticmethod def _parse_response(request_state, response): diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/user.py b/sdk/identity/azure-identity/azure/identity/_credentials/user.py index cf7e7d7ecaeb..214ebc6d8f9b 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/user.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/user.py @@ -8,7 +8,9 @@ from azure.core.credentials import AccessToken from azure.core.exceptions import ClientAuthenticationError -from .._internal import PublicClientCredential, wrap_exceptions +from .. import AuthenticationRequiredError +from .._internal import ARM_SCOPE, PublicClientCredential, wrap_exceptions +from .._internal.msal_credentials import _build_auth_profile try: from typing import TYPE_CHECKING @@ -17,7 +19,9 @@ if TYPE_CHECKING: # pylint:disable=unused-import,ungrouped-imports - from typing import Any, Callable, Optional + from typing import Any, Callable, Optional, Tuple + from azure.core.credentials import TokenCredential + from .. import AuthProfile class DeviceCodeCredential(PublicClientCredential): @@ -27,8 +31,6 @@ class DeviceCodeCredential(PublicClientCredential): A user must browse to the URL, enter the code, and authenticate with Azure Active Directory. If the user authenticates successfully, the credential receives an access token. - This credential doesn't cache tokens--each :func:`get_token` call begins a new authentication flow. - For more information about the device code flow, see Azure Active Directory documentation: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code @@ -49,6 +51,11 @@ class DeviceCodeCredential(PublicClientCredential): - ``expires_on`` (datetime.datetime) the UTC time at which the code will expire If this argument isn't provided, the credential will print instructions to stdout. :paramtype prompt_callback: Callable[str, str, ~datetime.datetime] + :keyword ~azure.identity.AuthProfile profile: a user profile from a prior authentication. If provided, keyword + arguments ``authority`` and ``tenant_id`` will be ignored because the profile contains this information. + :keyword bool silent_auth_only: authenticate only silently (without user interaction). False by default. If True, + :func:`~get_token` will raise :class:`~azure.identity.AuthenticationRequiredError` when it cannot + authenticate silently. """ def __init__(self, client_id, **kwargs): @@ -57,13 +64,10 @@ def __init__(self, client_id, **kwargs): self._prompt_callback = kwargs.pop("prompt_callback", None) super(DeviceCodeCredential, self).__init__(client_id=client_id, **kwargs) - @wrap_exceptions def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument # type: (*str, **Any) -> AccessToken """Request an access token for `scopes`. - This credential won't cache the token. Each call begins a new authentication flow. - .. note:: This method is called by Azure SDK clients. It isn't intended for use in application code. :param str scopes: desired scopes for the access token. This method requires at least one scope. @@ -71,13 +75,54 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` attribute gives a reason. Any error response from Azure Active Directory is available as the error's ``response`` attribute. + :raises ~azure.identity.AuthenticationRequiredError: the credential is configured to authenticate only silently + (without user interaction), and was unable to do so. """ if not scopes: raise ValueError("'get_token' requires at least one scope") + token = self._acquire_token_silent(*scopes, **kwargs) + if not token: + if self._silent_auth_only: + raise AuthenticationRequiredError() + + now = int(time.time()) + response = self._get_token_by_device_code(*scopes, **kwargs) + + # update profile because the user may have authenticated a different identity + self._profile = _build_auth_profile(response) + + token = AccessToken(response["access_token"], now + int(response["expires_in"])) + + return token + + @classmethod + def authenticate(cls, client_id, **kwargs): + # type: (str, **Any) -> Tuple[DeviceCodeCredential, AuthProfile] + """Authenticate a user. Returns a credential ready to get tokens for that user, and a user profile. + + Accepts the same keyword arguments as :class:`~DeviceCodeCredential` + + :param str client_id: Client ID of the Azure Active Directory application the user will sign in to + :rtype: ~azure.identity.DeviceCodeCredential, ~azure.identity.AuthProfile + :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` + attribute gives a reason. Any error response from Azure Active Directory is available as the error's + ``response`` attribute. + """ + # pylint:disable=protected-access + scope = kwargs.pop("scope", None) or ARM_SCOPE + + credential = cls(client_id, **kwargs) + response = credential._get_token_by_device_code(scope) + profile = _build_auth_profile(response) + credential._profile = profile + + return credential, profile + + @wrap_exceptions + def _get_token_by_device_code(self, *scopes): # MSAL requires scopes be a list scopes = list(scopes) # type: ignore - now = int(time.time()) app = self._get_app() flow = app.initiate_device_flow(scopes) @@ -95,7 +140,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument if self._timeout is not None and self._timeout < flow["expires_in"]: # user specified an effective timeout we will observe - deadline = now + self._timeout + deadline = int(time.time()) + self._timeout result = app.acquire_token_by_device_flow(flow, exit_condition=lambda flow: time.time() > deadline) else: # MSAL will stop polling when the device code expires @@ -108,8 +153,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument message = "Authentication failed: {}".format(result.get("error_description") or result.get("error")) raise ClientAuthenticationError(message=message) - token = AccessToken(result["access_token"], now + int(result["expires_in"])) - return token + return result class UsernamePasswordCredential(PublicClientCredential): @@ -135,6 +179,11 @@ class UsernamePasswordCredential(PublicClientCredential): defines authorities for other clouds. :keyword str tenant_id: tenant ID or a domain associated with a tenant. If not provided, defaults to the 'organizations' tenant, which supports only Azure Active Directory work or school accounts. + :keyword ~azure.identity.AuthProfile profile: a user profile from a prior authentication. If provided, keyword + arguments ``authority`` and ``tenant_id`` will be ignored because the profile contains this information. + :keyword bool silent_auth_only: authenticate only silently (without user interaction). False by default. If True, + :func:`~get_token` will raise :class:`~azure.identity.AuthenticationRequiredError` when it cannot + authenticate silently. """ def __init__(self, client_id, username, password, **kwargs): @@ -143,7 +192,6 @@ def __init__(self, client_id, username, password, **kwargs): self._username = username self._password = password - @wrap_exceptions def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument # type: (*str, **Any) -> AccessToken """Request an access token for `scopes`. @@ -155,30 +203,65 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` attribute gives a reason. Any error response from Azure Active Directory is available as the error's ``response`` attribute. + :raises ~azure.identity.AuthenticationRequiredError: the credential is configured to authenticate only silently + (without user interaction), and was unable to do so. """ if not scopes: raise ValueError("'get_token' requires at least one scope") + token = self._acquire_token_silent(*scopes, **kwargs) + if not token: + if self._silent_auth_only: + raise AuthenticationRequiredError() + + now = int(time.time()) + response = self._request_token(*scopes) + + # update profile because the user may have authenticated a different identity + self._profile = _build_auth_profile(response) + + token = AccessToken(response["access_token"], now + int(response["expires_in"])) + + return token + + @wrap_exceptions + def _request_token(self, *scopes): # MSAL requires scopes be a list scopes = list(scopes) # type: ignore - now = int(time.time()) app = self._get_app() - accounts = app.get_accounts(username=self._username) - result = None - for account in accounts: - result = app.acquire_token_silent(scopes, account=account) - if result: - break - - if not result: - # cache miss -> request a new token - with self._adapter: - result = app.acquire_token_by_username_password( - username=self._username, password=self._password, scopes=scopes - ) + with self._adapter: + result = app.acquire_token_by_username_password( + username=self._username, password=self._password, scopes=scopes + ) if "access_token" not in result: - raise ClientAuthenticationError(message="authentication failed: {}".format(result.get("error_description"))) + raise ClientAuthenticationError(message="Authentication failed: {}".format(result.get("error_description"))) + + return result + + @classmethod + def authenticate(cls, client_id, username, password, **kwargs): + # type: (str, str, str, **Any) -> Tuple[UsernamePasswordCredential, AuthProfile] + """Authenticate a user. Returns a credential ready to get tokens for that user, and a user profile. + + Accepts the same keyword arguments as :class:`~UsernamePasswordCredential` + + :param str client_id: Client ID of the Azure Active Directory application the user will sign in to + :param str username: the user's username (usually an email address) + :param str password: the user's password + :rtype: ~azure.identity.UsernamePasswordCredential, ~azure.identity.AuthProfile + :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` + attribute gives a reason. Any error response from Azure Active Directory is available as the error's + ``response`` attribute. + """ + # pylint:disable=protected-access + scope = kwargs.pop("scope", None) or ARM_SCOPE + + credential = cls(client_id, username, password, **kwargs) + + response = credential._request_token(scope) + profile = _build_auth_profile(response) + credential._profile = profile - return AccessToken(result["access_token"], now + int(result["expires_in"])) + return credential, profile diff --git a/sdk/identity/azure-identity/azure/identity/_exceptions.py b/sdk/identity/azure-identity/azure/identity/_exceptions.py index 22802306976f..8faee0851c1d 100644 --- a/sdk/identity/azure-identity/azure/identity/_exceptions.py +++ b/sdk/identity/azure-identity/azure/identity/_exceptions.py @@ -7,3 +7,7 @@ class CredentialUnavailableError(ClientAuthenticationError): """The credential did not attempt to authenticate because required data or state is unavailable.""" + + +class AuthenticationRequiredError(CredentialUnavailableError): + """The credential was unable to authenticate silently.""" diff --git a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py index a3cbd73d1586..5ff84fb7ca79 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py @@ -33,6 +33,8 @@ def _scopes_to_resource(*scopes): return resource +ARM_SCOPE = "https://management.azure.com/.default" + __all__ = [ "AadClient", "AadClientBase", diff --git a/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py b/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py index 78552ec106d7..f2fce4ccb311 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py @@ -6,15 +6,21 @@ This entails monkeypatching MSAL's OAuth client with an adapter substituting an azure-core pipeline for Requests. """ import abc +import json +import logging +import os +import sys import time import msal +from six.moves.urllib_parse import urlparse from azure.core.credentials import AccessToken from azure.core.exceptions import ClientAuthenticationError from .exception_wrapper import wrap_exceptions from .msal_transport_adapter import MsalTransportAdapter from .._internal import get_default_authority +from .._auth_profile import AuthProfile try: ABC = abc.ABC @@ -27,8 +33,50 @@ TYPE_CHECKING = False if TYPE_CHECKING: - # pylint:disable=unused-import - from typing import Any, Mapping, Optional, Type, Union + # pylint:disable=ungrouped-imports,unused-import + from typing import Any, Mapping, Optional, Tuple, Type, Union + from azure.core.credentials import TokenCredential + + +_LOGGER = logging.getLogger(__name__) + + +def _build_auth_profile(response): + """Build an AuthProfile from the result of an MSAL ClientApplication token request""" + + try: + client_info = json.loads(msal.oauth2cli.oidc.decode_part(response["client_info"])) + id_token = response["id_token_claims"] + + return AuthProfile( + environment=urlparse(id_token["iss"]).netloc, # "iss" is the URL of the issuing tenant + home_account_id="{uid}.{utid}".format(**client_info), + tenant_id=id_token["tid"], # tenant which issued the token, not necessarily user's home tenant + username=id_token["preferred_username"], + ) + except (KeyError, ValueError): + # ClientApplication always requests client_info and id token, whose shapes shouldn't change; this is surprising + return None + + +def _account_matches_profile(account, profile): + return ( + account.get("home_account_id") == profile.home_account_id and account.get("environment") == profile.environment + ) + + +def _load_cache(): + # type: () -> msal.TokenCache + + if sys.platform.startswith("win") and "LOCALAPPDATA" in os.environ: + from msal_extensions.token_cache import WindowsTokenCache + + return WindowsTokenCache( + cache_location=os.path.join(os.environ["LOCALAPPDATA"], ".IdentityService", "msal.cache") + ) + + _LOGGER.warning("Using an in-memory cache because persistent caching isn't supported on this platform.") + return msal.TokenCache() class MsalCredential(ABC): @@ -36,12 +84,19 @@ class MsalCredential(ABC): def __init__(self, client_id, client_credential=None, **kwargs): # type: (str, Optional[Union[str, Mapping[str, str]]], **Any) -> None - tenant_id = kwargs.pop("tenant_id", "organizations") - authority = kwargs.pop("authority", None) or get_default_authority() + self._profile = kwargs.pop("profile", None) # type: Optional[AuthProfile] + if self._profile: + authority = self._profile.environment + tenant_id = self._profile.tenant_id + else: + authority = kwargs.pop("authority", None) or get_default_authority() + tenant_id = kwargs.pop("tenant_id", "organizations") + self._base_url = "https://" + "/".join((authority.strip("/"), tenant_id.strip("/"))) self._client_credential = client_credential self._client_id = client_id - + self._cache = kwargs.pop("_cache", None) or _load_cache() + self._silent_auth_only = kwargs.pop("silent_auth_only", False) self._adapter = kwargs.pop("msal_adapter", None) or MsalTransportAdapter(**kwargs) # postpone creating the wrapped application because its initializer uses the network @@ -57,14 +112,20 @@ def _get_app(self): # type: () -> msal.ClientApplication pass - def _create_app(self, cls): - # type: (Type[msal.ClientApplication]) -> msal.ClientApplication + def _create_app(self, cls, **kwargs): + # type: (Type[msal.ClientApplication], **Any) -> msal.ClientApplication """Creates an MSAL application, patching msal.authority to use an azure-core pipeline during tenant discovery""" # MSAL application initializers use msal.authority to send AAD tenant discovery requests with self._adapter: # MSAL's "authority" is a URL e.g. https://login.microsoftonline.com/common - app = cls(client_id=self._client_id, client_credential=self._client_credential, authority=self._base_url) + app = cls( + client_id=self._client_id, + client_credential=self._client_credential, + authority=self._base_url, + token_cache=self._cache, + **kwargs + ) # monkeypatch the app to replace requests.Session with MsalTransportAdapter app.client.session.close() @@ -72,6 +133,23 @@ def _create_app(self, cls): return app + @wrap_exceptions + def _acquire_token_silent(self, *scopes, **kwargs): + if self._profile: + app = self._get_app() + for account in app.get_accounts(username=self._profile.username): + if not _account_matches_profile(account, self._profile): + continue + + now = int(time.time()) + token = app.acquire_token_silent(list(scopes), account=account, **kwargs) + try: + return AccessToken(token["access_token"], now + int(token["expires_in"])) + except (TypeError, KeyError): + # 'token' has an unexpected type or shape, which is surprising + continue + return None + class ConfidentialClientCredential(MsalCredential): """Wraps an MSAL ConfidentialClientApplication with the TokenCredential API""" diff --git a/sdk/identity/azure-identity/tests/helpers.py b/sdk/identity/azure-identity/tests/helpers.py index 7073a6c08e04..f31998fe2833 100644 --- a/sdk/identity/azure-identity/tests/helpers.py +++ b/sdk/identity/azure-identity/tests/helpers.py @@ -16,12 +16,29 @@ # build_* lifted from msal tests def build_id_token( - iss="issuer", sub="subject", aud="my_client_id", exp=None, iat=None, **claims + iss="issuer", + sub="subject", + aud="my_client_id", + username="username", + tenant_id="tenant id", + object_id="object id", + exp=None, + iat=None, + **claims ): # AAD issues "preferred_username", ADFS issues "upn" return "header.%s.signature" % base64.b64encode( json.dumps( dict( - {"iss": iss, "sub": sub, "aud": aud, "exp": exp or (time.time() + 100), "iat": iat or time.time()}, + { + "iss": iss, + "sub": sub, + "aud": aud, + "exp": exp or (time.time() + 100), + "iat": iat or time.time(), + "tid": tenant_id, + "oid": object_id, + "preferred_username": username, + }, **claims ) ).encode() @@ -83,7 +100,7 @@ def add_discrepancy(name, expected, actual): discrepancies.append("{}:\n\t expected: {}\n\t actual: {}".format(name, expected, actual)) if self.base_url and self.base_url != request.url.split("?")[0]: - add_discrepancy('base url', self.base_url, request.url) + add_discrepancy("base url", self.base_url, request.url) if self.url and self.url != request.url: add_discrepancy("url", self.url, request.url) diff --git a/sdk/identity/azure-identity/tests/test_auth_profile.py b/sdk/identity/azure-identity/tests/test_auth_profile.py new file mode 100644 index 000000000000..e27fbb31bd3d --- /dev/null +++ b/sdk/identity/azure-identity/tests/test_auth_profile.py @@ -0,0 +1,33 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import json + +from azure.identity import AuthProfile + + +def test_serialize_additional_data(): + """serialize should accept arbitrary additional key/value pairs, which deserialize should ignore""" + + attrs = ("environment", "home_account_id", "tenant_id", "username") + nums = (n for n in range(len(attrs))) + profile_values = {attr: next(nums) for attr in attrs} + additional_data = {"foo": "bar", "bar": "quux"} + + profile = AuthProfile(**profile_values) + serialized = profile.serialize(**additional_data) + + # AuthProfile's fields and the additional data should have been serialized + assert json.loads(serialized) == dict(profile_values, **additional_data) + + deserialized = AuthProfile.deserialize(serialized) + + # the deserialized profile and the constructed profile should have the same fields + assert sorted(vars(deserialized)) == sorted(vars(profile)) + + # the constructed and deserialized profiles should have the same values + assert all(getattr(deserialized, attr) == profile_values[attr] for attr in attrs) + + # deserialized profile should expose additional data like a dictionary + assert all(deserialized[key] == additional_data[key] for key in additional_data) diff --git a/sdk/identity/azure-identity/tests/test_device_code_credential.py b/sdk/identity/azure-identity/tests/test_device_code_credential.py index 98a9fe4216ab..8efe96936b1b 100644 --- a/sdk/identity/azure-identity/tests/test_device_code_credential.py +++ b/sdk/identity/azure-identity/tests/test_device_code_credential.py @@ -6,11 +6,19 @@ from azure.core.exceptions import ClientAuthenticationError from azure.core.pipeline.policies import SansIOHTTPPolicy -from azure.identity import DeviceCodeCredential +from azure.identity import AuthenticationRequiredError, DeviceCodeCredential from azure.identity._internal.user_agent import USER_AGENT +from msal import TokenCache import pytest -from helpers import build_aad_response, get_discovery_response, mock_response, Request, validating_transport +from helpers import ( + build_aad_response, + build_id_token, + get_discovery_response, + mock_response, + Request, + validating_transport, +) try: from unittest.mock import Mock @@ -22,10 +30,80 @@ def test_no_scopes(): """The credential should raise when get_token is called with no scopes""" credential = DeviceCodeCredential("client_id") - with pytest.raises(ClientAuthenticationError): + with pytest.raises(ValueError): credential.get_token() +def test_authenticate(): + """authenticate should return a ready-to-use credential instance and an AuthProfile for the authenticated user""" + + client_id = "client-id" + environment = "localhost" + issuer = "https://" + environment + tenant_id = "some-tenant" + authority = issuer + "/" + tenant_id + + access_token = "***" + scope = "scope" + + # mock AAD response with id token + object_id = "object-id" + home_tenant = "home-tenant-id" + username = "me@work.com" + id_token = build_id_token(aud=client_id, iss=issuer, object_id=object_id, tenant_id=home_tenant, username=username) + auth_response = build_aad_response( + uid=object_id, utid=home_tenant, access_token=access_token, refresh_token="**", id_token=id_token + ) + + transport = validating_transport( + requests=[Request(url_substring=issuer)] * 4, + responses=[get_discovery_response(authority)] * 2 # instance and tenant discovery + + [ + mock_response( # start device code flow + json_payload={ + "device_code": "_", + "user_code": "user-code", + "verification_uri": "verification-uri", + "expires_in": 42, + } + ), + mock_response(json_payload=dict(auth_response, scope=scope)), # poll for completion + ], + ) + + credential, profile = DeviceCodeCredential.authenticate( + client_id, + prompt_callback=Mock(), # prevent credential from printing to stdout + transport=transport, + scope=scope, + authority=environment, + tenant_id=tenant_id, + _cache=TokenCache(), + ) + + assert isinstance(credential, DeviceCodeCredential) + + # credential should have a cached access token for the scope used in authenticate + token = credential.get_token(scope) + assert token.token == access_token + + assert profile.environment == environment + assert profile.home_account_id == object_id + "." + home_tenant + assert profile.tenant_id == home_tenant + assert profile.username == username + + +def test_silent_auth_only(): + """When configured for strict silent auth, the credential should raise when silent auth fails""" + + empty_cache = TokenCache() # empty cache makes silent auth impossible + transport = Mock(send=Mock(side_effect=Exception("no request should be sent"))) + credential = DeviceCodeCredential("client-id", silent_auth_only=True, transport=transport, _cache=empty_cache) + + with pytest.raises(AuthenticationRequiredError): + credential.get_token("scope") + + def test_policies_configurable(): policy = Mock(spec_set=SansIOHTTPPolicy, on_request=Mock()) @@ -47,7 +125,7 @@ def test_policies_configurable(): ) credential = DeviceCodeCredential( - client_id="client-id", prompt_callback=Mock(), policies=[policy], transport=transport + client_id="client-id", prompt_callback=Mock(), policies=[policy], transport=transport, _cache=TokenCache() ) credential.get_token("scope") @@ -72,7 +150,9 @@ def test_user_agent(): ], ) - credential = DeviceCodeCredential(client_id="client-id", prompt_callback=Mock(), transport=transport) + credential = DeviceCodeCredential( + client_id="client-id", prompt_callback=Mock(), transport=transport, _cache=TokenCache() + ) credential.get_token("scope") @@ -110,7 +190,7 @@ def test_device_code_credential(): callback = Mock() credential = DeviceCodeCredential( - client_id="_", prompt_callback=callback, transport=transport, instance_discovery=False + client_id="_", prompt_callback=callback, transport=transport, instance_discovery=False, _cache=TokenCache() ) now = datetime.datetime.utcnow() @@ -142,7 +222,12 @@ def test_timeout(): ) credential = DeviceCodeCredential( - client_id="_", prompt_callback=Mock(), transport=transport, timeout=0.01, instance_discovery=False + client_id="_", + prompt_callback=Mock(), + transport=transport, + timeout=0.01, + instance_discovery=False, + _cache=TokenCache(), ) with pytest.raises(ClientAuthenticationError) as ex: diff --git a/sdk/identity/azure-identity/tests/test_interactive_credential.py b/sdk/identity/azure-identity/tests/test_interactive_credential.py index 4dd245e8973d..c255e762a99c 100644 --- a/sdk/identity/azure-identity/tests/test_interactive_credential.py +++ b/sdk/identity/azure-identity/tests/test_interactive_credential.py @@ -10,14 +10,21 @@ from azure.core.exceptions import ClientAuthenticationError from azure.core.pipeline.policies import SansIOHTTPPolicy -from azure.identity import InteractiveBrowserCredential +from azure.identity import AuthenticationRequiredError, InteractiveBrowserCredential from azure.identity._internal import AuthCodeRedirectServer from azure.identity._internal.user_agent import USER_AGENT - +from msal import TokenCache import pytest from six.moves import urllib, urllib_parse -from helpers import build_aad_response, get_discovery_response, mock_response, Request, validating_transport +from helpers import ( + build_aad_response, + build_id_token, + get_discovery_response, + mock_response, + Request, + validating_transport, +) try: from unittest.mock import Mock, patch @@ -25,13 +32,84 @@ from mock import Mock, patch # type: ignore +WEBBROWSER_OPEN = InteractiveBrowserCredential.__module__ + ".webbrowser.open" + + def test_no_scopes(): """The credential should raise when get_token is called with no scopes""" - with pytest.raises(ClientAuthenticationError): + with pytest.raises(ValueError): InteractiveBrowserCredential().get_token() +def test_authenticate(): + """authenticate should return a ready-to-use credential instance and an AuthProfile for the authenticated user""" + + client_id = "client-id" + environment = "localhost" + issuer = "https://" + environment + tenant_id = "some-tenant" + authority = issuer + "/" + tenant_id + + access_token = "***" + scope = "scope" + + # mock AAD response with id token + object_id = "object-id" + home_tenant = "home-tenant-id" + username = "me@work.com" + id_token = build_id_token(aud=client_id, iss=issuer, object_id=object_id, tenant_id=home_tenant, username=username) + auth_response = build_aad_response( + uid=object_id, utid=home_tenant, access_token=access_token, refresh_token="**", id_token=id_token + ) + + transport = validating_transport( + requests=[Request(url_substring=issuer)] * 3, + responses=[get_discovery_response(authority)] * 2 + [mock_response(json_payload=auth_response)], + ) + + # mock local server fakes successful authentication by immediately returning a well-formed response + oauth_state = "state" + auth_code_response = {"code": "authorization-code", "state": [oauth_state]} + server_class = Mock(return_value=Mock(wait_for_redirect=lambda: auth_code_response)) + + with patch(InteractiveBrowserCredential.__module__ + ".uuid.uuid4", lambda: oauth_state): + with patch(WEBBROWSER_OPEN, lambda _: True): + credential, profile = InteractiveBrowserCredential.authenticate( + _cache=TokenCache(), + authority=environment, + client_id=client_id, + scope=scope, + server_class=server_class, + tenant_id=tenant_id, + transport=transport, + ) + + assert isinstance(credential, InteractiveBrowserCredential) + + # credential should have a cached access token for the scope used in authenticate + with patch(WEBBROWSER_OPEN, Mock(side_effect=Exception("credential should authenticate silently"))): + token = credential.get_token(scope) + assert token.token == access_token + + assert profile.environment == environment + assert profile.home_account_id == object_id + "." + home_tenant + assert profile.tenant_id == home_tenant + assert profile.username == username + + +def test_silent_auth_only(): + """When configured for strict silent auth, the credential should raise when silent auth fails""" + + empty_cache = TokenCache() # empty cache makes silent auth impossible + transport = Mock(send=Mock(side_effect=Exception("no request should be sent"))) + credential = InteractiveBrowserCredential(silent_auth_only=True, transport=transport, _cache=empty_cache) + + with patch(WEBBROWSER_OPEN, Mock(side_effect=Exception("credential shouldn't try interactive authentication"))): + with pytest.raises(AuthenticationRequiredError): + credential.get_token("scope") + + @patch("azure.identity._credentials.browser.webbrowser.open", lambda _: True) def test_policies_configurable(): policy = Mock(spec_set=SansIOHTTPPolicy, on_request=Mock()) @@ -46,7 +124,9 @@ def test_policies_configurable(): auth_code_response = {"code": "authorization-code", "state": [oauth_state]} server_class = Mock(return_value=Mock(wait_for_redirect=lambda: auth_code_response)) - credential = InteractiveBrowserCredential(policies=[policy], transport=transport, server_class=server_class) + credential = InteractiveBrowserCredential( + policies=[policy], transport=transport, server_class=server_class, _cache=TokenCache() + ) with patch("azure.identity._credentials.browser.uuid.uuid4", lambda: oauth_state): credential.get_token("scope") @@ -66,7 +146,7 @@ def test_user_agent(): auth_code_response = {"code": "authorization-code", "state": [oauth_state]} server_class = Mock(return_value=Mock(wait_for_redirect=lambda: auth_code_response)) - credential = InteractiveBrowserCredential(transport=transport, server_class=server_class) + credential = InteractiveBrowserCredential(transport=transport, server_class=server_class, _cache=TokenCache()) with patch("azure.identity._credentials.browser.uuid.uuid4", lambda: oauth_state): credential.get_token("scope") @@ -101,7 +181,8 @@ def test_interactive_credential(mock_open): expires_in=expires_in, refresh_token=expected_refresh_token, uid="uid", - utid="utid", + utid=tenant_id, + id_token=build_id_token(aud=client_id, object_id="uid", tenant_id=tenant_id, iss=endpoint), token_type="Bearer", ) ), @@ -124,6 +205,7 @@ def test_interactive_credential(mock_open): transport=transport, instance_discovery=False, validate_authority=False, + _cache=TokenCache(), ) # The credential's auth code request includes a uuid which must be included in the redirect. Patching to @@ -176,6 +258,7 @@ def test_interactive_credential_timeout(): timeout=timeout, transport=transport, instance_discovery=False, # kwargs are passed to MSAL; this one prevents an AAD verification request + _cache=TokenCache(), ) with pytest.raises(ClientAuthenticationError) as ex: @@ -216,7 +299,7 @@ def test_redirect_server(): def test_no_browser(): transport = validating_transport(requests=[Request()] * 2, responses=[get_discovery_response()] * 2) credential = InteractiveBrowserCredential( - client_id="client-id", client_secret="secret", server_class=Mock(), transport=transport + client_id="client-id", client_secret="secret", server_class=Mock(), transport=transport, _cache=TokenCache() ) with pytest.raises(ClientAuthenticationError, match=r".*browser.*"): credential.get_token("scope") diff --git a/sdk/identity/azure-identity/tests/test_username_password_credential.py b/sdk/identity/azure-identity/tests/test_username_password_credential.py index 16eb0a246e0d..c4b45e183c5d 100644 --- a/sdk/identity/azure-identity/tests/test_username_password_credential.py +++ b/sdk/identity/azure-identity/tests/test_username_password_credential.py @@ -2,13 +2,20 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -from azure.core.exceptions import ClientAuthenticationError from azure.core.pipeline.policies import ContentDecodePolicy, SansIOHTTPPolicy -from azure.identity import UsernamePasswordCredential +from azure.identity import AuthenticationRequiredError, UsernamePasswordCredential from azure.identity._internal.user_agent import USER_AGENT +from msal import TokenCache import pytest -from helpers import build_aad_response, get_discovery_response, mock_response, Request, validating_transport +from helpers import ( + build_aad_response, + build_id_token, + get_discovery_response, + mock_response, + Request, + validating_transport, +) try: from unittest.mock import Mock @@ -20,10 +27,73 @@ def test_no_scopes(): """The credential should raise when get_token is called with no scopes""" credential = UsernamePasswordCredential("client-id", "username", "password") - with pytest.raises(ClientAuthenticationError): + with pytest.raises(ValueError): credential.get_token() +def test_authenticate(): + """authenticate should return a ready-to-use credential instance and an AuthProfile for the authenticated user""" + + client_id = "client-id" + environment = "localhost" + issuer = "https://" + environment + tenant_id = "some-tenant" + authority = issuer + "/" + tenant_id + + access_token = "***" + scope = "scope" + + # mock AAD response with id token + object_id = "object-id" + home_tenant = "home-tenant-id" + username = "me@work.com" + id_token = build_id_token(aud=client_id, iss=issuer, object_id=object_id, tenant_id=home_tenant, username=username) + auth_response = build_aad_response( + uid=object_id, utid=home_tenant, access_token=access_token, refresh_token="**", id_token=id_token + ) + + transport = validating_transport( + requests=[Request(url_substring=issuer)] * 4, + responses=[get_discovery_response(authority)] * 2 + + [mock_response(json_payload={}), mock_response(json_payload=auth_response)], + ) + + credential, profile = UsernamePasswordCredential.authenticate( + client_id, + username=username, + password="supersecret", + transport=transport, + scope=scope, + authority=environment, + tenant_id=tenant_id, + _cache=TokenCache(), + ) + + assert isinstance(credential, UsernamePasswordCredential) + + # credential should have a cached access token for the scope used in authenticate + token = credential.get_token(scope) + assert token.token == access_token + + assert profile.environment == environment + assert profile.home_account_id == object_id + "." + home_tenant + assert profile.tenant_id == home_tenant + assert profile.username == username + + +def test_silent_auth_only(): + """When configured for strict silent auth, the credential should raise when silent auth fails""" + + empty_cache = TokenCache() # empty cache makes silent auth impossible + transport = Mock(send=Mock(side_effect=Exception("no request should be sent"))) + credential = UsernamePasswordCredential( + "client-id", "username", "password", silent_auth_only=True, transport=transport, _cache=empty_cache + ) + + with pytest.raises(AuthenticationRequiredError): + credential.get_token("scope") + + def test_policies_configurable(): policy = Mock(spec_set=SansIOHTTPPolicy, on_request=Mock()) @@ -31,7 +101,9 @@ def test_policies_configurable(): requests=[Request()] * 3, responses=[get_discovery_response()] * 2 + [mock_response(json_payload=build_aad_response(access_token="**"))], ) - credential = UsernamePasswordCredential("client-id", "username", "password", policies=[policy], transport=transport) + credential = UsernamePasswordCredential( + "client-id", "username", "password", policies=[policy], transport=transport, _cache=TokenCache() + ) credential.get_token("scope") @@ -44,7 +116,9 @@ def test_user_agent(): responses=[get_discovery_response()] * 2 + [mock_response(json_payload=build_aad_response(access_token="**"))], ) - credential = UsernamePasswordCredential("client-id", "username", "password", transport=transport) + credential = UsernamePasswordCredential( + "client-id", "username", "password", transport=transport, _cache=TokenCache() + ) credential.get_token("scope") @@ -76,6 +150,7 @@ def test_username_password_credential(): password="secret_password", transport=transport, instance_discovery=False, # kwargs are passed to MSAL; this one prevents an AAD verification request + _cache=TokenCache(), ) token = credential.get_token("scope")