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)