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