Skip to content

Commit 9502e66

Browse files
authored
CAE support for azure-mgmt-core (Azure#19365)
* update azure-core requirement * package metadata * tests * add challenge auth policies * better calculation of base64 padding
1 parent 1e316d6 commit 9502e66

File tree

9 files changed

+437
-11
lines changed

9 files changed

+437
-11
lines changed

sdk/core/azure-mgmt-core/CHANGELOG.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
1-
21
# Release History
32

4-
## 1.2.3 (Unreleased)
3+
## 1.3.0b3 (2021-06-07)
4+
5+
### Changed
6+
7+
- Updated required `azure-core` version
8+
9+
## 1.3.0b2 (2021-05-13)
10+
11+
### Changed
12+
13+
- Updated required `azure-core` version
14+
15+
## 1.3.0b1 (2021-03-10)
16+
17+
### Features
518

19+
- ARMChallengeAuthenticationPolicy supports bearer token authorization and CAE challenges
620

721
## 1.2.2 (2020-11-09)
822

sdk/core/azure-mgmt-core/azure/mgmt/core/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
# regenerated.
1010
# --------------------------------------------------------------------------
1111

12-
VERSION = "1.2.3"
12+
VERSION = "1.3.0b3"

sdk/core/azure-mgmt-core/azure/mgmt/core/policies/__init__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
# --------------------------------------------------------------------------
2626

2727
from azure.core.pipeline.policies import HttpLoggingPolicy
28+
from ._authentication import ARMChallengeAuthenticationPolicy
2829
from ._base import ARMAutoResourceProviderRegistrationPolicy
2930

3031

@@ -48,13 +49,13 @@ class ARMHttpLoggingPolicy(HttpLoggingPolicy):
4849
])
4950

5051

51-
__all__ = ["ARMAutoResourceProviderRegistrationPolicy", "ARMHttpLoggingPolicy"]
52+
__all__ = ["ARMAutoResourceProviderRegistrationPolicy", "ARMChallengeAuthenticationPolicy", "ARMHttpLoggingPolicy"]
5253

5354
try:
54-
from ._base_async import ( # pylint: disable=unused-import
55-
AsyncARMAutoResourceProviderRegistrationPolicy,
56-
)
55+
# pylint: disable=unused-import
56+
from ._authentication_async import AsyncARMChallengeAuthenticationPolicy
57+
from ._base_async import AsyncARMAutoResourceProviderRegistrationPolicy
5758

58-
__all__.extend(["AsyncARMAutoResourceProviderRegistrationPolicy"])
59+
__all__.extend(["AsyncARMAutoResourceProviderRegistrationPolicy", "AsyncARMChallengeAuthenticationPolicy"])
5960
except (ImportError, SyntaxError):
6061
pass # Async not supported
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# --------------------------------------------------------------------------
2+
#
3+
# Copyright (c) Microsoft Corporation. All rights reserved.
4+
#
5+
# The MIT License (MIT)
6+
#
7+
# Permission is hereby granted, free of charge, to any person obtaining a copy
8+
# of this software and associated documentation files (the ""Software""), to
9+
# deal in the Software without restriction, including without limitation the
10+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
11+
# sell copies of the Software, and to permit persons to whom the Software is
12+
# furnished to do so, subject to the following conditions:
13+
#
14+
# The above copyright notice and this permission notice shall be included in
15+
# all copies or substantial portions of the Software.
16+
#
17+
# THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
22+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
23+
# IN THE SOFTWARE.
24+
#
25+
# --------------------------------------------------------------------------
26+
import base64
27+
from typing import TYPE_CHECKING
28+
29+
from azure.core.pipeline.policies import BearerTokenCredentialPolicy
30+
31+
if TYPE_CHECKING:
32+
from typing import Optional
33+
from azure.core.pipeline import PipelineRequest, PipelineResponse
34+
35+
36+
class ARMChallengeAuthenticationPolicy(BearerTokenCredentialPolicy):
37+
"""Adds a bearer token Authorization header to requests.
38+
39+
This policy internally handles Continuous Access Evaluation (CAE) challenges. When it can't complete a challenge,
40+
it will return the 401 (unauthorized) response from ARM.
41+
42+
:param ~azure.core.credentials.TokenCredential credential: credential for authorizing requests
43+
:param str scopes: required authentication scopes
44+
"""
45+
46+
def on_challenge(self, request, response): # pylint:disable=unused-argument
47+
# type: (PipelineRequest, PipelineResponse) -> bool
48+
"""Authorize request according to an ARM authentication challenge
49+
50+
:param ~azure.core.pipeline.PipelineRequest request: the request which elicited an authentication challenge
51+
:param ~azure.core.pipeline.PipelineResponse response: ARM's response
52+
:returns: a bool indicating whether the policy should send the request
53+
"""
54+
55+
challenge = response.http_response.headers.get("WWW-Authenticate")
56+
if challenge:
57+
claims = _parse_claims_challenge(challenge)
58+
if claims:
59+
self.authorize_request(request, *self._scopes, claims=claims)
60+
return True
61+
62+
return False
63+
64+
65+
def _parse_claims_challenge(challenge):
66+
# type: (str) -> Optional[str]
67+
"""Parse the "claims" parameter from an authentication challenge
68+
69+
Example challenge with claims:
70+
Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token",
71+
error_description="User session has been revoked",
72+
claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="
73+
74+
:return: the challenge's "claims" parameter or None, if it doesn't contain that parameter
75+
"""
76+
encoded_claims = None
77+
for parameter in challenge.split(","):
78+
if "claims=" in parameter:
79+
if encoded_claims:
80+
# multiple claims challenges, e.g. for cross-tenant auth, would require special handling
81+
return None
82+
encoded_claims = parameter[parameter.index("=") + 1 :].strip(" \"'")
83+
84+
if not encoded_claims:
85+
return None
86+
87+
padding_needed = -len(encoded_claims) % 4
88+
try:
89+
decoded_claims = base64.urlsafe_b64decode(encoded_claims + "=" * padding_needed).decode()
90+
return decoded_claims
91+
except Exception: # pylint:disable=broad-except
92+
return None
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# --------------------------------------------------------------------------
2+
#
3+
# Copyright (c) Microsoft Corporation. All rights reserved.
4+
#
5+
# The MIT License (MIT)
6+
#
7+
# Permission is hereby granted, free of charge, to any person obtaining a copy
8+
# of this software and associated documentation files (the ""Software""), to
9+
# deal in the Software without restriction, including without limitation the
10+
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
11+
# sell copies of the Software, and to permit persons to whom the Software is
12+
# furnished to do so, subject to the following conditions:
13+
#
14+
# The above copyright notice and this permission notice shall be included in
15+
# all copies or substantial portions of the Software.
16+
#
17+
# THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
22+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
23+
# IN THE SOFTWARE.
24+
#
25+
# --------------------------------------------------------------------------
26+
from typing import TYPE_CHECKING
27+
28+
from azure.core.pipeline.policies import AsyncBearerTokenCredentialPolicy
29+
30+
from ._authentication import _parse_claims_challenge
31+
32+
if TYPE_CHECKING:
33+
from azure.core.pipeline import PipelineRequest, PipelineResponse
34+
35+
36+
class AsyncARMChallengeAuthenticationPolicy(AsyncBearerTokenCredentialPolicy):
37+
"""Adds a bearer token Authorization header to requests.
38+
39+
This policy internally handles Continuous Access Evaluation (CAE) challenges. When it can't complete a challenge,
40+
it will return the 401 (unauthorized) response from ARM.
41+
42+
:param ~azure.core.credentials.TokenCredential credential: credential for authorizing requests
43+
:param str scopes: required authentication scopes
44+
"""
45+
46+
# pylint:disable=unused-argument
47+
async def on_challenge(self, request: "PipelineRequest", response: "PipelineResponse") -> bool:
48+
"""Authorize request according to an ARM authentication challenge
49+
50+
:param ~azure.core.pipeline.PipelineRequest request: the request which elicited an authentication challenge
51+
:param ~azure.core.pipeline.PipelineResponse response: the resource provider's response
52+
:returns: a bool indicating whether the policy should send the request
53+
"""
54+
55+
challenge = response.http_response.headers.get("WWW-Authenticate")
56+
claims = _parse_claims_challenge(challenge)
57+
if claims:
58+
await self.authorize_request(request, *self._scopes, claims=claims)
59+
return True
60+
61+
return False

sdk/core/azure-mgmt-core/setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
author_email='azpysdkhelp@microsoft.com',
4646
url='https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/core/azure-mgmt-core',
4747
classifiers=[
48-
"Development Status :: 5 - Production/Stable",
48+
"Development Status :: 4 - Beta",
4949
'Programming Language :: Python',
5050
'Programming Language :: Python :: 2',
5151
'Programming Language :: Python :: 2.7',
@@ -68,7 +68,7 @@
6868
'pytyped': ['py.typed'],
6969
},
7070
install_requires=[
71-
"azure-core<2.0.0,>=1.13.0",
71+
"azure-core<2.0.0,>=1.15.0",
7272
],
7373
extras_require={
7474
":python_version<'3.0'": ['azure-mgmt-nspkg'],
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# --------------------------------------------------------------------------
2+
#
3+
# Copyright (c) Microsoft Corporation. All rights reserved.
4+
#
5+
# The MIT License (MIT)
6+
#
7+
# Permission is hereby granted, free of charge, to any person obtaining a copy
8+
# of this software and associated documentation files (the ""Software""), to deal
9+
# in the Software without restriction, including without limitation the rights
10+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
# copies of the Software, and to permit persons to whom the Software is
12+
# furnished to do so, subject to the following conditions:
13+
#
14+
# The above copyright notice and this permission notice shall be included in
15+
# all copies or substantial portions of the Software.
16+
#
17+
# THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23+
# THE SOFTWARE.
24+
#
25+
# --------------------------------------------------------------------------
26+
import base64
27+
import time
28+
from unittest.mock import Mock
29+
30+
from azure.core.credentials import AccessToken
31+
from azure.core.pipeline import AsyncPipeline
32+
from azure.mgmt.core.policies import AsyncARMChallengeAuthenticationPolicy
33+
from azure.core.pipeline.transport import HttpRequest
34+
35+
import pytest
36+
37+
pytestmark = pytest.mark.asyncio
38+
39+
40+
async def test_claims_challenge():
41+
"""AsyncAsyncARMChallengeAuthenticationPolicy should pass claims from an authentication challenge to its credential"""
42+
43+
first_token = AccessToken("first", int(time.time()) + 3600)
44+
second_token = AccessToken("second", int(time.time()) + 3600)
45+
tokens = (t for t in (first_token, second_token))
46+
47+
expected_claims = '{"access_token": {"essential": "true"}'
48+
expected_scope = "scope"
49+
50+
challenge = 'Bearer authorization_uri="https://localhost", error=".", error_description=".", claims="{}"'.format(
51+
base64.b64encode(expected_claims.encode()).decode()
52+
)
53+
responses = (r for r in (Mock(status_code=401, headers={"WWW-Authenticate": challenge}), Mock(status_code=200)))
54+
55+
async def send(request):
56+
res = next(responses)
57+
if res.status_code == 401:
58+
expected_token = first_token.token
59+
else:
60+
expected_token = second_token.token
61+
assert request.headers["Authorization"] == "Bearer " + expected_token
62+
63+
return res
64+
65+
async def get_token(*scopes, **kwargs):
66+
assert scopes == (expected_scope,)
67+
return next(tokens)
68+
69+
credential = Mock(get_token=Mock(wraps=get_token))
70+
transport = Mock(send=Mock(wraps=send))
71+
policies = [AsyncARMChallengeAuthenticationPolicy(credential, expected_scope)]
72+
pipeline = AsyncPipeline(transport=transport, policies=policies)
73+
74+
response = await pipeline.run(HttpRequest("GET", "https://localhost"))
75+
76+
assert response.http_response.status_code == 200
77+
assert transport.send.call_count == 2
78+
assert credential.get_token.call_count == 2
79+
credential.get_token.assert_called_with(expected_scope, claims=expected_claims)
80+
with pytest.raises(StopIteration):
81+
next(tokens)
82+
with pytest.raises(StopIteration):
83+
next(responses)
84+
85+
86+
async def test_multiple_claims_challenges():
87+
"""ARMChallengeAuthenticationPolicy should not attempt to handle a response having multiple claims challenges"""
88+
89+
expected_header = ",".join(
90+
(
91+
'Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOiB7ImZvbyI6ICJiYXIifX0="',
92+
'Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token", error_description="User session has been revoked", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="',
93+
)
94+
)
95+
96+
async def send(request):
97+
return Mock(status_code=401, headers={"WWW-Authenticate": expected_header})
98+
99+
async def get_token(*_, **__):
100+
return AccessToken("***", 42)
101+
102+
transport = Mock(send=Mock(wraps=send))
103+
credential = Mock(get_token=Mock(wraps=get_token))
104+
policies = [AsyncARMChallengeAuthenticationPolicy(credential, "scope")]
105+
pipeline = AsyncPipeline(transport=transport, policies=policies)
106+
107+
response = await pipeline.run(HttpRequest("GET", "https://localhost"))
108+
109+
assert transport.send.call_count == 1
110+
assert credential.get_token.call_count == 1
111+
112+
# the policy should have returned the error response because it was unable to handle the challenge
113+
assert response.http_response.status_code == 401
114+
assert response.http_response.headers["WWW-Authenticate"] == expected_header

0 commit comments

Comments
 (0)