azure_cli_credential.go raw
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
7 package azidentity
8
9 import (
10 "context"
11 "encoding/base64"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/Azure/azure-sdk-for-go/sdk/azcore"
20 "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
21 "github.com/Azure/azure-sdk-for-go/sdk/internal/log"
22 )
23
24 const credNameAzureCLI = "AzureCLICredential"
25
26 // AzureCLICredentialOptions contains optional parameters for AzureCLICredential.
27 type AzureCLICredentialOptions struct {
28 // AdditionallyAllowedTenants specifies tenants to which the credential may authenticate, in addition to
29 // TenantID. When TenantID is empty, this option has no effect and the credential will authenticate to
30 // any requested tenant. Add the wildcard value "*" to allow the credential to authenticate to any tenant.
31 AdditionallyAllowedTenants []string
32
33 // Subscription is the name or ID of a subscription. Set this to acquire tokens for an account other
34 // than the Azure CLI's current account.
35 Subscription string
36
37 // TenantID identifies the tenant the credential should authenticate in.
38 // Defaults to the CLI's default tenant, which is typically the home tenant of the logged in user.
39 TenantID string
40
41 // inDefaultChain is true when the credential is part of DefaultAzureCredential
42 inDefaultChain bool
43 // exec is used by tests to fake invoking az
44 exec executor
45 }
46
47 // AzureCLICredential authenticates as the identity logged in to the Azure CLI.
48 type AzureCLICredential struct {
49 mu *sync.Mutex
50 opts AzureCLICredentialOptions
51 }
52
53 // NewAzureCLICredential constructs an AzureCLICredential. Pass nil to accept default options.
54 func NewAzureCLICredential(options *AzureCLICredentialOptions) (*AzureCLICredential, error) {
55 cp := AzureCLICredentialOptions{}
56 if options != nil {
57 cp = *options
58 }
59 for _, r := range cp.Subscription {
60 if !(alphanumeric(r) || r == '-' || r == '_' || r == ' ' || r == '.') {
61 return nil, fmt.Errorf(
62 "%s: Subscription %q contains invalid characters. If this is the name of a subscription, use its ID instead",
63 credNameAzureCLI,
64 cp.Subscription,
65 )
66 }
67 }
68 if cp.TenantID != "" && !validTenantID(cp.TenantID) {
69 return nil, errInvalidTenantID
70 }
71 if cp.exec == nil {
72 cp.exec = shellExec
73 }
74 cp.AdditionallyAllowedTenants = resolveAdditionalTenants(cp.AdditionallyAllowedTenants)
75 return &AzureCLICredential{mu: &sync.Mutex{}, opts: cp}, nil
76 }
77
78 // GetToken requests a token from the Azure CLI. This credential doesn't cache tokens, so every call invokes the CLI.
79 // This method is called automatically by Azure SDK clients.
80 func (c *AzureCLICredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
81 at := azcore.AccessToken{}
82 if len(opts.Scopes) != 1 {
83 return at, errors.New(credNameAzureCLI + ": GetToken() requires exactly one scope")
84 }
85 if !validScope(opts.Scopes[0]) {
86 return at, fmt.Errorf("%s.GetToken(): invalid scope %q", credNameAzureCLI, opts.Scopes[0])
87 }
88 tenant, err := resolveTenant(c.opts.TenantID, opts.TenantID, credNameAzureCLI, c.opts.AdditionallyAllowedTenants)
89 if err != nil {
90 return at, err
91 }
92 // 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
93 resource := strings.TrimSuffix(opts.Scopes[0], defaultSuffix)
94 command := "az account get-access-token -o json --resource " + resource
95 tenantArg := ""
96 if tenant != "" {
97 tenantArg = " --tenant " + tenant
98 command += tenantArg
99 }
100 if c.opts.Subscription != "" {
101 // subscription needs quotes because it may contain spaces
102 command += ` --subscription "` + c.opts.Subscription + `"`
103 }
104 if opts.Claims != "" {
105 encoded := base64.StdEncoding.EncodeToString([]byte(opts.Claims))
106 return at, fmt.Errorf(
107 "%s.GetToken(): Azure CLI requires multifactor authentication or additional claims. Run this command then retry the operation: az login%s --claims-challenge %s",
108 credNameAzureCLI,
109 tenantArg,
110 encoded,
111 )
112 }
113
114 c.mu.Lock()
115 defer c.mu.Unlock()
116
117 b, err := c.opts.exec(ctx, credNameAzureCLI, command)
118 if err == nil {
119 at, err = c.createAccessToken(b)
120 }
121 if err != nil {
122 err = unavailableIfInDAC(err, c.opts.inDefaultChain)
123 return at, err
124 }
125 msg := fmt.Sprintf("%s.GetToken() acquired a token for scope %q", credNameAzureCLI, strings.Join(opts.Scopes, ", "))
126 log.Write(EventAuthentication, msg)
127 return at, nil
128 }
129
130 func (c *AzureCLICredential) createAccessToken(tk []byte) (azcore.AccessToken, error) {
131 t := struct {
132 AccessToken string `json:"accessToken"`
133 Expires_On int64 `json:"expires_on"`
134 ExpiresOn string `json:"expiresOn"`
135 }{}
136 err := json.Unmarshal(tk, &t)
137 if err != nil {
138 return azcore.AccessToken{}, err
139 }
140
141 exp := time.Unix(t.Expires_On, 0)
142 if t.Expires_On == 0 {
143 exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOn, time.Local)
144 if err != nil {
145 return azcore.AccessToken{}, fmt.Errorf("%s: error parsing token expiration time %q: %v", credNameAzureCLI, t.ExpiresOn, err)
146 }
147 }
148
149 converted := azcore.AccessToken{
150 Token: t.AccessToken,
151 ExpiresOn: exp.UTC(),
152 }
153 return converted, nil
154 }
155
156 var _ azcore.TokenCredential = (*AzureCLICredential)(nil)
157