Skip to content

Commit 9c9b703

Browse files
authored
Merge pull request #77 from moredatapls/add-sp-authentication
Add support for service principal authentication
2 parents 01b8fe9 + a04bfd4 commit 9c9b703

File tree

5 files changed

+183
-39
lines changed

5 files changed

+183
-39
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ Application Options:
3131
--scrape.time.query= Scrape time for query results (time.duration) [$SCRAPE_TIME_QUERY]
3232
--scrape.time.live= Scrape time for live metrics (time.duration) (default: 30s) [$SCRAPE_TIME_LIVE]
3333
--stats.summary.maxage= Stats Summary metrics max age (time.duration) [$STATS_SUMMARY_MAX_AGE]
34-
--azuredevops.url= Azure DevOps url (empty if hosted by microsoft) [$AZURE_DEVOPS_URL]
34+
--azuredevops.url= Azure DevOps URL (empty if hosted by Microsoft) [$AZURE_DEVOPS_URL]
3535
--azuredevops.access-token= Azure DevOps access token [$AZURE_DEVOPS_ACCESS_TOKEN]
3636
--azuredevops.access-token-file= Azure DevOps access token (from file) [$AZURE_DEVOPS_ACCESS_TOKEN_FILE]
37+
--azuredevops.tenant-id= Azure tenant ID for Service Principal authentication [$AZURE_TENANT_ID]
38+
--azuredevops.client-id= Client ID for Service Principal authentication [$AZURE_CLIENT_ID]
39+
--azuredevops.client-secret= Client secret for Service Principal authentication [$AZURE_CLIENT_SECRET]
3740
--azuredevops.organisation= Azure DevOps organization [$AZURE_DEVOPS_ORGANISATION]
3841
--azuredevops.apiversion= Azure DevOps API version (default: 5.1) [$AZURE_DEVOPS_APIVERSION]
3942
--azuredevops.agentpool= Enable scrape metrics for agent pool (IDs) [$AZURE_DEVOPS_AGENTPOOL]

azure-devops-client/main.go

Lines changed: 167 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package AzureDevopsClient
22

33
import (
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

1618
type 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+
118228
func (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

160295
func (c *AzureDevopsClient) concurrencyLock() {

config/opts.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,12 @@ type (
3838

3939
// azure settings
4040
AzureDevops struct {
41-
Url *string `long:"azuredevops.url" env:"AZURE_DEVOPS_URL" description:"Azure DevOps url (empty if hosted by microsoft)"`
41+
Url *string `long:"azuredevops.url" env:"AZURE_DEVOPS_URL" description:"Azure DevOps URL (empty if hosted by Microsoft)"`
4242
AccessToken string `long:"azuredevops.access-token" env:"AZURE_DEVOPS_ACCESS_TOKEN" description:"Azure DevOps access token" json:"-"`
4343
AccessTokenFile *string `long:"azuredevops.access-token-file" env:"AZURE_DEVOPS_ACCESS_TOKEN_FILE" description:"Azure DevOps access token (from file)"`
44+
TenantId string `long:"azuredevops.tenant-id" env:"AZURE_TENANT_ID" description:"Azure tenant ID for Service Principal authentication" json:"-"`
45+
ClientId string `long:"azuredevops.client-id" env:"AZURE_CLIENT_ID" description:"Client ID for Service Principal authentication" json:"-"`
46+
ClientSecret string `long:"azuredevops.client-secret" env:"AZURE_CLIENT_SECRET" description:"Client secret for Service Principal authentication" json:"-"`
4447
Organisation string `long:"azuredevops.organisation" env:"AZURE_DEVOPS_ORGANISATION" description:"Azure DevOps organization" required:"true"`
4548
ApiVersion string `long:"azuredevops.apiversion" env:"AZURE_DEVOPS_APIVERSION" description:"Azure DevOps API version" default:"5.1"`
4649

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/webdevops/azure-devops-exporter
22

3-
go 1.22
3+
go 1.22.0
44

55
require (
66
github.com/go-resty/resty/v2 v2.11.0

main.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ var (
3636
)
3737

3838
func main() {
39-
initArgparser()
4039
initLogger()
40+
initArgparser()
4141

4242
logger.Infof("starting azure-devops-exporter v%s (%s; %s; by %v)", gitTag, gitCommit, runtime.Version(), Author)
4343
logger.Info(string(opts.GetJson()))
@@ -82,8 +82,8 @@ func initArgparser() {
8282
}
8383
}
8484

85-
if len(opts.AzureDevops.AccessToken) == 0 {
86-
logger.Fatalf("no Azure DevOps access token specified")
85+
if len(opts.AzureDevops.AccessToken) == 0 && (len(opts.AzureDevops.TenantId) == 0 || len(opts.AzureDevops.ClientId) == 0 || len(opts.AzureDevops.ClientSecret) == 0) {
86+
logger.Fatalf("neither an Azure DevOps PAT token nor client credentials (tenant ID, client ID, client secret) for service principal authentication have been provided")
8787
}
8888

8989
// ensure query paths and projects are splitted by '@'
@@ -148,7 +148,7 @@ func initArgparser() {
148148

149149
// Init and build Azure authorzier
150150
func initAzureDevOpsConnection() {
151-
AzureDevopsClient = AzureDevops.NewAzureDevopsClient()
151+
AzureDevopsClient = AzureDevops.NewAzureDevopsClient(logger)
152152
if opts.AzureDevops.Url != nil {
153153
AzureDevopsClient.HostUrl = opts.AzureDevops.Url
154154
}
@@ -160,6 +160,9 @@ func initAzureDevOpsConnection() {
160160

161161
AzureDevopsClient.SetOrganization(opts.AzureDevops.Organisation)
162162
AzureDevopsClient.SetAccessToken(opts.AzureDevops.AccessToken)
163+
AzureDevopsClient.SetTenantId(opts.AzureDevops.TenantId)
164+
AzureDevopsClient.SetClientId(opts.AzureDevops.ClientId)
165+
AzureDevopsClient.SetClientSecret(opts.AzureDevops.ClientSecret)
163166
AzureDevopsClient.SetApiVersion(opts.AzureDevops.ApiVersion)
164167
AzureDevopsClient.SetConcurrency(opts.Request.ConcurrencyLimit)
165168
AzureDevopsClient.SetRetries(opts.Request.Retries)

0 commit comments

Comments
 (0)