diff --git a/sdk/identity/azure-identity/azure/identity/__init__.py b/sdk/identity/azure-identity/azure/identity/__init__.py index 512b415a27ec..fd23d51c8e32 100644 --- a/sdk/identity/azure-identity/azure/identity/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/__init__.py @@ -8,6 +8,7 @@ ClientSecretCredential, EnvironmentCredential, ManagedIdentityCredential, + UsernamePasswordCredential, ) @@ -35,4 +36,5 @@ def __init__(self, **kwargs): "DefaultAzureCredential", "EnvironmentCredential", "ManagedIdentityCredential", + "UsernamePasswordCredential", ] diff --git a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py index 9ea29a25784d..cfdf935b801c 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py @@ -2,5 +2,5 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -from .msal_credentials import ConfidentialClientCredential -from .msal_transport_adapter import MsalTransportResponse, MsalTransportAdapter +from .msal_credentials import ConfidentialClientCredential, PublicClientCredential +from .msal_transport_adapter import MsalTransportAdapter, MsalTransportResponse 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 9bf44cbb3219..83906bf71c2e 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py @@ -5,35 +5,40 @@ """Credentials wrapping MSAL applications and delegating token acquisition and caching to them. This entails monkeypatching MSAL's OAuth client with an adapter substituting an azure-core pipeline for Requests. """ - +import abc import time +import msal +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError + +from .msal_transport_adapter import MsalTransportAdapter + try: - from typing import TYPE_CHECKING -except ImportError: - TYPE_CHECKING = False + ABC = abc.ABC +except AttributeError: # Python 2.7, abc exists, but not ABC + ABC = abc.ABCMeta("ABC", (object,), {"__slots__": ()}) # type: ignore try: from unittest import mock except ImportError: # python < 3.3 import mock # type: ignore +try: + from typing import TYPE_CHECKING +except ImportError: + TYPE_CHECKING = False + if TYPE_CHECKING: # pylint:disable=unused-import - from typing import Any, Mapping, Optional, Union + from typing import Any, Mapping, Optional, Type, Union -from azure.core.credentials import AccessToken -from azure.core.exceptions import ClientAuthenticationError -import msal - -from .msal_transport_adapter import MsalTransportAdapter - -class MsalCredential(object): +class MsalCredential(ABC): """Base class for credentials wrapping MSAL applications""" - def __init__(self, client_id, authority, app_class, client_credential=None, **kwargs): - # type: (str, str, msal.ClientApplication, Optional[Union[str, Mapping[str, str]]], Any) -> None + def __init__(self, client_id, authority, client_credential=None, **kwargs): + # type: (str, str, Optional[Union[str, Mapping[str, str]]], Any) -> None self._authority = authority self._client_credential = client_credential self._client_id = client_id @@ -41,35 +46,35 @@ def __init__(self, client_id, authority, app_class, client_credential=None, **kw self._adapter = kwargs.pop("msal_adapter", None) or MsalTransportAdapter(**kwargs) # postpone creating the wrapped application because its initializer uses the network - self._app_class = app_class self._msal_app = None # type: Optional[msal.ClientApplication] - @property - def _app(self): + @abc.abstractmethod + def get_token(self, *scopes): + # type: (str) -> AccessToken + pass + + @abc.abstractmethod + def _get_app(self): # type: () -> msal.ClientApplication - """The wrapped MSAL application""" + pass - if not self._msal_app: - # MSAL application initializers use msal.authority to send AAD tenant discovery requests - with mock.patch("msal.authority.requests", self._adapter): - app = self._app_class( - client_id=self._client_id, client_credential=self._client_credential, authority=self._authority - ) + def _create_app(self, cls): + # type: (Type[msal.ClientApplication]) -> msal.ClientApplication + """Creates an MSAL application, patching msal.authority to use an azure-core pipeline during tenant discovery""" - # monkeypatch the app to replace requests.Session with MsalTransportAdapter - app.client.session = self._adapter - self._msal_app = app + # MSAL application initializers use msal.authority to send AAD tenant discovery requests + with mock.patch("msal.authority.requests", self._adapter): + app = cls(client_id=self._client_id, client_credential=self._client_credential, authority=self._authority) - return self._msal_app + # monkeypatch the app to replace requests.Session with MsalTransportAdapter + app.client.session = self._adapter + + return app class ConfidentialClientCredential(MsalCredential): """Wraps an MSAL ConfidentialClientApplication with the TokenCredential API""" - def __init__(self, **kwargs): - # type: (Any) -> None - super(ConfidentialClientCredential, self).__init__(app_class=msal.ConfidentialClientApplication, **kwargs) - def get_token(self, *scopes): # type: (str) -> AccessToken @@ -79,10 +84,37 @@ def get_token(self, *scopes): # First try to get a cached access token or if a refresh token is cached, redeem it for an access token. # Failing that, acquire a new token. - app = self._app # type: msal.ConfidentialClientApplication + app = self._get_app() result = app.acquire_token_silent(scopes, account=None) or app.acquire_token_for_client(scopes) if "access_token" not in result: raise ClientAuthenticationError(message="authentication failed: {}".format(result.get("error_description"))) return AccessToken(result["access_token"], now + int(result["expires_in"])) + + def _get_app(self): + # type: () -> msal.ConfidentialClientApplication + if not self._msal_app: + self._msal_app = self._create_app(msal.ConfidentialClientApplication) + return self._msal_app + + +class PublicClientCredential(MsalCredential): + """Wraps an MSAL PublicClientApplication with the TokenCredential API""" + + def __init__(self, **kwargs): + # type: (Any) -> None + super(PublicClientCredential, self).__init__( + authority="https://login.microsoftonline.com/" + kwargs.pop("tenant", "organizations"), **kwargs + ) + + @abc.abstractmethod + def get_token(self, *scopes): + # type: (str) -> AccessToken + pass + + def _get_app(self): + # type: () -> msal.PublicClientApplication + if not self._msal_app: + self._msal_app = self._create_app(msal.PublicClientApplication) + return self._msal_app diff --git a/sdk/identity/azure-identity/azure/identity/constants.py b/sdk/identity/azure-identity/azure/identity/constants.py index 5b50dd98625b..1c2608e5da8b 100644 --- a/sdk/identity/azure-identity/azure/identity/constants.py +++ b/sdk/identity/azure-identity/azure/identity/constants.py @@ -13,6 +13,10 @@ class EnvironmentVariables: AZURE_CLIENT_CERTIFICATE_PATH = "AZURE_CLIENT_CERTIFICATE_PATH" CERT_VARS = (AZURE_CLIENT_ID, AZURE_CLIENT_CERTIFICATE_PATH, AZURE_TENANT_ID) + AZURE_USERNAME = "AZURE_USERNAME" + AZURE_PASSWORD = "AZURE_PASSWORD" + USERNAME_PASSWORD_VARS = (AZURE_CLIENT_ID, AZURE_USERNAME, AZURE_PASSWORD) + MSI_ENDPOINT = "MSI_ENDPOINT" MSI_SECRET = "MSI_SECRET" diff --git a/sdk/identity/azure-identity/azure/identity/credentials.py b/sdk/identity/azure-identity/azure/identity/credentials.py index d53edf8e2c62..2e09a306aa71 100644 --- a/sdk/identity/azure-identity/azure/identity/credentials.py +++ b/sdk/identity/azure-identity/azure/identity/credentials.py @@ -6,6 +6,7 @@ Credentials for Azure SDK authentication. """ import os +import time from azure.core import Configuration from azure.core.credentials import AccessToken @@ -14,6 +15,7 @@ from ._authn_client import AuthnClient from ._base import ClientSecretCredentialBase, CertificateCredentialBase +from ._internal import PublicClientCredential from ._managed_identity import ImdsCredential, MsiCredential from .constants import Endpoints, EnvironmentVariables @@ -26,6 +28,7 @@ # pylint:disable=unused-import from typing import Any, Dict, Mapping, Optional, Union from azure.core.credentials import TokenCredential + EnvironmentCredentialTypes = Union["CertificateCredential", "ClientSecretCredential", "UsernamePasswordCredential"] # pylint:disable=too-few-public-methods @@ -96,23 +99,29 @@ def get_token(self, *scopes): class EnvironmentCredential: """ - Authenticates as a service principal using a client ID/secret pair or a certificate, - depending on environment variable settings. - - These environment variables are required: + Authenticates as a service principal using a client secret or a certificate, or as a user with a username and + password, depending on environment variable settings. Configuration is attempted in this order, using these + environment variables: + Service principal with secret: - **AZURE_CLIENT_ID**: the service principal's client ID + - **AZURE_CLIENT_SECRET**: one of the service principal's client secrets - **AZURE_TENANT_ID**: ID of the service principal's tenant. Also called its 'directory' ID. - Additionally, set **one** of these to configure client secret or certificate authentication: - - - **AZURE_CLIENT_SECRET**: one of the service principal's client secrets + Service principal with certificate: + - **AZURE_CLIENT_ID**: the service principal's client ID - **AZURE_CLIENT_CERTIFICATE_PATH**: path to a PEM-encoded certificate file including the private key + - **AZURE_TENANT_ID**: ID of the service principal's tenant. Also called its 'directory' ID. + + User with username and password: + - **AZURE_CLIENT_ID**: the application's client ID + - **AZURE_USERNAME**: a username (usually an email address) + - **AZURE_PASSWORD**: that user's password """ def __init__(self, **kwargs): # type: (Mapping[str, Any]) -> None - self._credential = None # type: Optional[Union[CertificateCredential, ClientSecretCredential]] + self._credential = None # type: Optional[EnvironmentCredentialTypes] if all(os.environ.get(v) is not None for v in EnvironmentVariables.CLIENT_SECRET_VARS): self._credential = ClientSecretCredential( @@ -128,6 +137,14 @@ def __init__(self, **kwargs): certificate_path=os.environ[EnvironmentVariables.AZURE_CLIENT_CERTIFICATE_PATH], **kwargs ) + elif all(os.environ.get(v) is not None for v in EnvironmentVariables.USERNAME_PASSWORD_VARS): + self._credential = UsernamePasswordCredential( + client_id=os.environ[EnvironmentVariables.AZURE_CLIENT_ID], + username=os.environ[EnvironmentVariables.AZURE_USERNAME], + password=os.environ[EnvironmentVariables.AZURE_PASSWORD], + tenant=os.environ.get(EnvironmentVariables.AZURE_TENANT_ID), # optional for username/password auth + **kwargs + ) def get_token(self, *scopes): # type (*str) -> AccessToken @@ -139,10 +156,7 @@ def get_token(self, *scopes): :raises: :class:`azure.core.exceptions.ClientAuthenticationError` """ if not self._credential: - message = "Missing environment settings. To authenticate with one of the service principal's client secrets, set {}. To authenticate with a certificate, set {}.".format( - ", ".join(EnvironmentVariables.CLIENT_SECRET_VARS), ", ".join(EnvironmentVariables.CERT_VARS) - ) - raise ClientAuthenticationError(message=message) + raise ClientAuthenticationError(message="Incomplete environment configuration.") return self._credential.get_token(*scopes) @@ -233,3 +247,65 @@ def _get_error_message(history): else: attempts.append(credential.__class__.__name__) return "No valid token received. {}".format(". ".join(attempts)) + + +class UsernamePasswordCredential(PublicClientCredential): + """ + Authenticates a user with a username and password. In general, Microsoft doesn't recommend this kind of + authentication, because it's less secure than other authentication flows. + + Authentication with this credential is not interactive, so it is **not compatible with any form of + multi-factor authentication or consent prompting**. The application must already have the user's consent. + + This credential can only authenticate work and school accounts; Microsoft accounts are not supported. + See this document for more information about account types: + https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/sign-up-organization + + :param str client_id: the application's client ID + :param str username: the user's username (usually an email address) + :param str password: the user's password + + **Keyword arguments:** + + *tenant (str)* - a tenant ID or a domain associated with a tenant. If not provided, the credential defaults to the + 'organizations' tenant. + """ + + def __init__(self, client_id, username, password, **kwargs): + # type: (str, str, str, Any) -> None + super(UsernamePasswordCredential, self).__init__(client_id=client_id, **kwargs) + self._username = username + self._password = password + + def get_token(self, *scopes): + # type (*str) -> AccessToken + """ + Request an access token for `scopes`. + + :param str scopes: desired scopes for the token + :rtype: :class:`azure.core.credentials.AccessToken` + :raises: :class:`azure.core.exceptions.ClientAuthenticationError` + """ + + # 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 + 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"))) + + return AccessToken(result["access_token"], now + int(result["expires_in"])) diff --git a/sdk/identity/azure-identity/tests/test_identity.py b/sdk/identity/azure-identity/tests/test_identity.py index b017792595a8..4e7fb13b4f6b 100644 --- a/sdk/identity/azure-identity/tests/test_identity.py +++ b/sdk/identity/azure-identity/tests/test_identity.py @@ -21,6 +21,7 @@ EnvironmentCredential, ManagedIdentityCredential, ChainedTokenCredential, + UsernamePasswordCredential, ) from azure.identity._managed_identity import ImdsCredential from azure.identity.constants import EnvironmentVariables @@ -239,3 +240,33 @@ def test_imds_credential_retries(): def test_default_credential(): DefaultAzureCredential() + + +def test_username_password_credential(): + expected_token = "access-token" + transport = validating_transport( + requests=[Request()] * 2, # not validating requests because they're formed by MSAL + responses=[ + # expecting tenant discovery then a token request + mock_response(json_payload={"authorization_endpoint": "https://a/b", "token_endpoint": "https://a/b"}), + mock_response( + json_payload={ + "access_token": expected_token, + "expires_in": 42, + "token_type": "Bearer", + "ext_expires_in": 42, + } + ), + ], + ) + + credential = UsernamePasswordCredential( + client_id="some-guid", + username="user@azure", + password="secret_password", + transport=transport, + instance_discovery=False, # kwargs are passed to MSAL; this one prevents an AAD verification request + ) + + token = credential.get_token("scope") + assert token.token == expected_token diff --git a/sdk/keyvault/azure-keyvault-keys/tests/helpers.py b/sdk/keyvault/azure-keyvault-keys/tests/keys_helpers.py similarity index 100% rename from sdk/keyvault/azure-keyvault-keys/tests/helpers.py rename to sdk/keyvault/azure-keyvault-keys/tests/keys_helpers.py diff --git a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py index 04ead0b933b1..13054cc0b1d2 100644 --- a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py +++ b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth.py @@ -18,7 +18,7 @@ from azure.keyvault.keys._shared import ChallengeAuthPolicy, HttpChallenge, HttpChallengeCache import pytest -from helpers import mock_response, Request, validating_transport +from keys_helpers import mock_response, Request, validating_transport def test_challenge_cache(): diff --git a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth_async.py b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth_async.py index c4d72b1c7550..27cda5f91d8e 100644 --- a/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth_async.py +++ b/sdk/keyvault/azure-keyvault-keys/tests/test_challenge_auth_async.py @@ -19,7 +19,7 @@ from azure.keyvault.keys._shared import AsyncChallengeAuthPolicy, HttpChallenge, HttpChallengeCache import pytest -from helpers import async_validating_transport, mock_response, Request +from keys_helpers import async_validating_transport, mock_response, Request @pytest.mark.asyncio