From 8a0bc4f9353385bc167716fd8d9c89a1b663aafb Mon Sep 17 00:00:00 2001 From: magodo Date: Fri, 11 Jul 2025 16:07:39 +1000 Subject: [PATCH 1/3] Connection initializes Client with `AuthProvider` instead `AuthString` This change is to allow AAD based auth token to refresh when exipred. Previously, if the token encoded in the auth string is expired, the client will always fail. With this change, the Client is initialized with the `AuthProvider`, which is built on top of `azidentity` and MSAL-Go. Everytime the client is gonna make a request, the token will be retrieved from the underlying MSAL-Go library, either from its cache (if not expired) or a fresh new one retrieved via API. This means if the token got expired, a new token will be retireved and used by the client. However, there is one edge case: Since there is no "token expiration buffer" in the MSAL-Go right now, if the token returned from cache expires right after returning, the client will then use this invalid token for an API call, hence fail. There is no "retry" mechanism in the current client implementation to mitigate this. --- azuredevops/v7/auth.go | 13 +++++++++++++ azuredevops/v7/auth_aad.go | 30 ++++++++++++++++++++++++++++++ azuredevops/v7/auth_pat.go | 19 +++++++++++++++++++ azuredevops/v7/client.go | 14 ++++++++++---- azuredevops/v7/connection.go | 6 +++--- azuredevops/v7/go.mod | 15 +++++++++++++-- azuredevops/v7/go.sum | 20 ++++++++++++++++++-- 7 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 azuredevops/v7/auth.go create mode 100644 azuredevops/v7/auth_aad.go create mode 100644 azuredevops/v7/auth_pat.go diff --git a/azuredevops/v7/auth.go b/azuredevops/v7/auth.go new file mode 100644 index 00000000..966a39b4 --- /dev/null +++ b/azuredevops/v7/auth.go @@ -0,0 +1,13 @@ +package azuredevops + +import ( + "context" +) + +type Auth struct { + AuthString string +} + +type AuthProvider interface { + GetAuth(ctx context.Context) (string, error) +} diff --git a/azuredevops/v7/auth_aad.go b/azuredevops/v7/auth_aad.go new file mode 100644 index 00000000..28f66e93 --- /dev/null +++ b/azuredevops/v7/auth_aad.go @@ -0,0 +1,30 @@ +package azuredevops + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +type AADCred interface { + GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) +} + +type AuthProviderAAD struct { + cred AADCred + opts policy.TokenRequestOptions +} + +func NewAuthProviderAAD(cred AADCred, opts policy.TokenRequestOptions) AuthProvider { + return AuthProviderAAD{cred, opts} +} + +func (p AuthProviderAAD) GetAuth(ctx context.Context) (string, error) { + token, err := p.cred.GetToken(ctx, p.opts) + if err != nil { + return "", fmt.Errorf("failed to get AAD token: %v", err) + } + return "Bearer " + token.Token, nil +} diff --git a/azuredevops/v7/auth_pat.go b/azuredevops/v7/auth_pat.go new file mode 100644 index 00000000..a0149d15 --- /dev/null +++ b/azuredevops/v7/auth_pat.go @@ -0,0 +1,19 @@ +package azuredevops + +import ( + "context" + "encoding/base64" +) + +type AuthProviderPAT struct { + pat string +} + +func NewAuthProviderPAT(pat string) AuthProvider { + return AuthProviderPAT{pat} +} + +func (p AuthProviderPAT) GetAuth(_ context.Context) (string, error) { + auth := "_:" + p.pat + return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)), nil +} diff --git a/azuredevops/v7/client.go b/azuredevops/v7/client.go index e0d0d4ce..766d0321 100644 --- a/azuredevops/v7/client.go +++ b/azuredevops/v7/client.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "io/ioutil" "net/http" @@ -70,7 +71,7 @@ func NewClientWithOptions(connection *Connection, baseUrl string, options ...Cli client := &Client{ baseUrl: baseUrl, client: httpClient, - authorization: connection.AuthorizationString, + authProvider: connection.AuthProvider, suppressFedAuthRedirect: connection.SuppressFedAuthRedirect, forceMsaPassThrough: connection.ForceMsaPassThrough, userAgent: connection.UserAgent, @@ -84,7 +85,7 @@ func NewClientWithOptions(connection *Connection, baseUrl string, options ...Cli type Client struct { baseUrl string client *http.Client - authorization string + authProvider AuthProvider suppressFedAuthRedirect bool forceMsaPassThrough bool userAgent string @@ -169,9 +170,14 @@ func (client *Client) CreateRequestMessage(ctx context.Context, req = req.WithContext(ctx) } - if client.authorization != "" { - req.Header.Add(headerKeyAuthorization, client.authorization) + if client.authProvider != nil { + auth, err := client.authProvider.GetAuth(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get auth from auth cache: %v", err) + } + req.Header.Add(headerKeyAuthorization, auth) } + accept := acceptMediaType if apiVersion != "" { accept += ";api-version=" + apiVersion diff --git a/azuredevops/v7/connection.go b/azuredevops/v7/connection.go index eb76f53c..e954b5d4 100644 --- a/azuredevops/v7/connection.go +++ b/azuredevops/v7/connection.go @@ -16,10 +16,10 @@ import ( // Creates a new Azure DevOps connection instance using a personal access token. func NewPatConnection(organizationUrl string, personalAccessToken string) *Connection { - authorizationString := CreateBasicAuthHeaderValue("", personalAccessToken) organizationUrl = normalizeUrl(organizationUrl) + authProvider := NewAuthProviderPAT(personalAccessToken) return &Connection{ - AuthorizationString: authorizationString, + AuthProvider: authProvider, BaseUrl: organizationUrl, SuppressFedAuthRedirect: true, } @@ -34,7 +34,7 @@ func NewAnonymousConnection(organizationUrl string) *Connection { } type Connection struct { - AuthorizationString string + AuthProvider AuthProvider BaseUrl string UserAgent string SuppressFedAuthRedirect bool diff --git a/azuredevops/v7/go.mod b/azuredevops/v7/go.mod index 0c3ebc6c..5c909f45 100644 --- a/azuredevops/v7/go.mod +++ b/azuredevops/v7/go.mod @@ -1,5 +1,16 @@ module github.com/microsoft/azure-devops-go-api/azuredevops/v7 -go 1.12 +go 1.23.0 -require github.com/google/uuid v1.1.1 +toolchain go1.24.1 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/azuredevops/v7/go.sum b/azuredevops/v7/go.sum index b864886e..39e35ef0 100644 --- a/azuredevops/v7/go.sum +++ b/azuredevops/v7/go.sum @@ -1,2 +1,18 @@ -github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 h1:Bg8m3nq/X1DeePkAbCfb6ml6F3F0IunEhE8TMh+lY48= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 7fc85ff92766f7882d2a0e0a31fa81ab02a73aca Mon Sep 17 00:00:00 2001 From: magodo Date: Mon, 19 Jan 2026 03:00:56 +0000 Subject: [PATCH 2/3] Patch `core` models --- azuredevops/v7/core/models.go | 6 +++--- azuredevops/v7/core/models_ext.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 azuredevops/v7/core/models_ext.go diff --git a/azuredevops/v7/core/models.go b/azuredevops/v7/core/models.go index 7977022d..efb2901e 100644 --- a/azuredevops/v7/core/models.go +++ b/azuredevops/v7/core/models.go @@ -229,8 +229,8 @@ type sourceControlTypesValuesType struct { } var SourceControlTypesValues = sourceControlTypesValuesType{ - Tfvc: "tfvc", - Git: "git", + Tfvc: "Tfvc", + Git: "Git", } // The Team Context for an operation. @@ -270,7 +270,7 @@ type TeamProject struct { // The links to other objects related to this object. Links interface{} `json:"_links,omitempty"` // Set of capabilities this project has (such as process template & version control). - Capabilities *map[string]map[string]string `json:"capabilities,omitempty"` + Capabilities *TeamProjectCapabilities `json:"capabilities,omitempty"` // The shallow ref to the default team. DefaultTeam *WebApiTeamRef `json:"defaultTeam,omitempty"` } diff --git a/azuredevops/v7/core/models_ext.go b/azuredevops/v7/core/models_ext.go new file mode 100644 index 00000000..e9e3b621 --- /dev/null +++ b/azuredevops/v7/core/models_ext.go @@ -0,0 +1,14 @@ +package core + +type TeamProjectCapabilities struct { + Versioncontrol *TeamProjectCapabilitiesVersionControl `json:"versioncontrol,omitempty"` + ProcessTemplate *TeamProjectCapabilitiesProcessTemplate `json:"processTemplate,omitempty"` +} + +type TeamProjectCapabilitiesVersionControl struct { + SourceControlType *SourceControlTypes `json:"sourceControlType,omitempty"` +} + +type TeamProjectCapabilitiesProcessTemplate struct { + TemplateId *string `json:"templateTypeId,omitempty"` +} From 5ba47e0e551c2b0b585495d92092cbe8ad6f5e76 Mon Sep 17 00:00:00 2001 From: magodo Date: Thu, 29 Jan 2026 16:07:19 +1100 Subject: [PATCH 3/3] UnwrapError() only returns WrappedError (instead of its pointer sometimes) --- azuredevops/v7/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azuredevops/v7/client.go b/azuredevops/v7/client.go index 766d0321..526871cd 100644 --- a/azuredevops/v7/client.go +++ b/azuredevops/v7/client.go @@ -378,7 +378,7 @@ func trimByteOrderMark(body []byte) []byte { func (client *Client) UnwrapError(response *http.Response) (err error) { if response.ContentLength == 0 { message := "Request returned status: " + response.Status - return &WrappedError{ + return WrappedError{ Message: &message, StatusCode: &response.StatusCode, } @@ -415,7 +415,7 @@ func (client *Client) UnwrapError(response *http.Response) (err error) { var wrappedImproperError WrappedImproperError err = json.Unmarshal(body, &wrappedImproperError) if err == nil && wrappedImproperError.Value != nil && wrappedImproperError.Value.Message != nil { - return &WrappedError{ + return WrappedError{ Message: wrappedImproperError.Value.Message, StatusCode: &response.StatusCode, }