Skip to content

Commit

Permalink
Add AzurePipelinesCredential for service connection authentication (A…
Browse files Browse the repository at this point in the history
  • Loading branch information
chlowell authored May 13, 2024
1 parent cef4ac1 commit 4fd61cc
Show file tree
Hide file tree
Showing 5 changed files with 200 additions and 7 deletions.
10 changes: 3 additions & 7 deletions sdk/azidentity/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
1 change: 1 addition & 0 deletions sdk/azidentity/azidentity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
)

Expand Down
124 changes: 124 additions & 0 deletions sdk/azidentity/azure_pipelines_credential.go
Original file line number Diff line number Diff line change
@@ -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
}
70 changes: 70 additions & 0 deletions sdk/azidentity/azure_pipelines_credential_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
2 changes: 2 additions & 0 deletions sdk/azidentity/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 4fd61cc

Please sign in to comment.