Add AzurePipelinesCredential for service connection authentication (#22891)
This commit is contained in:
Родитель
cef4ac10f7
Коммит
4fd61cce02
|
@ -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)
|
||||
|
||||
|
|
|
@ -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}}
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
})
|
||||
}
|
|
@ -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
|
||||
|
|
Загрузка…
Ссылка в новой задаче