azure_cli_credential.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	"bytes"
 11	"context"
 12	"encoding/json"
 13	"errors"
 14	"fmt"
 15	"os"
 16	"os/exec"
 17	"runtime"
 18	"strings"
 19	"sync"
 20	"time"
 21
 22	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
 23	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
 24	"github.com/Azure/azure-sdk-for-go/sdk/internal/log"
 25)
 26
 27const credNameAzureCLI = "AzureCLICredential"
 28
 29type azTokenProvider func(ctx context.Context, scopes []string, tenant, subscription string) ([]byte, error)
 30
 31// AzureCLICredentialOptions contains optional parameters for AzureCLICredential.
 32type AzureCLICredentialOptions struct {
 33	// AdditionallyAllowedTenants specifies tenants for which the credential may acquire tokens, in addition
 34	// to TenantID. Add the wildcard value "*" to allow the credential to acquire tokens for any tenant the
 35	// logged in account can access.
 36	AdditionallyAllowedTenants []string
 37
 38	// Subscription is the name or ID of a subscription. Set this to acquire tokens for an account other
 39	// than the Azure CLI's current account.
 40	Subscription string
 41
 42	// TenantID identifies the tenant the credential should authenticate in.
 43	// Defaults to the CLI's default tenant, which is typically the home tenant of the logged in user.
 44	TenantID string
 45
 46	// inDefaultChain is true when the credential is part of DefaultAzureCredential
 47	inDefaultChain bool
 48	// tokenProvider is used by tests to fake invoking az
 49	tokenProvider azTokenProvider
 50}
 51
 52// init returns an instance of AzureCLICredentialOptions initialized with default values.
 53func (o *AzureCLICredentialOptions) init() {
 54	if o.tokenProvider == nil {
 55		o.tokenProvider = defaultAzTokenProvider
 56	}
 57}
 58
 59// AzureCLICredential authenticates as the identity logged in to the Azure CLI.
 60type AzureCLICredential struct {
 61	mu   *sync.Mutex
 62	opts AzureCLICredentialOptions
 63}
 64
 65// NewAzureCLICredential constructs an AzureCLICredential. Pass nil to accept default options.
 66func NewAzureCLICredential(options *AzureCLICredentialOptions) (*AzureCLICredential, error) {
 67	cp := AzureCLICredentialOptions{}
 68	if options != nil {
 69		cp = *options
 70	}
 71	for _, r := range cp.Subscription {
 72		if !(alphanumeric(r) || r == '-' || r == '_' || r == ' ' || r == '.') {
 73			return nil, fmt.Errorf("%s: invalid Subscription %q", credNameAzureCLI, cp.Subscription)
 74		}
 75	}
 76	if cp.TenantID != "" && !validTenantID(cp.TenantID) {
 77		return nil, errInvalidTenantID
 78	}
 79	cp.init()
 80	cp.AdditionallyAllowedTenants = resolveAdditionalTenants(cp.AdditionallyAllowedTenants)
 81	return &AzureCLICredential{mu: &sync.Mutex{}, opts: cp}, nil
 82}
 83
 84// GetToken requests a token from the Azure CLI. This credential doesn't cache tokens, so every call invokes the CLI.
 85// This method is called automatically by Azure SDK clients.
 86func (c *AzureCLICredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
 87	at := azcore.AccessToken{}
 88	if len(opts.Scopes) != 1 {
 89		return at, errors.New(credNameAzureCLI + ": GetToken() requires exactly one scope")
 90	}
 91	if !validScope(opts.Scopes[0]) {
 92		return at, fmt.Errorf("%s.GetToken(): invalid scope %q", credNameAzureCLI, opts.Scopes[0])
 93	}
 94	tenant, err := resolveTenant(c.opts.TenantID, opts.TenantID, credNameAzureCLI, c.opts.AdditionallyAllowedTenants)
 95	if err != nil {
 96		return at, err
 97	}
 98	c.mu.Lock()
 99	defer c.mu.Unlock()
100	b, err := c.opts.tokenProvider(ctx, opts.Scopes, tenant, c.opts.Subscription)
101	if err == nil {
102		at, err = c.createAccessToken(b)
103	}
104	if err != nil {
105		err = unavailableIfInChain(err, c.opts.inDefaultChain)
106		return at, err
107	}
108	msg := fmt.Sprintf("%s.GetToken() acquired a token for scope %q", credNameAzureCLI, strings.Join(opts.Scopes, ", "))
109	log.Write(EventAuthentication, msg)
110	return at, nil
111}
112
113// defaultAzTokenProvider invokes the Azure CLI to acquire a token. It assumes
114// callers have verified that all string arguments are safe to pass to the CLI.
115var defaultAzTokenProvider azTokenProvider = func(ctx context.Context, scopes []string, tenantID, subscription string) ([]byte, error) {
116	// pass the CLI a Microsoft Entra ID v1 resource because we don't know which CLI version is installed and older ones don't support v2 scopes
117	resource := strings.TrimSuffix(scopes[0], defaultSuffix)
118	// set a default timeout for this authentication iff the application hasn't done so already
119	var cancel context.CancelFunc
120	if _, hasDeadline := ctx.Deadline(); !hasDeadline {
121		ctx, cancel = context.WithTimeout(ctx, cliTimeout)
122		defer cancel()
123	}
124	commandLine := "az account get-access-token -o json --resource " + resource
125	if tenantID != "" {
126		commandLine += " --tenant " + tenantID
127	}
128	if subscription != "" {
129		// subscription needs quotes because it may contain spaces
130		commandLine += ` --subscription "` + subscription + `"`
131	}
132	var cliCmd *exec.Cmd
133	if runtime.GOOS == "windows" {
134		dir := os.Getenv("SYSTEMROOT")
135		if dir == "" {
136			return nil, newCredentialUnavailableError(credNameAzureCLI, "environment variable 'SYSTEMROOT' has no value")
137		}
138		cliCmd = exec.CommandContext(ctx, "cmd.exe", "/c", commandLine)
139		cliCmd.Dir = dir
140	} else {
141		cliCmd = exec.CommandContext(ctx, "/bin/sh", "-c", commandLine)
142		cliCmd.Dir = "/bin"
143	}
144	cliCmd.Env = os.Environ()
145	var stderr bytes.Buffer
146	cliCmd.Stderr = &stderr
147
148	output, err := cliCmd.Output()
149	if err != nil {
150		msg := stderr.String()
151		var exErr *exec.ExitError
152		if errors.As(err, &exErr) && exErr.ExitCode() == 127 || strings.HasPrefix(msg, "'az' is not recognized") {
153			msg = "Azure CLI not found on path"
154		}
155		if msg == "" {
156			msg = err.Error()
157		}
158		return nil, newCredentialUnavailableError(credNameAzureCLI, msg)
159	}
160
161	return output, nil
162}
163
164func (c *AzureCLICredential) createAccessToken(tk []byte) (azcore.AccessToken, error) {
165	t := struct {
166		AccessToken string `json:"accessToken"`
167		Expires_On  int64  `json:"expires_on"`
168		ExpiresOn   string `json:"expiresOn"`
169	}{}
170	err := json.Unmarshal(tk, &t)
171	if err != nil {
172		return azcore.AccessToken{}, err
173	}
174
175	exp := time.Unix(t.Expires_On, 0)
176	if t.Expires_On == 0 {
177		exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOn, time.Local)
178		if err != nil {
179			return azcore.AccessToken{}, fmt.Errorf("%s: error parsing token expiration time %q: %v", credNameAzureCLI, t.ExpiresOn, err)
180		}
181	}
182
183	converted := azcore.AccessToken{
184		Token:     t.AccessToken,
185		ExpiresOn: exp.UTC(),
186	}
187	return converted, nil
188}
189
190var _ azcore.TokenCredential = (*AzureCLICredential)(nil)