From 3f9ddb6812d8132b21ca55d59aac014017983b0a Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 11:51:10 -0700 Subject: [PATCH 01/24] identity broker package --- .../azure-identity-broker/CHANGELOG.md | 13 + sdk/identity/azure-identity-broker/LICENSE | 21 ++ .../azure-identity-broker/MANIFEST.in | 6 + sdk/identity/azure-identity-broker/README.md | 43 ++++ .../azure-identity-broker/azure/__init__.py | 1 + .../azure/identity/__init__.py | 1 + .../azure/identity/broker/__init__.py | 12 + .../azure/identity/broker/_auth_record.py | 114 +++++++++ .../azure/identity/broker/_browser.py | 107 ++++++++ .../azure/identity/broker/_constants.py | 55 +++++ .../azure/identity/broker/_decorators.py | 86 +++++++ .../azure/identity/broker/_exceptions.py | 50 ++++ .../azure/identity/broker/_interactive.py | 231 ++++++++++++++++++ .../azure/identity/broker/_msal_client.py | 130 ++++++++++ .../identity/broker/_msal_credentials.py | 118 +++++++++ .../identity/broker/_persistent_cache.py | 116 +++++++++ .../azure/identity/broker/_pipeline.py | 94 +++++++ .../azure/identity/broker/_user_agent.py | 9 + .../azure/identity/broker/_user_password.py | 76 ++++++ .../azure/identity/broker/_utils.py | 101 ++++++++ .../azure/identity/broker/_version.py | 6 + .../azure/identity/broker/py.typed | 0 .../dev_requirements.txt | 5 + .../azure-identity-broker/pyproject.toml | 4 + .../azure-identity-broker/sdk_packaging.toml | 2 + sdk/identity/azure-identity-broker/setup.py | 68 ++++++ .../azure-identity-broker/tests/conftest.py | 0 .../tests/test_broker.py | 30 +++ 28 files changed, 1499 insertions(+) create mode 100644 sdk/identity/azure-identity-broker/CHANGELOG.md create mode 100644 sdk/identity/azure-identity-broker/LICENSE create mode 100644 sdk/identity/azure-identity-broker/MANIFEST.in create mode 100644 sdk/identity/azure-identity-broker/README.md create mode 100644 sdk/identity/azure-identity-broker/azure/__init__.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/__init__.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/__init__.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_auth_record.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_constants.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_decorators.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_exceptions.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_interactive.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_msal_client.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_msal_credentials.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_persistent_cache.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_pipeline.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_version.py create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/py.typed create mode 100644 sdk/identity/azure-identity-broker/dev_requirements.txt create mode 100644 sdk/identity/azure-identity-broker/pyproject.toml create mode 100644 sdk/identity/azure-identity-broker/sdk_packaging.toml create mode 100644 sdk/identity/azure-identity-broker/setup.py create mode 100644 sdk/identity/azure-identity-broker/tests/conftest.py create mode 100644 sdk/identity/azure-identity-broker/tests/test_broker.py diff --git a/sdk/identity/azure-identity-broker/CHANGELOG.md b/sdk/identity/azure-identity-broker/CHANGELOG.md new file mode 100644 index 000000000000..f1dca41a1c18 --- /dev/null +++ b/sdk/identity/azure-identity-broker/CHANGELOG.md @@ -0,0 +1,13 @@ +# Release History + +## 1.0.0b1 (Unreleased) + +### Features Added + +- Added `azure.identity.broker.InteractiveBrowserCredential` and `azure.identity.broker.UsernamePasswordCredential` which have broker support. + +### Breaking Changes + +### Bugs Fixed + +### Other Changes diff --git a/sdk/identity/azure-identity-broker/LICENSE b/sdk/identity/azure-identity-broker/LICENSE new file mode 100644 index 000000000000..63447fd8bbbf --- /dev/null +++ b/sdk/identity/azure-identity-broker/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/sdk/identity/azure-identity-broker/MANIFEST.in b/sdk/identity/azure-identity-broker/MANIFEST.in new file mode 100644 index 000000000000..04a401707f16 --- /dev/null +++ b/sdk/identity/azure-identity-broker/MANIFEST.in @@ -0,0 +1,6 @@ +recursive-include samples *.py +recursive-include tests *.py +include *.md +include LICENSE +include azure/__init__.py +include azure/identity/py.typed diff --git a/sdk/identity/azure-identity-broker/README.md b/sdk/identity/azure-identity-broker/README.md new file mode 100644 index 000000000000..13224c5c4d4b --- /dev/null +++ b/sdk/identity/azure-identity-broker/README.md @@ -0,0 +1,43 @@ + + +# Azure Identity Broker plugin for Python + +## Getting started + +### Install the package + +Install the Azure Identity Broker plugin for Python with [pip](https://pypi.org/project/pip/): + +```bash +pip install azure-identity-broker +``` + + +## Examples + +Now you can create `azure.identity.broker.InteractiveBrowserCredential` and `azure.identity.broker.UsernamePasswordCredential` with broker support. + +```python + +import win32gui +from azure.identity.broker import InteractiveBrowserCredential + +# Get the handle of the current window +current_window_handle = win32gui.GetForegroundWindow() + +credential = InteractiveBrowserCredential(allow_broker=True, parent_window_handle=current_window_handle) +``` + + +## Troubleshooting + +This client raises exceptions defined in [Azure Core](https://learn.microsoft.com/python/api/azure-core/azure.core.exceptions?view=azure-python). + + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/sdk/identity/azure-identity-broker/azure/__init__.py b/sdk/identity/azure-identity-broker/azure/__init__.py new file mode 100644 index 000000000000..d55ccad1f573 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore diff --git a/sdk/identity/azure-identity-broker/azure/identity/__init__.py b/sdk/identity/azure-identity-broker/azure/identity/__init__.py new file mode 100644 index 000000000000..d55ccad1f573 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/__init__.py b/sdk/identity/azure-identity-broker/azure/identity/broker/__init__.py new file mode 100644 index 000000000000..0b0f1c52db55 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/__init__.py @@ -0,0 +1,12 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from ._browser import InteractiveBrowserCredential +from ._user_password import UsernamePasswordCredential + + +__all__ = [ + "InteractiveBrowserCredential", + "UsernamePasswordCredential", +] diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_auth_record.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_auth_record.py new file mode 100644 index 000000000000..63b3625e1160 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_auth_record.py @@ -0,0 +1,114 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import json + + +SUPPORTED_VERSIONS = {"1.0"} + + +class AuthenticationRecord: + """Non-secret account information for an authenticated user + + This class enables :class:`DeviceCodeCredential` and :class:`InteractiveBrowserCredential` to access + previously cached authentication data. Applications shouldn't construct instances of this class. They should + instead acquire one from a credential's **authenticate** method, such as + :func:`InteractiveBrowserCredential.authenticate`. See the user_authentication sample for more details. + + :param str tenant_id: The tenant the account should authenticate in. + :param str client_id: The client ID of the application which performed the original authentication. + :param str authority: The authority host used to authenticate the account. + :param str home_account_id: A unique identifier of the account. + :param str username: The user principal or service principal name of the account. + """ + + def __init__(self, tenant_id: str, client_id: str, authority: str, home_account_id: str, username: str) -> None: + self._authority = authority + self._client_id = client_id + self._home_account_id = home_account_id + self._tenant_id = tenant_id + self._username = username + + @property + def authority(self) -> str: + """The authority host used to authenticate the account. + + :rtype: str + """ + return self._authority + + @property + def client_id(self) -> str: + """The client ID of the application which performed the original authentication. + + :rtype: str + """ + return self._client_id + + @property + def home_account_id(self) -> str: + """A unique identifier of the account. + + :rtype: str + """ + return self._home_account_id + + @property + def tenant_id(self) -> str: + """The tenant the account should authenticate in. + + :rtype: str + """ + return self._tenant_id + + @property + def username(self) -> str: + """The user principal or service principal name of the account. + + :rtype: str + """ + return self._username + + @classmethod + def deserialize(cls, data: str) -> "AuthenticationRecord": + """Deserialize a record. + + :param str data: A serialized record. + :return: The deserialized record. + :rtype: ~azure.identity.AuthenticationRecord + """ + + deserialized = json.loads(data) + + version = deserialized.get("version") + if version not in SUPPORTED_VERSIONS: + raise ValueError( + 'Unexpected version "{}". This package supports these versions: {}'.format(version, SUPPORTED_VERSIONS) + ) + + return cls( + authority=deserialized["authority"], + client_id=deserialized["clientId"], + home_account_id=deserialized["homeAccountId"], + tenant_id=deserialized["tenantId"], + username=deserialized["username"], + ) + + def serialize(self) -> str: + """Serialize the record. + + :return: The serialized record. + :rtype: str + """ + + record = { + "authority": self._authority, + "clientId": self._client_id, + "homeAccountId": self._home_account_id, + "tenantId": self._tenant_id, + "username": self._username, + "version": "1.0", + } + + return json.dumps(record) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py new file mode 100644 index 000000000000..c921f2b68c2e --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -0,0 +1,107 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import socket +from typing import Dict, Any +from urllib.parse import urlparse + +from azure.core.exceptions import ClientAuthenticationError + +from ._interactive import InteractiveCredential +from ._exceptions import CredentialUnavailableError +from ._constants import DEVELOPER_SIGN_ON_CLIENT_ID +from ._decorators import wrap_exceptions +from ._utils import within_dac + + +class InteractiveBrowserCredential(InteractiveCredential): + """Opens a browser to interactively authenticate a user. + + :func:`~get_token` opens a browser to a login URL provided by Azure Active Directory and authenticates a user + there with the authorization code flow, using PKCE (Proof Key for Code Exchange) internally to protect the code. + + :keyword str authority: Authority of an Azure Active Directory endpoint, for example "login.microsoftonline.com", + the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.AzureAuthorityHosts` + defines authorities for other clouds. + :keyword str tenant_id: an Azure Active Directory tenant ID. Defaults to the "organizations" tenant, which can + authenticate work or school accounts. + :keyword str client_id: Client ID of the Azure Active Directory application users will sign in to. If + unspecified, users will authenticate to an Azure development application. + :keyword str login_hint: a username suggestion to pre-fill the login page's username/email address field. A user + may still log in with a different username. + :keyword str redirect_uri: a redirect URI for the application identified by `client_id` as configured in Azure + Active Directory, for example "http://localhost:8400". This is only required when passing a value for + **client_id**, and must match a redirect URI in the application's registration. The credential must be able to + bind a socket to this URI. + :keyword AuthenticationRecord authentication_record: :class:`AuthenticationRecord` returned by :func:`authenticate` + :keyword bool disable_automatic_authentication: if True, :func:`get_token` will raise + :class:`AuthenticationRequiredError` when user interaction is required to acquire a token. Defaults to False. + :keyword cache_persistence_options: configuration for persistent token caching. If unspecified, the credential + will cache tokens in memory. + :paramtype cache_persistence_options: ~azure.identity.TokenCachePersistenceOptions + :keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes). + :keyword bool disable_instance_discovery: Determines whether or not instance discovery is performed when attempting + to authenticate. Setting this to true will completely disable both instance discovery and authority validation. + This functionality is intended for use in scenarios where the metadata endpoint cannot be reached, such as in + private clouds or Azure Stack. The process of instance discovery entails retrieving authority metadata from + https://login.microsoft.com/ to validate the authority. By setting this to **True**, the validation of the + authority is disabled. As a result, it is crucial to ensure that the configured authority host is valid and + trustworthy. + :raises ValueError: invalid **redirect_uri** + + .. admonition:: Example: + + .. literalinclude:: ../samples/credential_creation_code_snippets.py + :start-after: [START create_interactive_browser_credential] + :end-before: [END create_interactive_browser_credential] + :language: python + :dedent: 4 + :caption: Create an InteractiveBrowserCredential. + """ + + def __init__(self, **kwargs: Any) -> None: + redirect_uri = kwargs.pop("redirect_uri", None) + if redirect_uri: + self._parsed_url = urlparse(redirect_uri) + if not (self._parsed_url.hostname and self._parsed_url.port): + raise ValueError('"redirect_uri" must be a URL with port number, for example "http://localhost:8400"') + else: + self._parsed_url = None + + self._login_hint = kwargs.pop("login_hint", None) + self._timeout = kwargs.pop("timeout", 300) + client_id = kwargs.pop("client_id", DEVELOPER_SIGN_ON_CLIENT_ID) + super(InteractiveBrowserCredential, self).__init__(client_id=client_id, **kwargs) + + @wrap_exceptions + def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: + scopes = list(scopes) # type: ignore + claims = kwargs.get("claims") + app = self._get_app(**kwargs) + port = self._parsed_url.port if self._parsed_url else None + + try: + result = app.acquire_token_interactive( + scopes=scopes, + login_hint=self._login_hint, + claims_challenge=claims, + timeout=self._timeout, + prompt="select_account", + port=port, + parent_window_handle=self._parent_window_handle, + enable_msa_passthrough=self._enable_msa_passthrough, + ) + except socket.error as ex: + raise CredentialUnavailableError(message="Couldn't start an HTTP server.") from ex + if "access_token" not in result and "error_description" in result: + if within_dac.get(): + raise CredentialUnavailableError(message=result["error_description"]) + raise ClientAuthenticationError(message=result.get("error_description")) + if "access_token" not in result: + if within_dac.get(): + raise CredentialUnavailableError(message="Failed to authenticate user") + raise ClientAuthenticationError(message="Failed to authenticate user") + + # base class will raise for other errors + return result diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_constants.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_constants.py new file mode 100644 index 000000000000..aab04de42b3d --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_constants.py @@ -0,0 +1,55 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + + +DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" +AZURE_VSCODE_CLIENT_ID = "aebc6443-996d-45c2-90f0-388ff96faa56" +VSCODE_CREDENTIALS_SECTION = "VS Code Azure" +DEFAULT_REFRESH_OFFSET = 300 +DEFAULT_TOKEN_REFRESH_RETRY_DELAY = 30 + +CACHE_NON_CAE_SUFFIX = ".nocae" # cspell:disable-line +CACHE_CAE_SUFFIX = ".cae" + + +class AzureAuthorityHosts: + AZURE_CHINA = "login.chinacloudapi.cn" + AZURE_GERMANY = "login.microsoftonline.de" + AZURE_GOVERNMENT = "login.microsoftonline.us" + AZURE_PUBLIC_CLOUD = "login.microsoftonline.com" + + +class KnownAuthorities(AzureAuthorityHosts): + """Alias of :class:`AzureAuthorityHosts`""" + + +class EnvironmentVariables: + 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_CLIENT_CERTIFICATE_PATH = "AZURE_CLIENT_CERTIFICATE_PATH" + AZURE_CLIENT_CERTIFICATE_PASSWORD = "AZURE_CLIENT_CERTIFICATE_PASSWORD" + 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) + + AZURE_POD_IDENTITY_AUTHORITY_HOST = "AZURE_POD_IDENTITY_AUTHORITY_HOST" + IDENTITY_ENDPOINT = "IDENTITY_ENDPOINT" + IDENTITY_HEADER = "IDENTITY_HEADER" + IDENTITY_SERVER_THUMBPRINT = "IDENTITY_SERVER_THUMBPRINT" + IMDS_ENDPOINT = "IMDS_ENDPOINT" + MSI_ENDPOINT = "MSI_ENDPOINT" + MSI_SECRET = "MSI_SECRET" + + AZURE_AUTHORITY_HOST = "AZURE_AUTHORITY_HOST" + AZURE_IDENTITY_DISABLE_MULTITENANTAUTH = "AZURE_IDENTITY_DISABLE_MULTITENANTAUTH" + AZURE_REGIONAL_AUTHORITY_NAME = "AZURE_REGIONAL_AUTHORITY_NAME" + + AZURE_FEDERATED_TOKEN_FILE = "AZURE_FEDERATED_TOKEN_FILE" + WORKLOAD_IDENTITY_VARS = (AZURE_AUTHORITY_HOST, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_decorators.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_decorators.py new file mode 100644 index 000000000000..558669279beb --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_decorators.py @@ -0,0 +1,86 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import functools +import logging +import json +import base64 + +from azure.core.exceptions import ClientAuthenticationError + +from ._utils import within_credential_chain + + +_LOGGER = logging.getLogger(__name__) + + +def log_get_token(class_name): + """Adds logging around get_token calls. + + :param str class_name: required for the sake of Python 2.7, which lacks an easy way to get the credential's class + name from the decorated function + :return: decorator function + :rtype: callable + """ + + def decorator(fn): + qualified_name = class_name + ".get_token" + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + token = fn(*args, **kwargs) + _LOGGER.log( + logging.DEBUG if within_credential_chain.get() else logging.INFO, "%s succeeded", qualified_name + ) + if _LOGGER.isEnabledFor(logging.DEBUG): + try: + base64_meta_data = token.token.split(".")[1].encode("utf-8") + b"==" + json_bytes = base64.decodebytes(base64_meta_data) + json_string = json_bytes.decode("utf-8") + json_dict = json.loads(json_string) + upn = json_dict.get("upn", "unavailableUpn") + log_string = ( + "[Authenticated account] Client ID: {}. Tenant ID: {}. User Principal Name: {}. " + "Object ID (user): {}".format(json_dict["appid"], json_dict["tid"], upn, json_dict["oid"]) + ) + _LOGGER.debug(log_string) + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Fail to log the account information") + return token + except Exception as ex: # pylint: disable=broad-except + _LOGGER.log( + logging.DEBUG if within_credential_chain.get() else logging.WARNING, + "%s failed: %s", + qualified_name, + ex, + exc_info=_LOGGER.isEnabledFor(logging.DEBUG), + ) + raise + + return wrapper + + return decorator + + +def wrap_exceptions(fn): + """Prevents leaking exceptions defined outside azure-core by raising ClientAuthenticationError from them. + + :param fn: The function to wrap. + :type fn: ~typing.Callable + :return: The wrapped function. + :rtype: callable + """ + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except ClientAuthenticationError: + raise + except Exception as ex: # pylint:disable=broad-except + auth_error = ClientAuthenticationError(message="Authentication failed: {}".format(ex)) + raise auth_error from ex + + return wrapper diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_exceptions.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_exceptions.py new file mode 100644 index 000000000000..f0230825b9ba --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_exceptions.py @@ -0,0 +1,50 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from typing import Any, Iterable, Optional + +from azure.core.exceptions import ClientAuthenticationError + + +class CredentialUnavailableError(ClientAuthenticationError): + """The credential did not attempt to authenticate because required data or state is unavailable.""" + + +class AuthenticationRequiredError(CredentialUnavailableError): + """Interactive authentication is required to acquire a token. + + This error is raised only by interactive user credentials configured not to automatically prompt for user + interaction as needed. Its properties provide additional information that may be required to authenticate. The + control_interactive_prompts sample demonstrates handling this error by calling a credential's "authenticate" + method. + + :param str scopes: Scopes requested during the failed authentication + :param str message: An error message explaining the reason for the exception. + :param str claims: Additional claims required in the next authentication. + """ + + def __init__( + self, scopes: Iterable[str], message: Optional[str] = None, claims: Optional[str] = None, **kwargs: Any + ) -> None: + self._claims = claims + self._scopes = scopes + if not message: + message = "Interactive authentication is required to get a token. Call 'authenticate' to begin." + super(AuthenticationRequiredError, self).__init__(message=message, **kwargs) + + @property + def scopes(self) -> Iterable[str]: + """Scopes requested during the failed authentication. + + :rtype: ~typing.Iterable[str] + """ + return self._scopes + + @property + def claims(self) -> Optional[str]: + """Additional claims required in the next authentication. + + :rtype: str or None + """ + return self._claims diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_interactive.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_interactive.py new file mode 100644 index 000000000000..6035d346e757 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_interactive.py @@ -0,0 +1,231 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Base class for credentials using MSAL for interactive user authentication""" + +import abc +import base64 +import json +import logging +import time +from typing import Any, Optional +from urllib.parse import urlparse + +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError + +from ._msal_credentials import MsalCredential +from ._auth_record import AuthenticationRecord +from ._constants import KnownAuthorities +from ._exceptions import AuthenticationRequiredError, CredentialUnavailableError +from ._decorators import wrap_exceptions + +ABC = abc.ABC + +_LOGGER = logging.getLogger(__name__) + +_DEFAULT_AUTHENTICATE_SCOPES = { + "https://" + KnownAuthorities.AZURE_CHINA: ("https://management.core.chinacloudapi.cn//.default",), + "https://" + KnownAuthorities.AZURE_GERMANY: ("https://management.core.cloudapi.de//.default",), + "https://" + KnownAuthorities.AZURE_GOVERNMENT: ("https://management.core.usgovcloudapi.net//.default",), + "https://" + KnownAuthorities.AZURE_PUBLIC_CLOUD: ("https://management.core.windows.net//.default",), +} + + +def _decode_client_info(raw) -> str: + """Decode client info. Taken from msal.oauth2cli.oidc. + + :param str raw: base64-encoded client info + :return: decoded client info + :rtype: str + """ + + raw += "=" * (-len(raw) % 4) + raw = str(raw) # On Python 2.7, argument of urlsafe_b64decode must be str, not unicode. + return base64.urlsafe_b64decode(raw).decode("utf-8") + + +def _build_auth_record(response): + """Build an AuthenticationRecord from the result of an MSAL ClientApplication token request. + + :param response: The result of a token request + :type response: dict[str, typing.Any] + :return: An AuthenticationRecord + :rtype: ~azure.identity.AuthenticationRecord + :raises ~azure.core.exceptions.ClientAuthenticationError: If the response doesn't contain expected data + """ + + try: + id_token = response["id_token_claims"] + + if "client_info" in response: + client_info = json.loads(_decode_client_info(response["client_info"])) + home_account_id = "{uid}.{utid}".format(**client_info) + else: + # MSAL uses the subject claim as home_account_id when the STS doesn't provide client_info + home_account_id = id_token["sub"] + + # "iss" is the URL of the issuing tenant e.g. https://authority/tenant + issuer = urlparse(id_token["iss"]) + + # tenant which issued the token, not necessarily user's home tenant + tenant_id = id_token.get("tid") or issuer.path.strip("/") + + # AAD returns "preferred_username", ADFS returns "upn" + username = id_token.get("preferred_username") or id_token["upn"] + + return AuthenticationRecord( + authority=issuer.netloc, + client_id=id_token["aud"], + home_account_id=home_account_id, + tenant_id=tenant_id, + username=username, + ) + except (KeyError, ValueError) as ex: + auth_error = ClientAuthenticationError( + message="Failed to build AuthenticationRecord from unexpected identity token" + ) + raise auth_error from ex + + +class InteractiveCredential(MsalCredential, ABC): + def __init__( + self, + *, + authentication_record: Optional[AuthenticationRecord] = None, + disable_automatic_authentication: bool = False, + **kwargs: Any + ) -> None: + self._disable_automatic_authentication = disable_automatic_authentication + self._auth_record = authentication_record + if self._auth_record: + kwargs.pop("client_id", None) # authentication_record overrides client_id argument + tenant_id = kwargs.pop("tenant_id", None) or self._auth_record.tenant_id + super(InteractiveCredential, self).__init__( + client_id=self._auth_record.client_id, + authority=self._auth_record.authority, + tenant_id=tenant_id, + **kwargs + ) + else: + super(InteractiveCredential, self).__init__(**kwargs) + + def get_token( + self, *scopes: str, claims: Optional[str] = None, tenant_id: Optional[str] = None, **kwargs: Any + ) -> AccessToken: + """Request an access token for `scopes`. + + This method is called automatically by Azure SDK clients. + + :param str scopes: desired scopes for the access token. This method requires at least one scope. + For more information about scopes, see + https://learn.microsoft.com/azure/active-directory/develop/scopes-oidc. + :keyword str claims: additional claims required in the token, such as those returned in a resource provider's + claims challenge following an authorization failure + :keyword str tenant_id: optional tenant to include in the token request. + :keyword bool enable_cae: indicates whether to enable Continuous Access Evaluation (CAE) for the requested + token. Defaults to False. + :return: An access token with the desired scopes. + :rtype: ~azure.core.credentials.AccessToken + :raises CredentialUnavailableError: the credential is unable to attempt authentication because it lacks + required data, state, or platform support + :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` + attribute gives a reason. + :raises AuthenticationRequiredError: user interaction is necessary to acquire a token, and the credential is + configured not to begin this automatically. Call :func:`authenticate` to begin interactive authentication. + """ + if not scopes: + message = "'get_token' requires at least one scope" + _LOGGER.warning("%s.get_token failed: %s", self.__class__.__name__, message) + raise ValueError(message) + + allow_prompt = kwargs.pop("_allow_prompt", not self._disable_automatic_authentication) + try: + token = self._acquire_token_silent(*scopes, claims=claims, tenant_id=tenant_id, **kwargs) + _LOGGER.info("%s.get_token succeeded", self.__class__.__name__) + return token + except Exception as ex: # pylint:disable=broad-except + if not (isinstance(ex, AuthenticationRequiredError) and allow_prompt): + _LOGGER.warning( + "%s.get_token failed: %s", + self.__class__.__name__, + ex, + exc_info=_LOGGER.isEnabledFor(logging.DEBUG), + ) + raise + + # silent authentication failed -> authenticate interactively + now = int(time.time()) + + try: + result = self._request_token(*scopes, claims=claims, tenant_id=tenant_id, **kwargs) + if "access_token" not in result: + message = "Authentication failed: {}".format(result.get("error_description") or result.get("error")) + response = self._client.get_error_response(result) + raise ClientAuthenticationError(message=message, response=response) + + # this may be the first authentication, or the user may have authenticated a different identity + self._auth_record = _build_auth_record(result) + except Exception as ex: # pylint:disable=broad-except + _LOGGER.warning( + "%s.get_token failed: %s", + self.__class__.__name__, + ex, + exc_info=_LOGGER.isEnabledFor(logging.DEBUG), + ) + raise + + _LOGGER.info("%s.get_token succeeded", self.__class__.__name__) + return AccessToken(result["access_token"], now + int(result["expires_in"])) + + def authenticate(self, **kwargs: Any) -> AuthenticationRecord: + """Interactively authenticate a user. + + :keyword Iterable[str] scopes: scopes to request during authentication, such as those provided by + :func:`AuthenticationRequiredError.scopes`. If provided, successful authentication will cache an access token + for these scopes. + :keyword str claims: additional claims required in the token, such as those provided by + :func:`AuthenticationRequiredError.claims` + :rtype: ~azure.identity.AuthenticationRecord + :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` + attribute gives a reason. + """ + + scopes = kwargs.pop("scopes", None) + if not scopes: + if self._authority not in _DEFAULT_AUTHENTICATE_SCOPES: + # the credential is configured to use a cloud whose ARM scope we can't determine + raise CredentialUnavailableError( + message="Authenticating in this environment requires a value for the 'scopes' keyword argument." + ) + + scopes = _DEFAULT_AUTHENTICATE_SCOPES[self._authority] + + _ = self.get_token(*scopes, _allow_prompt=True, **kwargs) + return self._auth_record # type: ignore + + @wrap_exceptions + def _acquire_token_silent(self, *scopes: str, **kwargs: Any) -> AccessToken: + result = None + claims = kwargs.get("claims") + if self._auth_record: + app = self._get_app(**kwargs) + for account in app.get_accounts(username=self._auth_record.username): + if account.get("home_account_id") != self._auth_record.home_account_id: + continue + + now = int(time.time()) + result = app.acquire_token_silent_with_error(list(scopes), account=account, claims_challenge=claims) + if result and "access_token" in result and "expires_in" in result: + return AccessToken(result["access_token"], now + int(result["expires_in"])) + + # if we get this far, result is either None or the content of an AAD error response + if result: + response = self._client.get_error_response(result) + raise AuthenticationRequiredError(scopes, claims=claims, response=response) + raise AuthenticationRequiredError(scopes, claims=claims) + + @abc.abstractmethod + def _request_token(self, *scopes, **kwargs): + pass diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_client.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_client.py new file mode 100644 index 000000000000..f6d2ce2862cf --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_client.py @@ -0,0 +1,130 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import threading +from typing import Any, Dict, Optional, Union + +from azure.core.exceptions import ClientAuthenticationError +from azure.core.pipeline.policies import ContentDecodePolicy +from azure.core.pipeline.transport import HttpRequest, HttpResponse +from azure.core.pipeline import PipelineResponse +from ._pipeline import build_pipeline + +RequestData = Union[Dict[str, str], str] +_POST = ["POST"] + + +class MsalResponse: + """Wraps HttpResponse according to msal.oauth2cli.http. + + :param response: The response to wrap. + :type response: ~azure.core.pipeline.transport.HttpResponse + """ + + def __init__(self, response: PipelineResponse) -> None: + self._response = response + + @property + def status_code(self) -> int: + return self._response.http_response.status_code + + @property + def text(self) -> str: + return self._response.http_response.text(encoding="utf-8") + + def raise_for_status(self): + if self.status_code < 400: + return + + if ContentDecodePolicy.CONTEXT_NAME in self._response.context: + content = self._response.context[ContentDecodePolicy.CONTEXT_NAME] + if not content: + message = "Unexpected response from Azure Active Directory" + elif "error" in content or "error_description" in content: + message = "Authentication failed: {}".format(content.get("error_description") or content.get("error")) + else: + for secret in ("access_token", "refresh_token"): + if secret in content: + content[secret] = "***" + message = 'Unexpected response from Azure Active Directory: "{}"'.format(content) + else: + message = "Unexpected response from Azure Active Directory" + + raise ClientAuthenticationError(message=message, response=self._response.http_response) + + +class MsalClient: # pylint:disable=client-accepts-api-version-keyword + """Wraps Pipeline according to msal.oauth2cli.http""" + + def __init__(self, **kwargs: Any) -> None: # pylint:disable=missing-client-constructor-parameter-credential + self._local = threading.local() + self._pipeline = build_pipeline(**kwargs) + + def __enter__(self): + self._pipeline.__enter__() + return self + + def __exit__(self, *args): + self._pipeline.__exit__(*args) + + def close(self) -> None: + self.__exit__() + + def post( + self, + url: str, + params: Optional[Dict[str, str]] = None, + data: Optional[RequestData] = None, + headers: Optional[Dict[str, str]] = None, + **kwargs: Any + ) -> MsalResponse: + # pylint:disable=unused-argument + request = HttpRequest("POST", url, headers=headers) + if params: + request.format_parameters(params) + if data: + if isinstance(data, dict): + request.headers["Content-Type"] = "application/x-www-form-urlencoded" + request.set_formdata_body(data) + elif isinstance(data, str): + body_bytes = data.encode("utf-8") + request.set_bytes_body(body_bytes) + else: + raise ValueError('expected "data" to be text or a dict') + + response = self._pipeline.run(request, stream=False, retry_on_methods=_POST) + self._store_auth_error(response) + return MsalResponse(response) + + def get( + self, url: str, params: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, **kwargs: Any + ) -> MsalResponse: + # pylint:disable=unused-argument + request = HttpRequest("GET", url, headers=headers) + if params: + request.format_parameters(params) + response = self._pipeline.run(request, stream=False) + self._store_auth_error(response) + return MsalResponse(response) + + def get_error_response(self, msal_result: Dict) -> Optional[HttpResponse]: + """Get the HTTP response associated with an MSAL error. + + :param msal_result: The result of an MSAL request. + :type msal_result: dict + :return: The HTTP response associated with the error, if any. + :rtype: ~azure.core.pipeline.transport.HttpResponse or None + """ + error_code, response = getattr(self._local, "error", (None, None)) + if response and error_code == msal_result.get("error"): + return response + return None + + def _store_auth_error(self, response: PipelineResponse) -> None: + if response.http_response.status_code >= 400: + # if the body doesn't contain "error", this isn't an OAuth 2 error, i.e. this isn't a + # response to an auth request, so no credential will want to include it with an exception + content = response.context.get(ContentDecodePolicy.CONTEXT_NAME) + if content and "error" in content: + self._local.error = (content["error"], response.http_response) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_credentials.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_credentials.py new file mode 100644 index 000000000000..d58b40983247 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_credentials.py @@ -0,0 +1,118 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import os +from typing import Any, List, Union, Dict, Optional + +import msal + +from ._msal_client import MsalClient +from ._utils import get_default_authority, normalize_authority, resolve_tenant, validate_tenant_id +from ._constants import EnvironmentVariables +from ._persistent_cache import _load_persistent_cache + + +class MsalCredential: # pylint: disable=too-many-instance-attributes + """Base class for credentials wrapping MSAL applications. + + :param str client_id: the principal's client ID + :param client_credential: client credential data for the application + :type client_credential: dict + """ + + def __init__( + self, + client_id: str, + client_credential: Optional[Union[str, Dict]] = None, + *, + additionally_allowed_tenants: Optional[List[str]] = None, + allow_broker: Optional[bool] = None, + parent_window_handle: Optional[int] = None, + enable_msa_passthrough: Optional[bool] = None, + authority: Optional[str] = None, + disable_instance_discovery: Optional[bool] = None, + tenant_id: Optional[str] = None, + **kwargs + ) -> None: + self._instance_discovery = None if disable_instance_discovery is None else not disable_instance_discovery + self._authority = normalize_authority(authority) if authority else get_default_authority() + self._regional_authority = os.environ.get(EnvironmentVariables.AZURE_REGIONAL_AUTHORITY_NAME) + if self._regional_authority and self._regional_authority.lower() in ["tryautodetect", "true"]: + self._regional_authority = msal.ConfidentialClientApplication.ATTEMPT_REGION_DISCOVERY + self._tenant_id = tenant_id or "organizations" + validate_tenant_id(self._tenant_id) + self._client = MsalClient(**kwargs) + self._client_credential = client_credential + self._client_id = client_id + self._allow_broker = allow_broker + self._parent_window_handle = parent_window_handle + self._enable_msa_passthrough = enable_msa_passthrough + self._additionally_allowed_tenants = additionally_allowed_tenants or [] + + self._client_applications: Dict[str, msal.ClientApplication] = {} + self._cae_client_applications: Dict[str, msal.ClientApplication] = {} + + self._cache = kwargs.pop("_cache", None) + self._cae_cache = kwargs.pop("_cae_cache", None) + self._cache_options = kwargs.pop("cache_persistence_options", None) + + super(MsalCredential, self).__init__() + + def __enter__(self): + self._client.__enter__() + return self + + def __exit__(self, *args): + self._client.__exit__(*args) + + def close(self) -> None: + self.__exit__() + + def _initialize_cache(self, is_cae: bool = False) -> msal.TokenCache: + if self._cache_options: + if is_cae: + self._cae_cache = _load_persistent_cache(self._cache_options, is_cae) + else: + self._cache = _load_persistent_cache(self._cache_options, is_cae) + else: + if is_cae: + self._cae_cache = msal.TokenCache() + else: + self._cache = msal.TokenCache() + + return self._cae_cache if is_cae else self._cache + + def _get_app(self, **kwargs: Any) -> msal.ClientApplication: + tenant_id = resolve_tenant( + self._tenant_id, additionally_allowed_tenants=self._additionally_allowed_tenants, **kwargs + ) + + client_applications_map = self._client_applications + capabilities = None + token_cache = self._cache + + app_class = msal.ConfidentialClientApplication if self._client_credential else msal.PublicClientApplication + + if kwargs.get("enable_cae"): + client_applications_map = self._cae_client_applications + capabilities = ["CP1"] + token_cache = self._cae_cache + + if not token_cache: + token_cache = self._initialize_cache(is_cae=bool(kwargs.get("enable_cae"))) + + if tenant_id not in client_applications_map: + client_applications_map[tenant_id] = app_class( + client_id=self._client_id, + client_credential=self._client_credential, + client_capabilities=capabilities, + authority="{}/{}".format(self._authority, tenant_id), + azure_region=self._regional_authority, + token_cache=token_cache, + http_client=self._client, + instance_discovery=self._instance_discovery, + allow_broker=self._allow_broker, + ) + + return client_applications_map[tenant_id] diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_persistent_cache.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_persistent_cache.py new file mode 100644 index 000000000000..972f6d4ba4df --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_persistent_cache.py @@ -0,0 +1,116 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import logging +import os +import sys +from typing import TYPE_CHECKING, Any + +from ._constants import CACHE_CAE_SUFFIX, CACHE_NON_CAE_SUFFIX + +if TYPE_CHECKING: + import msal_extensions + +_LOGGER = logging.getLogger(__name__) + + +class TokenCachePersistenceOptions: + """Options for persistent token caching. + + Most credentials accept an instance of this class to configure persistent token caching. The default values + configure a credential to use a cache shared with Microsoft developer tools and + :class:`~azure.identity.SharedTokenCacheCredential`. To isolate a credential's data from other applications, + specify a `name` for the cache. + + By default, the cache is encrypted with the current platform's user data protection API, and will raise an error + when this is not available. To configure the cache to fall back to an unencrypted file instead of raising an + error, specify `allow_unencrypted_storage=True`. + + .. warning:: The cache contains authentication secrets. If the cache is not encrypted, protecting it is the + application's responsibility. A breach of its contents will fully compromise accounts. + + .. admonition:: Example: + + .. literalinclude:: ../tests/test_persistent_cache.py + :start-after: [START snippet] + :end-before: [END snippet] + :language: python + :caption: Configuring a credential for persistent caching + :dedent: 8 + + :keyword str name: prefix name of the cache, used to isolate its data from other applications. Defaults to the + name of the cache shared by Microsoft dev tools and :class:`~azure.identity.SharedTokenCacheCredential`. + Additional strings may be appended to the name for further isolation. + :keyword bool allow_unencrypted_storage: whether the cache should fall back to storing its data in plain text when + encryption isn't possible. False by default. Setting this to True does not disable encryption. The cache will + always try to encrypt its data. + """ + + def __init__(self, *, allow_unencrypted_storage: bool = False, name: str = "msal.cache", **kwargs: Any) -> None: + # pylint:disable=unused-argument + self.allow_unencrypted_storage = allow_unencrypted_storage + self.name = name + + +def _load_persistent_cache( + options: TokenCachePersistenceOptions, is_cae: bool = False +) -> "msal_extensions.PersistedTokenCache": + import msal_extensions + + cache_suffix = CACHE_CAE_SUFFIX if is_cae else CACHE_NON_CAE_SUFFIX + persistence = _get_persistence( + allow_unencrypted=options.allow_unencrypted_storage, + account_name="MSALCache", + cache_name=options.name + cache_suffix, + ) + return msal_extensions.PersistedTokenCache(persistence) + + +def _get_persistence(allow_unencrypted, account_name, cache_name): + # type: (bool, str, str) -> msal_extensions.persistence.BasePersistence + """Get an msal_extensions persistence instance for the current platform. + + On Windows the cache is a file protected by the Data Protection API. On Linux and macOS the cache is stored by + libsecret and Keychain, respectively. On those platforms the cache uses the modified timestamp of a file on disk to + decide whether to reload the cache. + + :param bool allow_unencrypted: when True, the cache will be kept in plaintext should encryption be impossible in the + current environment + :param str account_name: the name of the account for which the cache is storing tokens + :param str cache_name: the name of the cache + :return: an msal_extensions persistence instance + :rtype: ~msal_extensions.persistence.BasePersistence + """ + import msal_extensions + + if sys.platform.startswith("win") and "LOCALAPPDATA" in os.environ: + cache_location = os.path.join(os.environ["LOCALAPPDATA"], ".IdentityService", cache_name) + return msal_extensions.FilePersistenceWithDataProtection(cache_location) + + if sys.platform.startswith("darwin"): + # the cache uses this file's modified timestamp to decide whether to reload + file_path = os.path.expanduser(os.path.join("~", ".IdentityService", cache_name)) + return msal_extensions.KeychainPersistence(file_path, "Microsoft.Developer.IdentityService", account_name) + + if sys.platform.startswith("linux"): + # The cache uses this file's modified timestamp to decide whether to reload. Note this path is the same + # as that of the plaintext fallback: a new encrypted cache will stomp an unencrypted cache. + file_path = os.path.expanduser(os.path.join("~", ".IdentityService", cache_name)) + try: + return msal_extensions.LibsecretPersistence( + file_path, cache_name, {"MsalClientID": "Microsoft.Developer.IdentityService"}, label=account_name + ) + except Exception as ex: # pylint:disable=broad-except + _LOGGER.debug('msal-extensions is unable to encrypt a persistent cache: "%s"', ex, exc_info=True) + if not allow_unencrypted: + error = ValueError( + "Cache encryption is impossible because libsecret dependencies are not installed or are unusable," + + " for example because no display is available (as in an SSH session). The chained exception has" + + ' more information. Specify "allow_unencrypted_storage=True" to store the cache unencrypted' + + " instead of raising this exception." + ) + raise error from ex + return msal_extensions.FilePersistence(file_path) + + raise NotImplementedError("A persistent cache is not available in this environment.") diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_pipeline.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_pipeline.py new file mode 100644 index 000000000000..88e563d71ba6 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_pipeline.py @@ -0,0 +1,94 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from azure.core.configuration import Configuration +from azure.core.pipeline import Pipeline +from azure.core.pipeline.policies import ( + ContentDecodePolicy, + CustomHookPolicy, + DistributedTracingPolicy, + HeadersPolicy, + NetworkTraceLoggingPolicy, + ProxyPolicy, + RetryPolicy, + UserAgentPolicy, + HttpLoggingPolicy, +) + + +from ._user_agent import USER_AGENT + + +def _get_config(**kwargs) -> Configuration: + """Configuration common to a/sync pipelines. + + :return: A configuration object. + :rtype: ~azure.core.configuration.Configuration + """ + config: Configuration = Configuration(**kwargs) + config.custom_hook_policy = CustomHookPolicy(**kwargs) + config.headers_policy = HeadersPolicy(**kwargs) + config.http_logging_policy = HttpLoggingPolicy(**kwargs) + config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) + config.proxy_policy = ProxyPolicy(**kwargs) + config.user_agent_policy = UserAgentPolicy(base_user_agent=USER_AGENT, **kwargs) + return config + + +def _get_policies(config, _per_retry_policies=None, **kwargs): + policies = [ + config.headers_policy, + config.user_agent_policy, + config.proxy_policy, + ContentDecodePolicy(**kwargs), + config.retry_policy, + ] + + if _per_retry_policies: + policies.extend(_per_retry_policies) + + policies.extend( + [ + config.custom_hook_policy, + config.logging_policy, + DistributedTracingPolicy(**kwargs), + config.http_logging_policy, + ] + ) + + return policies + + +def build_pipeline(transport=None, policies=None, **kwargs): + if not policies: + config = _get_config(**kwargs) + config.retry_policy = RetryPolicy(**kwargs) + policies = _get_policies(config, **kwargs) + if not transport: + from azure.core.pipeline.transport import ( # pylint: disable=non-abstract-transport-import, no-name-in-module + RequestsTransport, + ) + + transport = RequestsTransport(**kwargs) + + return Pipeline(transport, policies=policies) + + +def build_async_pipeline(transport=None, policies=None, **kwargs): + from azure.core.pipeline import AsyncPipeline + + if not policies: + from azure.core.pipeline.policies import AsyncRetryPolicy + + config = _get_config(**kwargs) + config.retry_policy = AsyncRetryPolicy(**kwargs) + policies = _get_policies(config, **kwargs) + if not transport: + from azure.core.pipeline.transport import ( # pylint: disable=non-abstract-transport-import, no-name-in-module + AioHttpTransport, + ) + + transport = AioHttpTransport(**kwargs) + + return AsyncPipeline(transport, policies=policies) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py new file mode 100644 index 000000000000..c4ecd59cdef7 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py @@ -0,0 +1,9 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import platform + +from .._version import VERSION + +USER_AGENT = "azsdk-python-identity-broker/{} Python/{} ({})".format(VERSION, platform.python_version(), platform.platform()) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py new file mode 100644 index 000000000000..18b87de0c859 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -0,0 +1,76 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from typing import Any, Dict + +from .._internal import InteractiveCredential, wrap_exceptions + + +class UsernamePasswordCredential(InteractiveCredential): + """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 consent from the user or + a directory admin. + + This credential can only authenticate work and school accounts; Microsoft accounts are not supported. + See `Azure Active Directory documentation + `_ for more information about + account types. + + :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 str authority: Authority of an Azure Active Directory endpoint, for example "login.microsoftonline.com", + the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.AzureAuthorityHosts` + 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 cache_persistence_options: Configuration for persistent token caching. If unspecified, the credential + will cache tokens in memory. + :paramtype cache_persistence_options: ~azure.identity.TokenCachePersistenceOptions + :keyword bool disable_instance_discovery: Determines whether or not instance discovery is performed when attempting + to authenticate. Setting this to true will completely disable both instance discovery and authority validation. + This functionality is intended for use in scenarios where the metadata endpoint cannot be reached, such as in + private clouds or Azure Stack. The process of instance discovery entails retrieving authority metadata from + https://login.microsoft.com/ to validate the authority. By setting this to **True**, the validation of the + authority is disabled. As a result, it is crucial to ensure that the configured authority host is valid and + trustworthy. + :keyword List[str] additionally_allowed_tenants: Specifies tenants in addition to the specified "tenant_id" + for which the credential may acquire tokens. Add the wildcard value "*" to allow the credential to + acquire tokens for any tenant the application can access. + + .. admonition:: Example: + + .. literalinclude:: ../samples/credential_creation_code_snippets.py + :start-after: [START create_username_password_credential] + :end-before: [END create_username_password_credential] + :language: python + :dedent: 4 + :caption: Create a UsernamePasswordCredential. + """ + + def __init__(self, client_id: str, username: str, password: str, **kwargs: Any) -> None: + # The base class will accept an AuthenticationRecord, allowing this credential to authenticate silently the + # first time it's asked for a token. However, we want to ensure this first authentication is not silent, to + # validate the given password. This class therefore doesn't document the authentication_record argument, and we + # discard it here. + kwargs.pop("authentication_record", None) + super(UsernamePasswordCredential, self).__init__(client_id=client_id, **kwargs) + self._username = username + self._password = password + + @wrap_exceptions + def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: + app = self._get_app(**kwargs) + return app.acquire_token_by_username_password( + username=self._username, + password=self._password, + scopes=list(scopes), + claims_challenge=kwargs.get("claims"), + ) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py new file mode 100644 index 000000000000..0bb176e19d25 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py @@ -0,0 +1,101 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import os +import logging +from contextvars import ContextVar +from typing import List, Optional + +from urllib.parse import urlparse + +from azure.core.exceptions import ClientAuthenticationError +from .._constants import EnvironmentVariables, KnownAuthorities + +within_credential_chain = ContextVar("within_credential_chain", default=False) +within_dac = ContextVar("within_dac", default=False) + +_LOGGER = logging.getLogger(__name__) + + +def normalize_authority(authority: str) -> str: + """Ensure authority uses https, strip trailing spaces and /. + + :param str authority: authority to normalize + :return: normalized authority + :rtype: str + :raises: ValueError if authority is not a valid https URL + """ + + parsed = urlparse(authority) + if not parsed.scheme: + return "https://" + authority.rstrip(" /") + if parsed.scheme != "https": + raise ValueError( + "'{}' is an invalid authority. The value must be a TLS protected (https) URL.".format(authority) + ) + + return authority.rstrip(" /") + + +def get_default_authority() -> str: + authority = os.environ.get(EnvironmentVariables.AZURE_AUTHORITY_HOST, KnownAuthorities.AZURE_PUBLIC_CLOUD) + return normalize_authority(authority) + + +VALID_TENANT_ID_CHARACTERS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + "0123456789" + "-.") + + +def validate_tenant_id(tenant_id: str) -> None: + """Raise ValueError if tenant_id is empty or contains a character invalid for a tenant ID. + + :param str tenant_id: tenant id to validate + :raises: ValueError if tenant_id is empty or contains a character invalid for a tenant ID. + """ + if not tenant_id or any(c not in VALID_TENANT_ID_CHARACTERS for c in tenant_id): + raise ValueError( + "Invalid tenant id provided. You can locate your tenant id by following the instructions here: " + + "https://docs.microsoft.com/partner-center/find-ids-and-domain-names" + ) + + +def resolve_tenant( + default_tenant: str, tenant_id: Optional[str] = None, *, additionally_allowed_tenants: List[str] = [], **_ +) -> str: + """Returns the correct tenant for a token request given a credential's configuration. + + :param str default_tenant: The tenant ID configured on the credential. + :param str tenant_id: The tenant ID requested by the user. + :keyword list[str] additionally_allowed_tenants: The list of additionally allowed tenants. + :return: The tenant ID to use for the token request. + :rtype: str + :raises: ~azure.core.exceptions.ClientAuthenticationError + """ + if tenant_id is None or tenant_id == default_tenant: + return default_tenant + if default_tenant == "adfs" or os.environ.get(EnvironmentVariables.AZURE_IDENTITY_DISABLE_MULTITENANTAUTH): + _LOGGER.info( + "A token was request for a different tenant than was configured on the credential, " + "but the configured value was used since multi tenant authentication has been disabled. " + "Configured tenant ID: %s, Requested tenant ID %s", + default_tenant, + tenant_id, + ) + return default_tenant + if not default_tenant: + return tenant_id + if "*" in additionally_allowed_tenants or tenant_id in additionally_allowed_tenants: + _LOGGER.info( + "A token was requested for a different tenant than was configured on the credential, " + "and the requested tenant ID was used to authenticate. Configured tenant ID: %s, " + "Requested tenant ID %s", + default_tenant, + tenant_id, + ) + return tenant_id + raise ClientAuthenticationError( + message="The current credential is not configured to acquire tokens for tenant {}. " + "To enable acquiring tokens for this tenant add it to the additionally_allowed_tenants " + 'when creating the credential, or add "*" to additionally_allowed_tenants to allow ' + "acquiring tokens for any tenant.".format(tenant_id) + ) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_version.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_version.py new file mode 100644 index 000000000000..ac9f392f513e --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_version.py @@ -0,0 +1,6 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +VERSION = "1.0.0b1" diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/py.typed b/sdk/identity/azure-identity-broker/azure/identity/broker/py.typed new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/sdk/identity/azure-identity-broker/dev_requirements.txt b/sdk/identity/azure-identity-broker/dev_requirements.txt new file mode 100644 index 000000000000..f0bbcba64d96 --- /dev/null +++ b/sdk/identity/azure-identity-broker/dev_requirements.txt @@ -0,0 +1,5 @@ +../../core/azure-core +aiohttp>=3.0 +typing_extensions>=3.7.2 +-e ../../../tools/azure-sdk-tools +-e ../../../tools/azure-devtools diff --git a/sdk/identity/azure-identity-broker/pyproject.toml b/sdk/identity/azure-identity-broker/pyproject.toml new file mode 100644 index 000000000000..e68e9286f132 --- /dev/null +++ b/sdk/identity/azure-identity-broker/pyproject.toml @@ -0,0 +1,4 @@ +[tool.azure-sdk-build] +type_check_samples = false +verifytypes = false +pyright = false diff --git a/sdk/identity/azure-identity-broker/sdk_packaging.toml b/sdk/identity/azure-identity-broker/sdk_packaging.toml new file mode 100644 index 000000000000..e7687fdae93b --- /dev/null +++ b/sdk/identity/azure-identity-broker/sdk_packaging.toml @@ -0,0 +1,2 @@ +[packaging] +auto_update = false \ No newline at end of file diff --git a/sdk/identity/azure-identity-broker/setup.py b/sdk/identity/azure-identity-broker/setup.py new file mode 100644 index 000000000000..701e9d9a1cb7 --- /dev/null +++ b/sdk/identity/azure-identity-broker/setup.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python + +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import re +import os.path +from io import open +from setuptools import find_packages, setup # type: ignore + +# Change the PACKAGE_NAME only to change folder and different name +PACKAGE_NAME = "azure-identity-broker" +PACKAGE_PPRINT_NAME = "Identity Broker plugin" + +package_folder_path = "azure/identity/broker" + +# 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) # type: ignore + +if not version: + raise RuntimeError("Cannot find version information") + +with open("README.md", encoding="utf-8") as f: + readme = f.read() +with open("CHANGELOG.md", encoding="utf-8") as f: + changelog = f.read() + +setup( + name=PACKAGE_NAME, + version=version, + description="Microsoft Azure {} for Python".format(PACKAGE_PPRINT_NAME), + long_description=readme + "\n\n" + changelog, + long_description_content_type="text/markdown", + license="MIT License", + author="Microsoft Corporation", + author_email="azpysdkhelp@microsoft.com", + url="https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/identity/azure-identity-broker", + keywords="azure, azure sdk", + classifiers=[ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: MIT License", + ], + zip_safe=False, + packages=[ + "azure.identity.broker", + ], + include_package_data=True, + package_data={ + "pytyped": ["py.typed"], + }, + python_requires=">=3.7", + install_requires=[ + "azure-identity<2.0.0", + "msal[broker]>=1.20,<2", + ], +) diff --git a/sdk/identity/azure-identity-broker/tests/conftest.py b/sdk/identity/azure-identity-broker/tests/conftest.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/sdk/identity/azure-identity-broker/tests/test_broker.py b/sdk/identity/azure-identity-broker/tests/test_broker.py new file mode 100644 index 000000000000..c5dc496f1b50 --- /dev/null +++ b/sdk/identity/azure-identity-broker/tests/test_broker.py @@ -0,0 +1,30 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from azure.identity.broker import InteractiveBrowserCredential, UsernamePasswordCredential + + +def test_interactive_browser_cred_with_broker(): + cred = InteractiveBrowserCredential(allow_broker=True) + assert cred._allow_broker + + +def test_interactive_browser_cred_without_broker(): + cred = InteractiveBrowserCredential() + assert not cred._allow_broker + + cred = InteractiveBrowserCredential(allow_broker=False) + assert not cred._allow_broker + +def test_username_password_cred_with_broker(): + cred = UsernamePasswordCredential(allow_broker=True) + assert cred._allow_broker + + +def test_username_password_cred_without_broker(): + cred = UsernamePasswordCredential() + assert not cred._allow_broker + + cred = UsernamePasswordCredential(allow_broker=False) + assert not cred._allow_broker From 5544a78dcadf1adaf07585318aee9943cdb8c862 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 12:39:41 -0700 Subject: [PATCH 02/24] update --- eng/.docsettings.yml | 1 + sdk/identity/azure-identity-broker/setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/eng/.docsettings.yml b/eng/.docsettings.yml index 3326fd1725b3..eb2a71c2cced 100644 --- a/eng/.docsettings.yml +++ b/eng/.docsettings.yml @@ -153,6 +153,7 @@ known_content_issues: - ['sdk/appconfiguration/azure-appconfiguration/swagger/README.md', '#4554'] - ['sdk/core/azure-core/tests/testserver_tests/coretestserver/README.md', '#4554'] - ['sdk/core/azure-core-experimental/README.md', '#4554'] + - ['sdk/identity/azure-identity-broker/README.md', '#4554'] package_indexing_exclusion_list: - 'azure-sdk-tools' - 'azure-template' diff --git a/sdk/identity/azure-identity-broker/setup.py b/sdk/identity/azure-identity-broker/setup.py index 701e9d9a1cb7..1bac95b25c16 100644 --- a/sdk/identity/azure-identity-broker/setup.py +++ b/sdk/identity/azure-identity-broker/setup.py @@ -62,7 +62,7 @@ }, python_requires=">=3.7", install_requires=[ - "azure-identity<2.0.0", + "azure-identity<2.0.0,>=1.12.0", "msal[broker]>=1.20,<2", ], ) From adf61b93f0c27a138848f879fd39df09ee2cb424 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 13:01:03 -0700 Subject: [PATCH 03/24] update --- sdk/identity/azure-identity-broker/tests/test_broker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/identity/azure-identity-broker/tests/test_broker.py b/sdk/identity/azure-identity-broker/tests/test_broker.py index c5dc496f1b50..dfc7d09d8cf1 100644 --- a/sdk/identity/azure-identity-broker/tests/test_broker.py +++ b/sdk/identity/azure-identity-broker/tests/test_broker.py @@ -23,8 +23,8 @@ def test_username_password_cred_with_broker(): def test_username_password_cred_without_broker(): - cred = UsernamePasswordCredential() + cred = UsernamePasswordCredential("client-id", "username", "password") assert not cred._allow_broker - cred = UsernamePasswordCredential(allow_broker=False) + cred = UsernamePasswordCredential("client-id", "username", "password", allow_broker=False) assert not cred._allow_broker From c419cbe21f799f23fd82363f261b9ae603af2301 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 13:02:25 -0700 Subject: [PATCH 04/24] black --- .../azure/identity/broker/_user_agent.py | 4 +++- sdk/identity/azure-identity-broker/tests/test_broker.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py index c4ecd59cdef7..e68bf707bc78 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py @@ -6,4 +6,6 @@ from .._version import VERSION -USER_AGENT = "azsdk-python-identity-broker/{} Python/{} ({})".format(VERSION, platform.python_version(), platform.platform()) +USER_AGENT = "azsdk-python-identity-broker/{} Python/{} ({})".format( + VERSION, platform.python_version(), platform.platform() +) diff --git a/sdk/identity/azure-identity-broker/tests/test_broker.py b/sdk/identity/azure-identity-broker/tests/test_broker.py index dfc7d09d8cf1..f659a8db3e21 100644 --- a/sdk/identity/azure-identity-broker/tests/test_broker.py +++ b/sdk/identity/azure-identity-broker/tests/test_broker.py @@ -17,6 +17,7 @@ def test_interactive_browser_cred_without_broker(): cred = InteractiveBrowserCredential(allow_broker=False) assert not cred._allow_broker + def test_username_password_cred_with_broker(): cred = UsernamePasswordCredential(allow_broker=True) assert cred._allow_broker From 9cea3572772593bf0f4d328760ffc122272593ba Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 13:23:40 -0700 Subject: [PATCH 05/24] update --- sdk/identity/azure-identity-broker/tests/test_broker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/tests/test_broker.py b/sdk/identity/azure-identity-broker/tests/test_broker.py index f659a8db3e21..8354b055abba 100644 --- a/sdk/identity/azure-identity-broker/tests/test_broker.py +++ b/sdk/identity/azure-identity-broker/tests/test_broker.py @@ -19,7 +19,7 @@ def test_interactive_browser_cred_without_broker(): def test_username_password_cred_with_broker(): - cred = UsernamePasswordCredential(allow_broker=True) + cred = UsernamePasswordCredential("client-id", "username", "password", allow_broker=True) assert cred._allow_broker From 043420aa48ccf1a7d23a3bafc29d5259fd2754ee Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 13:40:51 -0700 Subject: [PATCH 06/24] update --- .../azure/identity/broker/_browser.py | 11 +++++++++++ .../azure/identity/broker/_user_password.py | 8 +++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index c921f2b68c2e..ee269b25647d 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -41,6 +41,17 @@ class InteractiveBrowserCredential(InteractiveCredential): will cache tokens in memory. :paramtype cache_persistence_options: ~azure.identity.TokenCachePersistenceOptions :keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes). + :keyword bool allow_broker: An authentication broker is an application that runs on a user's machine that manages + the authentication handshakes and token maintenance for connected accounts. The Windows operating system uses + the Web Account Manager (WAM) as its authentication broker. If this parameter is set to True, the broker will + be used when possible. Defaults to False. + Check https://learn.microsoft.com/azure/active-directory/develop/scenario-desktop-acquire-token-wam for more + information about WAM. + :keyword int parent_window_handle: If your app is a GUI app running on a modern Windows system, + and your app opts in to use broker via `allow_broker`, you are required to also provide its window handle, + so that the sign in UI window will properly pop up on top of your window. + :keyword bool enable_msa_passthrough: Determines whether Microsoft Account (MSA) passthrough is enabled. Note, this + is only needed for select legacy first-party applications. Defaults to False. :keyword bool disable_instance_discovery: Determines whether or not instance discovery is performed when attempting to authenticate. Setting this to true will completely disable both instance discovery and authority validation. This functionality is intended for use in scenarios where the metadata endpoint cannot be reached, such as in diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index 18b87de0c859..872f902bad18 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -4,7 +4,7 @@ # ------------------------------------ from typing import Any, Dict -from .._internal import InteractiveCredential, wrap_exceptions +from ._interactive import InteractiveCredential, wrap_exceptions class UsernamePasswordCredential(InteractiveCredential): @@ -41,6 +41,12 @@ class UsernamePasswordCredential(InteractiveCredential): https://login.microsoft.com/ to validate the authority. By setting this to **True**, the validation of the authority is disabled. As a result, it is crucial to ensure that the configured authority host is valid and trustworthy. + :keyword bool allow_broker: An authentication broker is an application that runs on a user's machine that manages + the authentication handshakes and token maintenance for connected accounts. The Windows operating system uses + the Web Account Manager (WAM) as its authentication broker. If this parameter is set to True, the broker will + be used when possible. Defaults to False. + Check https://learn.microsoft.com/azure/active-directory/develop/scenario-desktop-acquire-token-wam for more + information about WAM. :keyword List[str] additionally_allowed_tenants: Specifies tenants in addition to the specified "tenant_id" for which the credential may acquire tokens. Add the wildcard value "*" to allow the credential to acquire tokens for any tenant the application can access. From 4aaf364fcc127062a3714e1e37dc6d555f903358 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 14:11:57 -0700 Subject: [PATCH 07/24] update --- .../azure/identity/broker/_auth_record.py | 114 --------- .../azure/identity/broker/_browser.py | 61 ++++- .../azure/identity/broker/_constants.py | 55 ----- .../azure/identity/broker/_decorators.py | 86 ------- .../azure/identity/broker/_exceptions.py | 50 ---- .../azure/identity/broker/_interactive.py | 231 ------------------ .../azure/identity/broker/_msal_client.py | 130 ---------- .../identity/broker/_msal_credentials.py | 118 --------- .../identity/broker/_persistent_cache.py | 116 --------- .../azure/identity/broker/_pipeline.py | 94 ------- .../azure/identity/broker/_user_agent.py | 11 - .../azure/identity/broker/_user_password.py | 11 +- .../azure/identity/broker/_utils.py | 107 ++------ .../azure/identity/broker/_version.py | 6 - 14 files changed, 78 insertions(+), 1112 deletions(-) delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_auth_record.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_constants.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_decorators.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_exceptions.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_interactive.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_msal_client.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_msal_credentials.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_persistent_cache.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_pipeline.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py delete mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_version.py diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_auth_record.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_auth_record.py deleted file mode 100644 index 63b3625e1160..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_auth_record.py +++ /dev/null @@ -1,114 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -import json - - -SUPPORTED_VERSIONS = {"1.0"} - - -class AuthenticationRecord: - """Non-secret account information for an authenticated user - - This class enables :class:`DeviceCodeCredential` and :class:`InteractiveBrowserCredential` to access - previously cached authentication data. Applications shouldn't construct instances of this class. They should - instead acquire one from a credential's **authenticate** method, such as - :func:`InteractiveBrowserCredential.authenticate`. See the user_authentication sample for more details. - - :param str tenant_id: The tenant the account should authenticate in. - :param str client_id: The client ID of the application which performed the original authentication. - :param str authority: The authority host used to authenticate the account. - :param str home_account_id: A unique identifier of the account. - :param str username: The user principal or service principal name of the account. - """ - - def __init__(self, tenant_id: str, client_id: str, authority: str, home_account_id: str, username: str) -> None: - self._authority = authority - self._client_id = client_id - self._home_account_id = home_account_id - self._tenant_id = tenant_id - self._username = username - - @property - def authority(self) -> str: - """The authority host used to authenticate the account. - - :rtype: str - """ - return self._authority - - @property - def client_id(self) -> str: - """The client ID of the application which performed the original authentication. - - :rtype: str - """ - return self._client_id - - @property - def home_account_id(self) -> str: - """A unique identifier of the account. - - :rtype: str - """ - return self._home_account_id - - @property - def tenant_id(self) -> str: - """The tenant the account should authenticate in. - - :rtype: str - """ - return self._tenant_id - - @property - def username(self) -> str: - """The user principal or service principal name of the account. - - :rtype: str - """ - return self._username - - @classmethod - def deserialize(cls, data: str) -> "AuthenticationRecord": - """Deserialize a record. - - :param str data: A serialized record. - :return: The deserialized record. - :rtype: ~azure.identity.AuthenticationRecord - """ - - deserialized = json.loads(data) - - version = deserialized.get("version") - if version not in SUPPORTED_VERSIONS: - raise ValueError( - 'Unexpected version "{}". This package supports these versions: {}'.format(version, SUPPORTED_VERSIONS) - ) - - return cls( - authority=deserialized["authority"], - client_id=deserialized["clientId"], - home_account_id=deserialized["homeAccountId"], - tenant_id=deserialized["tenantId"], - username=deserialized["username"], - ) - - def serialize(self) -> str: - """Serialize the record. - - :return: The serialized record. - :rtype: str - """ - - record = { - "authority": self._authority, - "clientId": self._client_id, - "homeAccountId": self._home_account_id, - "tenantId": self._tenant_id, - "username": self._username, - "version": "1.0", - } - - return json.dumps(record) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index ee269b25647d..71c4abb25750 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -5,17 +5,16 @@ import socket from typing import Dict, Any from urllib.parse import urlparse +import msal from azure.core.exceptions import ClientAuthenticationError +from azure.identity import InteractiveBrowserCredential as _InteractiveBrowserCredential, CredentialUnavailableError +from ._utils import wrap_exceptions -from ._interactive import InteractiveCredential -from ._exceptions import CredentialUnavailableError -from ._constants import DEVELOPER_SIGN_ON_CLIENT_ID -from ._decorators import wrap_exceptions -from ._utils import within_dac +DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" -class InteractiveBrowserCredential(InteractiveCredential): +class InteractiveBrowserCredential(_InteractiveBrowserCredential): """Opens a browser to interactively authenticate a user. :func:`~get_token` opens a browser to a login URL provided by Azure Active Directory and authenticates a user @@ -72,18 +71,25 @@ class InteractiveBrowserCredential(InteractiveCredential): """ def __init__(self, **kwargs: Any) -> None: + self._allow_broker = kwargs.pop("allow_broker", None) + self._parent_window_handle = kwargs.pop("parent_window_handle", None) + self._enable_msa_passthrough = kwargs.pop("enable_msa_passthrough", False) redirect_uri = kwargs.pop("redirect_uri", None) if redirect_uri: self._parsed_url = urlparse(redirect_uri) if not (self._parsed_url.hostname and self._parsed_url.port): - raise ValueError('"redirect_uri" must be a URL with port number, for example "http://localhost:8400"') + raise ValueError( + '"redirect_uri" must be a URL with port number, for example "http://localhost:8400"' + ) else: self._parsed_url = None self._login_hint = kwargs.pop("login_hint", None) self._timeout = kwargs.pop("timeout", 300) client_id = kwargs.pop("client_id", DEVELOPER_SIGN_ON_CLIENT_ID) - super(InteractiveBrowserCredential, self).__init__(client_id=client_id, **kwargs) + super(InteractiveBrowserCredential, self).__init__( + client_id=client_id, **kwargs + ) @wrap_exceptions def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: @@ -104,7 +110,9 @@ def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: enable_msa_passthrough=self._enable_msa_passthrough, ) except socket.error as ex: - raise CredentialUnavailableError(message="Couldn't start an HTTP server.") from ex + raise CredentialUnavailableError( + message="Couldn't start an HTTP server." + ) from ex if "access_token" not in result and "error_description" in result: if within_dac.get(): raise CredentialUnavailableError(message=result["error_description"]) @@ -116,3 +124,38 @@ def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: # base class will raise for other errors return result + + + def _get_app(self, **kwargs: Any) -> msal.ClientApplication: + tenant_id = resolve_tenant( + self._tenant_id, additionally_allowed_tenants=self._additionally_allowed_tenants, **kwargs + ) + + client_applications_map = self._client_applications + capabilities = None + token_cache = self._cache + + app_class = msal.ConfidentialClientApplication if self._client_credential else msal.PublicClientApplication + + if kwargs.get("enable_cae"): + client_applications_map = self._cae_client_applications + capabilities = ["CP1"] + token_cache = self._cae_cache + + if not token_cache: + token_cache = self._initialize_cache(is_cae=bool(kwargs.get("enable_cae"))) + + if tenant_id not in client_applications_map: + client_applications_map[tenant_id] = app_class( + client_id=self._client_id, + client_credential=self._client_credential, + client_capabilities=capabilities, + authority="{}/{}".format(self._authority, tenant_id), + azure_region=self._regional_authority, + token_cache=token_cache, + http_client=self._client, + instance_discovery=self._instance_discovery, + allow_broker=self._allow_broker, + ) + + return client_applications_map[tenant_id] diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_constants.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_constants.py deleted file mode 100644 index aab04de42b3d..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_constants.py +++ /dev/null @@ -1,55 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ - - -DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" -AZURE_VSCODE_CLIENT_ID = "aebc6443-996d-45c2-90f0-388ff96faa56" -VSCODE_CREDENTIALS_SECTION = "VS Code Azure" -DEFAULT_REFRESH_OFFSET = 300 -DEFAULT_TOKEN_REFRESH_RETRY_DELAY = 30 - -CACHE_NON_CAE_SUFFIX = ".nocae" # cspell:disable-line -CACHE_CAE_SUFFIX = ".cae" - - -class AzureAuthorityHosts: - AZURE_CHINA = "login.chinacloudapi.cn" - AZURE_GERMANY = "login.microsoftonline.de" - AZURE_GOVERNMENT = "login.microsoftonline.us" - AZURE_PUBLIC_CLOUD = "login.microsoftonline.com" - - -class KnownAuthorities(AzureAuthorityHosts): - """Alias of :class:`AzureAuthorityHosts`""" - - -class EnvironmentVariables: - 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_CLIENT_CERTIFICATE_PATH = "AZURE_CLIENT_CERTIFICATE_PATH" - AZURE_CLIENT_CERTIFICATE_PASSWORD = "AZURE_CLIENT_CERTIFICATE_PASSWORD" - 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) - - AZURE_POD_IDENTITY_AUTHORITY_HOST = "AZURE_POD_IDENTITY_AUTHORITY_HOST" - IDENTITY_ENDPOINT = "IDENTITY_ENDPOINT" - IDENTITY_HEADER = "IDENTITY_HEADER" - IDENTITY_SERVER_THUMBPRINT = "IDENTITY_SERVER_THUMBPRINT" - IMDS_ENDPOINT = "IMDS_ENDPOINT" - MSI_ENDPOINT = "MSI_ENDPOINT" - MSI_SECRET = "MSI_SECRET" - - AZURE_AUTHORITY_HOST = "AZURE_AUTHORITY_HOST" - AZURE_IDENTITY_DISABLE_MULTITENANTAUTH = "AZURE_IDENTITY_DISABLE_MULTITENANTAUTH" - AZURE_REGIONAL_AUTHORITY_NAME = "AZURE_REGIONAL_AUTHORITY_NAME" - - AZURE_FEDERATED_TOKEN_FILE = "AZURE_FEDERATED_TOKEN_FILE" - WORKLOAD_IDENTITY_VARS = (AZURE_AUTHORITY_HOST, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_decorators.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_decorators.py deleted file mode 100644 index 558669279beb..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_decorators.py +++ /dev/null @@ -1,86 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -import functools -import logging -import json -import base64 - -from azure.core.exceptions import ClientAuthenticationError - -from ._utils import within_credential_chain - - -_LOGGER = logging.getLogger(__name__) - - -def log_get_token(class_name): - """Adds logging around get_token calls. - - :param str class_name: required for the sake of Python 2.7, which lacks an easy way to get the credential's class - name from the decorated function - :return: decorator function - :rtype: callable - """ - - def decorator(fn): - qualified_name = class_name + ".get_token" - - @functools.wraps(fn) - def wrapper(*args, **kwargs): - try: - token = fn(*args, **kwargs) - _LOGGER.log( - logging.DEBUG if within_credential_chain.get() else logging.INFO, "%s succeeded", qualified_name - ) - if _LOGGER.isEnabledFor(logging.DEBUG): - try: - base64_meta_data = token.token.split(".")[1].encode("utf-8") + b"==" - json_bytes = base64.decodebytes(base64_meta_data) - json_string = json_bytes.decode("utf-8") - json_dict = json.loads(json_string) - upn = json_dict.get("upn", "unavailableUpn") - log_string = ( - "[Authenticated account] Client ID: {}. Tenant ID: {}. User Principal Name: {}. " - "Object ID (user): {}".format(json_dict["appid"], json_dict["tid"], upn, json_dict["oid"]) - ) - _LOGGER.debug(log_string) - except Exception: # pylint: disable=broad-except - _LOGGER.debug("Fail to log the account information") - return token - except Exception as ex: # pylint: disable=broad-except - _LOGGER.log( - logging.DEBUG if within_credential_chain.get() else logging.WARNING, - "%s failed: %s", - qualified_name, - ex, - exc_info=_LOGGER.isEnabledFor(logging.DEBUG), - ) - raise - - return wrapper - - return decorator - - -def wrap_exceptions(fn): - """Prevents leaking exceptions defined outside azure-core by raising ClientAuthenticationError from them. - - :param fn: The function to wrap. - :type fn: ~typing.Callable - :return: The wrapped function. - :rtype: callable - """ - - @functools.wraps(fn) - def wrapper(*args, **kwargs): - try: - return fn(*args, **kwargs) - except ClientAuthenticationError: - raise - except Exception as ex: # pylint:disable=broad-except - auth_error = ClientAuthenticationError(message="Authentication failed: {}".format(ex)) - raise auth_error from ex - - return wrapper diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_exceptions.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_exceptions.py deleted file mode 100644 index f0230825b9ba..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_exceptions.py +++ /dev/null @@ -1,50 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -from typing import Any, Iterable, Optional - -from azure.core.exceptions import ClientAuthenticationError - - -class CredentialUnavailableError(ClientAuthenticationError): - """The credential did not attempt to authenticate because required data or state is unavailable.""" - - -class AuthenticationRequiredError(CredentialUnavailableError): - """Interactive authentication is required to acquire a token. - - This error is raised only by interactive user credentials configured not to automatically prompt for user - interaction as needed. Its properties provide additional information that may be required to authenticate. The - control_interactive_prompts sample demonstrates handling this error by calling a credential's "authenticate" - method. - - :param str scopes: Scopes requested during the failed authentication - :param str message: An error message explaining the reason for the exception. - :param str claims: Additional claims required in the next authentication. - """ - - def __init__( - self, scopes: Iterable[str], message: Optional[str] = None, claims: Optional[str] = None, **kwargs: Any - ) -> None: - self._claims = claims - self._scopes = scopes - if not message: - message = "Interactive authentication is required to get a token. Call 'authenticate' to begin." - super(AuthenticationRequiredError, self).__init__(message=message, **kwargs) - - @property - def scopes(self) -> Iterable[str]: - """Scopes requested during the failed authentication. - - :rtype: ~typing.Iterable[str] - """ - return self._scopes - - @property - def claims(self) -> Optional[str]: - """Additional claims required in the next authentication. - - :rtype: str or None - """ - return self._claims diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_interactive.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_interactive.py deleted file mode 100644 index 6035d346e757..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_interactive.py +++ /dev/null @@ -1,231 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -"""Base class for credentials using MSAL for interactive user authentication""" - -import abc -import base64 -import json -import logging -import time -from typing import Any, Optional -from urllib.parse import urlparse - -from azure.core.credentials import AccessToken -from azure.core.exceptions import ClientAuthenticationError - -from ._msal_credentials import MsalCredential -from ._auth_record import AuthenticationRecord -from ._constants import KnownAuthorities -from ._exceptions import AuthenticationRequiredError, CredentialUnavailableError -from ._decorators import wrap_exceptions - -ABC = abc.ABC - -_LOGGER = logging.getLogger(__name__) - -_DEFAULT_AUTHENTICATE_SCOPES = { - "https://" + KnownAuthorities.AZURE_CHINA: ("https://management.core.chinacloudapi.cn//.default",), - "https://" + KnownAuthorities.AZURE_GERMANY: ("https://management.core.cloudapi.de//.default",), - "https://" + KnownAuthorities.AZURE_GOVERNMENT: ("https://management.core.usgovcloudapi.net//.default",), - "https://" + KnownAuthorities.AZURE_PUBLIC_CLOUD: ("https://management.core.windows.net//.default",), -} - - -def _decode_client_info(raw) -> str: - """Decode client info. Taken from msal.oauth2cli.oidc. - - :param str raw: base64-encoded client info - :return: decoded client info - :rtype: str - """ - - raw += "=" * (-len(raw) % 4) - raw = str(raw) # On Python 2.7, argument of urlsafe_b64decode must be str, not unicode. - return base64.urlsafe_b64decode(raw).decode("utf-8") - - -def _build_auth_record(response): - """Build an AuthenticationRecord from the result of an MSAL ClientApplication token request. - - :param response: The result of a token request - :type response: dict[str, typing.Any] - :return: An AuthenticationRecord - :rtype: ~azure.identity.AuthenticationRecord - :raises ~azure.core.exceptions.ClientAuthenticationError: If the response doesn't contain expected data - """ - - try: - id_token = response["id_token_claims"] - - if "client_info" in response: - client_info = json.loads(_decode_client_info(response["client_info"])) - home_account_id = "{uid}.{utid}".format(**client_info) - else: - # MSAL uses the subject claim as home_account_id when the STS doesn't provide client_info - home_account_id = id_token["sub"] - - # "iss" is the URL of the issuing tenant e.g. https://authority/tenant - issuer = urlparse(id_token["iss"]) - - # tenant which issued the token, not necessarily user's home tenant - tenant_id = id_token.get("tid") or issuer.path.strip("/") - - # AAD returns "preferred_username", ADFS returns "upn" - username = id_token.get("preferred_username") or id_token["upn"] - - return AuthenticationRecord( - authority=issuer.netloc, - client_id=id_token["aud"], - home_account_id=home_account_id, - tenant_id=tenant_id, - username=username, - ) - except (KeyError, ValueError) as ex: - auth_error = ClientAuthenticationError( - message="Failed to build AuthenticationRecord from unexpected identity token" - ) - raise auth_error from ex - - -class InteractiveCredential(MsalCredential, ABC): - def __init__( - self, - *, - authentication_record: Optional[AuthenticationRecord] = None, - disable_automatic_authentication: bool = False, - **kwargs: Any - ) -> None: - self._disable_automatic_authentication = disable_automatic_authentication - self._auth_record = authentication_record - if self._auth_record: - kwargs.pop("client_id", None) # authentication_record overrides client_id argument - tenant_id = kwargs.pop("tenant_id", None) or self._auth_record.tenant_id - super(InteractiveCredential, self).__init__( - client_id=self._auth_record.client_id, - authority=self._auth_record.authority, - tenant_id=tenant_id, - **kwargs - ) - else: - super(InteractiveCredential, self).__init__(**kwargs) - - def get_token( - self, *scopes: str, claims: Optional[str] = None, tenant_id: Optional[str] = None, **kwargs: Any - ) -> AccessToken: - """Request an access token for `scopes`. - - This method is called automatically by Azure SDK clients. - - :param str scopes: desired scopes for the access token. This method requires at least one scope. - For more information about scopes, see - https://learn.microsoft.com/azure/active-directory/develop/scopes-oidc. - :keyword str claims: additional claims required in the token, such as those returned in a resource provider's - claims challenge following an authorization failure - :keyword str tenant_id: optional tenant to include in the token request. - :keyword bool enable_cae: indicates whether to enable Continuous Access Evaluation (CAE) for the requested - token. Defaults to False. - :return: An access token with the desired scopes. - :rtype: ~azure.core.credentials.AccessToken - :raises CredentialUnavailableError: the credential is unable to attempt authentication because it lacks - required data, state, or platform support - :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` - attribute gives a reason. - :raises AuthenticationRequiredError: user interaction is necessary to acquire a token, and the credential is - configured not to begin this automatically. Call :func:`authenticate` to begin interactive authentication. - """ - if not scopes: - message = "'get_token' requires at least one scope" - _LOGGER.warning("%s.get_token failed: %s", self.__class__.__name__, message) - raise ValueError(message) - - allow_prompt = kwargs.pop("_allow_prompt", not self._disable_automatic_authentication) - try: - token = self._acquire_token_silent(*scopes, claims=claims, tenant_id=tenant_id, **kwargs) - _LOGGER.info("%s.get_token succeeded", self.__class__.__name__) - return token - except Exception as ex: # pylint:disable=broad-except - if not (isinstance(ex, AuthenticationRequiredError) and allow_prompt): - _LOGGER.warning( - "%s.get_token failed: %s", - self.__class__.__name__, - ex, - exc_info=_LOGGER.isEnabledFor(logging.DEBUG), - ) - raise - - # silent authentication failed -> authenticate interactively - now = int(time.time()) - - try: - result = self._request_token(*scopes, claims=claims, tenant_id=tenant_id, **kwargs) - if "access_token" not in result: - message = "Authentication failed: {}".format(result.get("error_description") or result.get("error")) - response = self._client.get_error_response(result) - raise ClientAuthenticationError(message=message, response=response) - - # this may be the first authentication, or the user may have authenticated a different identity - self._auth_record = _build_auth_record(result) - except Exception as ex: # pylint:disable=broad-except - _LOGGER.warning( - "%s.get_token failed: %s", - self.__class__.__name__, - ex, - exc_info=_LOGGER.isEnabledFor(logging.DEBUG), - ) - raise - - _LOGGER.info("%s.get_token succeeded", self.__class__.__name__) - return AccessToken(result["access_token"], now + int(result["expires_in"])) - - def authenticate(self, **kwargs: Any) -> AuthenticationRecord: - """Interactively authenticate a user. - - :keyword Iterable[str] scopes: scopes to request during authentication, such as those provided by - :func:`AuthenticationRequiredError.scopes`. If provided, successful authentication will cache an access token - for these scopes. - :keyword str claims: additional claims required in the token, such as those provided by - :func:`AuthenticationRequiredError.claims` - :rtype: ~azure.identity.AuthenticationRecord - :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed. The error's ``message`` - attribute gives a reason. - """ - - scopes = kwargs.pop("scopes", None) - if not scopes: - if self._authority not in _DEFAULT_AUTHENTICATE_SCOPES: - # the credential is configured to use a cloud whose ARM scope we can't determine - raise CredentialUnavailableError( - message="Authenticating in this environment requires a value for the 'scopes' keyword argument." - ) - - scopes = _DEFAULT_AUTHENTICATE_SCOPES[self._authority] - - _ = self.get_token(*scopes, _allow_prompt=True, **kwargs) - return self._auth_record # type: ignore - - @wrap_exceptions - def _acquire_token_silent(self, *scopes: str, **kwargs: Any) -> AccessToken: - result = None - claims = kwargs.get("claims") - if self._auth_record: - app = self._get_app(**kwargs) - for account in app.get_accounts(username=self._auth_record.username): - if account.get("home_account_id") != self._auth_record.home_account_id: - continue - - now = int(time.time()) - result = app.acquire_token_silent_with_error(list(scopes), account=account, claims_challenge=claims) - if result and "access_token" in result and "expires_in" in result: - return AccessToken(result["access_token"], now + int(result["expires_in"])) - - # if we get this far, result is either None or the content of an AAD error response - if result: - response = self._client.get_error_response(result) - raise AuthenticationRequiredError(scopes, claims=claims, response=response) - raise AuthenticationRequiredError(scopes, claims=claims) - - @abc.abstractmethod - def _request_token(self, *scopes, **kwargs): - pass diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_client.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_client.py deleted file mode 100644 index f6d2ce2862cf..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_client.py +++ /dev/null @@ -1,130 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -import threading -from typing import Any, Dict, Optional, Union - -from azure.core.exceptions import ClientAuthenticationError -from azure.core.pipeline.policies import ContentDecodePolicy -from azure.core.pipeline.transport import HttpRequest, HttpResponse -from azure.core.pipeline import PipelineResponse -from ._pipeline import build_pipeline - -RequestData = Union[Dict[str, str], str] -_POST = ["POST"] - - -class MsalResponse: - """Wraps HttpResponse according to msal.oauth2cli.http. - - :param response: The response to wrap. - :type response: ~azure.core.pipeline.transport.HttpResponse - """ - - def __init__(self, response: PipelineResponse) -> None: - self._response = response - - @property - def status_code(self) -> int: - return self._response.http_response.status_code - - @property - def text(self) -> str: - return self._response.http_response.text(encoding="utf-8") - - def raise_for_status(self): - if self.status_code < 400: - return - - if ContentDecodePolicy.CONTEXT_NAME in self._response.context: - content = self._response.context[ContentDecodePolicy.CONTEXT_NAME] - if not content: - message = "Unexpected response from Azure Active Directory" - elif "error" in content or "error_description" in content: - message = "Authentication failed: {}".format(content.get("error_description") or content.get("error")) - else: - for secret in ("access_token", "refresh_token"): - if secret in content: - content[secret] = "***" - message = 'Unexpected response from Azure Active Directory: "{}"'.format(content) - else: - message = "Unexpected response from Azure Active Directory" - - raise ClientAuthenticationError(message=message, response=self._response.http_response) - - -class MsalClient: # pylint:disable=client-accepts-api-version-keyword - """Wraps Pipeline according to msal.oauth2cli.http""" - - def __init__(self, **kwargs: Any) -> None: # pylint:disable=missing-client-constructor-parameter-credential - self._local = threading.local() - self._pipeline = build_pipeline(**kwargs) - - def __enter__(self): - self._pipeline.__enter__() - return self - - def __exit__(self, *args): - self._pipeline.__exit__(*args) - - def close(self) -> None: - self.__exit__() - - def post( - self, - url: str, - params: Optional[Dict[str, str]] = None, - data: Optional[RequestData] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs: Any - ) -> MsalResponse: - # pylint:disable=unused-argument - request = HttpRequest("POST", url, headers=headers) - if params: - request.format_parameters(params) - if data: - if isinstance(data, dict): - request.headers["Content-Type"] = "application/x-www-form-urlencoded" - request.set_formdata_body(data) - elif isinstance(data, str): - body_bytes = data.encode("utf-8") - request.set_bytes_body(body_bytes) - else: - raise ValueError('expected "data" to be text or a dict') - - response = self._pipeline.run(request, stream=False, retry_on_methods=_POST) - self._store_auth_error(response) - return MsalResponse(response) - - def get( - self, url: str, params: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, **kwargs: Any - ) -> MsalResponse: - # pylint:disable=unused-argument - request = HttpRequest("GET", url, headers=headers) - if params: - request.format_parameters(params) - response = self._pipeline.run(request, stream=False) - self._store_auth_error(response) - return MsalResponse(response) - - def get_error_response(self, msal_result: Dict) -> Optional[HttpResponse]: - """Get the HTTP response associated with an MSAL error. - - :param msal_result: The result of an MSAL request. - :type msal_result: dict - :return: The HTTP response associated with the error, if any. - :rtype: ~azure.core.pipeline.transport.HttpResponse or None - """ - error_code, response = getattr(self._local, "error", (None, None)) - if response and error_code == msal_result.get("error"): - return response - return None - - def _store_auth_error(self, response: PipelineResponse) -> None: - if response.http_response.status_code >= 400: - # if the body doesn't contain "error", this isn't an OAuth 2 error, i.e. this isn't a - # response to an auth request, so no credential will want to include it with an exception - content = response.context.get(ContentDecodePolicy.CONTEXT_NAME) - if content and "error" in content: - self._local.error = (content["error"], response.http_response) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_credentials.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_credentials.py deleted file mode 100644 index d58b40983247..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_msal_credentials.py +++ /dev/null @@ -1,118 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -import os -from typing import Any, List, Union, Dict, Optional - -import msal - -from ._msal_client import MsalClient -from ._utils import get_default_authority, normalize_authority, resolve_tenant, validate_tenant_id -from ._constants import EnvironmentVariables -from ._persistent_cache import _load_persistent_cache - - -class MsalCredential: # pylint: disable=too-many-instance-attributes - """Base class for credentials wrapping MSAL applications. - - :param str client_id: the principal's client ID - :param client_credential: client credential data for the application - :type client_credential: dict - """ - - def __init__( - self, - client_id: str, - client_credential: Optional[Union[str, Dict]] = None, - *, - additionally_allowed_tenants: Optional[List[str]] = None, - allow_broker: Optional[bool] = None, - parent_window_handle: Optional[int] = None, - enable_msa_passthrough: Optional[bool] = None, - authority: Optional[str] = None, - disable_instance_discovery: Optional[bool] = None, - tenant_id: Optional[str] = None, - **kwargs - ) -> None: - self._instance_discovery = None if disable_instance_discovery is None else not disable_instance_discovery - self._authority = normalize_authority(authority) if authority else get_default_authority() - self._regional_authority = os.environ.get(EnvironmentVariables.AZURE_REGIONAL_AUTHORITY_NAME) - if self._regional_authority and self._regional_authority.lower() in ["tryautodetect", "true"]: - self._regional_authority = msal.ConfidentialClientApplication.ATTEMPT_REGION_DISCOVERY - self._tenant_id = tenant_id or "organizations" - validate_tenant_id(self._tenant_id) - self._client = MsalClient(**kwargs) - self._client_credential = client_credential - self._client_id = client_id - self._allow_broker = allow_broker - self._parent_window_handle = parent_window_handle - self._enable_msa_passthrough = enable_msa_passthrough - self._additionally_allowed_tenants = additionally_allowed_tenants or [] - - self._client_applications: Dict[str, msal.ClientApplication] = {} - self._cae_client_applications: Dict[str, msal.ClientApplication] = {} - - self._cache = kwargs.pop("_cache", None) - self._cae_cache = kwargs.pop("_cae_cache", None) - self._cache_options = kwargs.pop("cache_persistence_options", None) - - super(MsalCredential, self).__init__() - - def __enter__(self): - self._client.__enter__() - return self - - def __exit__(self, *args): - self._client.__exit__(*args) - - def close(self) -> None: - self.__exit__() - - def _initialize_cache(self, is_cae: bool = False) -> msal.TokenCache: - if self._cache_options: - if is_cae: - self._cae_cache = _load_persistent_cache(self._cache_options, is_cae) - else: - self._cache = _load_persistent_cache(self._cache_options, is_cae) - else: - if is_cae: - self._cae_cache = msal.TokenCache() - else: - self._cache = msal.TokenCache() - - return self._cae_cache if is_cae else self._cache - - def _get_app(self, **kwargs: Any) -> msal.ClientApplication: - tenant_id = resolve_tenant( - self._tenant_id, additionally_allowed_tenants=self._additionally_allowed_tenants, **kwargs - ) - - client_applications_map = self._client_applications - capabilities = None - token_cache = self._cache - - app_class = msal.ConfidentialClientApplication if self._client_credential else msal.PublicClientApplication - - if kwargs.get("enable_cae"): - client_applications_map = self._cae_client_applications - capabilities = ["CP1"] - token_cache = self._cae_cache - - if not token_cache: - token_cache = self._initialize_cache(is_cae=bool(kwargs.get("enable_cae"))) - - if tenant_id not in client_applications_map: - client_applications_map[tenant_id] = app_class( - client_id=self._client_id, - client_credential=self._client_credential, - client_capabilities=capabilities, - authority="{}/{}".format(self._authority, tenant_id), - azure_region=self._regional_authority, - token_cache=token_cache, - http_client=self._client, - instance_discovery=self._instance_discovery, - allow_broker=self._allow_broker, - ) - - return client_applications_map[tenant_id] diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_persistent_cache.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_persistent_cache.py deleted file mode 100644 index 972f6d4ba4df..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_persistent_cache.py +++ /dev/null @@ -1,116 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -import logging -import os -import sys -from typing import TYPE_CHECKING, Any - -from ._constants import CACHE_CAE_SUFFIX, CACHE_NON_CAE_SUFFIX - -if TYPE_CHECKING: - import msal_extensions - -_LOGGER = logging.getLogger(__name__) - - -class TokenCachePersistenceOptions: - """Options for persistent token caching. - - Most credentials accept an instance of this class to configure persistent token caching. The default values - configure a credential to use a cache shared with Microsoft developer tools and - :class:`~azure.identity.SharedTokenCacheCredential`. To isolate a credential's data from other applications, - specify a `name` for the cache. - - By default, the cache is encrypted with the current platform's user data protection API, and will raise an error - when this is not available. To configure the cache to fall back to an unencrypted file instead of raising an - error, specify `allow_unencrypted_storage=True`. - - .. warning:: The cache contains authentication secrets. If the cache is not encrypted, protecting it is the - application's responsibility. A breach of its contents will fully compromise accounts. - - .. admonition:: Example: - - .. literalinclude:: ../tests/test_persistent_cache.py - :start-after: [START snippet] - :end-before: [END snippet] - :language: python - :caption: Configuring a credential for persistent caching - :dedent: 8 - - :keyword str name: prefix name of the cache, used to isolate its data from other applications. Defaults to the - name of the cache shared by Microsoft dev tools and :class:`~azure.identity.SharedTokenCacheCredential`. - Additional strings may be appended to the name for further isolation. - :keyword bool allow_unencrypted_storage: whether the cache should fall back to storing its data in plain text when - encryption isn't possible. False by default. Setting this to True does not disable encryption. The cache will - always try to encrypt its data. - """ - - def __init__(self, *, allow_unencrypted_storage: bool = False, name: str = "msal.cache", **kwargs: Any) -> None: - # pylint:disable=unused-argument - self.allow_unencrypted_storage = allow_unencrypted_storage - self.name = name - - -def _load_persistent_cache( - options: TokenCachePersistenceOptions, is_cae: bool = False -) -> "msal_extensions.PersistedTokenCache": - import msal_extensions - - cache_suffix = CACHE_CAE_SUFFIX if is_cae else CACHE_NON_CAE_SUFFIX - persistence = _get_persistence( - allow_unencrypted=options.allow_unencrypted_storage, - account_name="MSALCache", - cache_name=options.name + cache_suffix, - ) - return msal_extensions.PersistedTokenCache(persistence) - - -def _get_persistence(allow_unencrypted, account_name, cache_name): - # type: (bool, str, str) -> msal_extensions.persistence.BasePersistence - """Get an msal_extensions persistence instance for the current platform. - - On Windows the cache is a file protected by the Data Protection API. On Linux and macOS the cache is stored by - libsecret and Keychain, respectively. On those platforms the cache uses the modified timestamp of a file on disk to - decide whether to reload the cache. - - :param bool allow_unencrypted: when True, the cache will be kept in plaintext should encryption be impossible in the - current environment - :param str account_name: the name of the account for which the cache is storing tokens - :param str cache_name: the name of the cache - :return: an msal_extensions persistence instance - :rtype: ~msal_extensions.persistence.BasePersistence - """ - import msal_extensions - - if sys.platform.startswith("win") and "LOCALAPPDATA" in os.environ: - cache_location = os.path.join(os.environ["LOCALAPPDATA"], ".IdentityService", cache_name) - return msal_extensions.FilePersistenceWithDataProtection(cache_location) - - if sys.platform.startswith("darwin"): - # the cache uses this file's modified timestamp to decide whether to reload - file_path = os.path.expanduser(os.path.join("~", ".IdentityService", cache_name)) - return msal_extensions.KeychainPersistence(file_path, "Microsoft.Developer.IdentityService", account_name) - - if sys.platform.startswith("linux"): - # The cache uses this file's modified timestamp to decide whether to reload. Note this path is the same - # as that of the plaintext fallback: a new encrypted cache will stomp an unencrypted cache. - file_path = os.path.expanduser(os.path.join("~", ".IdentityService", cache_name)) - try: - return msal_extensions.LibsecretPersistence( - file_path, cache_name, {"MsalClientID": "Microsoft.Developer.IdentityService"}, label=account_name - ) - except Exception as ex: # pylint:disable=broad-except - _LOGGER.debug('msal-extensions is unable to encrypt a persistent cache: "%s"', ex, exc_info=True) - if not allow_unencrypted: - error = ValueError( - "Cache encryption is impossible because libsecret dependencies are not installed or are unusable," - + " for example because no display is available (as in an SSH session). The chained exception has" - + ' more information. Specify "allow_unencrypted_storage=True" to store the cache unencrypted' - + " instead of raising this exception." - ) - raise error from ex - return msal_extensions.FilePersistence(file_path) - - raise NotImplementedError("A persistent cache is not available in this environment.") diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_pipeline.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_pipeline.py deleted file mode 100644 index 88e563d71ba6..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_pipeline.py +++ /dev/null @@ -1,94 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -from azure.core.configuration import Configuration -from azure.core.pipeline import Pipeline -from azure.core.pipeline.policies import ( - ContentDecodePolicy, - CustomHookPolicy, - DistributedTracingPolicy, - HeadersPolicy, - NetworkTraceLoggingPolicy, - ProxyPolicy, - RetryPolicy, - UserAgentPolicy, - HttpLoggingPolicy, -) - - -from ._user_agent import USER_AGENT - - -def _get_config(**kwargs) -> Configuration: - """Configuration common to a/sync pipelines. - - :return: A configuration object. - :rtype: ~azure.core.configuration.Configuration - """ - config: Configuration = Configuration(**kwargs) - config.custom_hook_policy = CustomHookPolicy(**kwargs) - config.headers_policy = HeadersPolicy(**kwargs) - config.http_logging_policy = HttpLoggingPolicy(**kwargs) - config.logging_policy = NetworkTraceLoggingPolicy(**kwargs) - config.proxy_policy = ProxyPolicy(**kwargs) - config.user_agent_policy = UserAgentPolicy(base_user_agent=USER_AGENT, **kwargs) - return config - - -def _get_policies(config, _per_retry_policies=None, **kwargs): - policies = [ - config.headers_policy, - config.user_agent_policy, - config.proxy_policy, - ContentDecodePolicy(**kwargs), - config.retry_policy, - ] - - if _per_retry_policies: - policies.extend(_per_retry_policies) - - policies.extend( - [ - config.custom_hook_policy, - config.logging_policy, - DistributedTracingPolicy(**kwargs), - config.http_logging_policy, - ] - ) - - return policies - - -def build_pipeline(transport=None, policies=None, **kwargs): - if not policies: - config = _get_config(**kwargs) - config.retry_policy = RetryPolicy(**kwargs) - policies = _get_policies(config, **kwargs) - if not transport: - from azure.core.pipeline.transport import ( # pylint: disable=non-abstract-transport-import, no-name-in-module - RequestsTransport, - ) - - transport = RequestsTransport(**kwargs) - - return Pipeline(transport, policies=policies) - - -def build_async_pipeline(transport=None, policies=None, **kwargs): - from azure.core.pipeline import AsyncPipeline - - if not policies: - from azure.core.pipeline.policies import AsyncRetryPolicy - - config = _get_config(**kwargs) - config.retry_policy = AsyncRetryPolicy(**kwargs) - policies = _get_policies(config, **kwargs) - if not transport: - from azure.core.pipeline.transport import ( # pylint: disable=non-abstract-transport-import, no-name-in-module - AioHttpTransport, - ) - - transport = AioHttpTransport(**kwargs) - - return AsyncPipeline(transport, policies=policies) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py deleted file mode 100644 index e68bf707bc78..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_agent.py +++ /dev/null @@ -1,11 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ -import platform - -from .._version import VERSION - -USER_AGENT = "azsdk-python-identity-broker/{} Python/{} ({})".format( - VERSION, platform.python_version(), platform.platform() -) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index 872f902bad18..a9f9e1028738 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -3,11 +3,11 @@ # Licensed under the MIT License. # ------------------------------------ from typing import Any, Dict +from azure.identity import UsernamePasswordCredential as _UsernamePasswordCredential +from ._utils import wrap_exceptions -from ._interactive import InteractiveCredential, wrap_exceptions - -class UsernamePasswordCredential(InteractiveCredential): +class UsernamePasswordCredential(_UsernamePasswordCredential): """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 @@ -61,11 +61,14 @@ class UsernamePasswordCredential(InteractiveCredential): :caption: Create a UsernamePasswordCredential. """ - def __init__(self, client_id: str, username: str, password: str, **kwargs: Any) -> None: + def __init__( + self, client_id: str, username: str, password: str, **kwargs: Any + ) -> None: # The base class will accept an AuthenticationRecord, allowing this credential to authenticate silently the # first time it's asked for a token. However, we want to ensure this first authentication is not silent, to # validate the given password. This class therefore doesn't document the authentication_record argument, and we # discard it here. + self._allow_broker = kwargs.pop("allow_broker", None) kwargs.pop("authentication_record", None) super(UsernamePasswordCredential, self).__init__(client_id=client_id, **kwargs) self._username = username diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py index 0bb176e19d25..42beae8bf6c5 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py @@ -2,100 +2,31 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -import os -import logging +import functools from contextvars import ContextVar -from typing import List, Optional - -from urllib.parse import urlparse - from azure.core.exceptions import ClientAuthenticationError -from .._constants import EnvironmentVariables, KnownAuthorities - -within_credential_chain = ContextVar("within_credential_chain", default=False) -within_dac = ContextVar("within_dac", default=False) - -_LOGGER = logging.getLogger(__name__) - - -def normalize_authority(authority: str) -> str: - """Ensure authority uses https, strip trailing spaces and /. - - :param str authority: authority to normalize - :return: normalized authority - :rtype: str - :raises: ValueError if authority is not a valid https URL - """ - parsed = urlparse(authority) - if not parsed.scheme: - return "https://" + authority.rstrip(" /") - if parsed.scheme != "https": - raise ValueError( - "'{}' is an invalid authority. The value must be a TLS protected (https) URL.".format(authority) - ) - return authority.rstrip(" /") - - -def get_default_authority() -> str: - authority = os.environ.get(EnvironmentVariables.AZURE_AUTHORITY_HOST, KnownAuthorities.AZURE_PUBLIC_CLOUD) - return normalize_authority(authority) - - -VALID_TENANT_ID_CHARACTERS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + "0123456789" + "-.") +within_dac = ContextVar("within_dac", default=False) -def validate_tenant_id(tenant_id: str) -> None: - """Raise ValueError if tenant_id is empty or contains a character invalid for a tenant ID. +def wrap_exceptions(fn): + """Prevents leaking exceptions defined outside azure-core by raising ClientAuthenticationError from them. - :param str tenant_id: tenant id to validate - :raises: ValueError if tenant_id is empty or contains a character invalid for a tenant ID. + :param fn: The function to wrap. + :type fn: ~typing.Callable + :return: The wrapped function. + :rtype: callable """ - if not tenant_id or any(c not in VALID_TENANT_ID_CHARACTERS for c in tenant_id): - raise ValueError( - "Invalid tenant id provided. You can locate your tenant id by following the instructions here: " - + "https://docs.microsoft.com/partner-center/find-ids-and-domain-names" - ) - -def resolve_tenant( - default_tenant: str, tenant_id: Optional[str] = None, *, additionally_allowed_tenants: List[str] = [], **_ -) -> str: - """Returns the correct tenant for a token request given a credential's configuration. - - :param str default_tenant: The tenant ID configured on the credential. - :param str tenant_id: The tenant ID requested by the user. - :keyword list[str] additionally_allowed_tenants: The list of additionally allowed tenants. - :return: The tenant ID to use for the token request. - :rtype: str - :raises: ~azure.core.exceptions.ClientAuthenticationError - """ - if tenant_id is None or tenant_id == default_tenant: - return default_tenant - if default_tenant == "adfs" or os.environ.get(EnvironmentVariables.AZURE_IDENTITY_DISABLE_MULTITENANTAUTH): - _LOGGER.info( - "A token was request for a different tenant than was configured on the credential, " - "but the configured value was used since multi tenant authentication has been disabled. " - "Configured tenant ID: %s, Requested tenant ID %s", - default_tenant, - tenant_id, - ) - return default_tenant - if not default_tenant: - return tenant_id - if "*" in additionally_allowed_tenants or tenant_id in additionally_allowed_tenants: - _LOGGER.info( - "A token was requested for a different tenant than was configured on the credential, " - "and the requested tenant ID was used to authenticate. Configured tenant ID: %s, " - "Requested tenant ID %s", - default_tenant, - tenant_id, - ) - return tenant_id - raise ClientAuthenticationError( - message="The current credential is not configured to acquire tokens for tenant {}. " - "To enable acquiring tokens for this tenant add it to the additionally_allowed_tenants " - 'when creating the credential, or add "*" to additionally_allowed_tenants to allow ' - "acquiring tokens for any tenant.".format(tenant_id) - ) + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except ClientAuthenticationError: + raise + except Exception as ex: # pylint:disable=broad-except + auth_error = ClientAuthenticationError(message="Authentication failed: {}".format(ex)) + raise auth_error from ex + + return wrapper diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_version.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_version.py deleted file mode 100644 index ac9f392f513e..000000000000 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_version.py +++ /dev/null @@ -1,6 +0,0 @@ -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ - -VERSION = "1.0.0b1" From 16d1b2626035fafb605d016ae3c20bee6ef5c926 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 14:29:12 -0700 Subject: [PATCH 08/24] update --- .../azure/identity/broker/_browser.py | 15 +--- .../azure/identity/broker/_user_password.py | 8 +- .../azure/identity/broker/_utils.py | 77 +++++++++++++++++++ .../azure/identity/broker/_version.py | 5 ++ 4 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 sdk/identity/azure-identity-broker/azure/identity/broker/_version.py diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index 71c4abb25750..bb5383a33f15 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -9,7 +9,7 @@ from azure.core.exceptions import ClientAuthenticationError from azure.identity import InteractiveBrowserCredential as _InteractiveBrowserCredential, CredentialUnavailableError -from ._utils import wrap_exceptions +from ._utils import wrap_exceptions, resolve_tenant, within_dac DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" @@ -78,18 +78,14 @@ def __init__(self, **kwargs: Any) -> None: if redirect_uri: self._parsed_url = urlparse(redirect_uri) if not (self._parsed_url.hostname and self._parsed_url.port): - raise ValueError( - '"redirect_uri" must be a URL with port number, for example "http://localhost:8400"' - ) + raise ValueError('"redirect_uri" must be a URL with port number, for example "http://localhost:8400"') else: self._parsed_url = None self._login_hint = kwargs.pop("login_hint", None) self._timeout = kwargs.pop("timeout", 300) client_id = kwargs.pop("client_id", DEVELOPER_SIGN_ON_CLIENT_ID) - super(InteractiveBrowserCredential, self).__init__( - client_id=client_id, **kwargs - ) + super(InteractiveBrowserCredential, self).__init__(client_id=client_id, **kwargs) @wrap_exceptions def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: @@ -110,9 +106,7 @@ def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: enable_msa_passthrough=self._enable_msa_passthrough, ) except socket.error as ex: - raise CredentialUnavailableError( - message="Couldn't start an HTTP server." - ) from ex + raise CredentialUnavailableError(message="Couldn't start an HTTP server.") from ex if "access_token" not in result and "error_description" in result: if within_dac.get(): raise CredentialUnavailableError(message=result["error_description"]) @@ -125,7 +119,6 @@ def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: # base class will raise for other errors return result - def _get_app(self, **kwargs: Any) -> msal.ClientApplication: tenant_id = resolve_tenant( self._tenant_id, additionally_allowed_tenants=self._additionally_allowed_tenants, **kwargs diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index a9f9e1028738..55709bc59848 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -61,16 +61,16 @@ class UsernamePasswordCredential(_UsernamePasswordCredential): :caption: Create a UsernamePasswordCredential. """ - def __init__( - self, client_id: str, username: str, password: str, **kwargs: Any - ) -> None: + def __init__(self, client_id: str, username: str, password: str, **kwargs: Any) -> None: # The base class will accept an AuthenticationRecord, allowing this credential to authenticate silently the # first time it's asked for a token. However, we want to ensure this first authentication is not silent, to # validate the given password. This class therefore doesn't document the authentication_record argument, and we # discard it here. self._allow_broker = kwargs.pop("allow_broker", None) kwargs.pop("authentication_record", None) - super(UsernamePasswordCredential, self).__init__(client_id=client_id, **kwargs) + super(UsernamePasswordCredential, self).__init__( + client_id=client_id, username=username, password=password, **kwargs + ) self._username = username self._password = password diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py index 42beae8bf6c5..7fdde7630acd 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py @@ -2,13 +2,48 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ +from typing import List, Optional +import os import functools +import logging from contextvars import ContextVar from azure.core.exceptions import ClientAuthenticationError +class EnvironmentVariables: + 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_CLIENT_CERTIFICATE_PATH = "AZURE_CLIENT_CERTIFICATE_PATH" + AZURE_CLIENT_CERTIFICATE_PASSWORD = "AZURE_CLIENT_CERTIFICATE_PASSWORD" + 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) + + AZURE_POD_IDENTITY_AUTHORITY_HOST = "AZURE_POD_IDENTITY_AUTHORITY_HOST" + IDENTITY_ENDPOINT = "IDENTITY_ENDPOINT" + IDENTITY_HEADER = "IDENTITY_HEADER" + IDENTITY_SERVER_THUMBPRINT = "IDENTITY_SERVER_THUMBPRINT" + IMDS_ENDPOINT = "IMDS_ENDPOINT" + MSI_ENDPOINT = "MSI_ENDPOINT" + MSI_SECRET = "MSI_SECRET" + + AZURE_AUTHORITY_HOST = "AZURE_AUTHORITY_HOST" + AZURE_IDENTITY_DISABLE_MULTITENANTAUTH = "AZURE_IDENTITY_DISABLE_MULTITENANTAUTH" + AZURE_REGIONAL_AUTHORITY_NAME = "AZURE_REGIONAL_AUTHORITY_NAME" + + AZURE_FEDERATED_TOKEN_FILE = "AZURE_FEDERATED_TOKEN_FILE" + WORKLOAD_IDENTITY_VARS = (AZURE_AUTHORITY_HOST, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE) + + within_dac = ContextVar("within_dac", default=False) +_LOGGER = logging.getLogger(__name__) + def wrap_exceptions(fn): """Prevents leaking exceptions defined outside azure-core by raising ClientAuthenticationError from them. @@ -30,3 +65,45 @@ def wrapper(*args, **kwargs): raise auth_error from ex return wrapper + + +def resolve_tenant( + default_tenant: str, tenant_id: Optional[str] = None, *, additionally_allowed_tenants: List[str] = [], **_ +) -> str: + """Returns the correct tenant for a token request given a credential's configuration. + + :param str default_tenant: The tenant ID configured on the credential. + :param str tenant_id: The tenant ID requested by the user. + :keyword list[str] additionally_allowed_tenants: The list of additionally allowed tenants. + :return: The tenant ID to use for the token request. + :rtype: str + :raises: ~azure.core.exceptions.ClientAuthenticationError + """ + if tenant_id is None or tenant_id == default_tenant: + return default_tenant + if default_tenant == "adfs" or os.environ.get(EnvironmentVariables.AZURE_IDENTITY_DISABLE_MULTITENANTAUTH): + _LOGGER.info( + "A token was request for a different tenant than was configured on the credential, " + "but the configured value was used since multi tenant authentication has been disabled. " + "Configured tenant ID: %s, Requested tenant ID %s", + default_tenant, + tenant_id, + ) + return default_tenant + if not default_tenant: + return tenant_id + if "*" in additionally_allowed_tenants or tenant_id in additionally_allowed_tenants: + _LOGGER.info( + "A token was requested for a different tenant than was configured on the credential, " + "and the requested tenant ID was used to authenticate. Configured tenant ID: %s, " + "Requested tenant ID %s", + default_tenant, + tenant_id, + ) + return tenant_id + raise ClientAuthenticationError( + message="The current credential is not configured to acquire tokens for tenant {}. " + "To enable acquiring tokens for this tenant add it to the additionally_allowed_tenants " + 'when creating the credential, or add "*" to additionally_allowed_tenants to allow ' + "acquiring tokens for any tenant.".format(tenant_id) + ) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_version.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_version.py new file mode 100644 index 000000000000..a4040f093c86 --- /dev/null +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_version.py @@ -0,0 +1,5 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +VERSION = "1.0.0b1" From 8611f14c1d503c9f51928220cd40a32f4ccea289 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 14:56:23 -0700 Subject: [PATCH 09/24] update --- .../azure-identity-broker/azure/identity/broker/_browser.py | 3 ++- .../azure/identity/broker/_user_password.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index bb5383a33f15..f9d5569896b0 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -8,7 +8,8 @@ import msal from azure.core.exceptions import ClientAuthenticationError -from azure.identity import InteractiveBrowserCredential as _InteractiveBrowserCredential, CredentialUnavailableError +from azure.identity._credentials import InteractiveBrowserCredential as _InteractiveBrowserCredential +from azure.identity._exceptions import CredentialUnavailableError from ._utils import wrap_exceptions, resolve_tenant, within_dac DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index 55709bc59848..ef810528af6f 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. # ------------------------------------ from typing import Any, Dict -from azure.identity import UsernamePasswordCredential as _UsernamePasswordCredential +from azure.identity._credentials import UsernamePasswordCredential as _UsernamePasswordCredential from ._utils import wrap_exceptions From b349408e6a4d6dd9e5881fe8427331e87bcdb736 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 15:21:35 -0700 Subject: [PATCH 10/24] update --- sdk/identity/azure-identity-broker/CHANGELOG.md | 3 ++- .../azure/identity/broker/__init__.py | 8 ++++---- .../azure/identity/broker/_browser.py | 2 +- .../azure/identity/broker/_user_password.py | 2 +- .../azure-identity-broker/tests/test_broker.py | 14 +++++++------- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/sdk/identity/azure-identity-broker/CHANGELOG.md b/sdk/identity/azure-identity-broker/CHANGELOG.md index f1dca41a1c18..3b59ec910a83 100644 --- a/sdk/identity/azure-identity-broker/CHANGELOG.md +++ b/sdk/identity/azure-identity-broker/CHANGELOG.md @@ -4,7 +4,8 @@ ### Features Added -- Added `azure.identity.broker.InteractiveBrowserCredential` and `azure.identity.broker.UsernamePasswordCredential` which have broker support. +- Added `azure.identity.broker.InteractiveBrowserBrokerCredential` + and `azure.identity.broker.UsernamePasswordBrokerCredential` which have broker support. ### Breaking Changes diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/__init__.py b/sdk/identity/azure-identity-broker/azure/identity/broker/__init__.py index 0b0f1c52db55..8ac5cf563c93 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/__init__.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/__init__.py @@ -2,11 +2,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -from ._browser import InteractiveBrowserCredential -from ._user_password import UsernamePasswordCredential +from ._browser import InteractiveBrowserBrokerCredential +from ._user_password import UsernamePasswordBrokerCredential __all__ = [ - "InteractiveBrowserCredential", - "UsernamePasswordCredential", + "InteractiveBrowserBrokerCredential", + "UsernamePasswordBrokerCredential", ] diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index f9d5569896b0..9a4b3b22a12d 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -15,7 +15,7 @@ DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" -class InteractiveBrowserCredential(_InteractiveBrowserCredential): +class InteractiveBrowserBrokerCredential(_InteractiveBrowserCredential): """Opens a browser to interactively authenticate a user. :func:`~get_token` opens a browser to a login URL provided by Azure Active Directory and authenticates a user diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index ef810528af6f..bfbe054da8ba 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -7,7 +7,7 @@ from ._utils import wrap_exceptions -class UsernamePasswordCredential(_UsernamePasswordCredential): +class UsernamePasswordBrokerCredential(_UsernamePasswordCredential): """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 diff --git a/sdk/identity/azure-identity-broker/tests/test_broker.py b/sdk/identity/azure-identity-broker/tests/test_broker.py index 8354b055abba..07ad81c2022e 100644 --- a/sdk/identity/azure-identity-broker/tests/test_broker.py +++ b/sdk/identity/azure-identity-broker/tests/test_broker.py @@ -2,30 +2,30 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -from azure.identity.broker import InteractiveBrowserCredential, UsernamePasswordCredential +from azure.identity.broker import InteractiveBrowserBrokerCredential, UsernamePasswordBrokerCredential def test_interactive_browser_cred_with_broker(): - cred = InteractiveBrowserCredential(allow_broker=True) + cred = InteractiveBrowserBrokerCredential(allow_broker=True) assert cred._allow_broker def test_interactive_browser_cred_without_broker(): - cred = InteractiveBrowserCredential() + cred = InteractiveBrowserBrokerCredential() assert not cred._allow_broker - cred = InteractiveBrowserCredential(allow_broker=False) + cred = InteractiveBrowserBrokerCredential(allow_broker=False) assert not cred._allow_broker def test_username_password_cred_with_broker(): - cred = UsernamePasswordCredential("client-id", "username", "password", allow_broker=True) + cred = UsernamePasswordBrokerCredential("client-id", "username", "password", allow_broker=True) assert cred._allow_broker def test_username_password_cred_without_broker(): - cred = UsernamePasswordCredential("client-id", "username", "password") + cred = UsernamePasswordBrokerCredential("client-id", "username", "password") assert not cred._allow_broker - cred = UsernamePasswordCredential("client-id", "username", "password", allow_broker=False) + cred = UsernamePasswordBrokerCredential("client-id", "username", "password", allow_broker=False) assert not cred._allow_broker From 8dc4bab1e5f98ecd4b156d16b13606ce8b716af6 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 15:35:04 -0700 Subject: [PATCH 11/24] update --- .../azure-identity-broker/azure/identity/broker/_browser.py | 2 +- .../azure/identity/broker/_user_password.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index 9a4b3b22a12d..cfd2060979e3 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -86,7 +86,7 @@ def __init__(self, **kwargs: Any) -> None: self._login_hint = kwargs.pop("login_hint", None) self._timeout = kwargs.pop("timeout", 300) client_id = kwargs.pop("client_id", DEVELOPER_SIGN_ON_CLIENT_ID) - super(InteractiveBrowserCredential, self).__init__(client_id=client_id, **kwargs) + super(InteractiveBrowserBrokerCredential, self).__init__(client_id=client_id, **kwargs) @wrap_exceptions def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index bfbe054da8ba..3fdaaecb1a4e 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -68,7 +68,7 @@ def __init__(self, client_id: str, username: str, password: str, **kwargs: Any) # discard it here. self._allow_broker = kwargs.pop("allow_broker", None) kwargs.pop("authentication_record", None) - super(UsernamePasswordCredential, self).__init__( + super(UsernamePasswordBrokerCredential, self).__init__( client_id=client_id, username=username, password=password, **kwargs ) self._username = username From 364ada3be13ff0b898cbf6aed2a2690463351956 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 15:49:57 -0700 Subject: [PATCH 12/24] update --- sdk/identity/azure-identity-broker/dev_requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/identity/azure-identity-broker/dev_requirements.txt b/sdk/identity/azure-identity-broker/dev_requirements.txt index f0bbcba64d96..d3a9127fa126 100644 --- a/sdk/identity/azure-identity-broker/dev_requirements.txt +++ b/sdk/identity/azure-identity-broker/dev_requirements.txt @@ -3,3 +3,4 @@ aiohttp>=3.0 typing_extensions>=3.7.2 -e ../../../tools/azure-sdk-tools -e ../../../tools/azure-devtools +cryptography==40.0.2 \ No newline at end of file From d4bda7dd39ffe1c2e047fdca0a11c28a193a5b1f Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 15:50:26 -0700 Subject: [PATCH 13/24] update --- sdk/identity/azure-identity-broker/dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/dev_requirements.txt b/sdk/identity/azure-identity-broker/dev_requirements.txt index d3a9127fa126..bf7aa75ff55e 100644 --- a/sdk/identity/azure-identity-broker/dev_requirements.txt +++ b/sdk/identity/azure-identity-broker/dev_requirements.txt @@ -3,4 +3,4 @@ aiohttp>=3.0 typing_extensions>=3.7.2 -e ../../../tools/azure-sdk-tools -e ../../../tools/azure-devtools -cryptography==40.0.2 \ No newline at end of file +cryptography<=40.0.2 \ No newline at end of file From be70c71b9ca27f6cde9c3b026ff6b08164e6a653 Mon Sep 17 00:00:00 2001 From: Xiang Yan Date: Fri, 6 Oct 2023 16:55:00 -0700 Subject: [PATCH 14/24] Update sdk/identity/azure-identity-broker/MANIFEST.in Co-authored-by: Paul Van Eck --- sdk/identity/azure-identity-broker/MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/MANIFEST.in b/sdk/identity/azure-identity-broker/MANIFEST.in index 04a401707f16..e75c8caa6879 100644 --- a/sdk/identity/azure-identity-broker/MANIFEST.in +++ b/sdk/identity/azure-identity-broker/MANIFEST.in @@ -3,4 +3,4 @@ recursive-include tests *.py include *.md include LICENSE include azure/__init__.py -include azure/identity/py.typed +include azure/identity/broker/py.typed From f3441d14bfba3a48a123e5173e8c2df66be2e222 Mon Sep 17 00:00:00 2001 From: Xiang Yan Date: Fri, 6 Oct 2023 16:56:17 -0700 Subject: [PATCH 15/24] Update sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py Co-authored-by: Paul Van Eck --- .../azure/identity/broker/_user_password.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index 3fdaaecb1a4e..62d682e1d54b 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -71,8 +71,6 @@ def __init__(self, client_id: str, username: str, password: str, **kwargs: Any) super(UsernamePasswordBrokerCredential, self).__init__( client_id=client_id, username=username, password=password, **kwargs ) - self._username = username - self._password = password @wrap_exceptions def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: From 29025f1874a1785d59bbd1e42f070824f1415696 Mon Sep 17 00:00:00 2001 From: Xiang Yan Date: Fri, 6 Oct 2023 16:56:32 -0700 Subject: [PATCH 16/24] Update sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py Co-authored-by: Paul Van Eck --- .../azure/identity/broker/_user_password.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index 62d682e1d54b..679ac86704b6 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -67,7 +67,6 @@ def __init__(self, client_id: str, username: str, password: str, **kwargs: Any) # validate the given password. This class therefore doesn't document the authentication_record argument, and we # discard it here. self._allow_broker = kwargs.pop("allow_broker", None) - kwargs.pop("authentication_record", None) super(UsernamePasswordBrokerCredential, self).__init__( client_id=client_id, username=username, password=password, **kwargs ) From c81e562de9f3f677568238384709ce4b0a381a83 Mon Sep 17 00:00:00 2001 From: Xiang Yan Date: Fri, 6 Oct 2023 16:56:48 -0700 Subject: [PATCH 17/24] Update sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py Co-authored-by: Paul Van Eck --- .../azure/identity/broker/_browser.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index cfd2060979e3..db8822f09ea4 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -75,18 +75,7 @@ def __init__(self, **kwargs: Any) -> None: self._allow_broker = kwargs.pop("allow_broker", None) self._parent_window_handle = kwargs.pop("parent_window_handle", None) self._enable_msa_passthrough = kwargs.pop("enable_msa_passthrough", False) - redirect_uri = kwargs.pop("redirect_uri", None) - if redirect_uri: - self._parsed_url = urlparse(redirect_uri) - if not (self._parsed_url.hostname and self._parsed_url.port): - raise ValueError('"redirect_uri" must be a URL with port number, for example "http://localhost:8400"') - else: - self._parsed_url = None - - self._login_hint = kwargs.pop("login_hint", None) - self._timeout = kwargs.pop("timeout", 300) - client_id = kwargs.pop("client_id", DEVELOPER_SIGN_ON_CLIENT_ID) - super(InteractiveBrowserBrokerCredential, self).__init__(client_id=client_id, **kwargs) + super().__init__(**kwargs) @wrap_exceptions def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: From 0c1f98d99f79bf3b68113a982002d18c799caa26 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 17:09:44 -0700 Subject: [PATCH 18/24] update --- .../azure/identity/broker/_browser.py | 15 +---- .../azure/identity/broker/_user_password.py | 62 +++++++++++++------ .../azure/identity/broker/_utils.py | 34 +--------- 3 files changed, 46 insertions(+), 65 deletions(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index db8822f09ea4..71d2e3049e7b 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -8,9 +8,9 @@ import msal from azure.core.exceptions import ClientAuthenticationError -from azure.identity._credentials import InteractiveBrowserCredential as _InteractiveBrowserCredential -from azure.identity._exceptions import CredentialUnavailableError -from ._utils import wrap_exceptions, resolve_tenant, within_dac +from azure.identity import InteractiveBrowserCredential as _InteractiveBrowserCredential, CredentialUnavailableError +from azure.identity._internal.utils import within_dac +from ._utils import wrap_exceptions, resolve_tenant DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" @@ -60,15 +60,6 @@ class InteractiveBrowserBrokerCredential(_InteractiveBrowserCredential): authority is disabled. As a result, it is crucial to ensure that the configured authority host is valid and trustworthy. :raises ValueError: invalid **redirect_uri** - - .. admonition:: Example: - - .. literalinclude:: ../samples/credential_creation_code_snippets.py - :start-after: [START create_interactive_browser_credential] - :end-before: [END create_interactive_browser_credential] - :language: python - :dedent: 4 - :caption: Create an InteractiveBrowserCredential. """ def __init__(self, **kwargs: Any) -> None: diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index 679ac86704b6..a2a3d2d636b2 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -2,9 +2,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -from typing import Any, Dict -from azure.identity._credentials import UsernamePasswordCredential as _UsernamePasswordCredential -from ._utils import wrap_exceptions +from typing import Any +import msal +from azure.identity import UsernamePasswordCredential as _UsernamePasswordCredential +from ._utils import resolve_tenant class UsernamePasswordBrokerCredential(_UsernamePasswordCredential): @@ -50,15 +51,6 @@ class UsernamePasswordBrokerCredential(_UsernamePasswordCredential): :keyword List[str] additionally_allowed_tenants: Specifies tenants in addition to the specified "tenant_id" for which the credential may acquire tokens. Add the wildcard value "*" to allow the credential to acquire tokens for any tenant the application can access. - - .. admonition:: Example: - - .. literalinclude:: ../samples/credential_creation_code_snippets.py - :start-after: [START create_username_password_credential] - :end-before: [END create_username_password_credential] - :language: python - :dedent: 4 - :caption: Create a UsernamePasswordCredential. """ def __init__(self, client_id: str, username: str, password: str, **kwargs: Any) -> None: @@ -71,12 +63,42 @@ def __init__(self, client_id: str, username: str, password: str, **kwargs: Any) client_id=client_id, username=username, password=password, **kwargs ) - @wrap_exceptions - def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: - app = self._get_app(**kwargs) - return app.acquire_token_by_username_password( - username=self._username, - password=self._password, - scopes=list(scopes), - claims_challenge=kwargs.get("claims"), + def _get_app(self, **kwargs: Any) -> msal.ClientApplication: + tenant_id = resolve_tenant( + self._tenant_id, + additionally_allowed_tenants=self._additionally_allowed_tenants, + **kwargs + ) + + client_applications_map = self._client_applications + capabilities = None + token_cache = self._cache + + app_class = ( + msal.ConfidentialClientApplication + if self._client_credential + else msal.PublicClientApplication ) + + if kwargs.get("enable_cae"): + client_applications_map = self._cae_client_applications + capabilities = ["CP1"] + token_cache = self._cae_cache + + if not token_cache: + token_cache = self._initialize_cache(is_cae=bool(kwargs.get("enable_cae"))) + + if tenant_id not in client_applications_map: + client_applications_map[tenant_id] = app_class( + client_id=self._client_id, + client_credential=self._client_credential, + client_capabilities=capabilities, + authority="{}/{}".format(self._authority, tenant_id), + azure_region=self._regional_authority, + token_cache=token_cache, + http_client=self._client, + instance_discovery=self._instance_discovery, + allow_broker=self._allow_broker, + ) + + return client_applications_map[tenant_id] diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py index 7fdde7630acd..ec5b30c753b5 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py @@ -10,38 +10,6 @@ from azure.core.exceptions import ClientAuthenticationError -class EnvironmentVariables: - 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_CLIENT_CERTIFICATE_PATH = "AZURE_CLIENT_CERTIFICATE_PATH" - AZURE_CLIENT_CERTIFICATE_PASSWORD = "AZURE_CLIENT_CERTIFICATE_PASSWORD" - 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) - - AZURE_POD_IDENTITY_AUTHORITY_HOST = "AZURE_POD_IDENTITY_AUTHORITY_HOST" - IDENTITY_ENDPOINT = "IDENTITY_ENDPOINT" - IDENTITY_HEADER = "IDENTITY_HEADER" - IDENTITY_SERVER_THUMBPRINT = "IDENTITY_SERVER_THUMBPRINT" - IMDS_ENDPOINT = "IMDS_ENDPOINT" - MSI_ENDPOINT = "MSI_ENDPOINT" - MSI_SECRET = "MSI_SECRET" - - AZURE_AUTHORITY_HOST = "AZURE_AUTHORITY_HOST" - AZURE_IDENTITY_DISABLE_MULTITENANTAUTH = "AZURE_IDENTITY_DISABLE_MULTITENANTAUTH" - AZURE_REGIONAL_AUTHORITY_NAME = "AZURE_REGIONAL_AUTHORITY_NAME" - - AZURE_FEDERATED_TOKEN_FILE = "AZURE_FEDERATED_TOKEN_FILE" - WORKLOAD_IDENTITY_VARS = (AZURE_AUTHORITY_HOST, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE) - - -within_dac = ContextVar("within_dac", default=False) - _LOGGER = logging.getLogger(__name__) @@ -81,7 +49,7 @@ def resolve_tenant( """ if tenant_id is None or tenant_id == default_tenant: return default_tenant - if default_tenant == "adfs" or os.environ.get(EnvironmentVariables.AZURE_IDENTITY_DISABLE_MULTITENANTAUTH): + if default_tenant == "adfs" or os.environ.get("AZURE_IDENTITY_DISABLE_MULTITENANTAUTH"): _LOGGER.info( "A token was request for a different tenant than was configured on the credential, " "but the configured value was used since multi tenant authentication has been disabled. " From b1943e3eb0756230c8a06430e6867d89d09ad47d Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 17:22:46 -0700 Subject: [PATCH 19/24] update --- sdk/identity/azure-identity-broker/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/setup.py b/sdk/identity/azure-identity-broker/setup.py index 1bac95b25c16..8969353fe819 100644 --- a/sdk/identity/azure-identity-broker/setup.py +++ b/sdk/identity/azure-identity-broker/setup.py @@ -62,7 +62,7 @@ }, python_requires=">=3.7", install_requires=[ - "azure-identity<2.0.0,>=1.12.0", + "azure-identity<2.0.0,>=1.14.0", "msal[broker]>=1.20,<2", ], ) From aa034375c2cf5e4692bbff0e1329e21a7a776e1f Mon Sep 17 00:00:00 2001 From: Xiang Yan Date: Fri, 6 Oct 2023 17:27:04 -0700 Subject: [PATCH 20/24] Update sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py Co-authored-by: Paul Van Eck --- .../azure-identity-broker/azure/identity/broker/_browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index 71d2e3049e7b..8d0591090559 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -9,7 +9,7 @@ from azure.core.exceptions import ClientAuthenticationError from azure.identity import InteractiveBrowserCredential as _InteractiveBrowserCredential, CredentialUnavailableError -from azure.identity._internal.utils import within_dac +from azure.identity._internal.utils import within_dac # pylint:disable=protected-access from ._utils import wrap_exceptions, resolve_tenant DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" From 00dc9fb593765929cfbbf8842622989d42808a0e Mon Sep 17 00:00:00 2001 From: Xiang Yan Date: Fri, 6 Oct 2023 17:27:12 -0700 Subject: [PATCH 21/24] Update sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py Co-authored-by: Paul Van Eck --- .../azure-identity-broker/azure/identity/broker/_browser.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index 8d0591090559..005a595ad314 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -12,7 +12,6 @@ from azure.identity._internal.utils import within_dac # pylint:disable=protected-access from ._utils import wrap_exceptions, resolve_tenant -DEVELOPER_SIGN_ON_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" class InteractiveBrowserBrokerCredential(_InteractiveBrowserCredential): From d40557a11b0ce6373ff8ba670f9d0cc5b1b1c137 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 17:30:09 -0700 Subject: [PATCH 22/24] update --- .../azure-identity-broker/azure/identity/broker/_browser.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index 005a595ad314..85af59aa0369 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -4,7 +4,6 @@ # ------------------------------------ import socket from typing import Dict, Any -from urllib.parse import urlparse import msal from azure.core.exceptions import ClientAuthenticationError From 3c8bbd7acc20e0c6f3cd2691daab7ca0eaa84cc7 Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 17:53:42 -0700 Subject: [PATCH 23/24] update --- .../azure/identity/broker/_browser.py | 6 ++++-- .../azure/identity/broker/_user_password.py | 14 +++++--------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index 85af59aa0369..da9969441d81 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -7,12 +7,14 @@ import msal from azure.core.exceptions import ClientAuthenticationError -from azure.identity import InteractiveBrowserCredential as _InteractiveBrowserCredential, CredentialUnavailableError +from azure.identity._credentials import ( + InteractiveBrowserCredential as _InteractiveBrowserCredential, +) # pylint:disable=protected-access +from azure.identity._exceptions import CredentialUnavailableError # pylint:disable=protected-access from azure.identity._internal.utils import within_dac # pylint:disable=protected-access from ._utils import wrap_exceptions, resolve_tenant - class InteractiveBrowserBrokerCredential(_InteractiveBrowserCredential): """Opens a browser to interactively authenticate a user. diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py index a2a3d2d636b2..b159c61b13ca 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_user_password.py @@ -4,7 +4,9 @@ # ------------------------------------ from typing import Any import msal -from azure.identity import UsernamePasswordCredential as _UsernamePasswordCredential +from azure.identity._credentials import ( + UsernamePasswordCredential as _UsernamePasswordCredential, +) # pylint:disable=protected-access from ._utils import resolve_tenant @@ -65,20 +67,14 @@ def __init__(self, client_id: str, username: str, password: str, **kwargs: Any) def _get_app(self, **kwargs: Any) -> msal.ClientApplication: tenant_id = resolve_tenant( - self._tenant_id, - additionally_allowed_tenants=self._additionally_allowed_tenants, - **kwargs + self._tenant_id, additionally_allowed_tenants=self._additionally_allowed_tenants, **kwargs ) client_applications_map = self._client_applications capabilities = None token_cache = self._cache - app_class = ( - msal.ConfidentialClientApplication - if self._client_credential - else msal.PublicClientApplication - ) + app_class = msal.ConfidentialClientApplication if self._client_credential else msal.PublicClientApplication if kwargs.get("enable_cae"): client_applications_map = self._cae_client_applications From d6cbc31c12d5cff2a2de62f7e5e0c4eb290323af Mon Sep 17 00:00:00 2001 From: xiangyan99 Date: Fri, 6 Oct 2023 18:15:13 -0700 Subject: [PATCH 24/24] update --- .../azure-identity-broker/azure/identity/broker/_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py index ec5b30c753b5..861cf94a65d3 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_utils.py @@ -6,7 +6,6 @@ import os import functools import logging -from contextvars import ContextVar from azure.core.exceptions import ClientAuthenticationError