fix: never show one az account's subscriptions under another
CI / Checks (pull_request) Successful in 23s
PR title / Conventional title (pull_request) Successful in 1s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-11 09:41:57 +02:00
1 parent 17d2705e12
commit 57e545f1ab
3 files changed
+158 -18

No files matched your search

+84 -14
View File
@@ -3,6 +3,7 @@ package azure
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -24,19 +25,51 @@ import (
// every Key Vault request (the vault's auth challenge names a tenant), so
// this sends --tenant only for vaults in another tenant.
//
// Which account a subscription belongs to changes: az's profile has one
// entry per subscription, and signing in to another account that sees it
// moves it there. So the subscription is looked up for every new token,
// and a token az issues for another of the signed-in users is refused.
//
// 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)
user string // the account's user; "" for az's default account
tenant string // the account's tenant, if known
// accounts reads az's accounts, to pin the user; nil for the default
// account.
accounts func() ([]account, error)
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{}}
func newCLICredential(user, tenant string, accounts func() ([]account, error)) *cliCredential {
return &cliCredential{user: user, tenant: tenant, accounts: accounts, run: runAz, cache: map[string]azcore.AccessToken{}}
}
// pin is a subscription az maps to the user now, and the other users az
// holds.
func (c *cliCredential) pin() (subscription string, others []string, err error) {
if c.accounts == nil {
return "", nil, nil
}
accts, err := c.accounts()
if err != nil {
return "", nil, err
}
for _, a := range accts {
switch {
case a.user == c.user && a.tenant == c.tenant:
subscription = a.anchor
case !strings.EqualFold(a.user, c.user):
others = append(others, a.user)
}
}
if subscription == "" {
return "", nil, fmt.Errorf("az has no subscriptions for %s any more: sign in as %s", c.user, c.user)
}
return subscription, others, nil
}
// refreshMargin renews tokens this long before they expire.
@@ -50,21 +83,25 @@ func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOp
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:], " ")
otherTenant := opts.TenantID != "" && opts.TenantID != c.tenant
key := resource + " " + opts.TenantID
c.mu.Lock()
defer c.mu.Unlock()
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
return tok, nil
}
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
subscription, others, err := c.pin()
switch {
case otherTenant:
// Another tenant: az picks the account itself.
args = append(args, "--tenant", opts.TenantID)
case err != nil:
return azcore.AccessToken{}, err
case subscription != "":
args = append(args, "--subscription", subscription)
}
out, err := c.run(ctx, args...)
if err != nil {
return azcore.AccessToken{}, err
@@ -73,10 +110,43 @@ func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOp
if err != nil {
return azcore.AccessToken{}, err
}
if u, ok := tokenUser(tok.Token, others); ok {
return azcore.AccessToken{}, fmt.Errorf("az gave a token for %s, not %s: sign in as %s again", u, c.user, c.user)
}
c.cache[key] = tok
return tok, nil
}
// tokenUser reports which of users a token was issued to, if any, from
// its claims. The token isn't verified: this only catches az picking the
// wrong signed-in account.
func tokenUser(token string, users []string) (string, bool) {
parts := strings.Split(token, ".")
if len(parts) != 3 || len(users) == 0 {
return "", false
}
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
if err != nil {
return "", false
}
var claims map[string]any
if json.Unmarshal(b, &claims) != nil {
return "", false
}
for _, name := range []string{"upn", "unique_name", "preferred_username", "email"} {
v, _ := claims[name].(string)
if i := strings.LastIndexByte(v, '#'); i >= 0 {
v = v[i+1:] // live.com#user@example.com
}
for _, u := range users {
if v != "" && strings.EqualFold(v, u) {
return u, true
}
}
}
return "", false
}
func parseCLIToken(b []byte) (azcore.AccessToken, error) {
var t struct {
AccessToken string `json:"accessToken"`