Skip to content

Commit 1a598ea

Browse files
authored
[Core] Chain challenge responses with token exceptions (#42536)
Signed-off-by: Paul Van Eck <[email protected]>
1 parent 862a371 commit 1a598ea

File tree

5 files changed

+126
-20
lines changed

5 files changed

+126
-20
lines changed

sdk/core/azure-core/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
### Other Changes
1414

15+
- `BearerTokenCredentialPolicy` and `AsyncBearerTokenCredentialPolicy` will now properly surface credential exceptions when handling claims challenges. Previously, exceptions from credential token requests were suppressed; now they are raised and chained with the original 401 `HttpResponseError` response for better debugging visibility. #42536
16+
1517
## 1.35.0 (2025-07-02)
1618

1719
### Features Added

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import time
77
import base64
88
from typing import TYPE_CHECKING, Optional, TypeVar, MutableMapping, Any, Union, cast
9+
910
from azure.core.credentials import (
1011
TokenCredential,
1112
SupportsTokenInfo,
1213
TokenRequestOptions,
1314
TokenProvider,
1415
)
16+
from azure.core.exceptions import HttpResponseError
1517
from azure.core.pipeline import PipelineRequest, PipelineResponse
1618
from azure.core.pipeline.transport import (
1719
HttpResponse as LegacyHttpResponse,
@@ -165,7 +167,12 @@ def send(self, request: PipelineRequest[HTTPRequestType]) -> PipelineResponse[HT
165167
if response.http_response.status_code == 401:
166168
self._token = None # any cached token is invalid
167169
if "WWW-Authenticate" in response.http_response.headers:
168-
request_authorized = self.on_challenge(request, response)
170+
try:
171+
request_authorized = self.on_challenge(request, response)
172+
except Exception as ex:
173+
# Raise the exception from the token request with the original 401 response
174+
raise ex from HttpResponseError(response=response.http_response)
175+
169176
if request_authorized:
170177
# if we receive a challenge response, we retrieve a new token
171178
# which matches the new target. In this case, we don't want to remove
@@ -200,14 +207,11 @@ def on_challenge(
200207
encoded_claims = get_challenge_parameter(headers, "Bearer", "claims")
201208
if not encoded_claims:
202209
return False
203-
try:
204-
padding_needed = -len(encoded_claims) % 4
205-
claims = base64.urlsafe_b64decode(encoded_claims + "=" * padding_needed).decode("utf-8")
206-
if claims:
207-
self.authorize_request(request, *self._scopes, claims=claims)
208-
return True
209-
except Exception: # pylint:disable=broad-except
210-
return False
210+
padding_needed = -len(encoded_claims) % 4
211+
claims = base64.urlsafe_b64decode(encoded_claims + "=" * padding_needed).decode("utf-8")
212+
if claims:
213+
self.authorize_request(request, *self._scopes, claims=claims)
214+
return True
211215
return False
212216

213217
def on_response(

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
AsyncSupportsTokenInfo,
1414
AsyncTokenProvider,
1515
)
16+
from azure.core.exceptions import HttpResponseError
1617
from azure.core.pipeline import PipelineRequest, PipelineResponse
1718
from azure.core.pipeline.policies import AsyncHTTPPolicy
1819
from azure.core.pipeline.policies._authentication import (
@@ -110,7 +111,12 @@ async def send(
110111
if response.http_response.status_code == 401:
111112
self._token = None # any cached token is invalid
112113
if "WWW-Authenticate" in response.http_response.headers:
113-
request_authorized = await self.on_challenge(request, response)
114+
try:
115+
request_authorized = await self.on_challenge(request, response)
116+
except Exception as ex:
117+
# Raise the exception from the token request with the original 401 response
118+
raise ex from HttpResponseError(response=response.http_response)
119+
114120
if request_authorized:
115121
# if we receive a challenge response, we retrieve a new token
116122
# which matches the new target. In this case, we don't want to remove
@@ -145,14 +151,11 @@ async def on_challenge(
145151
encoded_claims = get_challenge_parameter(headers, "Bearer", "claims")
146152
if not encoded_claims:
147153
return False
148-
try:
149-
padding_needed = -len(encoded_claims) % 4
150-
claims = base64.urlsafe_b64decode(encoded_claims + "=" * padding_needed).decode("utf-8")
151-
if claims:
152-
await self.authorize_request(request, *self._scopes, claims=claims)
153-
return True
154-
except Exception: # pylint:disable=broad-except
155-
return False
154+
padding_needed = -len(encoded_claims) % 4
155+
claims = base64.urlsafe_b64decode(encoded_claims + "=" * padding_needed).decode("utf-8")
156+
if claims:
157+
await self.authorize_request(request, *self._scopes, claims=claims)
158+
return True
156159
return False
157160

158161
def on_response(

sdk/core/azure-core/tests/async_tests/test_authentication_async.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from azure.core.credentials import AccessToken, AccessTokenInfo
1414
from azure.core.credentials_async import AsyncTokenCredential, AsyncSupportsTokenInfo
15-
from azure.core.exceptions import ServiceRequestError
15+
from azure.core.exceptions import ServiceRequestError, HttpResponseError, ClientAuthenticationError
1616
from azure.core.pipeline import AsyncPipeline, PipelineRequest, PipelineContext, PipelineResponse
1717
from azure.core.pipeline.policies import (
1818
AsyncBearerTokenCredentialPolicy,
@@ -640,3 +640,52 @@ async def mock_get_token_info(*scopes, options=None):
640640

641641
# Verify the Authorization header was set correctly
642642
assert request.http_request.headers["Authorization"] == "Bearer claims_token"
643+
644+
645+
@pytest.mark.asyncio
646+
@pytest.mark.parametrize("http_request", HTTP_REQUESTS)
647+
async def test_async_bearer_policy_on_challenge_exception_chaining(http_request):
648+
"""Test that exceptions during async on_challenge are chained with HttpResponseError"""
649+
650+
# Mock credential that raises an exception during get_token_info with claims
651+
async def mock_get_token_info(*scopes, options=None):
652+
if options and "claims" in options:
653+
raise ClientAuthenticationError("Failed to request token info with claims")
654+
return AccessTokenInfo("initial_token", int(time.time()) + 3600)
655+
656+
fake_credential = Mock(
657+
spec_set=["get_token", "get_token_info"],
658+
get_token=AsyncMock(return_value=AccessToken("fallback", int(time.time()) + 3600)),
659+
get_token_info=mock_get_token_info,
660+
)
661+
policy = AsyncBearerTokenCredentialPolicy(fake_credential, "scope")
662+
663+
# Create a 401 response with insufficient_claims challenge
664+
test_claims = '{"access_token":{"foo":"bar"}}'
665+
encoded_claims = base64.urlsafe_b64encode(test_claims.encode()).decode().rstrip("=")
666+
challenge_header = f'Bearer error="insufficient_claims", claims="{encoded_claims}"'
667+
668+
response_mock = Mock(status_code=401, headers={"WWW-Authenticate": challenge_header})
669+
670+
# Mock transport that returns the 401 response
671+
async def mock_transport_send(request):
672+
return response_mock
673+
674+
transport = Mock(send=mock_transport_send)
675+
pipeline = AsyncPipeline(transport=transport, policies=[policy])
676+
677+
# Execute the request and verify exception chaining
678+
with pytest.raises(ClientAuthenticationError) as exc_info:
679+
await pipeline.run(http_request("GET", "https://example.com"))
680+
681+
# Verify the original exception is preserved
682+
original_exception = exc_info.value
683+
assert original_exception.message == "Failed to request token info with claims"
684+
685+
# Verify the exception is chained with HttpResponseError
686+
assert original_exception.__cause__ is not None
687+
assert isinstance(original_exception.__cause__, HttpResponseError)
688+
689+
# Verify the HttpResponseError contains the original 401 response
690+
http_response_error = original_exception.__cause__
691+
assert http_response_error.response is response_mock

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
AzureNamedKeyCredential,
1717
AccessTokenInfo,
1818
)
19-
from azure.core.exceptions import ServiceRequestError
19+
from azure.core.exceptions import ServiceRequestError, HttpResponseError, ClientAuthenticationError
2020
from azure.core.pipeline import Pipeline, PipelineRequest, PipelineContext, PipelineResponse
2121
from azure.core.pipeline.transport import HttpTransport, HttpRequest
2222
from azure.core.pipeline.policies import (
@@ -837,3 +837,51 @@ def mock_get_token_info(*scopes, options):
837837

838838
# Verify the Authorization header was set correctly
839839
assert request.http_request.headers["Authorization"] == "Bearer claims_token"
840+
841+
842+
@pytest.mark.parametrize("http_request", HTTP_REQUESTS)
843+
def test_bearer_policy_on_challenge_exception_chaining(http_request):
844+
"""Test that exceptions during on_challenge are chained with HttpResponseError"""
845+
846+
# Mock credential that raises an exception during get_token_info with claims
847+
def mock_get_token_info(*scopes, options=None):
848+
if options and "claims" in options:
849+
raise ClientAuthenticationError("Failed to request token info with claims")
850+
return AccessTokenInfo("initial_token", int(time.time()) + 3600)
851+
852+
fake_credential = Mock(
853+
spec_set=["get_token", "get_token_info"],
854+
get_token=Mock(return_value=AccessToken("fallback", int(time.time()) + 3600)),
855+
get_token_info=mock_get_token_info,
856+
)
857+
policy = BearerTokenCredentialPolicy(fake_credential, "scope")
858+
859+
# Create a 401 response with insufficient_claims challenge
860+
test_claims = '{"access_token":{"foo":"bar"}}'
861+
encoded_claims = base64.urlsafe_b64encode(test_claims.encode()).decode().rstrip("=")
862+
challenge_header = f'Bearer error="insufficient_claims", claims="{encoded_claims}"'
863+
864+
response_mock = Mock(status_code=401, headers={"WWW-Authenticate": challenge_header})
865+
866+
# Mock transport that returns the 401 response
867+
def mock_transport_send(request):
868+
return response_mock
869+
870+
transport = Mock(send=mock_transport_send)
871+
pipeline = Pipeline(transport=transport, policies=[policy])
872+
873+
# Execute the request and verify exception chaining
874+
with pytest.raises(ClientAuthenticationError) as exc_info:
875+
pipeline.run(http_request("GET", "https://example.com"))
876+
877+
# Verify the original exception is preserved
878+
original_exception = exc_info.value
879+
assert original_exception.message == "Failed to request token info with claims"
880+
881+
# Verify the exception is chained with HttpResponseError
882+
assert original_exception.__cause__ is not None
883+
assert isinstance(original_exception.__cause__, HttpResponseError)
884+
885+
# Verify the HttpResponseError contains the original 401 response
886+
http_response_error = original_exception.__cause__
887+
assert http_response_error.response is response_mock

0 commit comments

Comments
 (0)