From 4fd61cce02c1cd68dc66d314ce1205ccb93ad828 Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Mon, 13 May 2024 15:56:41 -0700 Subject: [PATCH] Add AzurePipelinesCredential for service connection authentication (#22891) --- sdk/azidentity/CHANGELOG.md | 10 +- sdk/azidentity/azidentity_test.go | 1 + sdk/azidentity/azure_pipelines_credential.go | 124 ++++++++++++++++++ .../azure_pipelines_credential_test.go | 70 ++++++++++ sdk/azidentity/ci.yml | 2 + 5 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 sdk/azidentity/azure_pipelines_credential.go create mode 100644 sdk/azidentity/azure_pipelines_credential_test.go diff --git a/sdk/azidentity/CHANGELOG.md b/sdk/azidentity/CHANGELOG.md index 7e55ad34d6..ba4a6f0786 100644 --- a/sdk/azidentity/CHANGELOG.md +++ b/sdk/azidentity/CHANGELOG.md @@ -1,14 +1,10 @@ # Release History -## 1.6.0-beta.4 (Unreleased) +## 1.6.0-beta.4 (2024-05-14) ### Features Added - -### Breaking Changes - -### Bugs Fixed - -### Other Changes +* `AzurePipelinesCredential` authenticates an Azure Pipeline service connection with + workload identity federation ## 1.6.0-beta.3 (2024-04-09) diff --git a/sdk/azidentity/azidentity_test.go b/sdk/azidentity/azidentity_test.go index 97d0311aa0..7c56ea8282 100644 --- a/sdk/azidentity/azidentity_test.go +++ b/sdk/azidentity/azidentity_test.go @@ -48,6 +48,7 @@ const ( var ( accessTokenRespSuccess = []byte(fmt.Sprintf(`{"access_token": "%s","expires_in": %d,"token_type":"Bearer"}`, tokenValue, tokenExpiresIn)) + ctx = context.Background() testTRO = policy.TokenRequestOptions{Scopes: []string{liveTestScope}} ) diff --git a/sdk/azidentity/azure_pipelines_credential.go b/sdk/azidentity/azure_pipelines_credential.go new file mode 100644 index 0000000000..10d4837a5d --- /dev/null +++ b/sdk/azidentity/azure_pipelines_credential.go @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azidentity + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" +) + +const ( + credNameAzurePipelines = "AzurePipelinesCredential" + oidcAPIVersion = "7.1" + systemAccessToken = "SYSTEM_ACCESSTOKEN" + systemOIDCRequestURI = "SYSTEM_OIDCREQUESTURI" +) + +// AzurePipelinesCredential authenticates with workload identity federation in an Azure Pipeline. See +// [Azure Pipelines documentation] for more information. +// +// [Azure Pipelines documentation]: https://learn.microsoft.com/azure/devops/pipelines/library/connect-to-azure?view=azure-devops#create-an-azure-resource-manager-service-connection-that-uses-workload-identity-federation +type AzurePipelinesCredential struct { + connectionID, oidcURI, systemAccessToken string + cred *ClientAssertionCredential +} + +// AzurePipelinesServiceConnectionCredentialOptions contains optional parameters for AzurePipelinesServiceConnectionCredential. +type AzurePipelinesServiceConnectionCredentialOptions struct { + azcore.ClientOptions + + // AdditionallyAllowedTenants specifies additional tenants for which the credential may acquire tokens. + // Add the wildcard value "*" to allow the credential to acquire tokens for any tenant in which the + // application is registered. + AdditionallyAllowedTenants []string + + // DisableInstanceDiscovery should be set true only by applications authenticating in disconnected clouds, or + // private clouds such as Azure Stack. It determines whether the credential requests Microsoft Entra instance metadata + // from https://login.microsoft.com before authenticating. Setting this to true will skip this request, making + // the application responsible for ensuring the configured authority is valid and trustworthy. + DisableInstanceDiscovery bool +} + +// NewAzurePipelinesCredential is the constructor for AzurePipelinesCredential. In addition to its required arguments, +// it reads a security token for the running build, which is required to authenticate the service connection, from the +// environment variable SYSTEM_ACCESSTOKEN. See the [Azure Pipelines documentation] for an example showing how to set +// this variable in build job YAML. +// +// [Azure Pipelines documentation]: https://learn.microsoft.com/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#systemaccesstoken +func NewAzurePipelinesCredential(tenantID, clientID, serviceConnectionID string, options *AzurePipelinesServiceConnectionCredentialOptions) (*AzurePipelinesCredential, error) { + if options == nil { + options = &AzurePipelinesServiceConnectionCredentialOptions{} + } + u := os.Getenv(systemOIDCRequestURI) + if u == "" { + return nil, fmt.Errorf("no value for environment variable %s. This should be set by Azure Pipelines", systemOIDCRequestURI) + } + sat := os.Getenv(systemAccessToken) + if sat == "" { + return nil, errors.New("no value for environment variable " + systemAccessToken) + } + a := AzurePipelinesCredential{ + connectionID: serviceConnectionID, + oidcURI: u, + systemAccessToken: sat, + } + caco := ClientAssertionCredentialOptions{ + AdditionallyAllowedTenants: options.AdditionallyAllowedTenants, + ClientOptions: options.ClientOptions, + DisableInstanceDiscovery: options.DisableInstanceDiscovery, + } + cred, err := NewClientAssertionCredential(tenantID, clientID, a.getAssertion, &caco) + if err != nil { + return nil, err + } + cred.client.name = credNameAzurePipelines + a.cred = cred + return &a, nil +} + +// GetToken requests an access token from Microsoft Entra ID. Azure SDK clients call this method automatically. +func (a *AzurePipelinesCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { + var err error + ctx, endSpan := runtime.StartSpan(ctx, credNameAzurePipelines+"."+traceOpGetToken, a.cred.client.azClient.Tracer(), nil) + defer func() { endSpan(err) }() + tk, err := a.cred.GetToken(ctx, opts) + return tk, err +} + +func (a *AzurePipelinesCredential) getAssertion(ctx context.Context) (string, error) { + url := a.oidcURI + "?api-version=" + oidcAPIVersion + "&serviceConnectionId=" + a.connectionID + url, err := runtime.EncodeQueryParams(url) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+a.systemAccessToken) + res, err := doForClient(a.cred.client.azClient, req) + if err != nil { + return "", err + } + b, err := runtime.Payload(res) + if err != nil { + return "", err + } + var r struct { + OIDCToken string `json:"oidcToken"` + } + err = json.Unmarshal(b, &r) + if err != nil { + return "", err + } + return r.OIDCToken, nil +} diff --git a/sdk/azidentity/azure_pipelines_credential_test.go b/sdk/azidentity/azure_pipelines_credential_test.go new file mode 100644 index 0000000000..7e9ac3e263 --- /dev/null +++ b/sdk/azidentity/azure_pipelines_credential_test.go @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azidentity + +import ( + "fmt" + "net/http" + "net/url" + "os" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/internal/mock" + "github.com/Azure/azure-sdk-for-go/sdk/internal/recording" + "github.com/stretchr/testify/require" +) + +func TestAzurePipelinesCredential(t *testing.T) { + t.Run("getAssertion", func(t *testing.T) { + srv, close := mock.NewServer() + defer close() + t.Setenv(systemOIDCRequestURI, srv.URL()) + oidcAccessToken := "token" + t.Setenv(systemAccessToken, oidcAccessToken) + connectionID := "connection" + expected, err := url.Parse(fmt.Sprintf( + "%s/?api-version=%s&serviceConnectionId=%s", + srv.URL(), oidcAPIVersion, connectionID, + )) + require.NoError(t, err, "test bug: expected URL should parse") + srv.AppendResponse( + mock.WithBody([]byte(fmt.Sprintf(`{"oidcToken":%q}`, oidcAccessToken))), + mock.WithPredicate(func(r *http.Request) bool { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, expected.Host, r.Host) + require.Equal(t, expected.Path, r.URL.Path) + require.Equal(t, expected.RawQuery, r.URL.RawQuery) + return true + }), + ) + srv.AppendResponse() + o := AzurePipelinesServiceConnectionCredentialOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: srv, + }, + } + cred, err := NewAzurePipelinesCredential(fakeTenantID, fakeClientID, connectionID, &o) + require.NoError(t, err) + actual, err := cred.getAssertion(ctx) + require.NoError(t, err) + require.Equal(t, oidcAccessToken, actual) + }) + t.Run("Live", func(t *testing.T) { + if recording.GetRecordMode() != recording.LiveMode { + t.Skip("this test runs only live in an Azure Pipeline with a configured service connection") + } + clientID := os.Getenv("AZURE_SERVICE_CONNECTION_CLIENT_ID") + connectionID := os.Getenv("AZURE_SERVICE_CONNECTION_ID") + tenantID := os.Getenv("AZURE_SERVICE_CONNECTION_TENANT_ID") + for _, s := range []string{clientID, connectionID, tenantID} { + if s == "" { + t.Skip("set AZURE_SERVICE_CONNECTION_CLIENT_ID, AZURE_SERVICE_CONNECTION_ID and AZURE_SERVICE_CONNECTION_TENANT_ID to run this test") + } + } + cred, err := NewAzurePipelinesCredential(tenantID, clientID, connectionID, nil) + require.NoError(t, err) + testGetTokenSuccess(t, cred, "https://vault.azure.net/.default") + }) +} diff --git a/sdk/azidentity/ci.yml b/sdk/azidentity/ci.yml index c9d22a72b4..4cd8c51447 100644 --- a/sdk/azidentity/ci.yml +++ b/sdk/azidentity/ci.yml @@ -29,6 +29,8 @@ extends: SubscriptionConfigurations: - $(sub-config-azure-cloud-test-resources) - $(sub-config-identity-test-resources) + EnvVars: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) RunLiveTests: true ServiceDirectory: azidentity UsePipelineProxy: false