azure_powershell_credential.go raw
1 // Copyright (c) Microsoft Corporation. All rights reserved.
2 // Licensed under the MIT License.
3
4 package azidentity
5
6 import (
7 "context"
8 "encoding/base64"
9 "encoding/binary"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "os/exec"
14 "runtime"
15 "strings"
16 "sync"
17 "time"
18 "unicode/utf16"
19
20 "github.com/Azure/azure-sdk-for-go/sdk/azcore"
21 "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
22 "github.com/Azure/azure-sdk-for-go/sdk/internal/log"
23 )
24
25 const (
26 credNameAzurePowerShell = "AzurePowerShellCredential"
27 noAzAccountModule = "Az.Accounts module not found"
28 )
29
30 // AzurePowerShellCredentialOptions contains optional parameters for AzurePowerShellCredential.
31 type AzurePowerShellCredentialOptions struct {
32 // AdditionallyAllowedTenants specifies tenants to which the credential may authenticate, in addition to
33 // TenantID. When TenantID is empty, this option has no effect and the credential will authenticate to
34 // any requested tenant. Add the wildcard value "*" to allow the credential to authenticate to any tenant.
35 AdditionallyAllowedTenants []string
36
37 // TenantID identifies the tenant the credential should authenticate in.
38 // Defaults to Azure PowerShell'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
44 // exec is used by tests to fake invoking Azure PowerShell
45 exec executor
46 }
47
48 // AzurePowerShellCredential authenticates as the identity logged in to Azure PowerShell.
49 type AzurePowerShellCredential struct {
50 mu *sync.Mutex
51 opts AzurePowerShellCredentialOptions
52 }
53
54 // NewAzurePowerShellCredential constructs an AzurePowerShellCredential. Pass nil to accept default options.
55 func NewAzurePowerShellCredential(options *AzurePowerShellCredentialOptions) (*AzurePowerShellCredential, error) {
56 cp := AzurePowerShellCredentialOptions{}
57
58 if options != nil {
59 cp = *options
60 }
61
62 if cp.TenantID != "" && !validTenantID(cp.TenantID) {
63 return nil, errInvalidTenantID
64 }
65
66 if cp.exec == nil {
67 cp.exec = shellExec
68 }
69
70 cp.AdditionallyAllowedTenants = resolveAdditionalTenants(cp.AdditionallyAllowedTenants)
71
72 return &AzurePowerShellCredential{mu: &sync.Mutex{}, opts: cp}, nil
73 }
74
75 // GetToken requests a token from Azure PowerShell. This credential doesn't cache tokens, so every call invokes Azure PowerShell.
76 // This method is called automatically by Azure SDK clients.
77 func (c *AzurePowerShellCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
78 at := azcore.AccessToken{}
79
80 if len(opts.Scopes) != 1 {
81 return at, errors.New(credNameAzurePowerShell + ": GetToken() requires exactly one scope")
82 }
83
84 if !validScope(opts.Scopes[0]) {
85 return at, fmt.Errorf("%s.GetToken(): invalid scope %q", credNameAzurePowerShell, opts.Scopes[0])
86 }
87
88 tenant, err := resolveTenant(c.opts.TenantID, opts.TenantID, credNameAzurePowerShell, c.opts.AdditionallyAllowedTenants)
89 if err != nil {
90 return at, err
91 }
92
93 // Always pass a Microsoft Entra ID v1 resource URI (not a v2 scope) because Get-AzAccessToken only supports v1 resource URIs.
94 resource := strings.TrimSuffix(opts.Scopes[0], defaultSuffix)
95
96 tenantArg := ""
97 if tenant != "" {
98 tenantArg = fmt.Sprintf(" -TenantId '%s'", tenant)
99 }
100
101 if opts.Claims != "" {
102 encoded := base64.StdEncoding.EncodeToString([]byte(opts.Claims))
103 return at, fmt.Errorf(
104 "%s.GetToken(): Azure PowerShell requires multifactor authentication or additional claims. Run this command then retry the operation: Connect-AzAccount%s -ClaimsChallenge '%s'",
105 credNameAzurePowerShell,
106 tenantArg,
107 encoded,
108 )
109 }
110
111 // Inline script to handle Get-AzAccessToken differences between Az.Accounts versions with SecureString handling and minimum version requirement
112 script := fmt.Sprintf(`
113 $ErrorActionPreference = 'Stop'
114 [version]$minimumVersion = '2.2.0'
115
116 $mod = Import-Module Az.Accounts -MinimumVersion $minimumVersion -PassThru -ErrorAction SilentlyContinue
117
118 if (-not $mod) {
119 Write-Error '%s'
120 }
121
122 $params = @{
123 ResourceUrl = '%s'
124 WarningAction = 'Ignore'
125 }
126
127 # Only force AsSecureString for Az.Accounts versions > 2.17.0 and < 5.0.0 which return plain text token by default.
128 # Newer Az.Accounts versions return SecureString token by default and no longer use AsSecureString parameter.
129 if ($mod.Version -ge [version]'2.17.0' -and $mod.Version -lt [version]'5.0.0') {
130 $params['AsSecureString'] = $true
131 }
132
133 $tenantId = '%s'
134 if ($tenantId.Length -gt 0) {
135 $params['TenantId'] = '%s'
136 }
137
138 $token = Get-AzAccessToken @params
139
140 $customToken = New-Object -TypeName psobject
141
142 # The following .NET interop pattern is supported in all PowerShell versions and safely converts SecureString to plain text.
143 if ($token.Token -is [System.Security.SecureString]) {
144 $ssPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($token.Token)
145 try {
146 $plainToken = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ssPtr)
147 } finally {
148 [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ssPtr)
149 }
150 $customToken | Add-Member -MemberType NoteProperty -Name Token -Value $plainToken
151 } else {
152 $customToken | Add-Member -MemberType NoteProperty -Name Token -Value $token.Token
153 }
154 $customToken | Add-Member -MemberType NoteProperty -Name ExpiresOn -Value $token.ExpiresOn.ToUnixTimeSeconds()
155
156 $jsonToken = $customToken | ConvertTo-Json
157 return $jsonToken
158 `, noAzAccountModule, resource, tenant, tenant)
159
160 // Windows: prefer pwsh.exe (PowerShell Core), fallback to powershell.exe (Windows PowerShell)
161 // Unix: only support pwsh (PowerShell Core)
162 exe := "pwsh"
163 if runtime.GOOS == "windows" {
164 if _, err := exec.LookPath("pwsh.exe"); err == nil {
165 exe = "pwsh.exe"
166 } else {
167 exe = "powershell.exe"
168 }
169 }
170
171 command := exe + " -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand " + base64EncodeUTF16LE(script)
172
173 c.mu.Lock()
174 defer c.mu.Unlock()
175
176 b, err := c.opts.exec(ctx, credNameAzurePowerShell, command)
177 if err == nil {
178 at, err = c.createAccessToken(b)
179 }
180
181 if err != nil {
182 err = unavailableIfInDAC(err, c.opts.inDefaultChain)
183 return at, err
184 }
185
186 msg := fmt.Sprintf("%s.GetToken() acquired a token for scope %q", credNameAzurePowerShell, strings.Join(opts.Scopes, ", "))
187 log.Write(EventAuthentication, msg)
188
189 return at, nil
190 }
191
192 func (c *AzurePowerShellCredential) createAccessToken(tk []byte) (azcore.AccessToken, error) {
193 t := struct {
194 Token string `json:"Token"`
195 ExpiresOn int64 `json:"ExpiresOn"`
196 }{}
197
198 err := json.Unmarshal(tk, &t)
199 if err != nil {
200 return azcore.AccessToken{}, err
201 }
202
203 converted := azcore.AccessToken{
204 Token: t.Token,
205 ExpiresOn: time.Unix(t.ExpiresOn, 0).UTC(),
206 }
207
208 return converted, nil
209 }
210
211 // Encodes a string to Base64 using UTF-16LE encoding
212 func base64EncodeUTF16LE(text string) string {
213 u16 := utf16.Encode([]rune(text))
214 buf := make([]byte, len(u16)*2)
215 for i, v := range u16 {
216 binary.LittleEndian.PutUint16(buf[i*2:], v)
217 }
218 return base64.StdEncoding.EncodeToString(buf)
219 }
220
221 // Decodes a Base64 UTF-16LE string back to string
222 func base64DecodeUTF16LE(encoded string) (string, error) {
223 data, err := base64.StdEncoding.DecodeString(encoded)
224 if err != nil {
225 return "", err
226 }
227 u16 := make([]uint16, len(data)/2)
228 for i := range u16 {
229 u16[i] = binary.LittleEndian.Uint16(data[i*2:])
230 }
231 return string(utf16.Decode(u16)), nil
232 }
233
234 var _ azcore.TokenCredential = (*AzurePowerShellCredential)(nil)
235