11package AzureDevopsClient
22
33import (
4+ "encoding/json"
45 "errors"
56 "fmt"
67 "net/url"
@@ -11,17 +12,30 @@ import (
1112
1213 resty "github.com/go-resty/resty/v2"
1314 "github.com/prometheus/client_golang/prometheus"
15+ "go.uber.org/zap"
1416)
1517
1618type AzureDevopsClient struct {
19+ logger * zap.SugaredLogger
20+
1721 // RequestCount has to be the first words
1822 // in order to be 64-aligned on 32-bit architectures.
1923 RequestCount uint64
2024 RequestRetries int
2125
2226 organization * string
2327 collection * string
24- accessToken * string
28+
29+ // we can either use a PAT token for authentication ...
30+ accessToken * string
31+
32+ // ... or client id and secret
33+ tenantId * string
34+ clientId * string
35+ clientSecret * string
36+
37+ entraIdToken * EntraIdToken
38+ entraIdTokenLastRefreshed int64
2539
2640 HostUrl * string
2741
@@ -48,8 +62,22 @@ type AzureDevopsClient struct {
4862 }
4963}
5064
51- func NewAzureDevopsClient () * AzureDevopsClient {
52- c := AzureDevopsClient {}
65+ type EntraIdToken struct {
66+ TokenType * string `json:"token_type"`
67+ ExpiresIn * int64 `json:"expires_in"`
68+ ExtExpiresIn * int64 `json:"ext_expires_in"`
69+ AccessToken * string `json:"access_token"`
70+ }
71+
72+ type EntraIdErrorResponse struct {
73+ Error * string `json:"error"`
74+ ErrorDescription * string `json:"error_description"`
75+ }
76+
77+ func NewAzureDevopsClient (logger * zap.SugaredLogger ) * AzureDevopsClient {
78+ c := AzureDevopsClient {
79+ logger : logger ,
80+ }
5381 c .Init ()
5482
5583 return & c
@@ -62,6 +90,8 @@ func (c *AzureDevopsClient) Init() {
6290 c .SetRetries (3 )
6391 c .SetConcurrency (10 )
6492
93+ c .entraIdTokenLastRefreshed = 0
94+
6595 c .LimitBuildsPerProject = 100
6696 c .LimitBuildsPerDefinition = 10
6797 c .LimitReleasesPerDefinition = 100
@@ -115,46 +145,151 @@ func (c *AzureDevopsClient) SetAccessToken(token string) {
115145 c .accessToken = & token
116146}
117147
148+ func (c * AzureDevopsClient ) SetTenantId (tenantId string ) {
149+ c .tenantId = & tenantId
150+ }
151+
152+ func (c * AzureDevopsClient ) SetClientId (clientId string ) {
153+ c .clientId = & clientId
154+ }
155+
156+ func (c * AzureDevopsClient ) SetClientSecret (clientSecret string ) {
157+ c .clientSecret = & clientSecret
158+ }
159+
160+ func (c * AzureDevopsClient ) SupportsPatAuthentication () bool {
161+ return c .accessToken != nil && len (* c .accessToken ) > 0
162+ }
163+
164+ func (c * AzureDevopsClient ) SupportsServicePrincipalAuthentication () bool {
165+ return c .tenantId != nil && len (* c .tenantId ) > 0 &&
166+ c .clientId != nil && len (* c .clientId ) > 0 &&
167+ c .clientSecret != nil && len (* c .clientSecret ) > 0
168+ }
169+
170+ func (c * AzureDevopsClient ) HasExpiredEntraIdAccessToken () bool {
171+ var currentUnix = time .Now ().Unix ()
172+
173+ // subtract 60 seconds of offset (should be enough time to use fire all requests)
174+ return (c .entraIdToken == nil || currentUnix >= c .entraIdTokenLastRefreshed + * c .entraIdToken .ExpiresIn - 60 )
175+ }
176+
177+ func (c * AzureDevopsClient ) RefreshEntraIdAccessToken () (string , error ) {
178+ var restClient = resty .New ()
179+
180+ restClient .SetBaseURL (fmt .Sprintf ("https://login.microsoftonline.com/%v/oauth2/v2.0/token" , * c .tenantId ))
181+
182+ restClient .SetFormData (map [string ]string {
183+ "client_id" : * c .clientId ,
184+ "client_secret" : * c .clientSecret ,
185+ "grant_type" : "client_credentials" ,
186+ "scope" : "499b84ac-1321-427f-aa17-267ca6975798/.default" , // the scope is always the same for Azure DevOps
187+ })
188+
189+ restClient .SetHeader ("Content-Type" , "application/x-www-form-urlencoded" )
190+ restClient .SetHeader ("Accept" , "application/json" )
191+ restClient .SetRetryCount (c .RequestRetries )
192+
193+ var response , err = restClient .R ().Post ("" )
194+
195+ if err != nil {
196+ return "" , err
197+ }
198+
199+ var responseBody = response .Body ()
200+
201+ var errorResponse * EntraIdErrorResponse
202+
203+ err = json .Unmarshal (responseBody , & errorResponse )
204+
205+ if err != nil {
206+ return "" , err
207+ }
208+
209+ if errorResponse .Error != nil && len (* errorResponse .Error ) > 0 {
210+ return "" , fmt .Errorf ("could not request a token, error: %v %v" , * errorResponse .Error , * errorResponse .ErrorDescription )
211+ }
212+
213+ err = json .Unmarshal (responseBody , & c .entraIdToken )
214+
215+ if err != nil {
216+ return "" , err
217+ }
218+
219+ if c .entraIdToken == nil || c .entraIdToken .AccessToken == nil {
220+ return "" , errors .New ("could not request an access token" )
221+ }
222+
223+ c .entraIdTokenLastRefreshed = time .Now ().Unix ()
224+
225+ return * c .entraIdToken .AccessToken , nil
226+ }
227+
118228func (c * AzureDevopsClient ) rest () * resty.Client {
229+ var client , err = c .restWithAuthentication ("dev.azure.com" )
230+
231+ if err != nil {
232+ c .logger .Fatalf ("could not create a rest client: %v" , err )
233+ }
234+
235+ return client
236+ }
237+
238+ func (c * AzureDevopsClient ) restVsrm () * resty.Client {
239+ var client , err = c .restWithAuthentication ("vsrm.dev.azure.com" )
240+
241+ if err != nil {
242+ c .logger .Fatalf ("could not create a rest client: %v" , err )
243+ }
244+
245+ return client
246+ }
247+
248+ func (c * AzureDevopsClient ) restWithAuthentication (domain string ) (* resty.Client , error ) {
119249 if c .restClient == nil {
120- c .restClient = resty .New ()
121- if c .HostUrl != nil {
122- c .restClient .SetBaseURL (* c .HostUrl + "/" + * c .organization + "/" )
123- } else {
124- c .restClient .SetBaseURL (fmt .Sprintf ("https://dev.azure.com/%v/" , * c .organization ))
125- }
126- c .restClient .SetHeader ("Accept" , "application/json" )
250+ c .restClient = c .restWithoutToken (domain )
251+ }
252+
253+ if c .SupportsPatAuthentication () {
127254 c .restClient .SetBasicAuth ("" , * c .accessToken )
128- c .restClient .SetRetryCount (c .RequestRetries )
129- if c .delayUntil != nil {
130- c .restClient .OnBeforeRequest (c .restOnBeforeRequestDelay )
131- } else {
132- c .restClient .OnBeforeRequest (c .restOnBeforeRequest )
133- }
255+ } else if c .SupportsServicePrincipalAuthentication () {
256+ if c .HasExpiredEntraIdAccessToken () {
257+ var accessToken , err = c .RefreshEntraIdAccessToken ()
134258
135- c .restClient .OnAfterResponse (c .restOnAfterResponse )
259+ if err != nil {
260+ return nil , err
261+ }
136262
263+ c .restClient .SetBasicAuth ("" , accessToken )
264+ }
265+ } else {
266+ return nil , errors .New ("no valid authentication method provided" )
137267 }
138268
139- return c .restClient
269+ return c .restClient , nil
140270}
141271
142- func (c * AzureDevopsClient ) restVsrm () * resty.Client {
143- if c .restClientVsrm == nil {
144- c .restClientVsrm = resty .New ()
145- if c .HostUrl != nil {
146- c .restClientVsrm .SetBaseURL (* c .HostUrl + "/" + * c .organization + "/" )
147- } else {
148- c .restClientVsrm .SetBaseURL (fmt .Sprintf ("https://vsrm.dev.azure.com/%v/" , * c .organization ))
149- }
150- c .restClientVsrm .SetHeader ("Accept" , "application/json" )
151- c .restClientVsrm .SetBasicAuth ("" , * c .accessToken )
152- c .restClientVsrm .SetRetryCount (c .RequestRetries )
153- c .restClientVsrm .OnBeforeRequest (c .restOnBeforeRequest )
154- c .restClientVsrm .OnAfterResponse (c .restOnAfterResponse )
272+ func (c * AzureDevopsClient ) restWithoutToken (domain string ) * resty.Client {
273+ var restClient = resty .New ()
274+
275+ if c .HostUrl != nil {
276+ restClient .SetBaseURL (* c .HostUrl + "/" + * c .organization + "/" )
277+ } else {
278+ restClient .SetBaseURL (fmt .Sprintf ("https://%v/%v/" , domain , * c .organization ))
279+ }
280+
281+ restClient .SetHeader ("Accept" , "application/json" )
282+ restClient .SetRetryCount (c .RequestRetries )
283+
284+ if c .delayUntil != nil {
285+ restClient .OnBeforeRequest (c .restOnBeforeRequestDelay )
286+ } else {
287+ restClient .OnBeforeRequest (c .restOnBeforeRequest )
155288 }
156289
157- return c .restClientVsrm
290+ restClient .OnAfterResponse (c .restOnAfterResponse )
291+
292+ return restClient
158293}
159294
160295func (c * AzureDevopsClient ) concurrencyLock () {
0 commit comments