Release / Build and publish (push) Successful in 23s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
124 lines
4.0 KiB
Go
124 lines
4.0 KiB
Go
package azure
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
|
)
|
|
|
|
// cliCredential gets tokens from the az CLI for one signed-in account.
|
|
//
|
|
// az can hold several accounts, but `az account get-access-token` can't be
|
|
// told which user to use: it picks the account a subscription belongs to.
|
|
// So each account is pinned with --subscription. az refuses --subscription
|
|
// together with --tenant, which the SDK's AzureCLICredential would pass for
|
|
// every Key Vault request (the vault's auth challenge names a tenant), so
|
|
// this sends --tenant only for vaults in another tenant.
|
|
//
|
|
// Tokens are cached in memory until shortly before they expire, so the
|
|
// many SDK clients don't each start an az process.
|
|
type cliCredential struct {
|
|
subscription string // pins the account; "" for az's default account
|
|
tenant string // the account's tenant, if known
|
|
run func(ctx context.Context, args ...string) ([]byte, error)
|
|
|
|
mu sync.Mutex
|
|
cache map[string]azcore.AccessToken // by resource and tenant argument
|
|
}
|
|
|
|
func newCLICredential(subscription, tenant string) *cliCredential {
|
|
return &cliCredential{subscription: subscription, tenant: tenant, run: runAz, cache: map[string]azcore.AccessToken{}}
|
|
}
|
|
|
|
// refreshMargin renews tokens this long before they expire.
|
|
const refreshMargin = 5 * time.Minute
|
|
|
|
func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
|
if len(opts.Scopes) != 1 {
|
|
return azcore.AccessToken{}, errors.New("az: exactly one scope is supported")
|
|
}
|
|
if opts.Claims != "" {
|
|
return azcore.AccessToken{}, errors.New("az: this request needs multifactor authentication; run az login again")
|
|
}
|
|
resource := strings.TrimSuffix(opts.Scopes[0], "/.default")
|
|
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
|
|
switch {
|
|
case opts.TenantID != "" && opts.TenantID != c.tenant:
|
|
// Another tenant: az picks the account itself.
|
|
args = append(args, "--tenant", opts.TenantID)
|
|
case c.subscription != "":
|
|
args = append(args, "--subscription", c.subscription)
|
|
}
|
|
key := strings.Join(args[5:], " ")
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
|
|
return tok, nil
|
|
}
|
|
out, err := c.run(ctx, args...)
|
|
if err != nil {
|
|
return azcore.AccessToken{}, err
|
|
}
|
|
tok, err := parseCLIToken(out)
|
|
if err != nil {
|
|
return azcore.AccessToken{}, err
|
|
}
|
|
c.cache[key] = tok
|
|
return tok, nil
|
|
}
|
|
|
|
func parseCLIToken(b []byte) (azcore.AccessToken, error) {
|
|
var t struct {
|
|
AccessToken string `json:"accessToken"`
|
|
ExpiresOn int64 `json:"expires_on"` // az 2.54+
|
|
ExpiresOnS string `json:"expiresOn"` // older az: local time
|
|
}
|
|
if err := json.Unmarshal(b, &t); err != nil {
|
|
return azcore.AccessToken{}, fmt.Errorf("az: unexpected output: %w", err)
|
|
}
|
|
if t.AccessToken == "" {
|
|
return azcore.AccessToken{}, errors.New("az: no access token in output")
|
|
}
|
|
exp := time.Unix(t.ExpiresOn, 0)
|
|
if t.ExpiresOn == 0 {
|
|
var err error
|
|
exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOnS, time.Local)
|
|
if err != nil {
|
|
return azcore.AccessToken{}, fmt.Errorf("az: unexpected expiry %q", t.ExpiresOnS)
|
|
}
|
|
}
|
|
return azcore.AccessToken{Token: t.AccessToken, ExpiresOn: exp}, nil
|
|
}
|
|
|
|
// azTimeout bounds one az call; a hung az would otherwise block a pane.
|
|
const azTimeout = 30 * time.Second
|
|
|
|
func runAz(ctx context.Context, args ...string) ([]byte, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, azTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "az", args...)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
return nil, errors.New("az CLI not found; install it or set AZURE_* for a service principal")
|
|
}
|
|
if err != nil {
|
|
if msg := strings.TrimSpace(stderr.String()); msg != "" {
|
|
return nil, errors.New(msg)
|
|
}
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|