Skip to content

Commit d81030c

Browse files
authored
Improve AzurePipelinesCredential diagnosability (Azure#23485)
1 parent 0f6cf2d commit d81030c

File tree

4 files changed

+66
-2
lines changed

4 files changed

+66
-2
lines changed

sdk/azidentity/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
### Bugs Fixed
1010

1111
### Other Changes
12+
* `AzurePipelinesCredential` sets an additional OIDC request header so that it
13+
receives a 401 instead of a 302 after presenting an invalid system access token
14+
* Allow logging of debugging headers for `AzurePipelinesCredential` and include
15+
them in error messages
1216

1317
## 1.8.0-beta.3 (2024-09-17)
1418

sdk/azidentity/TROUBLESHOOTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ azd auth token --output json --scope https://management.core.windows.net/.defaul
234234
|---|---|---|
235235
| AADSTS900023: Specified tenant identifier 'some tenant ID' is neither a valid DNS name, nor a valid external domain.|The `tenantID` argument to `NewAzurePipelinesCredential` is incorrect| Verify the tenant ID. It must identify the tenant of the user-assigned managed identity or service principal configured for the service connection.|
236236
| No service connection found with identifier |The `serviceConnectionID` argument to `NewAzurePipelinesCredential` is incorrect| Verify the service connection ID. This parameter refers to the `resourceId` of the Azure Service Connection. It can also be found in the query string of the service connection's configuration in Azure DevOps. [Azure Pipelines documentation](https://learn.microsoft.com/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml) has more information about service connections.|
237-
|302 (Found) response from OIDC endpoint|The `systemAccessToken` argument to `NewAzurePipelinesCredential` is incorrect|Check pipeline configuration. This value comes from the predefined variable `System.AccessToken` [as described in Azure Pipelines documentation](https://learn.microsoft.com/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#systemaccesstoken).|
237+
|401 (Unauthorized) response from OIDC endpoint|The `systemAccessToken` argument to `NewAzurePipelinesCredential` is incorrect|Check pipeline configuration. This value comes from the predefined variable `System.AccessToken` [as described in Azure Pipelines documentation](https://learn.microsoft.com/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#systemaccesstoken).|
238238

239239
## Get additional help
240240

sdk/azidentity/azure_pipelines_credential.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const (
2020
credNameAzurePipelines = "AzurePipelinesCredential"
2121
oidcAPIVersion = "7.1"
2222
systemOIDCRequestURI = "SYSTEM_OIDCREQUESTURI"
23+
xMsEdgeRef = "x-msedge-ref"
24+
xVssE2eId = "x-vss-e2eid"
2325
)
2426

2527
// AzurePipelinesCredential authenticates with workload identity federation in an Azure Pipeline. See
@@ -86,6 +88,8 @@ func NewAzurePipelinesCredential(tenantID, clientID, serviceConnectionID, system
8688
if options == nil {
8789
options = &AzurePipelinesCredentialOptions{}
8890
}
91+
// these headers are useful to the DevOps team when debugging OIDC error responses
92+
options.ClientOptions.Logging.AllowedHeaders = append(options.ClientOptions.Logging.AllowedHeaders, xMsEdgeRef, xVssE2eId)
8993
caco := ClientAssertionCredentialOptions{
9094
AdditionallyAllowedTenants: options.AdditionallyAllowedTenants,
9195
Cache: options.Cache,
@@ -121,12 +125,19 @@ func (a *AzurePipelinesCredential) getAssertion(ctx context.Context) (string, er
121125
return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't create OIDC token request: "+err.Error(), nil)
122126
}
123127
req.Header.Set("Authorization", "Bearer "+a.systemAccessToken)
128+
// instruct endpoint to return 401 instead of 302, if the system access token is invalid
129+
req.Header.Set("X-TFS-FedAuthRedirect", "Suppress")
124130
res, err := doForClient(a.cred.client.azClient, req)
125131
if err != nil {
126132
return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't send OIDC token request: "+err.Error(), nil)
127133
}
128134
if res.StatusCode != http.StatusOK {
129-
msg := res.Status + " response from the OIDC endpoint. Check service connection ID and Pipeline configuration"
135+
msg := res.Status + " response from the OIDC endpoint. Check service connection ID and Pipeline configuration."
136+
for _, h := range []string{xMsEdgeRef, xVssE2eId} {
137+
if v := res.Header.Get(h); v != "" {
138+
msg += fmt.Sprintf("\n%s: %s", h, v)
139+
}
140+
}
130141
// include the response because its body, if any, probably contains an error message.
131142
// OK responses aren't included with errors because they probably contain secrets
132143
return "", newAuthenticationFailedError(credNameAzurePipelines, msg, res)

sdk/azidentity/azure_pipelines_credential_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"testing"
1313

1414
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
15+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/log"
1516
"github.com/Azure/azure-sdk-for-go/sdk/internal/mock"
1617
"github.com/Azure/azure-sdk-for-go/sdk/internal/recording"
1718
"github.com/stretchr/testify/require"
@@ -35,6 +36,7 @@ func TestAzurePipelinesCredential(t *testing.T) {
3536
require.Equal(t, expected.Host, r.Host)
3637
require.Equal(t, expected.Path, r.URL.Path)
3738
require.Equal(t, expected.RawQuery, r.URL.RawQuery)
39+
require.Equal(t, "Suppress", r.Header.Get("X-TFS-FedAuthRedirect"))
3840
return true
3941
}),
4042
)
@@ -50,6 +52,53 @@ func TestAzurePipelinesCredential(t *testing.T) {
5052
require.NoError(t, err)
5153
require.Equal(t, tokenValue, actual)
5254
})
55+
t.Run("OIDC error headers", func(t *testing.T) {
56+
expected := map[string]string{
57+
xMsEdgeRef: "foo",
58+
xVssE2eId: "bar",
59+
}
60+
// for matching the expected headers in messages, canonicalized or not
61+
regexFmt := `(?i)%s:\s+%s`
62+
63+
srv, close := mock.NewServer()
64+
defer close()
65+
t.Setenv(systemOIDCRequestURI, srv.URL())
66+
ro := []mock.ResponseOption{mock.WithStatusCode(http.StatusUnauthorized)}
67+
for k, v := range expected {
68+
ro = append(ro, mock.WithHeader(k, v))
69+
}
70+
srv.AppendResponse(ro...)
71+
72+
logged := false
73+
log.SetEvents(log.EventResponse)
74+
log.SetListener(func(e log.Event, m string) {
75+
if e == log.EventResponse {
76+
logged = true
77+
for k, v := range expected {
78+
rx := fmt.Sprintf(regexFmt, k, v)
79+
require.Regexp(t, rx, m, fmt.Sprintf(`expected header "%s: %s" in log message`, k, v))
80+
}
81+
}
82+
})
83+
defer func() {
84+
log.SetEvents()
85+
log.SetListener(nil)
86+
}()
87+
88+
o := AzurePipelinesCredentialOptions{
89+
ClientOptions: azcore.ClientOptions{
90+
Transport: srv,
91+
},
92+
}
93+
cred, err := NewAzurePipelinesCredential(fakeTenantID, fakeClientID, "connectionID", tokenValue, &o)
94+
require.NoError(t, err)
95+
_, err = cred.getAssertion(ctx)
96+
for k, v := range expected {
97+
rx := fmt.Sprintf(regexFmt, k, v)
98+
require.Regexp(t, rx, err.Error(), fmt.Sprintf(`expected header "%s: %s" in error message`, k, v))
99+
}
100+
require.True(t, logged, "test bug: response should have been logged")
101+
})
53102
t.Run("Live", func(t *testing.T) {
54103
if recording.GetRecordMode() != recording.LiveMode {
55104
t.Skip("this test runs only live in an Azure Pipeline with a configured service connection")

0 commit comments

Comments
 (0)