Skip to content

Commit fdf11b3

Browse files
authored
Use MSAL's custom transport API (#11892)
1 parent b0d25bb commit fdf11b3

11 files changed

Lines changed: 158 additions & 332 deletions

File tree

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Release History
22

33
## 1.4.0b6 (Unreleased)
4+
- Upgraded minimum `msal` version to 1.3.0
45
- The async `AzureCliCredential` correctly invokes `/bin/sh`
56
([#12048](https://github.com/Azure/azure-sdk-for-python/issues/12048))
67

@@ -18,14 +19,14 @@
1819
identity by its client ID, continue using the `client_id` argument. To
1920
specify an identity by any other ID, use the `identity_config` argument,
2021
for example: `ManagedIdentityCredential(identity_config={"object_id": ".."})`
21-
([#10989](https://github.com/Azure/azure-sdk-for-python/issues/10989))
22+
([#10989](https://github.com/Azure/azure-sdk-for-python/issues/10989))
2223
- `CertificateCredential` and `ClientSecretCredential` can optionally store
2324
access tokens they acquire in a persistent cache. To enable this, construct
2425
the credential with `enable_persistent_cache=True`. On Linux, the persistent
2526
cache requires libsecret and `pygobject`. If these are unavailable or
2627
unusable (e.g. in an SSH session), loading the persistent cache will raise an
2728
error. You may optionally configure the credential to fall back to an
28-
unencrypted cache by constructing it with keyword argument
29+
unencrypted cache by constructing it with keyword argument
2930
`allow_unencrypted_cache=True`.
3031
([#11347](https://github.com/Azure/azure-sdk-for-python/issues/11347))
3132
- `AzureCliCredential` raises `CredentialUnavailableError` when no user is
@@ -66,7 +67,7 @@
6667

6768
## 1.4.0b3 (2020-05-04)
6869
- `EnvironmentCredential` correctly initializes `UsernamePasswordCredential`
69-
with the value of `AZURE_TENANT_ID`
70+
with the value of `AZURE_TENANT_ID`
7071
([#11127](https://github.com/Azure/azure-sdk-for-python/pull/11127))
7172
- Values for the constructor keyword argument `authority` and
7273
`AZURE_AUTHORITY_HOST` may optionally specify an "https" scheme. For example,
@@ -86,7 +87,7 @@ with the value of `AZURE_TENANT_ID`
8687
- `enable_persistent_cache=True` configures these credentials to use a
8788
persistent cache on supported platforms (in this release, Windows only).
8889
By default they cache in memory only.
89-
- Now `DefaultAzureCredential` can authenticate with the identity signed in to
90+
- Now `DefaultAzureCredential` can authenticate with the identity signed in to
9091
Visual Studio Code's Azure extension.
9192
([#10472](https://github.com/Azure/azure-sdk-for-python/issues/10472))
9293

sdk/identity/azure-identity/azure/identity/_credentials/user_password.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ def __init__(self, client_id, username, password, **kwargs):
5555
def _request_token(self, *scopes, **kwargs):
5656
# type: (*str, **Any) -> dict
5757
app = self._get_app()
58-
with self._adapter:
59-
return app.acquire_token_by_username_password(
60-
username=self._username, password=self._password, scopes=list(scopes)
61-
)
58+
return app.acquire_token_by_username_password(
59+
username=self._username, password=self._password, scopes=list(scopes)
60+
)

sdk/identity/azure-identity/azure/identity/_internal/__init__.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ def get_default_authority():
3737
from .certificate_credential_base import CertificateCredentialBase
3838
from .client_secret_credential_base import ClientSecretCredentialBase
3939
from .exception_wrapper import wrap_exceptions
40-
from .msal_credentials import ConfidentialClientCredential, InteractiveCredential, PublicClientCredential
41-
from .msal_transport_adapter import MsalTransportAdapter, MsalTransportResponse
40+
from .msal_credentials import InteractiveCredential, PublicClientCredential
4241

4342

4443
def _scopes_to_resource(*scopes):
@@ -62,11 +61,8 @@ def _scopes_to_resource(*scopes):
6261
"AadClientCertificate",
6362
"CertificateCredentialBase",
6463
"ClientSecretCredentialBase",
65-
"ConfidentialClientCredential",
6664
"get_default_authority",
6765
"InteractiveCredential",
68-
"MsalTransportAdapter",
69-
"MsalTransportResponse",
7066
"normalize_authority",
7167
"PublicClientCredential",
7268
"wrap_exceptions",
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# ------------------------------------
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
# ------------------------------------
5+
import six
6+
7+
from azure.core.configuration import Configuration
8+
from azure.core.exceptions import ClientAuthenticationError
9+
from azure.core.pipeline import Pipeline
10+
from azure.core.pipeline.policies import (
11+
ContentDecodePolicy,
12+
DistributedTracingPolicy,
13+
HttpLoggingPolicy,
14+
NetworkTraceLoggingPolicy,
15+
ProxyPolicy,
16+
RetryPolicy,
17+
UserAgentPolicy,
18+
)
19+
from azure.core.pipeline.transport import HttpRequest, RequestsTransport
20+
21+
from .user_agent import USER_AGENT
22+
23+
try:
24+
from typing import TYPE_CHECKING
25+
except ImportError:
26+
TYPE_CHECKING = False
27+
28+
if TYPE_CHECKING:
29+
# pylint:disable=unused-import,ungrouped-imports
30+
from typing import Any, Dict, List, Optional, Union
31+
from azure.core.pipeline import PipelineResponse
32+
from azure.core.pipeline.policies import HTTPPolicy, SansIOHTTPPolicy
33+
from azure.core.pipeline.transport import HttpTransport
34+
35+
PolicyList = List[Union[HTTPPolicy, SansIOHTTPPolicy]]
36+
RequestData = Union[Dict[str, str], str]
37+
38+
39+
class MsalResponse(object):
40+
"""Wraps HttpResponse according to msal.oauth2cli.http"""
41+
42+
def __init__(self, response):
43+
# type: (PipelineResponse) -> None
44+
self._response = response
45+
46+
@property
47+
def status_code(self):
48+
# type: () -> int
49+
return self._response.http_response.status_code
50+
51+
@property
52+
def text(self):
53+
# type: () -> str
54+
return self._response.http_response.text(encoding="utf-8")
55+
56+
def raise_for_status(self):
57+
if self.status_code < 400:
58+
return
59+
60+
if ContentDecodePolicy.CONTEXT_NAME in self._response.context:
61+
content = self._response.context[ContentDecodePolicy.CONTEXT_NAME]
62+
if "error" in content or "error_description" in content:
63+
message = "Authentication failed: {}".format(content.get("error_description") or content.get("error"))
64+
else:
65+
for secret in ("access_token", "refresh_token"):
66+
if secret in content:
67+
content[secret] = "***"
68+
message = 'Unexpected response from Azure Active Directory: "{}"'.format(content)
69+
else:
70+
message = "Unexpected response from Azure Active Directory"
71+
72+
raise ClientAuthenticationError(message=message, response=self._response.http_response)
73+
74+
75+
class MsalClient(object):
76+
"""Wraps Pipeline according to msal.oauth2cli.http"""
77+
78+
def __init__(self, **kwargs): # pylint:disable=missing-client-constructor-parameter-credential
79+
# type: (**Any) -> None
80+
self._pipeline = _build_pipeline(**kwargs)
81+
82+
def post(self, url, params=None, data=None, headers=None, **kwargs): # pylint:disable=unused-argument
83+
# type: (str, Optional[Dict[str, str]], RequestData, Optional[Dict[str, str]], **Any) -> MsalResponse
84+
request = HttpRequest("POST", url, headers=headers)
85+
if params:
86+
request.format_parameters(params)
87+
if data:
88+
if isinstance(data, dict):
89+
request.headers["Content-Type"] = "application/x-www-form-urlencoded"
90+
request.set_formdata_body(data)
91+
elif isinstance(data, six.text_type):
92+
body_bytes = six.ensure_binary(data)
93+
request.set_bytes_body(body_bytes)
94+
else:
95+
raise ValueError('expected "data" to be text or a dict')
96+
97+
response = self._pipeline.run(request)
98+
return MsalResponse(response)
99+
100+
def get(self, url, params=None, headers=None, **kwargs): # pylint:disable=unused-argument
101+
# type: (str, Optional[Dict[str, str]], Optional[Dict[str, str]], **Any) -> MsalResponse
102+
request = HttpRequest("GET", url, headers=headers)
103+
if params:
104+
request.format_parameters(params)
105+
response = self._pipeline.run(request)
106+
return MsalResponse(response)
107+
108+
109+
def _create_config(**kwargs):
110+
# type: (Any) -> Configuration
111+
config = Configuration(**kwargs)
112+
config.logging_policy = NetworkTraceLoggingPolicy(**kwargs)
113+
config.retry_policy = RetryPolicy(**kwargs)
114+
config.proxy_policy = ProxyPolicy(**kwargs)
115+
config.user_agent_policy = UserAgentPolicy(base_user_agent=USER_AGENT, **kwargs)
116+
return config
117+
118+
119+
def _build_pipeline(config=None, policies=None, transport=None, **kwargs):
120+
# type: (Optional[Configuration], Optional[PolicyList], Optional[HttpTransport], **Any) -> Pipeline
121+
config = config or _create_config(**kwargs)
122+
123+
if policies is None: # [] is a valid policy list
124+
policies = [
125+
ContentDecodePolicy(),
126+
config.user_agent_policy,
127+
config.proxy_policy,
128+
config.retry_policy,
129+
config.logging_policy,
130+
DistributedTracingPolicy(**kwargs),
131+
HttpLoggingPolicy(**kwargs),
132+
]
133+
134+
if not transport:
135+
transport = RequestsTransport(**kwargs)
136+
137+
return Pipeline(transport=transport, policies=policies)

sdk/identity/azure-identity/azure/identity/_internal/msal_credentials.py

Lines changed: 9 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22
# Copyright (c) Microsoft Corporation.
33
# Licensed under the MIT License.
44
# ------------------------------------
5-
"""Credentials wrapping MSAL applications and delegating token acquisition and caching to them.
6-
This entails monkeypatching MSAL's OAuth client with an adapter substituting an azure-core pipeline for Requests.
7-
"""
85
import abc
96
import base64
107
import json
@@ -17,7 +14,7 @@
1714
from azure.core.exceptions import ClientAuthenticationError
1815

1916
from .exception_wrapper import wrap_exceptions
20-
from .msal_transport_adapter import MsalTransportAdapter
17+
from .msal_client import MsalClient
2118
from .persistent_cache import load_user_cache
2219
from .._constants import KnownAuthorities
2320
from .._exceptions import AuthenticationRequiredError, CredentialUnavailableError
@@ -102,7 +99,7 @@ def __init__(self, client_id, client_credential=None, **kwargs):
10299
else:
103100
self._cache = msal.TokenCache()
104101

105-
self._adapter = kwargs.pop("msal_adapter", None) or MsalTransportAdapter(**kwargs)
102+
self._client = MsalClient(**kwargs)
106103

107104
# postpone creating the wrapped application because its initializer uses the network
108105
self._msal_app = None # type: Optional[msal.ClientApplication]
@@ -119,53 +116,17 @@ def _get_app(self):
119116

120117
def _create_app(self, cls):
121118
# type: (Type[msal.ClientApplication]) -> msal.ClientApplication
122-
"""Creates an MSAL application, patching msal.authority to use an azure-core pipeline during tenant discovery"""
123-
124-
# MSAL application initializers use msal.authority to send AAD tenant discovery requests
125-
with self._adapter:
126-
# MSAL's "authority" is a URL e.g. https://login.microsoftonline.com/common
127-
app = cls(
128-
client_id=self._client_id,
129-
client_credential=self._client_credential,
130-
authority="{}/{}".format(self._authority, self._tenant_id),
131-
token_cache=self._cache,
132-
)
133-
134-
# monkeypatch the app to replace requests.Session with MsalTransportAdapter
135-
app.client.session.close()
136-
app.client.session = self._adapter
119+
app = cls(
120+
client_id=self._client_id,
121+
client_credential=self._client_credential,
122+
authority="{}/{}".format(self._authority, self._tenant_id),
123+
token_cache=self._cache,
124+
http_client=self._client,
125+
)
137126

138127
return app
139128

140129

141-
class ConfidentialClientCredential(MsalCredential):
142-
"""Wraps an MSAL ConfidentialClientApplication with the TokenCredential API"""
143-
144-
@wrap_exceptions
145-
def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
146-
# type: (*str, **Any) -> AccessToken
147-
148-
# MSAL requires scopes be a list
149-
scopes = list(scopes) # type: ignore
150-
now = int(time.time())
151-
152-
# First try to get a cached access token or if a refresh token is cached, redeem it for an access token.
153-
# Failing that, acquire a new token.
154-
app = self._get_app()
155-
result = app.acquire_token_silent(scopes, account=None) or app.acquire_token_for_client(scopes)
156-
157-
if "access_token" not in result:
158-
raise ClientAuthenticationError(message="authentication failed: {}".format(result.get("error_description")))
159-
160-
return AccessToken(result["access_token"], now + int(result["expires_in"]))
161-
162-
def _get_app(self):
163-
# type: () -> msal.ConfidentialClientApplication
164-
if not self._msal_app:
165-
self._msal_app = self._create_app(msal.ConfidentialClientApplication)
166-
return self._msal_app
167-
168-
169130
class PublicClientCredential(MsalCredential):
170131
"""Wraps an MSAL PublicClientApplication with the TokenCredential API"""
171132

0 commit comments

Comments
 (0)