Skip to content

Commit 185f44d

Browse files
committed
add a first draft for sp auth
1 parent 01b8fe9 commit 185f44d

File tree

5 files changed

+161
-35
lines changed

5 files changed

+161
-35
lines changed

.vscode/launch.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
"version": "0.2.0",
6+
"configurations": [
7+
{
8+
"name": "Launch Package",
9+
"type": "go",
10+
"request": "launch",
11+
"mode": "auto",
12+
"program": "${workspaceFolder}",
13+
}
14+
]
15+
}

azure-devops-client/main.go

Lines changed: 135 additions & 30 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"
@@ -21,7 +22,17 @@ type AzureDevopsClient struct {
2122

2223
organization *string
2324
collection *string
24-
accessToken *string
25+
26+
// we can either use a PAT token for authentication ...
27+
accessToken *string
28+
29+
// ... or client id and secret
30+
tenantId *string
31+
clientId *string
32+
clientSecret *string
33+
34+
entraIdToken *EntraIdToken
35+
entraIdTokenLastRefreshed int64
2536

2637
HostUrl *string
2738

@@ -48,6 +59,13 @@ type AzureDevopsClient struct {
4859
}
4960
}
5061

62+
type EntraIdToken struct {
63+
token_type *string
64+
expires_in *int64
65+
ext_expires_in *int64
66+
access_token *string
67+
}
68+
5169
func NewAzureDevopsClient() *AzureDevopsClient {
5270
c := AzureDevopsClient{}
5371
c.Init()
@@ -62,6 +80,8 @@ func (c *AzureDevopsClient) Init() {
6280
c.SetRetries(3)
6381
c.SetConcurrency(10)
6482

83+
c.entraIdTokenLastRefreshed = 0
84+
6585
c.LimitBuildsPerProject = 100
6686
c.LimitBuildsPerDefinition = 10
6787
c.LimitReleasesPerDefinition = 100
@@ -115,46 +135,131 @@ func (c *AzureDevopsClient) SetAccessToken(token string) {
115135
c.accessToken = &token
116136
}
117137

138+
func (c *AzureDevopsClient) SetTenantId(tenantId string) {
139+
c.tenantId = &tenantId
140+
}
141+
142+
func (c *AzureDevopsClient) SetClientId(clientId string) {
143+
c.clientId = &clientId
144+
}
145+
146+
func (c *AzureDevopsClient) SetClientSecret(clientSecret string) {
147+
c.clientSecret = &clientSecret
148+
}
149+
150+
func (c *AzureDevopsClient) SupportsPatAuthentication() bool {
151+
return c.accessToken != nil
152+
}
153+
154+
func (c *AzureDevopsClient) SupportsServicePrincipalAuthentication() bool {
155+
return c.tenantId != nil && c.clientId != nil && c.clientSecret != nil
156+
}
157+
158+
func (c *AzureDevopsClient) HasExpiredEntraIdAccessToken() bool {
159+
var currentUnix = time.Now().Unix()
160+
161+
// subtract 60 seconds of offset (should be enough time to use fire all requests)
162+
return (c.entraIdToken == nil || currentUnix >= c.entraIdTokenLastRefreshed+*c.entraIdToken.expires_in-60)
163+
}
164+
165+
func (c *AzureDevopsClient) RefreshEntraIdAccessToken() (string, error) {
166+
var restClient = resty.New()
167+
168+
restClient.SetBaseURL(fmt.Sprintf("https://login.microsoftonline.com/%v/oauth2/v2.0/token", *c.tenantId))
169+
170+
restClient.SetFormData(map[string]string{
171+
"client_id": *c.clientId,
172+
"client_secret": *c.clientSecret,
173+
"grant_type": "client_credentials",
174+
"scope": "499b84ac-1321-427f-aa17-267ca6975798", // the scope is always the same for Azure DevOps
175+
})
176+
177+
restClient.SetHeader("Content-Type", "application/x-www-form-urlencoded")
178+
restClient.SetHeader("Accept", "application/json")
179+
restClient.SetRetryCount(c.RequestRetries)
180+
181+
var response, err = restClient.R().Post("")
182+
183+
if err != nil {
184+
return "", err
185+
}
186+
187+
err = json.Unmarshal(response.Body(), &c.entraIdToken)
188+
189+
if err != nil {
190+
return "", err
191+
}
192+
193+
c.entraIdTokenLastRefreshed = time.Now().Unix()
194+
195+
return *c.entraIdToken.access_token, nil
196+
}
197+
118198
func (c *AzureDevopsClient) rest() *resty.Client {
199+
var client, err = c.restWithAuthentication("dev.azure.com")
200+
201+
if err != nil {
202+
// TODO handle error!
203+
}
204+
205+
return client
206+
}
207+
208+
func (c *AzureDevopsClient) restVsrm() *resty.Client {
209+
var client, err = c.restWithAuthentication("vsrm.dev.azure.com")
210+
211+
if err != nil {
212+
// TODO handle error!
213+
}
214+
215+
return client
216+
}
217+
218+
func (c *AzureDevopsClient) restWithAuthentication(domain string) (*resty.Client, error) {
119219
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")
220+
c.restClient = c.restWithoutToken(domain)
221+
}
222+
223+
if c.SupportsPatAuthentication() {
127224
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-
}
225+
} else if c.SupportsServicePrincipalAuthentication() {
226+
if c.HasExpiredEntraIdAccessToken() {
227+
var accessToken, err = c.RefreshEntraIdAccessToken()
134228

135-
c.restClient.OnAfterResponse(c.restOnAfterResponse)
229+
if err != nil {
230+
return nil, err
231+
}
136232

233+
c.restClient.SetBasicAuth("", accessToken)
234+
}
235+
} else {
236+
return nil, errors.New("no valid authentication method provided")
137237
}
138238

139-
return c.restClient
239+
return c.restClient, nil
140240
}
141241

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)
242+
func (c *AzureDevopsClient) restWithoutToken(domain string) *resty.Client {
243+
var restClient = resty.New()
244+
245+
if c.HostUrl != nil {
246+
restClient.SetBaseURL(*c.HostUrl + "/" + *c.organization + "/")
247+
} else {
248+
restClient.SetBaseURL(fmt.Sprintf("https://%v/%v/", domain, *c.organization))
249+
}
250+
251+
restClient.SetHeader("Accept", "application/json")
252+
restClient.SetRetryCount(c.RequestRetries)
253+
254+
if c.delayUntil != nil {
255+
restClient.OnBeforeRequest(c.restOnBeforeRequestDelay)
256+
} else {
257+
restClient.OnBeforeRequest(c.restOnBeforeRequest)
155258
}
156259

157-
return c.restClientVsrm
260+
restClient.OnAfterResponse(c.restOnAfterResponse)
261+
262+
return restClient
158263
}
159264

160265
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: 6 additions & 3 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 '@'
@@ -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)