diff --git a/azure-devops/azure/devops/client.py b/azure-devops/azure/devops/client.py index ad71109..a4a6510 100644 --- a/azure-devops/azure/devops/client.py +++ b/azure-devops/azure/devops/client.py @@ -9,6 +9,7 @@ import os import re import uuid +import www_authenticate from msrest import Deserializer, Serializer from msrest.exceptions import DeserializationError, SerializationError @@ -285,10 +286,31 @@ def _handle_error(self, request, response): pass elif response.content is not None: error_message = response.content.decode("utf-8") + ' ' + if response.status_code == 401: full_message_format = '{error_message}The requested resource requires user authentication: {url}' - raise AzureDevOpsAuthenticationError(full_message_format.format(error_message=error_message, - url=request.url)) + formatted_message = full_message_format.format(error_message=error_message, url=request.url) + + # Check for WWW-Authenticate header and extract claims challenge if present + claims_challenge = None + if 'WWW-Authenticate' in response.headers: + www_auth_header = response.headers['WWW-Authenticate'] + logger.debug('Received WWW-Authenticate header: %s', www_auth_header) + + try: + # Parse the WWW-Authenticate header + parsed = www_authenticate.parse(www_auth_header) + + # Extract the claims challenge from bearer params if present + claims_challenge = parsed.get("bearer", {}).get("claims") + if claims_challenge: + logger.debug('Claims challenge extracted: %s', claims_challenge) + except Exception as ex: + # If parsing fails, log the error but continue without claims + logger.debug('Failed to parse WWW-Authenticate header: %s', str(ex)) + + # Raise authentication error with claims challenge if found + raise AzureDevOpsAuthenticationError(formatted_message, claims_challenge=claims_challenge) else: full_message_format = '{error_message}Operation returned a {status_code} status code.' raise AzureDevOpsClientRequestError(full_message_format.format(error_message=error_message, diff --git a/azure-devops/azure/devops/exceptions.py b/azure-devops/azure/devops/exceptions.py index 18a5f9c..a32738e 100644 --- a/azure-devops/azure/devops/exceptions.py +++ b/azure-devops/azure/devops/exceptions.py @@ -15,7 +15,25 @@ class AzureDevOpsClientError(ClientException): class AzureDevOpsAuthenticationError(AuthenticationError): - pass + """AzureDevOpsAuthenticationError. + Extends the AuthenticationError to include support for claims challenges. + """ + + def __init__(self, message, claims_challenge=None, inner_exception=None): + """ + :param message: The error message. + :param claims_challenge: Optional claims challenge string from the WWW-Authenticate header. + :param inner_exception: Optional inner exception. + """ + super(AzureDevOpsAuthenticationError, self).__init__(message, inner_exception) + self.claims_challenge = claims_challenge + + def get_claims_challenge(self): + """Get the claims challenge value if one exists. + + :return: The claims challenge string or None. + """ + return self.claims_challenge class AzureDevOpsClientRequestError(ClientRequestError): diff --git a/azure-devops/setup.py b/azure-devops/setup.py index ad66d69..a934b85 100644 --- a/azure-devops/setup.py +++ b/azure-devops/setup.py @@ -16,7 +16,9 @@ # http://pypi.python.org/pypi/setuptools REQUIRES = [ - "msrest>=0.7.1,<0.8.0" + "msrest>=0.7.1,<0.8.0", + "www-authenticate>=0.9.1", + "msal>=1.20.0" ] CLASSIFIERS = [