sdk/azidentity/azure_cli_credential.go (149 lines of code) (raw):

//go:build go1.18 // +build go1.18 // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package azidentity import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "runtime" "strings" "sync" "time" "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/internal/log" ) const credNameAzureCLI = "AzureCLICredential" type azTokenProvider func(ctx context.Context, scopes []string, tenant, subscription string) ([]byte, error) // AzureCLICredentialOptions contains optional parameters for AzureCLICredential. type AzureCLICredentialOptions struct { // AdditionallyAllowedTenants specifies tenants to which the credential may authenticate, in addition to // TenantID. When TenantID is empty, this option has no effect and the credential will authenticate to // any requested tenant. Add the wildcard value "*" to allow the credential to authenticate to any tenant. AdditionallyAllowedTenants []string // Subscription is the name or ID of a subscription. Set this to acquire tokens for an account other // than the Azure CLI's current account. Subscription string // TenantID identifies the tenant the credential should authenticate in. // Defaults to the CLI's default tenant, which is typically the home tenant of the logged in user. TenantID string // inDefaultChain is true when the credential is part of DefaultAzureCredential inDefaultChain bool // tokenProvider is used by tests to fake invoking az tokenProvider azTokenProvider } // init returns an instance of AzureCLICredentialOptions initialized with default values. func (o *AzureCLICredentialOptions) init() { if o.tokenProvider == nil { o.tokenProvider = defaultAzTokenProvider } } // AzureCLICredential authenticates as the identity logged in to the Azure CLI. type AzureCLICredential struct { mu *sync.Mutex opts AzureCLICredentialOptions } // NewAzureCLICredential constructs an AzureCLICredential. Pass nil to accept default options. func NewAzureCLICredential(options *AzureCLICredentialOptions) (*AzureCLICredential, error) { cp := AzureCLICredentialOptions{} if options != nil { cp = *options } for _, r := range cp.Subscription { if !(alphanumeric(r) || r == '-' || r == '_' || r == ' ' || r == '.') { return nil, fmt.Errorf( "%s: Subscription %q contains invalid characters. If this is the name of a subscription, use its ID instead", credNameAzureCLI, cp.Subscription, ) } } if cp.TenantID != "" && !validTenantID(cp.TenantID) { return nil, errInvalidTenantID } cp.init() cp.AdditionallyAllowedTenants = resolveAdditionalTenants(cp.AdditionallyAllowedTenants) return &AzureCLICredential{mu: &sync.Mutex{}, opts: cp}, nil } // GetToken requests a token from the Azure CLI. This credential doesn't cache tokens, so every call invokes the CLI. // This method is called automatically by Azure SDK clients. func (c *AzureCLICredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { at := azcore.AccessToken{} if len(opts.Scopes) != 1 { return at, errors.New(credNameAzureCLI + ": GetToken() requires exactly one scope") } if !validScope(opts.Scopes[0]) { return at, fmt.Errorf("%s.GetToken(): invalid scope %q", credNameAzureCLI, opts.Scopes[0]) } tenant, err := resolveTenant(c.opts.TenantID, opts.TenantID, credNameAzureCLI, c.opts.AdditionallyAllowedTenants) if err != nil { return at, err } c.mu.Lock() defer c.mu.Unlock() b, err := c.opts.tokenProvider(ctx, opts.Scopes, tenant, c.opts.Subscription) if err == nil { at, err = c.createAccessToken(b) } if err != nil { err = unavailableIfInChain(err, c.opts.inDefaultChain) return at, err } msg := fmt.Sprintf("%s.GetToken() acquired a token for scope %q", credNameAzureCLI, strings.Join(opts.Scopes, ", ")) log.Write(EventAuthentication, msg) return at, nil } // defaultAzTokenProvider invokes the Azure CLI to acquire a token. It assumes // callers have verified that all string arguments are safe to pass to the CLI. var defaultAzTokenProvider azTokenProvider = func(ctx context.Context, scopes []string, tenantID, subscription string) ([]byte, error) { // 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 resource := strings.TrimSuffix(scopes[0], defaultSuffix) // set a default timeout for this authentication iff the application hasn't done so already var cancel context.CancelFunc if _, hasDeadline := ctx.Deadline(); !hasDeadline { ctx, cancel = context.WithTimeout(ctx, cliTimeout) defer cancel() } commandLine := "az account get-access-token -o json --resource " + resource if tenantID != "" { commandLine += " --tenant " + tenantID } if subscription != "" { // subscription needs quotes because it may contain spaces commandLine += ` --subscription "` + subscription + `"` } var cliCmd *exec.Cmd if runtime.GOOS == "windows" { dir := os.Getenv("SYSTEMROOT") if dir == "" { return nil, newCredentialUnavailableError(credNameAzureCLI, "environment variable 'SYSTEMROOT' has no value") } cliCmd = exec.CommandContext(ctx, "cmd.exe", "/c", commandLine) cliCmd.Dir = dir } else { cliCmd = exec.CommandContext(ctx, "/bin/sh", "-c", commandLine) cliCmd.Dir = "/bin" } cliCmd.Env = os.Environ() var stderr bytes.Buffer cliCmd.Stderr = &stderr output, err := cliCmd.Output() if err != nil { msg := stderr.String() var exErr *exec.ExitError if errors.As(err, &exErr) && exErr.ExitCode() == 127 || strings.HasPrefix(msg, "'az' is not recognized") { msg = "Azure CLI not found on path" } if msg == "" { msg = err.Error() } return nil, newCredentialUnavailableError(credNameAzureCLI, msg) } return output, nil } func (c *AzureCLICredential) createAccessToken(tk []byte) (azcore.AccessToken, error) { t := struct { AccessToken string `json:"accessToken"` Expires_On int64 `json:"expires_on"` ExpiresOn string `json:"expiresOn"` }{} err := json.Unmarshal(tk, &t) if err != nil { return azcore.AccessToken{}, err } exp := time.Unix(t.Expires_On, 0) if t.Expires_On == 0 { exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOn, time.Local) if err != nil { return azcore.AccessToken{}, fmt.Errorf("%s: error parsing token expiration time %q: %v", credNameAzureCLI, t.ExpiresOn, err) } } converted := azcore.AccessToken{ Token: t.AccessToken, ExpiresOn: exp.UTC(), } return converted, nil } var _ azcore.TokenCredential = (*AzureCLICredential)(nil)