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 }