From 4721866932880a69d38c67346be98c364c8ecb23 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 23 Jul 2021 16:54:44 -0700 Subject: [PATCH 01/12] OnBehalfOfCredential --- .../azure-identity/azure/identity/__init__.py | 4 + .../azure/identity/_credentials/__init__.py | 2 + .../identity/_credentials/on_behalf_of.py | 135 ++++++++++++++++++ .../azure/identity/_user_assertion.py | 56 ++++++++ sdk/identity/azure-identity/tests/test_obo.py | 133 +++++++++++++++++ 5 files changed, 330 insertions(+) create mode 100644 sdk/identity/azure-identity/azure/identity/_credentials/on_behalf_of.py create mode 100644 sdk/identity/azure-identity/azure/identity/_user_assertion.py create mode 100644 sdk/identity/azure-identity/tests/test_obo.py diff --git a/sdk/identity/azure-identity/azure/identity/__init__.py b/sdk/identity/azure-identity/azure/identity/__init__.py index 4840728113a8..a35d1899566c 100644 --- a/sdk/identity/azure-identity/azure/identity/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/__init__.py @@ -21,11 +21,13 @@ EnvironmentCredential, InteractiveBrowserCredential, ManagedIdentityCredential, + OnBehalfOfCredential, SharedTokenCacheCredential, UsernamePasswordCredential, VisualStudioCodeCredential, ) from ._persistent_cache import TokenCachePersistenceOptions +from ._user_assertion import UserAssertion __all__ = [ @@ -45,10 +47,12 @@ "EnvironmentCredential", "InteractiveBrowserCredential", "KnownAuthorities", + "OnBehalfOfCredential", "RegionalAuthority", "ManagedIdentityCredential", "SharedTokenCacheCredential", "TokenCachePersistenceOptions", + "UserAssertion", "UsernamePasswordCredential", "VisualStudioCodeCredential", ] diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py b/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py index 05dc788d1bde..11c7b26db428 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/__init__.py @@ -12,6 +12,7 @@ from .default import DefaultAzureCredential from .environment import EnvironmentCredential from .managed_identity import ManagedIdentityCredential +from .on_behalf_of import OnBehalfOfCredential from .shared_cache import SharedTokenCacheCredential from .azure_cli import AzureCliCredential from .device_code import DeviceCodeCredential @@ -32,6 +33,7 @@ "EnvironmentCredential", "InteractiveBrowserCredential", "ManagedIdentityCredential", + "OnBehalfOfCredential", "SharedTokenCacheCredential", "AzureCliCredential", "UsernamePasswordCredential", diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/on_behalf_of.py b/sdk/identity/azure-identity/azure/identity/_credentials/on_behalf_of.py new file mode 100644 index 000000000000..59fa82bcac6f --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_credentials/on_behalf_of.py @@ -0,0 +1,135 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import functools +import os +import time +from typing import TYPE_CHECKING + +import msal + +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError + +from .. import CredentialUnavailableError +from .._constants import EnvironmentVariables +from .._internal import get_default_authority, normalize_authority, resolve_tenant, validate_tenant_id +from .._internal.decorators import log_get_token +from .._internal.interactive import _build_auth_record +from .._internal.msal_client import MsalClient +from .._user_assertion import get_assertion + +if TYPE_CHECKING: + from typing import Any + + +class OnBehalfOfCredential(object): + """Authenticates a service principal via the on-behalf-of flow. + + This flow is typically used by middle-tier services that authorize requests to other services with a delegated + user identity. Because this is not an interactive authentication flow, an application using it must have admin + consent for any delegated permissions before requesting tokens for them. See `Azure Active Directory documentation + `_ for a more detailed + description of the on-behalf-of flow. + + Each token requested by this credential requires a user assertion from :class:`~azure.identity.UserAssertion`: + + .. literalinclude:: ../tests/test_obo.py + :start-after: [START snippet] + :end-before: [END snippet] + :language: python + :dedent: 8 + + :param str tenant_id: ID of the service principal's tenant. Also called its "directory" ID. + :param str client_id: the service principal's client ID + :param str client_secret: one of the service principal's client secrets + + :keyword bool allow_multitenant_authentication: when True, enables the credential to acquire tokens from any tenant + the application is registered in. When False, which is the default, the credential will acquire tokens only from + the tenant specified by **tenant_id**. + """ + + def __init__(self, tenant_id, client_id, client_secret, **kwargs): + validate_tenant_id(tenant_id) + self._resolve_tenant = functools.partial( + resolve_tenant, tenant_id, kwargs.pop("allow_multitenant_authentication", False) + ) + + authority = kwargs.pop("authority", None) + self._authority = normalize_authority(authority) if authority else get_default_authority() + regional_authority = kwargs.pop( + "regional_authority", os.environ.get(EnvironmentVariables.AZURE_REGIONAL_AUTHORITY_NAME) + ) + + self._client = MsalClient(**kwargs) + + # msal.ConfidentialClientApplication arguments which don't vary by user or tenant + self._confidential_client_args = { + "azure_region": regional_authority, + "client_capabilities": None if "AZURE_IDENTITY_DISABLE_CP1" in os.environ else ["CP1"], + "client_credential": client_secret, + "client_id": client_id, + "http_client": self._client, + } + + @log_get_token("OnBehalfOfCredential") + def get_token(self, *scopes, **kwargs): + # type: (*str, **Any) -> AccessToken + """Request an access token for `scopes`. + + This method is called automatically by Azure SDK clients. + + :param str scopes: desired scope for the access token + + :rtype: :class:`azure.core.credentials.AccessToken` + """ + if not scopes: + raise ValueError('"get_token" requires at least one scope') + + user_assertion = get_assertion() + if not user_assertion: + raise CredentialUnavailableError( + ( + "This credential requires a user assertion to acquire tokens. See " + + "https://aka.ms/azsdk/python/identity/docs#azure.identity.OnBehalfOfCredential for more details." + ) + ) + + # pylint:disable=protected-access + tenant_id = self._resolve_tenant(**kwargs) + if tenant_id not in user_assertion._client_applications: + user_assertion._client_applications[tenant_id] = msal.ConfidentialClientApplication( + authority=self._authority + "/" + tenant_id, **self._confidential_client_args + ) + client_application = user_assertion._client_applications[tenant_id] + + request_time = int(time.time()) + result = None + + if user_assertion._record: + # we already acquired a token with this assertion, so silent authentication may work + for account in client_application.get_accounts(username=user_assertion._record.username): + if account.get("home_account_id") != user_assertion._record.home_account_id: + continue + result = client_application.acquire_token_silent_with_error( + list(scopes), account=account, claims_challenge=kwargs.get("claims") + ) + if result and "access_token" in result and "expires_in" in result: + break + + if not result: + result = client_application.acquire_token_on_behalf_of( + user_assertion._assertion, list(scopes), claims_challenge=kwargs.get("claims") + ) + try: + user_assertion._record = _build_auth_record(result) + except Exception: # pylint:disable=broad-except + pass + + 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) + + return AccessToken(result["access_token"], request_time + int(result["expires_in"])) diff --git a/sdk/identity/azure-identity/azure/identity/_user_assertion.py b/sdk/identity/azure-identity/azure/identity/_user_assertion.py new file mode 100644 index 000000000000..a271ec8b11af --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_user_assertion.py @@ -0,0 +1,56 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Dict, Optional + import msal + from . import AuthenticationRecord + + +try: + from contextvars import ContextVar + + _assertion_var = ContextVar("_user_assertion_context", default=None) + + def get_assertion(): + return _assertion_var.get() + + def _set_assertion(user_assertion): + _assertion_var.set(user_assertion) + + +except ImportError: + from threading import local + + _assertion_local = local() + _assertion_local.user_assertion = None + + def get_assertion(): + return _assertion_local.user_assertion + + def _set_assertion(user_assertion): + _assertion_local.user_assertion = user_assertion + + +class UserAssertion(object): + def __init__(self, user_assertion): + # type: (str) -> None + """A user assertion. + + :param str user_assertion: the user assertion. Typically an access token issued to the user. + """ + self._assertion = user_assertion + self._client_applications = {} # type: Dict[str, msal.ConfidentialClientApplication] + self._record = None # type: Optional[AuthenticationRecord] + + def __enter__(self): + if get_assertion(): + raise ValueError("Another UserAssertion is already active for this context") + _set_assertion(self) + return self + + def __exit__(self, *args): + _set_assertion(None) diff --git a/sdk/identity/azure-identity/tests/test_obo.py b/sdk/identity/azure-identity/tests/test_obo.py new file mode 100644 index 000000000000..be5d7a1d9e55 --- /dev/null +++ b/sdk/identity/azure-identity/tests/test_obo.py @@ -0,0 +1,133 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from azure.core.pipeline.policies import ContentDecodePolicy, SansIOHTTPPolicy +from azure.identity import OnBehalfOfCredential, UserAssertion, UsernamePasswordCredential +from azure.identity._internal.user_agent import USER_AGENT +from six.moves.urllib_parse import urlparse + +import os + +try: + from unittest.mock import MagicMock, Mock, patch +except ImportError: + from mock import MagicMock, Mock, patch # type: ignore + + +import pytest + +from helpers import build_aad_response, get_discovery_response, mock_response +from recorded_test_case import RecordedTestCase + + +class RecordedTests(RecordedTestCase): + def __init__(self, *args, **kwargs): + + from azure_devtools.scenario_tests import GeneralNameReplacer, RequestUrlNormalizer + from recording_processors import IdTokenProcessor, RecordingRedactor + + scrubber = GeneralNameReplacer() + super(RecordedTestCase, self).__init__( + *args, + recording_processors=[RecordingRedactor(record_unique_values=True), scrubber], + replay_processors=[RequestUrlNormalizer(), IdTokenProcessor()], + **kwargs + ) + + def test_obo(self): + TENANT_ID = os.environ["OBO_TENANT_ID"] + CLIENT_ID = os.environ["OBO_CLIENT_ID"] + CLIENT_SECRET = os.environ["OBO_CLIENT_SECRET"] + + user_credential = UsernamePasswordCredential(CLIENT_ID, os.environ["OBO_USERNAME"], os.environ["OBO_PASSWORD"]) + user_token = user_credential.get_token(os.environ["OBO_SCOPE"]) + + # avoid showing a specific client in the snippet + class AzureClient(SubscriptionClient): + def get_resource(self): + return list(self.subscriptions.list()) + + # [START obo] + credential = OnBehalfOfCredential(TENANT_ID, CLIENT_ID, CLIENT_SECRET) + client = AzureClient(credential) + + # typically the user token comes from an incoming HTTP request from the user + with UserAssertion(user_token.token): + # all token requests in this block will use the same assertion + client.get_resource() + # [END obo] + + + +def test_tenant_id_validation(): + """The credential should raise ValueError when given an invalid tenant_id""" + valid_ids = {"c878a2ab-8ef4-413b-83a0-199afb84d7fb", "contoso.onmicrosoft.com", "organizations", "common"} + for tenant in valid_ids: + OnBehalfOfCredential(tenant, "client-id", "secret") + invalid_ids = {"", "my tenant", "my_tenant", "/", "\\", '"my-tenant"', "'my-tenant'"} + for tenant in invalid_ids: + with pytest.raises(ValueError): + OnBehalfOfCredential(tenant, "client-id", "secret") + + +def test_no_scopes(): + """The credential should raise ValueError when get_token is called with no scopes""" + credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret") + with pytest.raises(ValueError): + with UserAssertion("..."): + credential.get_token() + + +@pytest.mark.skip("depends on outstanding PR") +def test_close(): + transport = MagicMock() + credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret", transport=transport) + assert transport.__exit__.call_count == 0 + + credential.close() + assert transport.__exit__.call_count == 1 + + +@pytest.mark.skip("depends on outstanding PR") +def test_context_manager(): + transport = MagicMock() + credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret", transport=transport) + + with credential: + assert transport.__enter__.call_count == 1 + + assert transport.__enter__.call_count == 1 + assert transport.__exit__.call_count == 1 + + +def test_policies_configurable(): + policy = Mock(spec_set=SansIOHTTPPolicy, on_request=Mock(), on_exception=lambda _: False) + + def send(request, **_): + parsed = urlparse(request.url) + tenant = parsed.path.split("/")[1] + if "/oauth2/v2.0/token" not in parsed.path: + return get_discovery_response("https://{}/{}".format(parsed.netloc, tenant)) + return mock_response(json_payload=build_aad_response(access_token="***")) + + credential = OnBehalfOfCredential( + "tenant-id", "client-id", "client-secret", policies=[ContentDecodePolicy(), policy], transport=Mock(send=send) + ) + with UserAssertion("..."): + credential.get_token("scope") + assert policy.on_request.called + + +def test_user_agent(): + def send(request, **_): + assert request.headers["User-Agent"] == USER_AGENT + parsed = urlparse(request.url) + tenant = parsed.path.split("/")[1] + if "/oauth2/v2.0/token" not in parsed.path: + return get_discovery_response("https://{}/{}".format(parsed.netloc, tenant)) + return mock_response(json_payload=build_aad_response(access_token="***")) + + credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret", transport=Mock(send=send)) + with UserAssertion("..."): + credential.get_token("scope") From c46044729324fb1352e32d34af69c4d0e026506b Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 29 Jul 2021 13:59:23 -0700 Subject: [PATCH 02/12] tests --- sdk/identity/azure-identity/tests/helpers.py | 3 + .../tests/recorded_test_case.py | 4 +- .../tests/recording_processors.py | 9 +- .../tests/recordings/test_obo.test_obo.yaml | 358 ++++++++++++++++++ .../recordings/test_obo_async.test_obo.yaml | 242 ++++++++++++ sdk/identity/azure-identity/tests/test_obo.py | 191 ++++++---- .../azure-identity/tests/test_obo_async.py | 186 +++++++++ 7 files changed, 918 insertions(+), 75 deletions(-) create mode 100644 sdk/identity/azure-identity/tests/recordings/test_obo.test_obo.yaml create mode 100644 sdk/identity/azure-identity/tests/recordings/test_obo_async.test_obo.yaml create mode 100644 sdk/identity/azure-identity/tests/test_obo_async.py diff --git a/sdk/identity/azure-identity/tests/helpers.py b/sdk/identity/azure-identity/tests/helpers.py index b1be22602c72..8b8c1ecc46da 100644 --- a/sdk/identity/azure-identity/tests/helpers.py +++ b/sdk/identity/azure-identity/tests/helpers.py @@ -14,6 +14,9 @@ import mock # type: ignore +FAKE_CLIENT_ID = "fake-client-id" + + def build_id_token( iss="issuer", sub="subject", diff --git a/sdk/identity/azure-identity/tests/recorded_test_case.py b/sdk/identity/azure-identity/tests/recorded_test_case.py index 18a7a5182480..e56316a2fac4 100644 --- a/sdk/identity/azure-identity/tests/recorded_test_case.py +++ b/sdk/identity/azure-identity/tests/recorded_test_case.py @@ -8,7 +8,7 @@ from devtools_testutils.azure_testcase import AzureTestCase import pytest -from recording_processors import RecordingRedactor +from recording_processors import IdTokenProcessor, RecordingRedactor PLAYBACK_CLIENT_ID = "client-id" @@ -19,7 +19,7 @@ def __init__(self, *args, **kwargs): super(RecordedTestCase, self).__init__( *args, recording_processors=[RecordingRedactor(), scrubber], - replay_processors=[RequestUrlNormalizer()], + replay_processors=[IdTokenProcessor(), RequestUrlNormalizer()], **kwargs ) self.scrubber = scrubber diff --git a/sdk/identity/azure-identity/tests/recording_processors.py b/sdk/identity/azure-identity/tests/recording_processors.py index e0cd4058606d..6a96c5864efa 100644 --- a/sdk/identity/azure-identity/tests/recording_processors.py +++ b/sdk/identity/azure-identity/tests/recording_processors.py @@ -11,6 +11,7 @@ from azure_devtools.scenario_tests import RecordingProcessor import six +from helpers import FAKE_CLIENT_ID SECRET_FIELDS = frozenset( { @@ -79,10 +80,7 @@ def _get_fake_value(self, real_value): class IdTokenProcessor(RecordingProcessor): def process_response(self, response): - """Changes the "exp" claim of recorded id tokens to be in the future during playback - - This is necessary because msal always validates id tokens, raising an exception when they've expired. - """ + """Modifies an id token's claims to pass MSAL validation during playback""" try: # decode the recorded token body = json.loads(six.ensure_str(response["body"]["string"])) @@ -93,6 +91,9 @@ def process_response(self, response): payload = json.loads(six.ensure_str(decoded_payload)) payload["exp"] = int(time.time()) + 3600 + # set the audience to match the client ID used in test playback + payload["aud"] = FAKE_CLIENT_ID + # write the modified token to the response body new_payload = six.ensure_binary(json.dumps(payload)) body["id_token"] = ".".join((header, base64.b64encode(new_payload).decode("utf-8"), signed)) diff --git a/sdk/identity/azure-identity/tests/recordings/test_obo.test_obo.yaml b/sdk/identity/azure-identity/tests/recordings/test_obo.test_obo.yaml new file mode 100644 index 000000000000..773c2a35aa70 --- /dev/null +++ b/sdk/identity/azure-identity/tests/recordings/test_obo.test_obo.yaml @@ -0,0 +1,358 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-identity/1.7.0b3 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + method: GET + uri: https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration + response: + body: + string: '{"token_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + "token_endpoint_auth_methods_supported": ["client_secret_post", "private_key_jwt", + "client_secret_basic"], "jwks_uri": "https://login.microsoftonline.com/tenant/discovery/v2.0/keys", + "response_modes_supported": ["query", "fragment", "form_post"], "subject_types_supported": + ["pairwise"], "id_token_signing_alg_values_supported": ["RS256"], "response_types_supported": + ["code", "id_token", "code id_token", "id_token token"], "scopes_supported": + ["openid", "profile", "email", "offline_access"], "issuer": "https://login.microsoftonline.com/tenant/v2.0", + "request_uri_parameter_supported": false, "userinfo_endpoint": "https://graph.microsoft.com/oidc/userinfo", + "authorization_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", + "device_authorization_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/devicecode", + "http_logout_supported": true, "frontchannel_logout_supported": true, "end_session_endpoint": + "https://login.microsoftonline.com/tenant/oauth2/v2.0/logout", "claims_supported": + ["sub", "iss", "cloud_instance_name", "cloud_instance_host_name", "cloud_graph_host_name", + "msgraph_host", "aud", "exp", "iat", "auth_time", "acr", "nonce", "preferred_username", + "name", "tid", "ver", "at_hash", "c_hash", "email"], "kerberos_endpoint": + "https://login.microsoftonline.com/tenant/kerberos", "tenant_region_scope": + "NA", "cloud_instance_name": "microsoftonline.com", "cloud_graph_host_name": + "graph.windows.net", "msgraph_host": "graph.microsoft.com", "rbac_url": "https://pas.windows.net"}' + headers: + access-control-allow-methods: + - GET, OPTIONS + access-control-allow-origin: + - '*' + cache-control: + - max-age=86400, private + content-length: + - '1753' + content-type: + - application/json; charset=utf-8 + date: + - Thu, 29 Jul 2021 20:37:31 GMT + p3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + set-cookie: + - fpc=AirMv4PHgBVBo-o6QOflKag; expires=Sat, 28-Aug-2021 20:37:32 GMT; path=/; + secure; HttpOnly; SameSite=None + - esctx=AQABAAAAAAD--DLA3VO7QrddgJg7WevrtQlEc_N-SuCmbdpHdwJasCjkr87Cdqydgq-yIwincsxAaRfQ-YS-Wb35C1WqAkgQIcX5Dv3KzIOYWt0K16YPNDJjbsnoyypsv8RoaiOJAhTekIeCSli3avgYDybMxyYTe9VgWF4oTuFdbU4p3gf9wriM-T4_X9sybZMRbvauELEgAA; + domain=.login.microsoftonline.com; path=/; secure; HttpOnly; SameSite=None + - x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly + - stsservicecookie=estsfd; path=/; secure; samesite=none; httponly + strict-transport-security: + - max-age=31536000; includeSubDomains + x-content-type-options: + - nosniff + x-ms-ests-server: + - 2.1.11898.12 - NCUS ProdSlices + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Cookie: + - esctx=AQABAAAAAAD--DLA3VO7QrddgJg7WevrtQlEc_N-SuCmbdpHdwJasCjkr87Cdqydgq-yIwincsxAaRfQ-YS-Wb35C1WqAkgQIcX5Dv3KzIOYWt0K16YPNDJjbsnoyypsv8RoaiOJAhTekIeCSli3avgYDybMxyYTe9VgWF4oTuFdbU4p3gf9wriM-T4_X9sybZMRbvauELEgAA; + fpc=AirMv4PHgBVBo-o6QOflKag; stsservicecookie=estsfd; x-ms-gateway-slice=estsfd + User-Agent: + - azsdk-python-identity/1.7.0b3 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + method: GET + uri: https://login.microsoftonline.com/common/userrealm/username?api-version=1.0 + response: + body: + string: '{"ver": "1.0", "account_type": "Managed", "domain_name": "chlowehotmail.onmicrosoft.com", + "cloud_instance_name": "microsoftonline.com", "cloud_audience_urn": "urn:federation:MicrosoftOnline"}' + headers: + cache-control: + - no-store, no-cache + content-disposition: + - inline; filename=userrealm.json + content-length: + - '182' + content-type: + - application/json; charset=utf-8 + date: + - Thu, 29 Jul 2021 20:37:31 GMT + expires: + - '-1' + p3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + pragma: + - no-cache + set-cookie: + - fpc=AirMv4PHgBVBo-o6QOflKag; expires=Sat, 28-Aug-2021 20:37:32 GMT; path=/; + secure; HttpOnly; SameSite=None + - x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly + - stsservicecookie=estsfd; path=/; secure; samesite=none; httponly + strict-transport-security: + - max-age=31536000; includeSubDomains + x-content-type-options: + - nosniff + x-ms-ests-server: + - 2.1.11898.12 - SCUS ProdSlices + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '304' + Content-Type: + - application/x-www-form-urlencoded + Cookie: + - esctx=AQABAAAAAAD--DLA3VO7QrddgJg7WevrtQlEc_N-SuCmbdpHdwJasCjkr87Cdqydgq-yIwincsxAaRfQ-YS-Wb35C1WqAkgQIcX5Dv3KzIOYWt0K16YPNDJjbsnoyypsv8RoaiOJAhTekIeCSli3avgYDybMxyYTe9VgWF4oTuFdbU4p3gf9wriM-T4_X9sybZMRbvauELEgAA; + fpc=AirMv4PHgBVBo-o6QOflKag; stsservicecookie=estsfd; x-ms-gateway-slice=estsfd + User-Agent: + - azsdk-python-identity/1.7.0b3 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + x-client-cpu: + - x64 + x-client-current-telemetry: + - 4|301,0| + x-client-last-telemetry: + - 4|0||| + x-client-os: + - linux + x-client-sku: + - MSAL.Python + x-client-ver: + - 1.12.0 + method: POST + uri: https://login.microsoftonline.com/tenant/oauth2/v2.0/token + response: + body: + string: '{"token_type": "Bearer", "scope": "api://foo/Foo", "expires_in": 3599, + "ext_expires_in": 3599, "access_token": "redacted", "refresh_token": "redacted", + "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Im5PbzNaRHJPRFhFSzFqS1doWHNsSFJfS1hFZyJ9.eyJhdWQiOiJkNzBmYzVhZS01MmU0LTQ4OTUtYmYxYi04OTQxMDNkN2VmMTQiLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vM2M2MzFiYjctYTlmNy00MzQzLWE1YmEtYTYxNTkxMzVmMWZjL3YyLjAiLCJpYXQiOjE2Mjc1OTA3NTIsIm5iZiI6MTYyNzU5MDc1MiwiZXhwIjoxNjI3NTk0NjUyLCJuYW1lIjoidGVzdGVyIGEiLCJvaWQiOiJjZGQ2ZWUyNi1lNGJhLTQyNGUtODJlNS1lN2ZhNzc4M2I1ODAiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ0ZXN0ZXJhQGNobG93ZWhvdG1haWwub25taWNyb3NvZnQuY29tIiwicmgiOiIwLkFSY0F0eHRqUFBlcFEwT2x1cVlWa1RYeF9LN0ZEOWZrVXBWSXZ4dUpRUVBYN3hRWEFQcy4iLCJzdWIiOiJERWVYNWpEZVU5dDU0WkJpVnVKdktncFRRN0ctYWs2LWpidGZuaHp1RFlVIiwidGlkIjoiM2M2MzFiYjctYTlmNy00MzQzLWE1YmEtYTYxNTkxMzVmMWZjIiwidXRpIjoiSVBuVDJGVDM5RUMtWVRiU25PMWpBQSIsInZlciI6IjIuMCJ9.OHoO8nx0JYTlmJTspjBSMWUjcu5waN_n5rth-z17WLY9SYV8LVgaJT-Bv8bvcJXFkRmhHhKf_49wOyFsnG4lSKToC1fWmJw2b0K_AcTzntuZI1g6nvsJTVhstuauTQr78HgYuWkxleCWsj7Z4UIQ2RA1Qj9n-F4i4_D4pdFO2BpPg2NRwFWVjCJtXR3z3UfClqIxCjYewd5KSnmjTC45zMLxe6ASU0jSyd4kgx65Behlk1vmcB_YNEKjkS5RpOgrd6OOLsUQ4XQnqkmA3Tl4Wt_gq7dW3ovnVayf8sGyCqGoXhDiYItr__CruuD2xV0SYQQWujde-FrekrhZCSlXdQ", + "client_info": "eyJ1aWQiOiJjZGQ2ZWUyNi1lNGJhLTQyNGUtODJlNS1lN2ZhNzc4M2I1ODAiLCJ1dGlkIjoiM2M2MzFiYjctYTlmNy00MzQzLWE1YmEtYTYxNTkxMzVmMWZjIn0"}' + headers: + cache-control: + - no-store, no-cache + content-length: + - '3768' + content-type: + - application/json; charset=utf-8 + date: + - Thu, 29 Jul 2021 20:37:32 GMT + expires: + - '-1' + p3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + pragma: + - no-cache + set-cookie: + - fpc=AirMv4PHgBVBo-o6QOflKajArTo4AQAAAIsIldgOAAAA; expires=Sat, 28-Aug-2021 + 20:37:32 GMT; path=/; secure; HttpOnly; SameSite=None + - x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly + - stsservicecookie=estsfd; path=/; secure; samesite=none; httponly + strict-transport-security: + - max-age=31536000; includeSubDomains + x-content-type-options: + - nosniff + x-ms-clitelem: + - 1,0,0,, + x-ms-ests-server: + - 2.1.11898.12 - NCUS ProdSlices + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-identity/1.7.0b3 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + method: GET + uri: https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration + response: + body: + string: '{"token_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + "token_endpoint_auth_methods_supported": ["client_secret_post", "private_key_jwt", + "client_secret_basic"], "jwks_uri": "https://login.microsoftonline.com/tenant/discovery/v2.0/keys", + "response_modes_supported": ["query", "fragment", "form_post"], "subject_types_supported": + ["pairwise"], "id_token_signing_alg_values_supported": ["RS256"], "response_types_supported": + ["code", "id_token", "code id_token", "id_token token"], "scopes_supported": + ["openid", "profile", "email", "offline_access"], "issuer": "https://login.microsoftonline.com/tenant/v2.0", + "request_uri_parameter_supported": false, "userinfo_endpoint": "https://graph.microsoft.com/oidc/userinfo", + "authorization_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", + "device_authorization_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/devicecode", + "http_logout_supported": true, "frontchannel_logout_supported": true, "end_session_endpoint": + "https://login.microsoftonline.com/tenant/oauth2/v2.0/logout", "claims_supported": + ["sub", "iss", "cloud_instance_name", "cloud_instance_host_name", "cloud_graph_host_name", + "msgraph_host", "aud", "exp", "iat", "auth_time", "acr", "nonce", "preferred_username", + "name", "tid", "ver", "at_hash", "c_hash", "email"], "kerberos_endpoint": + "https://login.microsoftonline.com/tenant/kerberos", "tenant_region_scope": + "NA", "cloud_instance_name": "microsoftonline.com", "cloud_graph_host_name": + "graph.windows.net", "msgraph_host": "graph.microsoft.com", "rbac_url": "https://pas.windows.net"}' + headers: + access-control-allow-methods: + - GET, OPTIONS + access-control-allow-origin: + - '*' + cache-control: + - max-age=86400, private + content-length: + - '1753' + content-type: + - application/json; charset=utf-8 + date: + - Thu, 29 Jul 2021 20:37:32 GMT + p3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + set-cookie: + - fpc=Ai789is3BctBiohHVUk7ivc; expires=Sat, 28-Aug-2021 20:37:32 GMT; path=/; + secure; HttpOnly; SameSite=None + - esctx=AQABAAAAAAD--DLA3VO7QrddgJg7WevrCfLlyQoXMal6UyrcFqEyuxdFsiypUZpCamjZIwSECrhlUaIfyFHLu6iVFxYFnMw-X_uNPRTo-oubW_HYf-x5-cSzl65-7pWdrG-CkPewZv6Xc-783aNW9jiKn4N18K48OvR2p_sHkeHQNPHnpyih-yTg8M6JAGD7GB7_HWwFuXsgAA; + domain=.login.microsoftonline.com; path=/; secure; HttpOnly; SameSite=None + - x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly + - stsservicecookie=estsfd; path=/; secure; samesite=none; httponly + strict-transport-security: + - max-age=31536000; includeSubDomains + x-content-type-options: + - nosniff + x-ms-ests-server: + - 2.1.11898.12 - NCUS ProdSlices + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '1821' + Content-Type: + - application/x-www-form-urlencoded + Cookie: + - esctx=AQABAAAAAAD--DLA3VO7QrddgJg7WevrCfLlyQoXMal6UyrcFqEyuxdFsiypUZpCamjZIwSECrhlUaIfyFHLu6iVFxYFnMw-X_uNPRTo-oubW_HYf-x5-cSzl65-7pWdrG-CkPewZv6Xc-783aNW9jiKn4N18K48OvR2p_sHkeHQNPHnpyih-yTg8M6JAGD7GB7_HWwFuXsgAA; + fpc=Ai789is3BctBiohHVUk7ivc; stsservicecookie=estsfd; x-ms-gateway-slice=estsfd + User-Agent: + - azsdk-python-identity/1.7.0b3 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + x-client-cpu: + - x64 + x-client-current-telemetry: + - 4|523,0| + x-client-last-telemetry: + - 4|0||| + x-client-os: + - linux + x-client-sku: + - MSAL.Python + x-client-ver: + - 1.12.0 + method: POST + uri: https://login.microsoftonline.com/tenant/oauth2/v2.0/token + response: + body: + string: '{"token_type": "Bearer", "scope": "https://management.azure.com/user_impersonation + https://management.azure.com/.default", "expires_in": 3597, "ext_expires_in": + 3597, "access_token": "redacted", "refresh_token": "redacted", "id_token": + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Im5PbzNaRHJPRFhFSzFqS1doWHNsSFJfS1hFZyJ9.eyJhdWQiOiJkNzBmYzVhZS01MmU0LTQ4OTUtYmYxYi04OTQxMDNkN2VmMTQiLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vM2M2MzFiYjctYTlmNy00MzQzLWE1YmEtYTYxNTkxMzVmMWZjL3YyLjAiLCJpYXQiOjE2Mjc1OTA3NTIsIm5iZiI6MTYyNzU5MDc1MiwiZXhwIjoxNjI3NTk0NjUwLCJuYW1lIjoidGVzdGVyIGEiLCJvaWQiOiJjZGQ2ZWUyNi1lNGJhLTQyNGUtODJlNS1lN2ZhNzc4M2I1ODAiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ0ZXN0ZXJhQGNobG93ZWhvdG1haWwub25taWNyb3NvZnQuY29tIiwicmgiOiIwLkFSY0F0eHRqUFBlcFEwT2x1cVlWa1RYeF9LN0ZEOWZrVXBWSXZ4dUpRUVBYN3hRWEFQcy4iLCJzdWIiOiJERWVYNWpEZVU5dDU0WkJpVnVKdktncFRRN0ctYWs2LWpidGZuaHp1RFlVIiwidGlkIjoiM2M2MzFiYjctYTlmNy00MzQzLWE1YmEtYTYxNTkxMzVmMWZjIiwidXRpIjoicy1kdDVQREVvMFdvQ3lmU3RPUnNBQSIsInZlciI6IjIuMCJ9.TS-ssfyD2TJH_DpL9srLLtlvla5jRuaQDpN8StJcRCbgTSOTdWIJQnJtIX-kN2n4_-XVPAU58m-CnrB7s1SWEOiysumV5iykSSYcgWcbpjCtVM7MzmSNfbDw8NdQ0Quqlo2mEqin8rKGR1QyaFj-N1GSM76kHpeX_iU4W5_ouD_H1jpuT_FLj6roC5a0t09GQwKQnxIrl5cNmv2nzylO1Dqkyzw2lNmDdldF4pR2XMr_jnLGxlpZtO07VXQiQMHCMffhYo5RMcjkqU5JTtJIOpaAul2q8ZDJSNUCGeMC_S3ZFdN_Fn31HRXNZ2_tQCyykoHilPjO2MKdp5qMzcyJ2g", + "client_info": "eyJ1aWQiOiJjZGQ2ZWUyNi1lNGJhLTQyNGUtODJlNS1lN2ZhNzc4M2I1ODAiLCJ1dGlkIjoiM2M2MzFiYjctYTlmNy00MzQzLWE1YmEtYTYxNTkxMzVmMWZjIn0"}' + headers: + cache-control: + - no-store, no-cache + content-length: + - '4199' + content-type: + - application/json; charset=utf-8 + date: + - Thu, 29 Jul 2021 20:37:32 GMT + expires: + - '-1' + p3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + pragma: + - no-cache + set-cookie: + - fpc=Ai789is3BctBiohHVUk7ivf0CaNgAQAAAIwIldgOAAAA; expires=Sat, 28-Aug-2021 + 20:37:33 GMT; path=/; secure; HttpOnly; SameSite=None + - x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly + - stsservicecookie=estsfd; path=/; secure; samesite=none; httponly + strict-transport-security: + - max-age=31536000; includeSubDomains + x-content-type-options: + - nosniff + x-ms-clitelem: + - 1,0,0,, + x-ms-ests-server: + - 2.1.11898.12 - WUS2 ProdSlices + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-azure-mgmt-resource/18.1.0 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + method: GET + uri: https://management.azure.com/subscriptions?api-version=2019-11-01 + response: + body: + string: '{"value":[]}' + headers: + cache-control: + - no-cache + content-length: + - '432' + content-type: + - application/json; charset=utf-8 + date: + - Thu, 29 Jul 2021 20:37:32 GMT + expires: + - '-1' + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + vary: + - Accept-Encoding + x-content-type-options: + - nosniff + status: + code: 200 + message: OK +version: 1 diff --git a/sdk/identity/azure-identity/tests/recordings/test_obo_async.test_obo.yaml b/sdk/identity/azure-identity/tests/recordings/test_obo_async.test_obo.yaml new file mode 100644 index 000000000000..9180cffcb3b4 --- /dev/null +++ b/sdk/identity/azure-identity/tests/recordings/test_obo_async.test_obo.yaml @@ -0,0 +1,242 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-identity/1.7.0b3 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + method: GET + uri: https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration + response: + body: + string: '{"token_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + "token_endpoint_auth_methods_supported": ["client_secret_post", "private_key_jwt", + "client_secret_basic"], "jwks_uri": "https://login.microsoftonline.com/tenant/discovery/v2.0/keys", + "response_modes_supported": ["query", "fragment", "form_post"], "subject_types_supported": + ["pairwise"], "id_token_signing_alg_values_supported": ["RS256"], "response_types_supported": + ["code", "id_token", "code id_token", "id_token token"], "scopes_supported": + ["openid", "profile", "email", "offline_access"], "issuer": "https://login.microsoftonline.com/tenant/v2.0", + "request_uri_parameter_supported": false, "userinfo_endpoint": "https://graph.microsoft.com/oidc/userinfo", + "authorization_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", + "device_authorization_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/devicecode", + "http_logout_supported": true, "frontchannel_logout_supported": true, "end_session_endpoint": + "https://login.microsoftonline.com/tenant/oauth2/v2.0/logout", "claims_supported": + ["sub", "iss", "cloud_instance_name", "cloud_instance_host_name", "cloud_graph_host_name", + "msgraph_host", "aud", "exp", "iat", "auth_time", "acr", "nonce", "preferred_username", + "name", "tid", "ver", "at_hash", "c_hash", "email"], "kerberos_endpoint": + "https://login.microsoftonline.com/tenant/kerberos", "tenant_region_scope": + "NA", "cloud_instance_name": "microsoftonline.com", "cloud_graph_host_name": + "graph.windows.net", "msgraph_host": "graph.microsoft.com", "rbac_url": "https://pas.windows.net"}' + headers: + access-control-allow-methods: + - GET, OPTIONS + access-control-allow-origin: + - '*' + cache-control: + - max-age=86400, private + content-length: + - '1753' + content-type: + - application/json; charset=utf-8 + date: + - Thu, 29 Jul 2021 23:50:35 GMT + p3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + set-cookie: + - fpc=AgM6PyUQrlRFtDVeyHRceRo; expires=Sat, 28-Aug-2021 23:50:35 GMT; path=/; + secure; HttpOnly; SameSite=None + - esctx=AQABAAAAAAD--DLA3VO7QrddgJg7WevrYVp7dmTcJk80HOvMZlreoTTU-jykp75TDEzUJYIEm2RV9vlHZaFiAU4PEeZylzGCD-d1GCKMux2i3xr0Flv0BXW000Rl-x7Qq3a1teLTX-mpH6Ej_vKLSjoH9I7ErJUwI3dAkMWWC7qn1tF_NZTrh-Vjv29gvPaVpZH6-qqt8E8gAA; + domain=.login.microsoftonline.com; path=/; secure; HttpOnly; SameSite=None + - x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly + - stsservicecookie=estsfd; path=/; secure; samesite=none; httponly + strict-transport-security: + - max-age=31536000; includeSubDomains + x-content-type-options: + - nosniff + x-ms-ests-server: + - 2.1.11898.12 - NCUS ProdSlices + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Cookie: + - esctx=AQABAAAAAAD--DLA3VO7QrddgJg7WevrYVp7dmTcJk80HOvMZlreoTTU-jykp75TDEzUJYIEm2RV9vlHZaFiAU4PEeZylzGCD-d1GCKMux2i3xr0Flv0BXW000Rl-x7Qq3a1teLTX-mpH6Ej_vKLSjoH9I7ErJUwI3dAkMWWC7qn1tF_NZTrh-Vjv29gvPaVpZH6-qqt8E8gAA; + fpc=AgM6PyUQrlRFtDVeyHRceRo; stsservicecookie=estsfd; x-ms-gateway-slice=estsfd + User-Agent: + - azsdk-python-identity/1.7.0b3 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + method: GET + uri: https://login.microsoftonline.com/common/userrealm/username?api-version=1.0 + response: + body: + string: '{"ver": "1.0", "account_type": "Managed", "domain_name": "chlowehotmail.onmicrosoft.com", + "cloud_instance_name": "microsoftonline.com", "cloud_audience_urn": "urn:federation:MicrosoftOnline"}' + headers: + cache-control: + - no-store, no-cache + content-disposition: + - inline; filename=userrealm.json + content-length: + - '182' + content-type: + - application/json; charset=utf-8 + date: + - Thu, 29 Jul 2021 23:50:35 GMT + expires: + - '-1' + p3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + pragma: + - no-cache + set-cookie: + - fpc=AgM6PyUQrlRFtDVeyHRceRo; expires=Sat, 28-Aug-2021 23:50:35 GMT; path=/; + secure; HttpOnly; SameSite=None + - x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly + - stsservicecookie=estsfd; path=/; secure; samesite=none; httponly + strict-transport-security: + - max-age=31536000; includeSubDomains + x-content-type-options: + - nosniff + x-ms-ests-server: + - 2.1.11898.12 - WUS2 ProdSlices + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '304' + Content-Type: + - application/x-www-form-urlencoded + Cookie: + - esctx=AQABAAAAAAD--DLA3VO7QrddgJg7WevrYVp7dmTcJk80HOvMZlreoTTU-jykp75TDEzUJYIEm2RV9vlHZaFiAU4PEeZylzGCD-d1GCKMux2i3xr0Flv0BXW000Rl-x7Qq3a1teLTX-mpH6Ej_vKLSjoH9I7ErJUwI3dAkMWWC7qn1tF_NZTrh-Vjv29gvPaVpZH6-qqt8E8gAA; + fpc=AgM6PyUQrlRFtDVeyHRceRo; stsservicecookie=estsfd; x-ms-gateway-slice=estsfd + User-Agent: + - azsdk-python-identity/1.7.0b3 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + x-client-cpu: + - x64 + x-client-current-telemetry: + - 4|301,0| + x-client-last-telemetry: + - 4|0||| + x-client-os: + - linux + x-client-sku: + - MSAL.Python + x-client-ver: + - 1.12.0 + method: POST + uri: https://login.microsoftonline.com/tenant/oauth2/v2.0/token + response: + body: + string: '{"token_type": "Bearer", "scope": "api://foo/Foo", "expires_in": 3599, + "ext_expires_in": 3599, "access_token": "redacted", "refresh_token": "redacted", + "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Im5PbzNaRHJPRFhFSzFqS1doWHNsSFJfS1hFZyJ9.eyJhdWQiOiJkNzBmYzVhZS01MmU0LTQ4OTUtYmYxYi04OTQxMDNkN2VmMTQiLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vM2M2MzFiYjctYTlmNy00MzQzLWE1YmEtYTYxNTkxMzVmMWZjL3YyLjAiLCJpYXQiOjE2Mjc2MDIzMzUsIm5iZiI6MTYyNzYwMjMzNSwiZXhwIjoxNjI3NjA2MjM1LCJuYW1lIjoidGVzdGVyIGEiLCJvaWQiOiJjZGQ2ZWUyNi1lNGJhLTQyNGUtODJlNS1lN2ZhNzc4M2I1ODAiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ0ZXN0ZXJhQGNobG93ZWhvdG1haWwub25taWNyb3NvZnQuY29tIiwicmgiOiIwLkFSY0F0eHRqUFBlcFEwT2x1cVlWa1RYeF9LN0ZEOWZrVXBWSXZ4dUpRUVBYN3hRWEFQcy4iLCJzdWIiOiJERWVYNWpEZVU5dDU0WkJpVnVKdktncFRRN0ctYWs2LWpidGZuaHp1RFlVIiwidGlkIjoiM2M2MzFiYjctYTlmNy00MzQzLWE1YmEtYTYxNTkxMzVmMWZjIiwidXRpIjoiaWVtR1NxakJSRUtZQmdRRlVnd0ZBQSIsInZlciI6IjIuMCJ9.EguIVB9KF0t-HbOJDoIIRGVEf2hDkB6rrgPxUErxb8pMZ9Ypg18UZFf0D8pWM1dsrKPmUI4dnOZjfO3GuAm5kV6Q8PFLhJ-U53vK9rBONrI3yBwt5gTZ5DObqfGoE7vTXshZauAxnNC4iKm4kRIDUAMYwOK82j4LTczxfwc9Q1q1c1l7DKmUcKMqooBV5zA0BPT1sVKp58rK8ssldh8KaUr96DNoCsjazgTjRokP7ahuIKfGWlaEimebOg6m2H1BPSPYOhhbzUlIIu3Y3p0cGra-uD305t39BZSbfdGIWW80MboIRpwQlGjo88Zknwq4ISAJSkFm38Kruf3i_KZWZA", + "client_info": "eyJ1aWQiOiJjZGQ2ZWUyNi1lNGJhLTQyNGUtODJlNS1lN2ZhNzc4M2I1ODAiLCJ1dGlkIjoiM2M2MzFiYjctYTlmNy00MzQzLWE1YmEtYTYxNTkxMzVmMWZjIn0"}' + headers: + cache-control: + - no-store, no-cache + content-length: + - '3768' + content-type: + - application/json; charset=utf-8 + date: + - Thu, 29 Jul 2021 23:50:35 GMT + expires: + - '-1' + p3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + pragma: + - no-cache + set-cookie: + - fpc=AgM6PyUQrlRFtDVeyHRceRrArTo4AQAAAMo1ldgOAAAA; expires=Sat, 28-Aug-2021 + 23:50:35 GMT; path=/; secure; HttpOnly; SameSite=None + - x-ms-gateway-slice=estsfd; path=/; secure; samesite=none; httponly + - stsservicecookie=estsfd; path=/; secure; samesite=none; httponly + strict-transport-security: + - max-age=31536000; includeSubDomains + x-content-type-options: + - nosniff + x-ms-clitelem: + - 1,0,0,, + x-ms-ests-server: + - 2.1.11898.12 - SCUS ProdSlices + status: + code: 200 + message: OK +- request: + body: null + headers: + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - azsdk-python-identity/1.7.0b3 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + method: POST + uri: https://login.microsoftonline.com/tenant/oauth2/v2.0/token + response: + body: + string: '{"token_type": "Bearer", "scope": "https://management.azure.com/user_impersonation + https://management.azure.com/.default", "expires_in": 3598, "ext_expires_in": + 3598, "access_token": "redacted", "refresh_token": "redacted"}' + headers: + cache-control: no-store, no-cache + content-length: '2908' + content-type: application/json; charset=utf-8 + date: Thu, 29 Jul 2021 23:50:35 GMT + expires: '-1' + p3p: CP="DSP CUR OTPi IND OTRi ONL FIN" + pragma: no-cache + set-cookie: stsservicecookie=estsfd; path=/; secure; samesite=none; httponly + strict-transport-security: max-age=31536000; includeSubDomains + x-content-type-options: nosniff + x-ms-ests-server: 2.1.11898.12 - WUS2 ProdSlices + status: + code: 200 + message: OK + url: https://login.microsoftonline.com/3c631bb7-a9f7-4343-a5ba-a6159135f1fc/oauth2/v2.0/token +- request: + body: null + headers: + Accept: + - application/json + User-Agent: + - azsdk-python-azure-mgmt-resource/18.1.0 Python/3.8.10 (Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-glibc2.29) + method: GET + uri: https://management.azure.com/subscriptions?api-version=2019-11-01 + response: + body: + string: '{"value":[]}' + headers: + cache-control: no-cache + content-length: '416' + content-type: application/json; charset=utf-8 + date: Thu, 29 Jul 2021 23:50:35 GMT + expires: '-1' + pragma: no-cache + strict-transport-security: max-age=31536000; includeSubDomains + vary: Accept-Encoding + x-content-type-options: nosniff + status: + code: 200 + message: OK + url: https://management.azure.com/subscriptions?api-version=2019-11-01 +version: 1 diff --git a/sdk/identity/azure-identity/tests/test_obo.py b/sdk/identity/azure-identity/tests/test_obo.py index be5d7a1d9e55..a82c1d0b4010 100644 --- a/sdk/identity/azure-identity/tests/test_obo.py +++ b/sdk/identity/azure-identity/tests/test_obo.py @@ -2,62 +2,151 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -from azure.core.pipeline.policies import ContentDecodePolicy, SansIOHTTPPolicy -from azure.identity import OnBehalfOfCredential, UserAssertion, UsernamePasswordCredential -from azure.identity._internal.user_agent import USER_AGENT -from six.moves.urllib_parse import urlparse - import os try: - from unittest.mock import MagicMock, Mock, patch + from unittest.mock import Mock except ImportError: - from mock import MagicMock, Mock, patch # type: ignore - + from mock import Mock # type: ignore +from azure_devtools.scenario_tests import RecordingProcessor +from azure.core.pipeline.policies import ContentDecodePolicy, SansIOHTTPPolicy +from azure.identity import CredentialUnavailableError, OnBehalfOfCredential, UserAssertion, UsernamePasswordCredential +from azure.identity._internal.user_agent import USER_AGENT +from azure.mgmt.resource import SubscriptionClient import pytest +from six.moves.urllib_parse import urlparse -from helpers import build_aad_response, get_discovery_response, mock_response +from helpers import build_aad_response, build_id_token, FAKE_CLIENT_ID, get_discovery_response, mock_response from recorded_test_case import RecordedTestCase -class RecordedTests(RecordedTestCase): - def __init__(self, *args, **kwargs): +class SubscriptionListRemover(RecordingProcessor): + def process_response(self, response): + if "/subscriptions/" in response["body"]["string"]: + response["body"]["string"] = '{"value":[]}' + return response - from azure_devtools.scenario_tests import GeneralNameReplacer, RequestUrlNormalizer - from recording_processors import IdTokenProcessor, RecordingRedactor - scrubber = GeneralNameReplacer() - super(RecordedTestCase, self).__init__( - *args, - recording_processors=[RecordingRedactor(record_unique_values=True), scrubber], - replay_processors=[RequestUrlNormalizer(), IdTokenProcessor()], - **kwargs - ) +class RecordedTests(RecordedTestCase): + def __init__(self, *args, **kwargs): + super(RecordedTests, self).__init__(*args, **kwargs) + + if self.is_live: + missing_variables = [ + var + for var in ( + "OBO_CLIENT_ID", + "OBO_CLIENT_SECRET", + "OBO_PASSWORD", + "OBO_SCOPE", + "OBO_TENANT_ID", + "OBO_USERNAME", + ) + if var not in os.environ + ] + if any(missing_variables): + pytest.skip("No value for environment variables: " + ", ".join(missing_variables)) + + self.recording_processors.append(SubscriptionListRemover()) + self.obo_settings = { + "client_id": os.environ["OBO_CLIENT_ID"], + "client_secret": os.environ["OBO_CLIENT_SECRET"], + "password": os.environ["OBO_PASSWORD"], + "scope": os.environ["OBO_SCOPE"], + "tenant_id": os.environ["OBO_TENANT_ID"], + "username": os.environ["OBO_USERNAME"], + } + self.scrubber.register_name_pair(self.obo_settings["tenant_id"], "tenant") + self.scrubber.register_name_pair(self.obo_settings["username"], "username") + + else: + self.obo_settings = { + "client_id": FAKE_CLIENT_ID, + "client_secret": "secret", + "password": "fake-password", + "scope": "api://scope", + "tenant_id": "tenant", + "username": "username", + } def test_obo(self): - TENANT_ID = os.environ["OBO_TENANT_ID"] - CLIENT_ID = os.environ["OBO_CLIENT_ID"] - CLIENT_SECRET = os.environ["OBO_CLIENT_SECRET"] + client_id = self.obo_settings["client_id"] + client_secret = self.obo_settings["client_secret"] + tenant_id = self.obo_settings["tenant_id"] - user_credential = UsernamePasswordCredential(CLIENT_ID, os.environ["OBO_USERNAME"], os.environ["OBO_PASSWORD"]) - user_token = user_credential.get_token(os.environ["OBO_SCOPE"]) + user_credential = UsernamePasswordCredential( + client_id, self.obo_settings["username"], self.obo_settings["password"], tenant_id=tenant_id + ) + user_token = user_credential.get_token(self.obo_settings["scope"]).token - # avoid showing a specific client in the snippet + # wrap a real client to avoid showing a specific one in the snippet and thus implying the client type matters class AzureClient(SubscriptionClient): def get_resource(self): - return list(self.subscriptions.list()) + list(self.subscriptions.list()) - # [START obo] - credential = OnBehalfOfCredential(TENANT_ID, CLIENT_ID, CLIENT_SECRET) + # [START snippet] + credential = OnBehalfOfCredential(tenant_id, client_id, client_secret) client = AzureClient(credential) - # typically the user token comes from an incoming HTTP request from the user - with UserAssertion(user_token.token): - # all token requests in this block will use the same assertion + # typically the assertion is an access token from an incoming HTTP request from the user + with UserAssertion(user_token): + # all client calls in this block are authenticated on behalf of the same user client.get_resource() - # [END obo] + # [END snippet] + + +def test_caching(): + client_id = "client-id" + scope = "scope" + tenant = "tenant-id" + + def send(request, **_): + assert request.headers["User-Agent"] == USER_AGENT + parsed = urlparse(request.url) + authority = "https://{}/{}".format(parsed.netloc, tenant) + if "/oauth2/v2.0/token" not in parsed.path: + return get_discovery_response(authority) + return mock_response( + json_payload=build_aad_response( + access_token=request.body["assertion"], + refresh_token="***", + id_token=build_id_token(aud=client_id, iss=authority), + ) + ) + + transport = Mock(send=Mock(wraps=send)) + credential = OnBehalfOfCredential(tenant, client_id, "secret", transport=transport) + + with UserAssertion("A"): + token = credential.get_token(scope) + assert token.token == "A" + requests_sent = transport.send.call_count # exact request count is up to msal + assert requests_sent > 0 + # credential should return a cached token + token = credential.get_token(scope) + assert token.token == "A" + assert transport.send.call_count == requests_sent + + with UserAssertion("B"): + token = credential.get_token(scope) + assert token.token == "B" + assert transport.send.call_count > requests_sent + + +def test_nested_assertion(): + with UserAssertion("A"): + for assertion in ("A", "B"): + with pytest.raises(ValueError): + with UserAssertion(assertion): + pass + + +def test_requires_assertion(): + """The credential should raise CredentialUnavailableError when no user assertion is set""" + with pytest.raises(CredentialUnavailableError): + OnBehalfOfCredential("tenant-id", "client-id", "secret").get_token("scope") def test_tenant_id_validation(): @@ -79,28 +168,6 @@ def test_no_scopes(): credential.get_token() -@pytest.mark.skip("depends on outstanding PR") -def test_close(): - transport = MagicMock() - credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret", transport=transport) - assert transport.__exit__.call_count == 0 - - credential.close() - assert transport.__exit__.call_count == 1 - - -@pytest.mark.skip("depends on outstanding PR") -def test_context_manager(): - transport = MagicMock() - credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret", transport=transport) - - with credential: - assert transport.__enter__.call_count == 1 - - assert transport.__enter__.call_count == 1 - assert transport.__exit__.call_count == 1 - - def test_policies_configurable(): policy = Mock(spec_set=SansIOHTTPPolicy, on_request=Mock(), on_exception=lambda _: False) @@ -117,17 +184,3 @@ def send(request, **_): with UserAssertion("..."): credential.get_token("scope") assert policy.on_request.called - - -def test_user_agent(): - def send(request, **_): - assert request.headers["User-Agent"] == USER_AGENT - parsed = urlparse(request.url) - tenant = parsed.path.split("/")[1] - if "/oauth2/v2.0/token" not in parsed.path: - return get_discovery_response("https://{}/{}".format(parsed.netloc, tenant)) - return mock_response(json_payload=build_aad_response(access_token="***")) - - credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret", transport=Mock(send=send)) - with UserAssertion("..."): - credential.get_token("scope") diff --git a/sdk/identity/azure-identity/tests/test_obo_async.py b/sdk/identity/azure-identity/tests/test_obo_async.py new file mode 100644 index 000000000000..7ca3915429af --- /dev/null +++ b/sdk/identity/azure-identity/tests/test_obo_async.py @@ -0,0 +1,186 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import os +from urllib.parse import urlparse +from unittest.mock import Mock + +from azure.core.pipeline.policies import ContentDecodePolicy, SansIOHTTPPolicy +from azure.identity import CredentialUnavailableError, UserAssertion, UsernamePasswordCredential +from azure.identity.aio import OnBehalfOfCredential +from azure.identity._internal.user_agent import USER_AGENT +from azure.mgmt.resource.subscriptions.aio import SubscriptionClient +import pytest + +from helpers import build_aad_response, build_id_token, FAKE_CLIENT_ID, get_discovery_response, mock_response +from recorded_test_case import RecordedTestCase +from test_obo import SubscriptionListRemover + + +class RecordedTests(RecordedTestCase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.is_live: + missing_variables = [ + var + for var in ( + "OBO_CLIENT_ID", + "OBO_CLIENT_SECRET", + "OBO_PASSWORD", + "OBO_SCOPE", + "OBO_TENANT_ID", + "OBO_USERNAME", + ) + if var not in os.environ + ] + if any(missing_variables): + pytest.skip("No value for environment variables: " + ", ".join(missing_variables)) + + self.recording_processors.append(SubscriptionListRemover()) + self.obo_settings = { + "client_id": os.environ["OBO_CLIENT_ID"], + "client_secret": os.environ["OBO_CLIENT_SECRET"], + "password": os.environ["OBO_PASSWORD"], + "scope": os.environ["OBO_SCOPE"], + "tenant_id": os.environ["OBO_TENANT_ID"], + "username": os.environ["OBO_USERNAME"], + } + self.scrubber.register_name_pair(self.obo_settings["tenant_id"], "tenant") + self.scrubber.register_name_pair(self.obo_settings["username"], "username") + + else: + self.obo_settings = { + "client_id": FAKE_CLIENT_ID, + "client_secret": "secret", + "password": "fake-password", + "scope": "api://scope", + "tenant_id": "tenant", + "username": "username", + } + + @RecordedTestCase.await_prepared_test + async def test_obo(self): + client_id = self.obo_settings["client_id"] + client_secret = self.obo_settings["client_secret"] + tenant_id = self.obo_settings["tenant_id"] + + user_credential = UsernamePasswordCredential( + client_id, self.obo_settings["username"], self.obo_settings["password"], tenant_id=tenant_id + ) + user_token = user_credential.get_token(self.obo_settings["scope"]).token + + # wrap a real client to avoid showing a specific one in the snippet and thus implying the client type matters + class AzureClient(SubscriptionClient): + async def get_resource(self): + async for _ in self.subscriptions.list(): + pass + + # [START snippet] + credential = OnBehalfOfCredential(tenant_id, client_id, client_secret) + client = AzureClient(credential) + + # typically the assertion is an access token from an incoming HTTP request from the user + with UserAssertion(user_token): + # all client calls in this block are authenticated on behalf of the same user + await client.get_resource() + # [END snippet] + + +@pytest.mark.asyncio +async def test_caching(): + client_id = "client-id" + scope = "scope" + tenant = "tenant-id" + + async def send(request, **_): + parsed = urlparse(request.url) + authority = "https://{}/{}".format(parsed.netloc, tenant) + return mock_response( + json_payload=build_aad_response( + access_token=request.body["assertion"], + refresh_token=request.body["assertion"], + id_token=build_id_token(aud=client_id, iss=authority), + ) + ) + + transport = Mock(send=Mock(wraps=send)) + credential = OnBehalfOfCredential(tenant, client_id, "secret", transport=transport) + + with UserAssertion("A"): + token = await credential.get_token(scope) + assert token.token == "A" + requests_sent = transport.send.call_count # exact request count is up to msal + assert requests_sent > 0 + + # credential should return a cached token + token = await credential.get_token(scope) + assert token.token == "A" + assert transport.send.call_count == requests_sent + + with UserAssertion("B"): + token = await credential.get_token(scope) + assert token.token == "B" + assert transport.send.call_count > requests_sent + + +@pytest.mark.asyncio +async def test_requires_assertion(): + """The credential should raise CredentialUnavailableError when no user assertion is set""" + with pytest.raises(CredentialUnavailableError): + await OnBehalfOfCredential("tenant-id", "client-id", "secret").get_token("scope") + + +def test_tenant_id_validation(): + """The credential should raise ValueError when given an invalid tenant_id""" + valid_ids = {"c878a2ab-8ef4-413b-83a0-199afb84d7fb", "contoso.onmicrosoft.com", "organizations", "common"} + for tenant in valid_ids: + OnBehalfOfCredential(tenant, "client-id", "secret") + invalid_ids = {"", "my tenant", "my_tenant", "/", "\\", '"my-tenant"', "'my-tenant'"} + for tenant in invalid_ids: + with pytest.raises(ValueError): + OnBehalfOfCredential(tenant, "client-id", "secret") + + +@pytest.mark.asyncio +async def test_no_scopes(): + """The credential should raise ValueError when get_token is called with no scopes""" + credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret") + with pytest.raises(ValueError): + with UserAssertion("..."): + await credential.get_token() + + +@pytest.mark.asyncio +async def test_policies_configurable(): + policy = Mock(spec_set=SansIOHTTPPolicy, on_request=Mock(), on_exception=lambda _: False) + + async def send(request, **_): + parsed = urlparse(request.url) + tenant = parsed.path.split("/")[1] + if "/oauth2/v2.0/token" not in parsed.path: + return get_discovery_response("https://{}/{}".format(parsed.netloc, tenant)) + return mock_response(json_payload=build_aad_response(access_token="***")) + + credential = OnBehalfOfCredential( + "tenant-id", "client-id", "client-secret", policies=[ContentDecodePolicy(), policy], transport=Mock(send=send) + ) + with UserAssertion("..."): + await credential.get_token("scope") + assert policy.on_request.called + + +@pytest.mark.asyncio +async def test_user_agent(): + async def send(request, **_): + assert request.headers["User-Agent"] == USER_AGENT + parsed = urlparse(request.url) + tenant = parsed.path.split("/")[1] + if "/oauth2/v2.0/token" not in parsed.path: + return get_discovery_response("https://{}/{}".format(parsed.netloc, tenant)) + return mock_response(json_payload=build_aad_response(access_token="***")) + + credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret", transport=Mock(send=send)) + with UserAssertion("..."): + await credential.get_token("scope") From e5ec3de17c84697e25ed2484011b1c5541e094a6 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 29 Jul 2021 17:12:06 -0700 Subject: [PATCH 03/12] add OBO methods for async AadClient --- .../azure/identity/_internal/aad_client.py | 5 +++++ .../azure/identity/_internal/aad_client_base.py | 17 +++++++++++++++++ .../azure/identity/aio/_internal/aad_client.py | 14 ++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/sdk/identity/azure-identity/azure/identity/_internal/aad_client.py b/sdk/identity/azure-identity/azure/identity/_internal/aad_client.py index 93b877afd667..c530a473b35d 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/aad_client.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/aad_client.py @@ -61,6 +61,11 @@ def obtain_token_by_refresh_token(self, scopes, refresh_token, **kwargs): response = self._pipeline.run(request, stream=False, retry_on_methods=self._POST, **kwargs) return self._process_response(response, now) + def obtain_token_on_behalf_of(self, scopes, secret, user_assertion, **kwargs): + # type: (Iterable[str], str, str, **Any) -> AccessToken + # no need for an implementation, non-async OnBehalfOfCredential acquires tokens through MSAL + raise NotImplementedError() + # pylint:disable=no-self-use def _build_pipeline(self, config=None, policies=None, transport=None, **kwargs): # type: (Optional[Configuration], Optional[List[Policy]], Optional[HttpTransport], **Any) -> Pipeline diff --git a/sdk/identity/azure-identity/azure/identity/_internal/aad_client_base.py b/sdk/identity/azure-identity/azure/identity/_internal/aad_client_base.py index ed2d0161e443..0533fed357e6 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/aad_client_base.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/aad_client_base.py @@ -92,6 +92,10 @@ def obtain_token_by_client_secret(self, scopes, secret, **kwargs): def obtain_token_by_refresh_token(self, scopes, refresh_token, **kwargs): pass + @abc.abstractmethod + def obtain_token_on_behalf_of(self, scopes, secret, user_assertion, **kwargs): + pass + @abc.abstractmethod def _build_pipeline(self, config=None, policies=None, transport=None, **kwargs): pass @@ -214,6 +218,19 @@ def _get_jwt_assertion(self, certificate, audience): return jwt_bytes.decode("utf-8") + def _get_on_behalf_of_request(self, scopes, secret, user_assertion, **kwargs): + # type: (Iterable[str], str, str, **Any) -> HttpRequest + data = { + "assertion": user_assertion, + "client_id": self._client_id, + "client_secret": secret, + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "requested_token_use": "on_behalf_of", + "scope": " ".join(scopes), + } + request = self._post(data, **kwargs) + return request + def _get_refresh_token_request(self, scopes, refresh_token, **kwargs): # type: (Iterable[str], str, **Any) -> HttpRequest data = { diff --git a/sdk/identity/azure-identity/azure/identity/aio/_internal/aad_client.py b/sdk/identity/azure-identity/azure/identity/aio/_internal/aad_client.py index 76c8c8af5bab..c900f0295fa7 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_internal/aad_client.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_internal/aad_client.py @@ -15,6 +15,7 @@ DistributedTracingPolicy, HttpLoggingPolicy, ) +from azure.core.pipeline.transport import HttpRequest from ..._internal import AadClientBase from ..._internal.user_agent import USER_AGENT @@ -58,8 +59,9 @@ async def obtain_token_by_authorization_code( response = await self._pipeline.run(request, retry_on_methods=self._POST, **kwargs) return self._process_response(response, now) - async def obtain_token_by_client_certificate(self, scopes, certificate, **kwargs): - # type: (Iterable[str], AadClientCertificate, **Any) -> AccessToken + async def obtain_token_by_client_certificate( + self, scopes: "Iterable[str]", certificate: "AadClientCertificate", **kwargs: "Any" + ) -> "AccessToken": request = self._get_client_certificate_request(scopes, certificate, **kwargs) now = int(time.time()) response = await self._pipeline.run(request, stream=False, retry_on_methods=self._POST, **kwargs) @@ -81,6 +83,14 @@ async def obtain_token_by_refresh_token( response = await self._pipeline.run(request, retry_on_methods=self._POST, **kwargs) return self._process_response(response, now) + async def obtain_token_on_behalf_of( + self, scopes: "Iterable[str]", secret: str, user_assertion: str, **kwargs: "Any" + ) -> "AccessToken": + request = self._get_on_behalf_of_request(scopes=scopes, secret=secret, user_assertion=user_assertion, **kwargs) + now = int(time.time()) + response = await self._pipeline.run(request, retry_on_methods=self._POST, **kwargs) + return self._process_response(response, now) + # pylint:disable=no-self-use def _build_pipeline( self, From d92633423642abb9720005428bf1043f52ff0471 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 29 Jul 2021 17:12:15 -0700 Subject: [PATCH 04/12] async credential --- .../azure/identity/_user_assertion.py | 2 + .../azure/identity/aio/__init__.py | 7 ++ .../identity/aio/_credentials/__init__.py | 8 ++ .../identity/aio/_credentials/on_behalf_of.py | 96 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 sdk/identity/azure-identity/azure/identity/aio/_credentials/on_behalf_of.py diff --git a/sdk/identity/azure-identity/azure/identity/_user_assertion.py b/sdk/identity/azure-identity/azure/identity/_user_assertion.py index a271ec8b11af..067107c5dcde 100644 --- a/sdk/identity/azure-identity/azure/identity/_user_assertion.py +++ b/sdk/identity/azure-identity/azure/identity/_user_assertion.py @@ -8,6 +8,7 @@ from typing import Dict, Optional import msal from . import AuthenticationRecord + from ._internal import AadClientBase try: @@ -43,6 +44,7 @@ def __init__(self, user_assertion): :param str user_assertion: the user assertion. Typically an access token issued to the user. """ self._assertion = user_assertion + self._async_clients = {} # type: Dict[str, AadClientBase] self._client_applications = {} # type: Dict[str, msal.ConfidentialClientApplication] self._record = None # type: Optional[AuthenticationRecord] diff --git a/sdk/identity/azure-identity/azure/identity/aio/__init__.py b/sdk/identity/azure-identity/azure/identity/aio/__init__.py index 817b87ad06fd..2f123ea2fcfa 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/aio/__init__.py @@ -34,3 +34,10 @@ "SharedTokenCacheCredential", "VisualStudioCodeCredential", ] + +try: + from ._credentials import OnBehalfOfCredential # pylint:disable=unused-import + + __all__.append("OnBehalfOfCredential") +except ImportError: + pass diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py index 5ddfe6360133..9ee00864c96b 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/__init__.py @@ -30,3 +30,11 @@ "SharedTokenCacheCredential", "VisualStudioCodeCredential", ] + + +try: + from .on_behalf_of import OnBehalfOfCredential # pylint:disable=unused-import + + __all__.append("OnBehalfOfCredential") +except ImportError: + pass diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/on_behalf_of.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/on_behalf_of.py new file mode 100644 index 000000000000..e9a284cb90cc --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/on_behalf_of.py @@ -0,0 +1,96 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import functools +import sys +from typing import TYPE_CHECKING + +from azure.identity import CredentialUnavailableError +from .._internal import AadClient +from .._internal.decorators import log_get_token_async +from ..._internal import resolve_tenant, validate_tenant_id +from ..._user_assertion import get_assertion + +if TYPE_CHECKING: + from typing import Any + from azure.core.credentials import AccessToken + + +# UserAssertion requires contextvars, added in 3.7, to operate correctly in an async context +if sys.version >= "3.7": + + class OnBehalfOfCredential: + """Authenticates a service principal via the on-behalf-of flow. + + This flow is typically used by middle-tier services that authorize requests to other services with a delegated + user identity. Because this is not an interactive authentication flow, an application using it must have admin + consent for any delegated permissions before requesting tokens for them. See `Azure Active Directory + documentation `_ for a + more detailed description of the on-behalf-of flow. + + Each token requested by this credential requires a user assertion from :class:`~azure.identity.UserAssertion`: + + .. literalinclude:: ../tests/test_obo_async.py + :start-after: [START snippet] + :end-before: [END snippet] + :language: python + :dedent: 8 + + :param str tenant_id: ID of the service principal's tenant. Also called its "directory" ID. + :param str client_id: the service principal's client ID + :param str client_secret: one of the service principal's client secrets + + :keyword bool allow_multitenant_authentication: when True, enables the credential to acquire tokens from any + tenant the application is registered in. When False, which is the default, the credential will acquire + tokens only from the tenant specified by **tenant_id**. + """ + + def __init__(self, tenant_id: str, client_id: str, client_secret: str, **kwargs: "Any") -> None: + validate_tenant_id(tenant_id) + self._resolve_tenant = functools.partial( + resolve_tenant, tenant_id, kwargs.pop("allow_multitenant_authentication", False) + ) + self._client_args = dict(kwargs, client_id=client_id) + self._secret = client_secret + + @log_get_token_async + async def get_token(self, *scopes: "Any", **kwargs: "Any") -> "AccessToken": + """Asynchronously request an access token for `scopes`. + + This method is called automatically by Azure SDK clients. + + :param str scopes: desired scope for the access token + + :rtype: :class:`azure.core.credentials.AccessToken` + """ + if not scopes: + raise ValueError('"get_token" requires at least one scope') + + user_assertion = get_assertion() + if not user_assertion: + raise CredentialUnavailableError( + ( + "This credential requires a user assertion to acquire tokens. See " + + "https://aka.ms/azsdk/python/identity/aio/docs#azure.identity.aio.OnBehalfOfCredential for " + + "more details." + ) + ) + + # pylint:disable=protected-access + tenant_id = self._resolve_tenant(**kwargs) + if tenant_id not in user_assertion._async_clients: + user_assertion._async_clients[tenant_id] = AadClient(tenant_id=tenant_id, **self._client_args) + client = user_assertion._async_clients[tenant_id] + + token = client.get_cached_access_token(scopes) + if not token: + refresh_tokens = client.get_cached_refresh_tokens(scopes) + if len(refresh_tokens) == 1: # there should be only one + token = await client.obtain_token_by_refresh_token(scopes, refresh_tokens[0]["secret"], **kwargs) + else: + token = await client.obtain_token_on_behalf_of( + scopes, self._secret, user_assertion._assertion, **kwargs + ) + + return token From b01afb6741e33c73e2ade23015926d611d6fcc56 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 2 Aug 2021 13:42:07 -0700 Subject: [PATCH 05/12] changelog --- sdk/identity/azure-identity/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index d32cffe770d4..d85ac49b95af 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -3,6 +3,10 @@ ## 1.7.0b3 (Unreleased) ### Features Added +- `OnBehalfOfCredential` supports the on-behalf-of authentication flow for + accessing resources on behalf of users. The async version of this credential + is available only on Python 3.7+. + ([#19308](https://github.com/Azure/azure-sdk-for-python/issues/19308)) ### Breaking Changes > These changes do not impact the API of stable versions such as 1.6.0. From 9672baafeb2ee9097b58097b3ae0a08c2b0634d1 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 2 Aug 2021 15:01:16 -0700 Subject: [PATCH 06/12] remove unused import --- .../azure-identity/azure/identity/aio/_internal/aad_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/identity/azure-identity/azure/identity/aio/_internal/aad_client.py b/sdk/identity/azure-identity/azure/identity/aio/_internal/aad_client.py index c900f0295fa7..215ac357c68f 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_internal/aad_client.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_internal/aad_client.py @@ -15,7 +15,6 @@ DistributedTracingPolicy, HttpLoggingPolicy, ) -from azure.core.pipeline.transport import HttpRequest from ..._internal import AadClientBase from ..._internal.user_agent import USER_AGENT From 9c169b3b2bc93ac091984d6e7dfc9de4c58863ec Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 2 Aug 2021 15:14:37 -0700 Subject: [PATCH 07/12] skip async tests on 3.6 --- sdk/identity/azure-identity/conftest.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/conftest.py b/sdk/identity/azure-identity/conftest.py index 31edc7ee95e6..1e8f47fae941 100644 --- a/sdk/identity/azure-identity/conftest.py +++ b/sdk/identity/azure-identity/conftest.py @@ -10,8 +10,11 @@ from azure.identity._constants import DEVELOPER_SIGN_ON_CLIENT_ID, EnvironmentVariables +collect_ignore_glob = [] if sys.version_info < (3, 5, 3): - collect_ignore_glob = ["*_async.py"] + collect_ignore_glob.append("*_async.py") +if sys.version_info < (3, 7): + collect_ignore = ["tests/test_obo_async.py"] # OBO implementation requires 3.7+ RECORD_IMDS = "--record-imds" From 732a87e9a28c320d84ad04eaa46cee9d7792c9af Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 2 Aug 2021 15:19:02 -0700 Subject: [PATCH 08/12] =?UTF-8?q?that=20would've=20been=20embarrassing=20?= =?UTF-8?q?=F0=9F=98=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../azure/identity/aio/_credentials/on_behalf_of.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/on_behalf_of.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/on_behalf_of.py index e9a284cb90cc..f18b99f7ca54 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/on_behalf_of.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/on_behalf_of.py @@ -18,7 +18,7 @@ # UserAssertion requires contextvars, added in 3.7, to operate correctly in an async context -if sys.version >= "3.7": +if sys.version_info >= (3, 7): class OnBehalfOfCredential: """Authenticates a service principal via the on-behalf-of flow. From 896dc9ec1d6e4b01be4f5bfd30273df527c14ecc Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 2 Aug 2021 16:07:52 -0700 Subject: [PATCH 09/12] update dev_requirements --- sdk/identity/azure-identity/dev_requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/identity/azure-identity/dev_requirements.txt b/sdk/identity/azure-identity/dev_requirements.txt index 414d718794ab..18e7140817fa 100644 --- a/sdk/identity/azure-identity/dev_requirements.txt +++ b/sdk/identity/azure-identity/dev_requirements.txt @@ -1,5 +1,6 @@ ../../core/azure-core aiohttp>=3.0; python_version >= '3.5' +azure-mgmt-subscription>=19.0.0 mock;python_version<"3.3" typing_extensions>=3.7.2 -e ../../../tools/azure-sdk-tools From 4b60049a41d4d98291d52a806d0cc535d51c2d4d Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Aug 2021 10:23:19 -0700 Subject: [PATCH 10/12] relax user-agent assertion --- sdk/identity/azure-identity/tests/test_obo.py | 2 +- .../azure-identity/tests/test_obo_async.py | 16 +--------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/sdk/identity/azure-identity/tests/test_obo.py b/sdk/identity/azure-identity/tests/test_obo.py index a82c1d0b4010..5b2cb61e398b 100644 --- a/sdk/identity/azure-identity/tests/test_obo.py +++ b/sdk/identity/azure-identity/tests/test_obo.py @@ -102,7 +102,7 @@ def test_caching(): tenant = "tenant-id" def send(request, **_): - assert request.headers["User-Agent"] == USER_AGENT + assert request.headers["User-Agent"].startswith(USER_AGENT) parsed = urlparse(request.url) authority = "https://{}/{}".format(parsed.netloc, tenant) if "/oauth2/v2.0/token" not in parsed.path: diff --git a/sdk/identity/azure-identity/tests/test_obo_async.py b/sdk/identity/azure-identity/tests/test_obo_async.py index 7ca3915429af..0d1ec931c132 100644 --- a/sdk/identity/azure-identity/tests/test_obo_async.py +++ b/sdk/identity/azure-identity/tests/test_obo_async.py @@ -95,6 +95,7 @@ async def test_caching(): tenant = "tenant-id" async def send(request, **_): + assert request.headers["User-Agent"].startswith(USER_AGENT) parsed = urlparse(request.url) authority = "https://{}/{}".format(parsed.netloc, tenant) return mock_response( @@ -169,18 +170,3 @@ async def send(request, **_): with UserAssertion("..."): await credential.get_token("scope") assert policy.on_request.called - - -@pytest.mark.asyncio -async def test_user_agent(): - async def send(request, **_): - assert request.headers["User-Agent"] == USER_AGENT - parsed = urlparse(request.url) - tenant = parsed.path.split("/")[1] - if "/oauth2/v2.0/token" not in parsed.path: - return get_discovery_response("https://{}/{}".format(parsed.netloc, tenant)) - return mock_response(json_payload=build_aad_response(access_token="***")) - - credential = OnBehalfOfCredential("tenant-id", "client-id", "client-secret", transport=Mock(send=send)) - with UserAssertion("..."): - await credential.get_token("scope") From 3129768efd57a7b790b61df2e7bcc2b5c5b2125c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Aug 2021 13:05:27 -0700 Subject: [PATCH 11/12] whoops, had the wrong package name --- sdk/identity/azure-identity/dev_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure-identity/dev_requirements.txt b/sdk/identity/azure-identity/dev_requirements.txt index 18e7140817fa..71afa54f91c6 100644 --- a/sdk/identity/azure-identity/dev_requirements.txt +++ b/sdk/identity/azure-identity/dev_requirements.txt @@ -1,6 +1,6 @@ ../../core/azure-core aiohttp>=3.0; python_version >= '3.5' -azure-mgmt-subscription>=19.0.0 +azure-mgmt-resource>=19.0.0 mock;python_version<"3.3" typing_extensions>=3.7.2 -e ../../../tools/azure-sdk-tools From ed1a222680202d0b6f0c0fd3c9e111e516c259d0 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 5 Aug 2021 13:21:31 -0700 Subject: [PATCH 12/12] Thanks, McCoy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: McCoy PatiƱo <39780829+mccoyp@users.noreply.github.com> --- .../azure-identity/azure/identity/_credentials/on_behalf_of.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/on_behalf_of.py b/sdk/identity/azure-identity/azure/identity/_credentials/on_behalf_of.py index 59fa82bcac6f..5403327d3a2d 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/on_behalf_of.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/on_behalf_of.py @@ -51,6 +51,7 @@ class OnBehalfOfCredential(object): """ def __init__(self, tenant_id, client_id, client_secret, **kwargs): + # type: (str, str, str, **Any) -> None validate_tenant_id(tenant_id) self._resolve_tenant = functools.partial( resolve_tenant, tenant_id, kwargs.pop("allow_multitenant_authentication", False)