workload_identity.go

  1//go:build go1.18
  2// +build go1.18
  3
  4// Copyright (c) Microsoft Corporation. All rights reserved.
  5// Licensed under the MIT License.
  6
  7package azidentity
  8
  9import (
 10	"context"
 11	"errors"
 12	"os"
 13	"sync"
 14	"time"
 15
 16	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
 17	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
 18	"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
 19)
 20
 21const credNameWorkloadIdentity = "WorkloadIdentityCredential"
 22
 23// WorkloadIdentityCredential supports Azure workload identity on Kubernetes.
 24// See [Azure Kubernetes Service documentation] for more information.
 25//
 26// [Azure Kubernetes Service documentation]: https://learn.microsoft.com/azure/aks/workload-identity-overview
 27type WorkloadIdentityCredential struct {
 28	assertion, file string
 29	cred            *ClientAssertionCredential
 30	expires         time.Time
 31	mtx             *sync.RWMutex
 32}
 33
 34// WorkloadIdentityCredentialOptions contains optional parameters for WorkloadIdentityCredential.
 35type WorkloadIdentityCredentialOptions 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	// ClientID of the service principal. Defaults to the value of the environment variable AZURE_CLIENT_ID.
 43	ClientID string
 44	// DisableInstanceDiscovery should be set true only by applications authenticating in disconnected clouds, or
 45	// private clouds such as Azure Stack. It determines whether the credential requests Microsoft Entra instance metadata
 46	// from https://login.microsoft.com before authenticating. Setting this to true will skip this request, making
 47	// the application responsible for ensuring the configured authority is valid and trustworthy.
 48	DisableInstanceDiscovery bool
 49	// TenantID of the service principal. Defaults to the value of the environment variable AZURE_TENANT_ID.
 50	TenantID string
 51	// TokenFilePath is the path of a file containing a Kubernetes service account token. Defaults to the value of the
 52	// environment variable AZURE_FEDERATED_TOKEN_FILE.
 53	TokenFilePath string
 54}
 55
 56// NewWorkloadIdentityCredential constructs a WorkloadIdentityCredential. Service principal configuration is read
 57// from environment variables as set by the Azure workload identity webhook. Set options to override those values.
 58func NewWorkloadIdentityCredential(options *WorkloadIdentityCredentialOptions) (*WorkloadIdentityCredential, error) {
 59	if options == nil {
 60		options = &WorkloadIdentityCredentialOptions{}
 61	}
 62	ok := false
 63	clientID := options.ClientID
 64	if clientID == "" {
 65		if clientID, ok = os.LookupEnv(azureClientID); !ok {
 66			return nil, errors.New("no client ID specified. Check pod configuration or set ClientID in the options")
 67		}
 68	}
 69	file := options.TokenFilePath
 70	if file == "" {
 71		if file, ok = os.LookupEnv(azureFederatedTokenFile); !ok {
 72			return nil, errors.New("no token file specified. Check pod configuration or set TokenFilePath in the options")
 73		}
 74	}
 75	tenantID := options.TenantID
 76	if tenantID == "" {
 77		if tenantID, ok = os.LookupEnv(azureTenantID); !ok {
 78			return nil, errors.New("no tenant ID specified. Check pod configuration or set TenantID in the options")
 79		}
 80	}
 81	w := WorkloadIdentityCredential{file: file, mtx: &sync.RWMutex{}}
 82	caco := ClientAssertionCredentialOptions{
 83		AdditionallyAllowedTenants: options.AdditionallyAllowedTenants,
 84		ClientOptions:              options.ClientOptions,
 85		DisableInstanceDiscovery:   options.DisableInstanceDiscovery,
 86	}
 87	cred, err := NewClientAssertionCredential(tenantID, clientID, w.getAssertion, &caco)
 88	if err != nil {
 89		return nil, err
 90	}
 91	// we want "WorkloadIdentityCredential" in log messages, not "ClientAssertionCredential"
 92	cred.client.name = credNameWorkloadIdentity
 93	w.cred = cred
 94	return &w, nil
 95}
 96
 97// GetToken requests an access token from Microsoft Entra ID. Azure SDK clients call this method automatically.
 98func (w *WorkloadIdentityCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
 99	var err error
100	ctx, endSpan := runtime.StartSpan(ctx, credNameWorkloadIdentity+"."+traceOpGetToken, w.cred.client.azClient.Tracer(), nil)
101	defer func() { endSpan(err) }()
102	tk, err := w.cred.GetToken(ctx, opts)
103	return tk, err
104}
105
106// getAssertion returns the specified file's content, which is expected to be a Kubernetes service account token.
107// Kubernetes is responsible for updating the file as service account tokens expire.
108func (w *WorkloadIdentityCredential) getAssertion(context.Context) (string, error) {
109	w.mtx.RLock()
110	if w.expires.Before(time.Now()) {
111		// ensure only one goroutine at a time updates the assertion
112		w.mtx.RUnlock()
113		w.mtx.Lock()
114		defer w.mtx.Unlock()
115		// double check because another goroutine may have acquired the write lock first and done the update
116		if now := time.Now(); w.expires.Before(now) {
117			content, err := os.ReadFile(w.file)
118			if err != nil {
119				return "", err
120			}
121			w.assertion = string(content)
122			// Kubernetes rotates service account tokens when they reach 80% of their total TTL. The shortest TTL
123			// is 1 hour. That implies the token we just read is valid for at least 12 minutes (20% of 1 hour),
124			// but we add some margin for safety.
125			w.expires = now.Add(10 * time.Minute)
126		}
127	} else {
128		defer w.mtx.RUnlock()
129	}
130	return w.assertion, nil
131}