azure_pipelines_credential.go

  1// Copyright (c) Microsoft Corporation. All rights reserved.
  2// Licensed under the MIT License.
  3
  4package azidentity
  5
  6import (
  7	"context"
  8	"encoding/json"
  9	"errors"
 10	"fmt"
 11	"net/http"
 12	"os"
 13
 14	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
 15	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
 16	"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
 17)
 18
 19const (
 20	credNameAzurePipelines = "AzurePipelinesCredential"
 21	oidcAPIVersion         = "7.1"
 22	systemOIDCRequestURI   = "SYSTEM_OIDCREQUESTURI"
 23)
 24
 25// AzurePipelinesCredential authenticates with workload identity federation in an Azure Pipeline. See
 26// [Azure Pipelines documentation] for more information.
 27//
 28// [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
 29type AzurePipelinesCredential struct {
 30	connectionID, oidcURI, systemAccessToken string
 31	cred                                     *ClientAssertionCredential
 32}
 33
 34// AzurePipelinesCredentialOptions contains optional parameters for AzurePipelinesCredential.
 35type AzurePipelinesCredentialOptions struct {
 36	azcore.ClientOptions
 37
 38	// AdditionallyAllowedTenants specifies additional tenants for which the credential may acquire tokens.
 39	// Add the wildcard value "*" to allow the credential to acquire tokens for any tenant in which the
 40	// application is registered.
 41	AdditionallyAllowedTenants []string
 42
 43	// DisableInstanceDiscovery should be set true only by applications authenticating in disconnected clouds, or
 44	// private clouds such as Azure Stack. It determines whether the credential requests Microsoft Entra instance metadata
 45	// from https://login.microsoft.com before authenticating. Setting this to true will skip this request, making
 46	// the application responsible for ensuring the configured authority is valid and trustworthy.
 47	DisableInstanceDiscovery bool
 48}
 49
 50// NewAzurePipelinesCredential is the constructor for AzurePipelinesCredential.
 51//
 52//   - tenantID: tenant ID of the service principal federated with the service connection
 53//   - clientID: client ID of that service principal
 54//   - serviceConnectionID: ID of the service connection to authenticate
 55//   - systemAccessToken: security token for the running build. See [Azure Pipelines documentation] for
 56//     an example showing how to get this value.
 57//
 58// [Azure Pipelines documentation]: https://learn.microsoft.com/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#systemaccesstoken
 59func NewAzurePipelinesCredential(tenantID, clientID, serviceConnectionID, systemAccessToken string, options *AzurePipelinesCredentialOptions) (*AzurePipelinesCredential, error) {
 60	if !validTenantID(tenantID) {
 61		return nil, errInvalidTenantID
 62	}
 63	if clientID == "" {
 64		return nil, errors.New("no client ID specified")
 65	}
 66	if serviceConnectionID == "" {
 67		return nil, errors.New("no service connection ID specified")
 68	}
 69	if systemAccessToken == "" {
 70		return nil, errors.New("no system access token specified")
 71	}
 72	u := os.Getenv(systemOIDCRequestURI)
 73	if u == "" {
 74		return nil, fmt.Errorf("no value for environment variable %s. This should be set by Azure Pipelines", systemOIDCRequestURI)
 75	}
 76	a := AzurePipelinesCredential{
 77		connectionID:      serviceConnectionID,
 78		oidcURI:           u,
 79		systemAccessToken: systemAccessToken,
 80	}
 81	if options == nil {
 82		options = &AzurePipelinesCredentialOptions{}
 83	}
 84	caco := ClientAssertionCredentialOptions{
 85		AdditionallyAllowedTenants: options.AdditionallyAllowedTenants,
 86		ClientOptions:              options.ClientOptions,
 87		DisableInstanceDiscovery:   options.DisableInstanceDiscovery,
 88	}
 89	cred, err := NewClientAssertionCredential(tenantID, clientID, a.getAssertion, &caco)
 90	if err != nil {
 91		return nil, err
 92	}
 93	cred.client.name = credNameAzurePipelines
 94	a.cred = cred
 95	return &a, nil
 96}
 97
 98// GetToken requests an access token from Microsoft Entra ID. Azure SDK clients call this method automatically.
 99func (a *AzurePipelinesCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
100	var err error
101	ctx, endSpan := runtime.StartSpan(ctx, credNameAzurePipelines+"."+traceOpGetToken, a.cred.client.azClient.Tracer(), nil)
102	defer func() { endSpan(err) }()
103	tk, err := a.cred.GetToken(ctx, opts)
104	return tk, err
105}
106
107func (a *AzurePipelinesCredential) getAssertion(ctx context.Context) (string, error) {
108	url := a.oidcURI + "?api-version=" + oidcAPIVersion + "&serviceConnectionId=" + a.connectionID
109	url, err := runtime.EncodeQueryParams(url)
110	if err != nil {
111		return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't encode OIDC URL: "+err.Error(), nil, nil)
112	}
113	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
114	if err != nil {
115		return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't create OIDC token request: "+err.Error(), nil, nil)
116	}
117	req.Header.Set("Authorization", "Bearer "+a.systemAccessToken)
118	res, err := doForClient(a.cred.client.azClient, req)
119	if err != nil {
120		return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't send OIDC token request: "+err.Error(), nil, nil)
121	}
122	if res.StatusCode != http.StatusOK {
123		msg := res.Status + " response from the OIDC endpoint. Check service connection ID and Pipeline configuration"
124		// include the response because its body, if any, probably contains an error message.
125		// OK responses aren't included with errors because they probably contain secrets
126		return "", newAuthenticationFailedError(credNameAzurePipelines, msg, res, nil)
127	}
128	b, err := runtime.Payload(res)
129	if err != nil {
130		return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't read OIDC response content: "+err.Error(), nil, nil)
131	}
132	var r struct {
133		OIDCToken string `json:"oidcToken"`
134	}
135	err = json.Unmarshal(b, &r)
136	if err != nil {
137		return "", newAuthenticationFailedError(credNameAzurePipelines, "unexpected response from OIDC endpoint", nil, nil)
138	}
139	return r.OIDCToken, nil
140}