common/azure_ps_context_credential.go (141 lines of code) (raw):
//go:build go1.18
// +build go1.18
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package common
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
)
const credNamePSContext = "PSContextCredential"
type PSTokenProvider func(ctx context.Context, options policy.TokenRequestOptions) ([]byte, error)
func validTenantID(tenantID string) bool {
match, err := regexp.MatchString("^[0-9a-zA-Z-.]+$", tenantID)
if err != nil {
return false
}
return match
}
func resolveTenant(defaultTenant, specified, credName string, additionalTenants []string) (string, error) {
if specified == "" || specified == defaultTenant {
return defaultTenant, nil
}
if defaultTenant == "adfs" {
return "", errors.New("ADFS doesn't support tenants")
}
if !validTenantID(specified) {
return "", errors.New("Invalid tenant")
}
for _, t := range additionalTenants {
if t == "*" || t == specified {
return specified, nil
}
}
return "", fmt.Errorf(`%s isn't configured to acquire tokens for tenant %q. To enable acquiring tokens for this tenant add it to the AdditionallyAllowedTenants on the credential options, or add "*" to allow acquiring tokens for any tenant`, credName, specified)
}
// PowershellContextCredentialOptions contains optional parameters for AzureDeveloperCLICredential.
type PowershellContextCredentialOptions struct {
// TenantID identifies the tenant the credential should authenticate in. Defaults to the azd environment,
// which is the tenant of the selected Azure subscription.
TenantID string
tokenProvider PSTokenProvider
}
// PowershellContextCredential authenticates as the identity logged in to the [Azure Developer CLI].
//
// [Azure Developer CLI]: https://learn.microsoft.com/azure/developer/azure-developer-cli/overview
type PowershellContextCredential struct {
mu *sync.Mutex
opts PowershellContextCredentialOptions
}
// NewPowershellContextCredential constructs an AzureDeveloperCLICredential. Pass nil to accept default options.
func NewPowershellContextCredential(options *PowershellContextCredentialOptions) (*PowershellContextCredential, error) {
cp := PowershellContextCredentialOptions{}
if options != nil {
cp = *options
}
if cp.TenantID != "" && !validTenantID(cp.TenantID) {
return nil, errors.New("invalid tenant id")
}
if cp.tokenProvider == nil {
cp.tokenProvider = defaultAzdTokenProvider
}
return &PowershellContextCredential{mu: &sync.Mutex{}, opts: cp}, nil
}
// GetToken requests a token from the Azure Developer CLI. This credential doesn't cache tokens, so every call invokes azd.
// This method is called automatically by Azure SDK clients.
func (c *PowershellContextCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
at := azcore.AccessToken{}
if len(opts.Scopes) != 1 {
return at, errors.New(credNamePSContext + ": GetToken() exactly one scope")
}
tenant, err := resolveTenant(c.opts.TenantID, opts.TenantID, credNamePSContext, nil)
if err != nil {
return at, err
}
c.mu.Lock()
defer c.mu.Unlock()
opts.TenantID = tenant
b, err := c.opts.tokenProvider(ctx, opts)
if err == nil {
at, err = c.createAccessToken(b)
}
if err != nil {
return at, err
}
//msg := fmt.Sprintf("%s.GetToken() acquired a token for scope %q", credNamePSContext, strings.Join(opts.Scopes, ", "))
return at, nil
}
// We ignore resource because PS does not support all Resources. Disk scope is not supported
// and we are here only with Storage scope
var defaultAzdTokenProvider PSTokenProvider = func(ctx context.Context, opts policy.TokenRequestOptions) ([]byte, error) {
// 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, 10*time.Minute)
defer cancel()
}
r := regexp.MustCompile("(?s){.*Token.*ExpiresOn.*}")
cmd := "Get-AzAccessToken"
// set options
if len(opts.Scopes) != 1 {
return nil, errors.New("exactly one scope must be specified")
} else {
cmd += fmt.Sprintf(" -ResourceUrl \"%s\"", strings.TrimSuffix(opts.Scopes[0], "/.default"))
}
if opts.TenantID != "" {
cmd += fmt.Sprintf(" -TenantId \"%s\"", opts.TenantID)
}
// We're going to get broken on this in Az 14.0 and Az.Accounts 5.0, so we may as well fix it now.
cmd += " -AsSecureString | Foreach-Object {[PSCustomObject]@{Token= $($_.Token | ConvertFrom-SecureString -AsPlainText); ExpiresOn = $_.ExpiresOn}} | ConvertTo-Json"
cliCmd := exec.CommandContext(ctx, "pwsh", "-Command", cmd)
cliCmd.Env = os.Environ()
var stderr bytes.Buffer
cliCmd.Stderr = &stderr
output, err := cliCmd.Output()
if err != nil {
msg := stderr.String()
if msg == "" {
msg = err.Error()
}
return nil, errors.New(credNamePSContext + msg)
}
output = []byte(r.FindString(string(output)))
if string(output) == "" {
invalidTokenMsg := " Invalid output received while retrieving token with Powershell. Run command \"" + cmd + "\"" +
" on powershell and verify that the output is indeed a valid token."
return nil, errors.New(credNamePSContext + invalidTokenMsg)
}
return output, nil
}
func (c *PowershellContextCredential) createAccessToken(tk []byte) (azcore.AccessToken, error) {
t := struct {
AccessToken string `json:"Token"`
ExpiresOn string `json:"ExpiresOn"`
}{}
err := json.Unmarshal(tk, &t)
if err != nil {
return azcore.AccessToken{}, errors.New(err.Error())
}
parseErr := "error parsing token expiration time %q: %v"
exp, err := time.Parse(time.RFC3339, t.ExpiresOn)
if err != nil {
return azcore.AccessToken{}, fmt.Errorf(parseErr, t.ExpiresOn, err)
}
return azcore.AccessToken{
ExpiresOn: exp.UTC(),
Token: t.AccessToken,
}, nil
}
var _ azcore.TokenCredential = (*PowershellContextCredential)(nil)