diff --git a/azuredevops/client.go b/azuredevops/client.go index 921af58..4e6e79c 100644 --- a/azuredevops/client.go +++ b/azuredevops/client.go @@ -447,4 +447,4 @@ type InvalidApiVersion struct { func (e InvalidApiVersion) Error() string { return "The requested api-version is not in a valid format: " + e.ApiVersion -} +} \ No newline at end of file diff --git a/azuredevops/client_options.go b/azuredevops/client_options.go index 315288f..10dcec8 100644 --- a/azuredevops/client_options.go +++ b/azuredevops/client_options.go @@ -12,4 +12,4 @@ func WithHTTPClient(httpClient *http.Client) ClientOptionFunc { return func(c *Client) { c.client = httpClient } -} +} \ No newline at end of file diff --git a/azuredevops/models.go b/azuredevops/models.go index 0079749..78936d2 100644 --- a/azuredevops/models.go +++ b/azuredevops/models.go @@ -146,4 +146,4 @@ func (e WrappedError) Error() string { return "" } return *e.Message -} +} \ No newline at end of file diff --git a/azuredevops/v7/client.go b/azuredevops/v7/client.go index e0d0d4c..fab627e 100644 --- a/azuredevops/v7/client.go +++ b/azuredevops/v7/client.go @@ -31,6 +31,10 @@ const ( headerKeyForceMsaPassThrough = "X-VSS-ForceMsaPassThrough" headerKeySession = "X-TFS-Session" headerUserAgent = "User-Agent" + headerKeyWWWAuthenticate = "WWW-Authenticate" + + // CAE (Continuous Access Evaluation) constants + caeErrorInsufficientClaims = "insufficient_claims" // media types MediaTypeTextPlain = "text/plain" @@ -93,11 +97,49 @@ type Client struct { func (client *Client) SendRequest(request *http.Request) (response *http.Response, err error) { resp, err := client.client.Do(request) // todo: add retry logic if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + // Check for CAE challenge before unwrapping general error + if resp.StatusCode == http.StatusUnauthorized { + if caeChallenge, isCAE := client.extractCAEChallenge(resp); isCAE { + return resp, &CAEChallengeError{ + ClaimsChallenge: caeChallenge, + StatusCode: resp.StatusCode, + Message: "Continuous Access Evaluation challenge received", + } + } + } err = client.UnwrapError(resp) } return resp, err } +// extractCAEChallenge checks if the response contains a CAE challenge and extracts the claims +func (client *Client) extractCAEChallenge(resp *http.Response) (string, bool) { + // Get all WWW-Authenticate headers (in case of multiple) + wwwAuthHeaders := resp.Header.Values(headerKeyWWWAuthenticate) + if len(wwwAuthHeaders) == 0 { + return "", false + } + + // match key=value pairs in WWW-Authenticate header for unordered fields + errorRegex := regexp.MustCompile(`(?i)\berror\s*=\s*"?([^",\s]+)"?`) + claimsRegex := regexp.MustCompile(`(?i)\bclaims\s*=\s*"([^"]+)"`) + + // Check each WWW-Authenticate header for CAE challenge + for _, wwwAuthHeader := range wwwAuthHeaders { + // First check if this header has error="insufficient_claims" + errorMatches := errorRegex.FindStringSubmatch(wwwAuthHeader) + if len(errorMatches) > 1 && strings.EqualFold(errorMatches[1], caeErrorInsufficientClaims) { + // Now extract the claims value from this same header + claimsMatches := claimsRegex.FindStringSubmatch(wwwAuthHeader) + if len(claimsMatches) > 1 { + return claimsMatches[1], true + } + } + } + + return "", false +} + func (client *Client) Send(ctx context.Context, httpMethod string, locationId uuid.UUID, diff --git a/azuredevops/v7/models.go b/azuredevops/v7/models.go index 0079749..2a51417 100644 --- a/azuredevops/v7/models.go +++ b/azuredevops/v7/models.go @@ -147,3 +147,13 @@ func (e WrappedError) Error() string { } return *e.Message } + +type CAEChallengeError struct { + ClaimsChallenge string + StatusCode int + Message string +} + +func (e *CAEChallengeError) Error() string { + return e.Message +} \ No newline at end of file