From 55958bdf2de1b297a5c38da5e2c290f4f804602c Mon Sep 17 00:00:00 2001 From: Chimmuanya Iheanyi-Igwe Date: Thu, 9 Oct 2025 10:48:37 -0700 Subject: [PATCH 1/4] initial starting files --- azuredevops/client.go | 103 ++++++++++++++++++++++++++++++++++ azuredevops/client_options.go | 21 +++++++ azuredevops/models.go | 17 ++++++ 3 files changed, 141 insertions(+) diff --git a/azuredevops/client.go b/azuredevops/client.go index 921af58c..925907a9 100644 --- a/azuredevops/client.go +++ b/azuredevops/client.go @@ -31,6 +31,11 @@ const ( headerKeyForceMsaPassThrough = "X-VSS-ForceMsaPassThrough" headerKeySession = "X-TFS-Session" headerUserAgent = "User-Agent" + headerKeyWWWAuthenticate = "WWW-Authenticate" + + // CAE (Continuous Access Evaluation) constants + caeErrorInsufficientClaims = "insufficient_claims" + caeClientCapability = "cp1" // media types MediaTypeTextPlain = "text/plain" @@ -74,6 +79,7 @@ func NewClientWithOptions(connection *Connection, baseUrl string, options ...Cli suppressFedAuthRedirect: connection.SuppressFedAuthRedirect, forceMsaPassThrough: connection.ForceMsaPassThrough, userAgent: connection.UserAgent, + clientCapabilities: []string{}, // Initialize empty capabilities } for _, fn := range options { fn(client) @@ -88,16 +94,93 @@ type Client struct { suppressFedAuthRedirect bool forceMsaPassThrough bool userAgent string + clientCapabilities []string + tokenRefreshHandler TokenRefreshHandler } 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", + } + } + } + // 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) { + if resp.StatusCode != http.StatusUnauthorized { + return "", false + } + + wwwAuthHeader := resp.Header.Get(headerKeyWWWAuthenticate) + if wwwAuthHeader == "" { + return "", false + } + + // Parse WWW-Authenticate header for CAE challenge + if strings.Contains(wwwAuthHeader, caeErrorInsufficientClaims) { + // Extract claims parameter using regex + claimsRegex := regexp.MustCompile(`claims="([^"]+)"`) + matches := claimsRegex.FindStringSubmatch(wwwAuthHeader) + if len(matches) > 1 { + return matches[1], true + } + } + + return "", false +} + +// SendWithCAERetry sends a request with automatic CAE challenge handling +func (client *Client) SendWithCAERetry(ctx context.Context, + httpMethod string, + locationId uuid.UUID, + apiVersion string, + routeValues map[string]string, + queryParameters url.Values, + body io.Reader, + mediaType string, + acceptMediaType string, + additionalHeaders map[string]string) (response *http.Response, err error) { + + // First attempt + resp, err := client.Send(ctx, httpMethod, locationId, apiVersion, routeValues, queryParameters, body, mediaType, acceptMediaType, additionalHeaders) + + // Check if we got a CAE challenge + if caeErr, ok := err.(*CAEChallengeError); ok && client.tokenRefreshHandler != nil { + // Attempt to refresh token with claims challenge + newToken, refreshErr := client.tokenRefreshHandler.RefreshTokenWithClaims(ctx, caeErr.ClaimsChallenge) + if refreshErr != nil { + return nil, refreshErr + } + + // Update authorization and retry + client.authorization = "Bearer " + newToken + return client.Send(ctx, httpMethod, locationId, apiVersion, routeValues, queryParameters, body, mediaType, acceptMediaType, additionalHeaders) + } + + return resp, err +} + func (client *Client) Send(ctx context.Context, httpMethod string, locationId uuid.UUID, @@ -243,6 +326,26 @@ func setApiResourceLocationCache(url string, locationsMap *map[uuid.UUID]ApiReso apiResourceLocationCache[url] = locationsMap } +// IsCAEEnabled returns true if Continuous Access Evaluation is enabled for this client +func (client *Client) IsCAEEnabled() bool { + for _, capability := range client.clientCapabilities { + if capability == caeClientCapability { + return true + } + } + return false +} + +// GetClientCapabilities returns the client capabilities +func (client *Client) GetClientCapabilities() []string { + return client.clientCapabilities +} + +// SetTokenRefreshHandler sets the token refresh handler for CAE +func (client *Client) SetTokenRefreshHandler(handler TokenRefreshHandler) { + client.tokenRefreshHandler = handler +} + func (client *Client) getResourceLocationsFromServer(ctx context.Context) ([]ApiResourceLocation, error) { optionsUri := combineUrl(client.baseUrl, "_apis") request, err := client.CreateRequestMessage(ctx, http.MethodOptions, optionsUri, "", nil, "", MediaTypeApplicationJson, nil) diff --git a/azuredevops/client_options.go b/azuredevops/client_options.go index 315288f6..9b8df4a1 100644 --- a/azuredevops/client_options.go +++ b/azuredevops/client_options.go @@ -13,3 +13,24 @@ func WithHTTPClient(httpClient *http.Client) ClientOptionFunc { c.client = httpClient } } + +// WithCAEEnabled enables Continuous Access Evaluation support +func WithCAEEnabled() ClientOptionFunc { + return func(c *Client) { + c.clientCapabilities = append(c.clientCapabilities, caeClientCapability) + } +} + +// WithTokenRefreshHandler sets a token refresh handler for CAE +func WithTokenRefreshHandler(handler TokenRefreshHandler) ClientOptionFunc { + return func(c *Client) { + c.tokenRefreshHandler = handler + } +} + +// WithClientCapabilities sets custom client capabilities +func WithClientCapabilities(capabilities []string) ClientOptionFunc { + return func(c *Client) { + c.clientCapabilities = capabilities + } +} diff --git a/azuredevops/models.go b/azuredevops/models.go index 0079749f..2e8770fb 100644 --- a/azuredevops/models.go +++ b/azuredevops/models.go @@ -4,6 +4,7 @@ package azuredevops import ( + "context" "encoding/json" "strconv" "strings" @@ -147,3 +148,19 @@ func (e WrappedError) Error() string { } return *e.Message } + +// CAEChallengeError represents a Continuous Access Evaluation challenge +type CAEChallengeError struct { + ClaimsChallenge string + StatusCode int + Message string +} + +func (e *CAEChallengeError) Error() string { + return e.Message +} + +// TokenRefreshHandler interface for handling token refresh with CAE claims +type TokenRefreshHandler interface { + RefreshTokenWithClaims(ctx context.Context, claimsChallenge string) (string, error) +} From d5513d060d597aac2a2458479dd9389fec378247 Mon Sep 17 00:00:00 2001 From: Chimmuanya Iheanyi-Igwe Date: Tue, 14 Oct 2025 16:38:55 -0700 Subject: [PATCH 2/4] detect if CAE claims challenge received and extract claims --- azuredevops/client.go | 91 ++++++----------------------------- azuredevops/client_options.go | 23 +-------- azuredevops/models.go | 9 +--- 3 files changed, 18 insertions(+), 105 deletions(-) diff --git a/azuredevops/client.go b/azuredevops/client.go index 925907a9..94b03392 100644 --- a/azuredevops/client.go +++ b/azuredevops/client.go @@ -35,7 +35,6 @@ const ( // CAE (Continuous Access Evaluation) constants caeErrorInsufficientClaims = "insufficient_claims" - caeClientCapability = "cp1" // media types MediaTypeTextPlain = "text/plain" @@ -95,22 +94,11 @@ type Client struct { forceMsaPassThrough bool userAgent string clientCapabilities []string - tokenRefreshHandler TokenRefreshHandler } 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", - } - } - } // Check for CAE challenge before unwrapping general error if resp.StatusCode == http.StatusUnauthorized { if caeChallenge, isCAE := client.extractCAEChallenge(resp); isCAE { @@ -128,59 +116,32 @@ func (client *Client) SendRequest(request *http.Request) (response *http.Respons // extractCAEChallenge checks if the response contains a CAE challenge and extracts the claims func (client *Client) extractCAEChallenge(resp *http.Response) (string, bool) { - if resp.StatusCode != http.StatusUnauthorized { + // Get all WWW-Authenticate headers (in case of multiple) + wwwAuthHeaders := resp.Header.Values(headerKeyWWWAuthenticate) + if len(wwwAuthHeaders) == 0 { return "", false } - wwwAuthHeader := resp.Header.Get(headerKeyWWWAuthenticate) - if wwwAuthHeader == "" { - 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*"([^"]+)"`) - // Parse WWW-Authenticate header for CAE challenge - if strings.Contains(wwwAuthHeader, caeErrorInsufficientClaims) { - // Extract claims parameter using regex - claimsRegex := regexp.MustCompile(`claims="([^"]+)"`) - matches := claimsRegex.FindStringSubmatch(wwwAuthHeader) - if len(matches) > 1 { - return matches[1], true + // 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 } -// SendWithCAERetry sends a request with automatic CAE challenge handling -func (client *Client) SendWithCAERetry(ctx context.Context, - httpMethod string, - locationId uuid.UUID, - apiVersion string, - routeValues map[string]string, - queryParameters url.Values, - body io.Reader, - mediaType string, - acceptMediaType string, - additionalHeaders map[string]string) (response *http.Response, err error) { - - // First attempt - resp, err := client.Send(ctx, httpMethod, locationId, apiVersion, routeValues, queryParameters, body, mediaType, acceptMediaType, additionalHeaders) - - // Check if we got a CAE challenge - if caeErr, ok := err.(*CAEChallengeError); ok && client.tokenRefreshHandler != nil { - // Attempt to refresh token with claims challenge - newToken, refreshErr := client.tokenRefreshHandler.RefreshTokenWithClaims(ctx, caeErr.ClaimsChallenge) - if refreshErr != nil { - return nil, refreshErr - } - - // Update authorization and retry - client.authorization = "Bearer " + newToken - return client.Send(ctx, httpMethod, locationId, apiVersion, routeValues, queryParameters, body, mediaType, acceptMediaType, additionalHeaders) - } - - return resp, err -} - func (client *Client) Send(ctx context.Context, httpMethod string, locationId uuid.UUID, @@ -326,26 +287,6 @@ func setApiResourceLocationCache(url string, locationsMap *map[uuid.UUID]ApiReso apiResourceLocationCache[url] = locationsMap } -// IsCAEEnabled returns true if Continuous Access Evaluation is enabled for this client -func (client *Client) IsCAEEnabled() bool { - for _, capability := range client.clientCapabilities { - if capability == caeClientCapability { - return true - } - } - return false -} - -// GetClientCapabilities returns the client capabilities -func (client *Client) GetClientCapabilities() []string { - return client.clientCapabilities -} - -// SetTokenRefreshHandler sets the token refresh handler for CAE -func (client *Client) SetTokenRefreshHandler(handler TokenRefreshHandler) { - client.tokenRefreshHandler = handler -} - func (client *Client) getResourceLocationsFromServer(ctx context.Context) ([]ApiResourceLocation, error) { optionsUri := combineUrl(client.baseUrl, "_apis") request, err := client.CreateRequestMessage(ctx, http.MethodOptions, optionsUri, "", nil, "", MediaTypeApplicationJson, nil) diff --git a/azuredevops/client_options.go b/azuredevops/client_options.go index 9b8df4a1..10dcec89 100644 --- a/azuredevops/client_options.go +++ b/azuredevops/client_options.go @@ -12,25 +12,4 @@ func WithHTTPClient(httpClient *http.Client) ClientOptionFunc { return func(c *Client) { c.client = httpClient } -} - -// WithCAEEnabled enables Continuous Access Evaluation support -func WithCAEEnabled() ClientOptionFunc { - return func(c *Client) { - c.clientCapabilities = append(c.clientCapabilities, caeClientCapability) - } -} - -// WithTokenRefreshHandler sets a token refresh handler for CAE -func WithTokenRefreshHandler(handler TokenRefreshHandler) ClientOptionFunc { - return func(c *Client) { - c.tokenRefreshHandler = handler - } -} - -// WithClientCapabilities sets custom client capabilities -func WithClientCapabilities(capabilities []string) ClientOptionFunc { - return func(c *Client) { - c.clientCapabilities = capabilities - } -} +} \ No newline at end of file diff --git a/azuredevops/models.go b/azuredevops/models.go index 2e8770fb..2a514176 100644 --- a/azuredevops/models.go +++ b/azuredevops/models.go @@ -4,7 +4,6 @@ package azuredevops import ( - "context" "encoding/json" "strconv" "strings" @@ -149,7 +148,6 @@ func (e WrappedError) Error() string { return *e.Message } -// CAEChallengeError represents a Continuous Access Evaluation challenge type CAEChallengeError struct { ClaimsChallenge string StatusCode int @@ -158,9 +156,4 @@ type CAEChallengeError struct { func (e *CAEChallengeError) Error() string { return e.Message -} - -// TokenRefreshHandler interface for handling token refresh with CAE claims -type TokenRefreshHandler interface { - RefreshTokenWithClaims(ctx context.Context, claimsChallenge string) (string, error) -} +} \ No newline at end of file From 4c7713b76c079a0c72c3d47a1c00fa5aee683d8b Mon Sep 17 00:00:00 2001 From: Chimmuanya Iheanyi-Igwe Date: Tue, 14 Oct 2025 16:45:35 -0700 Subject: [PATCH 3/4] remove client capabilities --- azuredevops/client.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/azuredevops/client.go b/azuredevops/client.go index 94b03392..69325201 100644 --- a/azuredevops/client.go +++ b/azuredevops/client.go @@ -78,7 +78,6 @@ func NewClientWithOptions(connection *Connection, baseUrl string, options ...Cli suppressFedAuthRedirect: connection.SuppressFedAuthRedirect, forceMsaPassThrough: connection.ForceMsaPassThrough, userAgent: connection.UserAgent, - clientCapabilities: []string{}, // Initialize empty capabilities } for _, fn := range options { fn(client) @@ -93,7 +92,6 @@ type Client struct { suppressFedAuthRedirect bool forceMsaPassThrough bool userAgent string - clientCapabilities []string } func (client *Client) SendRequest(request *http.Request) (response *http.Response, err error) { From 79b500a934ac667d18c2cea86938e256285f9ba9 Mon Sep 17 00:00:00 2001 From: Chimmuanya Iheanyi-Igwe Date: Wed, 15 Oct 2025 15:01:07 -0700 Subject: [PATCH 4/4] update v7 files --- azuredevops/client.go | 44 +--------------------------------------- azuredevops/models.go | 10 --------- azuredevops/v7/client.go | 42 ++++++++++++++++++++++++++++++++++++++ azuredevops/v7/models.go | 10 +++++++++ 4 files changed, 53 insertions(+), 53 deletions(-) diff --git a/azuredevops/client.go b/azuredevops/client.go index 69325201..4e6e79c0 100644 --- a/azuredevops/client.go +++ b/azuredevops/client.go @@ -31,10 +31,6 @@ 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" @@ -97,49 +93,11 @@ 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, @@ -489,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/models.go b/azuredevops/models.go index 2a514176..78936d22 100644 --- a/azuredevops/models.go +++ b/azuredevops/models.go @@ -146,14 +146,4 @@ func (e WrappedError) Error() string { return "" } 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 diff --git a/azuredevops/v7/client.go b/azuredevops/v7/client.go index e0d0d4ce..fab627e5 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 0079749f..2a514176 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