Skip to content

Commit 7766533

Browse files
authored
[azure-mgmt-core] update ARMChallengeAuthenticationPolicy (Azure#38703)
* update cae policy * update test * update changelog * update version * format * update test * fix pylint
1 parent 949398a commit 7766533

File tree

7 files changed

+109
-143
lines changed

7 files changed

+109
-143
lines changed

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

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

3+
## 1.5.1 (2023-XX-XX)
4+
5+
### Other Changes
6+
7+
- `ARMChallengeAuthenticationPolicy` adopt `on_challenge` in `BearerTokenCredentialPolicy` of `azure-core` to support complete CAE challenges.
8+
39
## 1.5.0 (2024-10-31)
410

511
### Features Added

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.5.0"
12+
VERSION = "1.5.1"

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

Lines changed: 1 addition & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,13 @@
2323
# IN THE SOFTWARE.
2424
#
2525
# --------------------------------------------------------------------------
26-
import base64
2726
import time
2827
from typing import Optional, Union, MutableMapping, List, Any, Sequence, TypeVar, Generic
2928

3029
from azure.core.credentials import AccessToken, TokenCredential
3130
from azure.core.credentials_async import AsyncTokenCredential
3231
from azure.core.pipeline.policies import BearerTokenCredentialPolicy, SansIOHTTPPolicy
33-
from azure.core.pipeline import PipelineRequest, PipelineResponse
32+
from azure.core.pipeline import PipelineRequest
3433
from azure.core.exceptions import ServiceRequestError
3534
from azure.core.pipeline.transport import (
3635
HttpRequest as LegacyHttpRequest,
@@ -49,33 +48,8 @@ class ARMChallengeAuthenticationPolicy(BearerTokenCredentialPolicy):
4948
5049
This policy internally handles Continuous Access Evaluation (CAE) challenges. When it can't complete a challenge,
5150
it will return the 401 (unauthorized) response from ARM.
52-
53-
:param ~azure.core.credentials.TokenCredential credential: credential for authorizing requests
54-
:param str scopes: required authentication scopes
5551
"""
5652

57-
def on_challenge(
58-
self,
59-
request: PipelineRequest[HTTPRequestType],
60-
response: PipelineResponse[HTTPRequestType, HTTPResponseType],
61-
) -> bool:
62-
"""Authorize request according to an ARM authentication challenge
63-
64-
:param ~azure.core.pipeline.PipelineRequest request: the request which elicited an authentication challenge
65-
:param ~azure.core.pipeline.PipelineResponse response: ARM's response
66-
:returns: a bool indicating whether the policy should send the request
67-
:rtype: bool
68-
"""
69-
70-
challenge = response.http_response.headers.get("WWW-Authenticate")
71-
if challenge:
72-
claims = _parse_claims_challenge(challenge)
73-
if claims:
74-
self.authorize_request(request, *self._scopes, claims=claims)
75-
return True
76-
77-
return False
78-
7953

8054
# pylint:disable=too-few-public-methods
8155
class _AuxiliaryAuthenticationPolicyBase(Generic[TokenCredentialType]):
@@ -150,33 +124,3 @@ def on_request(self, request: PipelineRequest[HTTPRequestType]) -> None:
150124
self._aux_tokens = self._get_auxiliary_tokens(*self._scopes)
151125

152126
self._update_headers(request.http_request.headers)
153-
154-
155-
def _parse_claims_challenge(challenge: str) -> Optional[str]:
156-
"""Parse the "claims" parameter from an authentication challenge
157-
158-
Example challenge with claims:
159-
Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token",
160-
error_description="User session has been revoked",
161-
claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="
162-
163-
:param str challenge: The authentication challenge
164-
:return: the challenge's "claims" parameter or None, if it doesn't contain that parameter
165-
"""
166-
encoded_claims = None
167-
for parameter in challenge.split(","):
168-
if "claims=" in parameter:
169-
if encoded_claims:
170-
# multiple claims challenges, e.g. for cross-tenant auth, would require special handling
171-
return None
172-
encoded_claims = parameter[parameter.index("=") + 1 :].strip(" \"'")
173-
174-
if not encoded_claims:
175-
return None
176-
177-
padding_needed = -len(encoded_claims) % 4
178-
try:
179-
decoded_claims = base64.urlsafe_b64decode(encoded_claims + "=" * padding_needed).decode()
180-
return decoded_claims
181-
except Exception: # pylint:disable=broad-except
182-
return None

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

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
# IN THE SOFTWARE.
2424
#
2525
# --------------------------------------------------------------------------
26-
from typing import cast, Awaitable, Optional, List, Union, Any
26+
from typing import Awaitable, Optional, List, Union, Any
2727
import inspect
2828

2929
from azure.core.pipeline.policies import (
@@ -40,7 +40,7 @@
4040
from azure.core.credentials_async import AsyncTokenCredential
4141

4242

43-
from ._authentication import _parse_claims_challenge, _AuxiliaryAuthenticationPolicyBase
43+
from ._authentication import _AuxiliaryAuthenticationPolicyBase
4444

4545

4646
HTTPRequestType = Union[LegacyHttpRequest, HttpRequest]
@@ -66,33 +66,8 @@ class AsyncARMChallengeAuthenticationPolicy(AsyncBearerTokenCredentialPolicy):
6666
6767
This policy internally handles Continuous Access Evaluation (CAE) challenges. When it can't complete a challenge,
6868
it will return the 401 (unauthorized) response from ARM.
69-
70-
:param ~azure.core.credentials.TokenCredential credential: credential for authorizing requests
71-
:param str scopes: required authentication scopes
7269
"""
7370

74-
# pylint:disable=unused-argument
75-
async def on_challenge(
76-
self,
77-
request: PipelineRequest[HTTPRequestType],
78-
response: PipelineResponse[HTTPRequestType, AsyncHTTPResponseType],
79-
) -> bool:
80-
"""Authorize request according to an ARM authentication challenge
81-
82-
:param ~azure.core.pipeline.PipelineRequest request: the request which elicited an authentication challenge
83-
:param ~azure.core.pipeline.PipelineResponse response: the resource provider's response
84-
:returns: a bool indicating whether the policy should send the request
85-
:rtype: bool
86-
"""
87-
# Casting, as the code seems to be certain that on_challenge this header will be present
88-
challenge: str = cast(str, response.http_response.headers.get("WWW-Authenticate"))
89-
claims = _parse_claims_challenge(challenge)
90-
if claims:
91-
await self.authorize_request(request, *self._scopes, claims=claims)
92-
return True
93-
94-
return False
95-
9671

9772
class AsyncAuxiliaryAuthenticationPolicy(
9873
_AuxiliaryAuthenticationPolicyBase[AsyncTokenCredential],

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"pytyped": ["py.typed"],
7070
},
7171
install_requires=[
72-
"azure-core>=1.31.0",
72+
"azure-core>=1.32.0",
7373
],
7474
python_requires=">=3.8",
7575
)

sdk/core/azure-mgmt-core/tests/asynctests/test_authentication_async.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async def test_claims_challenge():
5050
expected_claims = '{"access_token": {"essential": "true"}'
5151
expected_scope = "scope"
5252

53-
challenge = 'Bearer authorization_uri="https://localhost", error=".", error_description=".", claims="{}"'.format(
53+
challenge = 'Bearer authorization_uri="https://localhost", error="insufficient_claims", error_description=".", claims="{}"'.format(
5454
base64.b64encode(expected_claims.encode()).decode()
5555
)
5656
responses = (
@@ -97,34 +97,68 @@ async def get_token(*scopes, **kwargs):
9797

9898

9999
async def test_multiple_claims_challenges():
100-
"""ARMChallengeAuthenticationPolicy should not attempt to handle a response having multiple claims challenges"""
100+
"""ARMChallengeAuthenticationPolicy handle a response having multiple claims challenges"""
101+
first_token = AccessToken("first", int(time.time()) + 3600)
102+
second_token = AccessToken("second", int(time.time()) + 3600)
103+
tokens = (t for t in (first_token, second_token))
104+
105+
expected_claims = '{"access_token": {"essential": "true"}'
106+
expected_scope = "scope"
107+
108+
claims = base64.b64encode(expected_claims.encode()).decode()
101109

102110
expected_header = ",".join(
103111
(
104-
'Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOiB7ImZvbyI6ICJiYXIifX0="',
105-
'Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token", error_description="User session has been revoked", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="',
112+
'Bearer realm="", authorization_uri="https://localhost", client_id="00", error="insufficient_claims", claims="{}"'.format(
113+
claims
114+
),
115+
'Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token", error_description="User session has been revoked", claims="{}"'.format(
116+
claims
117+
),
118+
)
119+
)
120+
121+
responses = (
122+
r
123+
for r in (
124+
Mock(status_code=401, headers={"WWW-Authenticate": expected_header}),
125+
Mock(status_code=200),
106126
)
107127
)
108128

109129
async def send(request):
110-
return Mock(status_code=401, headers={"WWW-Authenticate": expected_header})
130+
res = next(responses)
131+
if res.status_code == 401:
132+
expected_token = first_token.token
133+
else:
134+
expected_token = second_token.token
135+
assert request.headers["Authorization"] == "Bearer " + expected_token
111136

112-
async def get_token(*_, **__):
113-
return AccessToken("***", 42)
137+
return res
138+
139+
async def get_token(*scopes, **kwargs):
140+
assert scopes == (expected_scope,)
141+
return next(tokens)
114142

115-
transport = Mock(send=Mock(wraps=send))
116143
credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token))
117-
policies = [AsyncARMChallengeAuthenticationPolicy(credential, "scope")]
144+
transport = Mock(send=Mock(wraps=send))
145+
policies = [AsyncARMChallengeAuthenticationPolicy(credential, expected_scope)]
118146
pipeline = AsyncPipeline(transport=transport, policies=policies)
119147

120148
response = await pipeline.run(HttpRequest("GET", "https://localhost"))
121149

122-
assert transport.send.call_count == 1
123-
assert credential.get_token.call_count == 1
150+
assert response.http_response.status_code == 200
151+
assert transport.send.call_count == 2
152+
assert credential.get_token.call_count == 2
153+
154+
args, kwargs = credential.get_token.call_args
155+
assert expected_scope in args
156+
assert kwargs["claims"] == expected_claims
124157

125-
# the policy should have returned the error response because it was unable to handle the challenge
126-
assert response.http_response.status_code == 401
127-
assert response.http_response.headers["WWW-Authenticate"] == expected_header
158+
with pytest.raises(StopIteration):
159+
next(tokens)
160+
with pytest.raises(StopIteration):
161+
next(responses)
128162

129163

130164
async def test_auxiliary_authentication_policy():

sdk/core/azure-mgmt-core/tests/test_authentication.py

Lines changed: 50 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
from azure.core.credentials import AccessToken
3030
from azure.core.pipeline import Pipeline
3131
from azure.mgmt.core.policies._authentication import (
32-
_parse_claims_challenge,
3332
ARMChallengeAuthenticationPolicy,
3433
AuxiliaryAuthenticationPolicy,
3534
)
@@ -48,36 +47,6 @@
4847
CLAIM_IP = base64.b64encode(ip_claim).decode()[:-2] # Trim off padding = characters
4948

5049

51-
@pytest.mark.parametrize(
52-
"challenge,expected_claims",
53-
(
54-
# CAE - insufficient claims
55-
(
56-
f'Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000", error="insufficient_claims", claims="{CLAIM_TOKEN}"',
57-
'{"access_token": {"foo": "bar"}}',
58-
),
59-
# CAE - sessions revoked
60-
(
61-
f'Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token", error_description="User session has been revoked", claims={CLAIM_NBF}',
62-
'{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}',
63-
),
64-
# CAE - IP policy
65-
(
66-
f'Bearer authorization_uri="https://login.windows.net/", error="invalid_token", error_description="Tenant IP Policy validate failed.", claims={CLAIM_IP}',
67-
'{"access_token":{"nbf":{"essential":true,"value":"1610563006"},"xms_rp_ipaddr":{"value":"1.2.3.4"}}}',
68-
),
69-
# ARM
70-
(
71-
'Bearer authorization_uri="https://login.windows.net/", error="invalid_token", error_description="The authentication failed because of missing \'Authorization\' header."',
72-
None,
73-
),
74-
),
75-
)
76-
def test_challenge_parsing(challenge, expected_claims):
77-
claims = _parse_claims_challenge(challenge)
78-
assert claims == expected_claims
79-
80-
8150
def test_auxiliary_authentication_policy():
8251
"""The auxiliary authentication policy should add a header containing a token from its credential"""
8352
first_token = AccessToken("first", int(time.time()) + 3600)
@@ -119,7 +88,7 @@ def test_claims_challenge():
11988
expected_claims = '{"access_token": {"essential": "true"}'
12089
expected_scope = "scope"
12190

122-
challenge = 'Bearer authorization_uri="https://localhost", error=".", error_description=".", claims="{}"'.format(
91+
challenge = 'Bearer authorization_uri="https://localhost", error="insufficient_claims", error_description=".", claims="{}"'.format(
12392
base64.b64encode(expected_claims.encode()).decode()
12493
)
12594
responses = (
@@ -166,28 +135,66 @@ def get_token(*scopes, **kwargs):
166135

167136

168137
def test_multiple_claims_challenges():
169-
"""ARMChallengeAuthenticationPolicy should not attempt to handle a response having multiple claims challenges"""
138+
"""ARMChallengeAuthenticationPolicy handle a response having multiple claims challenges"""
139+
140+
first_token = AccessToken("first", int(time.time()) + 3600)
141+
second_token = AccessToken("second", int(time.time()) + 3600)
142+
tokens = (t for t in (first_token, second_token))
143+
144+
expected_claims = '{"access_token": {"essential": "true"}'
145+
expected_scope = "scope"
146+
147+
claims = base64.b64encode(expected_claims.encode()).decode()
170148

171149
expected_header = ",".join(
172150
(
173-
'Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOiB7ImZvbyI6ICJiYXIifX0="',
174-
'Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token", error_description="User session has been revoked", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="',
151+
'Bearer realm="", authorization_uri="https://localhost", client_id="00", error="insufficient_claims", claims="{}"'.format(
152+
claims
153+
),
154+
'Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token", error_description="User session has been revoked", claims="{}"'.format(
155+
claims
156+
),
157+
)
158+
)
159+
160+
responses = (
161+
r
162+
for r in (
163+
Mock(status_code=401, headers={"WWW-Authenticate": expected_header}),
164+
Mock(status_code=200),
175165
)
176166
)
177167

178168
def send(request):
179-
return Mock(status_code=401, headers={"WWW-Authenticate": expected_header})
169+
res = next(responses)
170+
if res.status_code == 401:
171+
expected_token = first_token.token
172+
else:
173+
expected_token = second_token.token
174+
assert request.headers["Authorization"] == "Bearer " + expected_token
175+
176+
return res
180177

178+
def get_token(*scopes, **kwargs):
179+
assert scopes == (expected_scope,)
180+
return next(tokens)
181+
182+
credential = Mock(spec_set=["get_token"], get_token=Mock(wraps=get_token))
181183
transport = Mock(send=Mock(wraps=send))
182-
credential = FakeTokenCredential()
183-
policies = [ARMChallengeAuthenticationPolicy(credential, "scope")]
184+
policies = [ARMChallengeAuthenticationPolicy(credential, expected_scope)]
184185
pipeline = Pipeline(transport=transport, policies=policies)
185186

186187
response = pipeline.run(HttpRequest("GET", "https://localhost"))
187188

188-
assert transport.send.call_count == 1
189-
assert credential.get_token_count == 1
189+
assert response.http_response.status_code == 200
190+
assert transport.send.call_count == 2
191+
assert credential.get_token.call_count == 2
190192

191-
# the policy should have returned the error response because it was unable to handle the challenge
192-
assert response.http_response.status_code == 401
193-
assert response.http_response.headers["WWW-Authenticate"] == expected_header
193+
args, kwargs = credential.get_token.call_args
194+
assert expected_scope in args
195+
assert kwargs["claims"] == expected_claims
196+
197+
with pytest.raises(StopIteration):
198+
next(tokens)
199+
with pytest.raises(StopIteration):
200+
next(responses)

0 commit comments

Comments
 (0)