Improve AzurePipelinesCredential diagnosability (#23485)

This commit is contained in:
Charles Lowell 2024-09-26 18:31:46 -07:00 коммит произвёл GitHub
Родитель 0f6cf2d8bd
Коммит d81030cbcc
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
4 изменённых файлов: 66 добавлений и 2 удалений

Просмотреть файл

@ -9,6 +9,10 @@
### Bugs Fixed ### Bugs Fixed
### Other Changes ### Other Changes
* `AzurePipelinesCredential` sets an additional OIDC request header so that it
receives a 401 instead of a 302 after presenting an invalid system access token
* Allow logging of debugging headers for `AzurePipelinesCredential` and include
them in error messages
## 1.8.0-beta.3 (2024-09-17) ## 1.8.0-beta.3 (2024-09-17)

Просмотреть файл

@ -234,7 +234,7 @@ azd auth token --output json --scope https://management.core.windows.net/.defaul
|---|---|---| |---|---|---|
| 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.| | 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.|
| 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.| | 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.|
|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).| |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).|
## Get additional help ## Get additional help

Просмотреть файл

@ -20,6 +20,8 @@ const (
credNameAzurePipelines = "AzurePipelinesCredential" credNameAzurePipelines = "AzurePipelinesCredential"
oidcAPIVersion = "7.1" oidcAPIVersion = "7.1"
systemOIDCRequestURI = "SYSTEM_OIDCREQUESTURI" systemOIDCRequestURI = "SYSTEM_OIDCREQUESTURI"
xMsEdgeRef = "x-msedge-ref"
xVssE2eId = "x-vss-e2eid"
) )
// AzurePipelinesCredential authenticates with workload identity federation in an Azure Pipeline. See // AzurePipelinesCredential authenticates with workload identity federation in an Azure Pipeline. See
@ -86,6 +88,8 @@ func NewAzurePipelinesCredential(tenantID, clientID, serviceConnectionID, system
if options == nil { if options == nil {
options = &AzurePipelinesCredentialOptions{} options = &AzurePipelinesCredentialOptions{}
} }
// these headers are useful to the DevOps team when debugging OIDC error responses
options.ClientOptions.Logging.AllowedHeaders = append(options.ClientOptions.Logging.AllowedHeaders, xMsEdgeRef, xVssE2eId)
caco := ClientAssertionCredentialOptions{ caco := ClientAssertionCredentialOptions{
AdditionallyAllowedTenants: options.AdditionallyAllowedTenants, AdditionallyAllowedTenants: options.AdditionallyAllowedTenants,
Cache: options.Cache, Cache: options.Cache,
@ -121,12 +125,19 @@ func (a *AzurePipelinesCredential) getAssertion(ctx context.Context) (string, er
return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't create OIDC token request: "+err.Error(), nil) return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't create OIDC token request: "+err.Error(), nil)
} }
req.Header.Set("Authorization", "Bearer "+a.systemAccessToken) req.Header.Set("Authorization", "Bearer "+a.systemAccessToken)
// instruct endpoint to return 401 instead of 302, if the system access token is invalid
req.Header.Set("X-TFS-FedAuthRedirect", "Suppress")
res, err := doForClient(a.cred.client.azClient, req) res, err := doForClient(a.cred.client.azClient, req)
if err != nil { if err != nil {
return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't send OIDC token request: "+err.Error(), nil) return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't send OIDC token request: "+err.Error(), nil)
} }
if res.StatusCode != http.StatusOK { if res.StatusCode != http.StatusOK {
msg := res.Status + " response from the OIDC endpoint. Check service connection ID and Pipeline configuration" msg := res.Status + " response from the OIDC endpoint. Check service connection ID and Pipeline configuration."
for _, h := range []string{xMsEdgeRef, xVssE2eId} {
if v := res.Header.Get(h); v != "" {
msg += fmt.Sprintf("\n%s: %s", h, v)
}
}
// include the response because its body, if any, probably contains an error message. // include the response because its body, if any, probably contains an error message.
// OK responses aren't included with errors because they probably contain secrets // OK responses aren't included with errors because they probably contain secrets
return "", newAuthenticationFailedError(credNameAzurePipelines, msg, res) return "", newAuthenticationFailedError(credNameAzurePipelines, msg, res)

Просмотреть файл

@ -12,6 +12,7 @@ import (
"testing" "testing"
"github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/log"
"github.com/Azure/azure-sdk-for-go/sdk/internal/mock" "github.com/Azure/azure-sdk-for-go/sdk/internal/mock"
"github.com/Azure/azure-sdk-for-go/sdk/internal/recording" "github.com/Azure/azure-sdk-for-go/sdk/internal/recording"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -35,6 +36,7 @@ func TestAzurePipelinesCredential(t *testing.T) {
require.Equal(t, expected.Host, r.Host) require.Equal(t, expected.Host, r.Host)
require.Equal(t, expected.Path, r.URL.Path) require.Equal(t, expected.Path, r.URL.Path)
require.Equal(t, expected.RawQuery, r.URL.RawQuery) require.Equal(t, expected.RawQuery, r.URL.RawQuery)
require.Equal(t, "Suppress", r.Header.Get("X-TFS-FedAuthRedirect"))
return true return true
}), }),
) )
@ -50,6 +52,53 @@ func TestAzurePipelinesCredential(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tokenValue, actual) require.Equal(t, tokenValue, actual)
}) })
t.Run("OIDC error headers", func(t *testing.T) {
expected := map[string]string{
xMsEdgeRef: "foo",
xVssE2eId: "bar",
}
// for matching the expected headers in messages, canonicalized or not
regexFmt := `(?i)%s:\s+%s`
srv, close := mock.NewServer()
defer close()
t.Setenv(systemOIDCRequestURI, srv.URL())
ro := []mock.ResponseOption{mock.WithStatusCode(http.StatusUnauthorized)}
for k, v := range expected {
ro = append(ro, mock.WithHeader(k, v))
}
srv.AppendResponse(ro...)
logged := false
log.SetEvents(log.EventResponse)
log.SetListener(func(e log.Event, m string) {
if e == log.EventResponse {
logged = true
for k, v := range expected {
rx := fmt.Sprintf(regexFmt, k, v)
require.Regexp(t, rx, m, fmt.Sprintf(`expected header "%s: %s" in log message`, k, v))
}
}
})
defer func() {
log.SetEvents()
log.SetListener(nil)
}()
o := AzurePipelinesCredentialOptions{
ClientOptions: azcore.ClientOptions{
Transport: srv,
},
}
cred, err := NewAzurePipelinesCredential(fakeTenantID, fakeClientID, "connectionID", tokenValue, &o)
require.NoError(t, err)
_, err = cred.getAssertion(ctx)
for k, v := range expected {
rx := fmt.Sprintf(regexFmt, k, v)
require.Regexp(t, rx, err.Error(), fmt.Sprintf(`expected header "%s: %s" in error message`, k, v))
}
require.True(t, logged, "test bug: response should have been logged")
})
t.Run("Live", func(t *testing.T) { t.Run("Live", func(t *testing.T) {
if recording.GetRecordMode() != recording.LiveMode { if recording.GetRecordMode() != recording.LiveMode {
t.Skip("this test runs only live in an Azure Pipeline with a configured service connection") t.Skip("this test runs only live in an Azure Pipeline with a configured service connection")