Skip to content
55 changes: 54 additions & 1 deletion cli/azd/pkg/account/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ package account

import (
"context"
"errors"
"fmt"
"regexp"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/auth"
)

var (
// Matches AADSTS70043 (refresh token expired due to sign-in frequency) and AADSTS700082 (refresh token expired)
aadRefreshTokenExpiredRegex = regexp.MustCompile(`AADSTS(70043|700082)`)
)

// SubscriptionCredentialProvider provides an [azcore.TokenCredential] configured
// to use the tenant id that corresponds to the tenant the given subscription
// is located in.
Expand Down Expand Up @@ -38,8 +47,52 @@ func (p *subscriptionCredentialProvider) CredentialForSubscription(
) (azcore.TokenCredential, error) {
tenantId, err := p.subResolver.LookupTenant(ctx, subscriptionId)
if err != nil {
// If we can't resolve the tenant for this subscription, it might be because:
// 1. User manually set AZURE_SUBSCRIPTION_ID in .env
// 2. User called `azd env set AZURE_SUBSCRIPTION_ID` instead of selecting from azd's cache
// In these cases, suggest they also set AZURE_TENANT_ID
return nil, fmt.Errorf(
"%w\n\n"+
"If you manually set the subscription ID (e.g., via AZURE_SUBSCRIPTION_ID in .env or `azd env set`), "+
"you must also set AZURE_TENANT_ID to the tenant ID that contains this subscription. "+
"Alternatively, run `azd auth login --tenant-id <tenant-id>` "+
"to allow azd to discover subscriptions in that tenant.",
err,
)
}

cred, err := p.credProvider.GetTokenCredential(ctx, tenantId)
if err != nil {
// If this is an AADSTS refresh token error, enhance it with tenant-specific login guidance
if aadRefreshTokenExpiredRegex.MatchString(err.Error()) {
// Check if the error already has a suggestion (ErrorWithSuggestion from auth layer)
var errWithSuggestion *internal.ErrorWithSuggestion
if errors.As(err, &errWithSuggestion) {
// Enhance the existing suggestion with tenant-specific guidance
enhancedSuggestion := fmt.Sprintf(
"%s To re-authenticate specifically to this tenant, run `azd auth login --tenant-id %s`.",
errWithSuggestion.Suggestion,
tenantId,
)
return nil, &internal.ErrorWithSuggestion{
Err: errWithSuggestion.Err,
Suggestion: enhancedSuggestion,
}
}

// If it's not wrapped yet, create a new ErrorWithSuggestion
return nil, &internal.ErrorWithSuggestion{
Err: err,
Suggestion: fmt.Sprintf(
"Access to tenant '%s' has expired or requires re-authentication. "+
"Run `azd auth login --tenant-id %s` to re-authenticate to this tenant.",
tenantId,
tenantId,
),
}
}
return nil, err
}

return p.credProvider.GetTokenCredential(ctx, tenantId)
return cred, nil
}
74 changes: 74 additions & 0 deletions cli/azd/pkg/account/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/azure/azure-dev/cli/azd/internal"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -66,6 +67,79 @@ func TestSubscriptionCredentialProvider(t *testing.T) {
})
}

func TestSubscriptionCredentialProvider_AADSTSErrors(t *testing.T) {
t.Parallel()

tenantId := "fafbff54-b655-4648-98a2-dc3ada4df86e"
subscriptionId := "d0a01878-d7f8-41ce-a4bc-2ead16199965"

t.Run("AADSTS70043_WithoutExistingSuggestion", func(t *testing.T) {
provider := NewSubscriptionCredentialProvider(
subscriptionTenantResolverFunc(func(ctx context.Context, subId string) (string, error) {
return tenantId, nil
}),
multiTenantCredentialProviderFunc(func(ctx context.Context, tid string) (azcore.TokenCredential, error) {
return nil, errors.New("AADSTS70043: The refresh token has expired")
}),
)

_, err := provider.CredentialForSubscription(context.Background(), subscriptionId)
assert.Error(t, err)

// The error should be wrapped in an ErrorWithSuggestion
var errWithSuggestion *internal.ErrorWithSuggestion
assert.True(t, errors.As(err, &errWithSuggestion), "error should be wrapped in ErrorWithSuggestion")

// Check that the suggestion includes tenant-specific guidance
assert.Contains(t, errWithSuggestion.Suggestion, tenantId)
assert.Contains(t, errWithSuggestion.Suggestion, "azd auth login --tenant-id")

// The underlying error should contain AADSTS70043
assert.Contains(t, errWithSuggestion.Error(), "AADSTS70043")
})

t.Run("AADSTS700082_RefreshTokenExpired", func(t *testing.T) {
provider := NewSubscriptionCredentialProvider(
subscriptionTenantResolverFunc(func(ctx context.Context, subId string) (string, error) {
return tenantId, nil
}),
multiTenantCredentialProviderFunc(func(ctx context.Context, tid string) (azcore.TokenCredential, error) {
return nil, errors.New("AADSTS700082: The refresh token has expired")
}),
)

_, err := provider.CredentialForSubscription(context.Background(), subscriptionId)
assert.Error(t, err)

// The error should be wrapped in an ErrorWithSuggestion
var errWithSuggestion *internal.ErrorWithSuggestion
assert.True(t, errors.As(err, &errWithSuggestion), "error should be wrapped in ErrorWithSuggestion")

// Check that the suggestion includes tenant-specific guidance
assert.Contains(t, errWithSuggestion.Suggestion, tenantId)
assert.Contains(t, errWithSuggestion.Suggestion, "azd auth login --tenant-id")

// The underlying error should contain AADSTS700082
assert.Contains(t, errWithSuggestion.Error(), "AADSTS700082")
})

t.Run("TenantLookupFailure_EnhancedError", func(t *testing.T) {
provider := NewSubscriptionCredentialProvider(
subscriptionTenantResolverFunc(func(ctx context.Context, subId string) (string, error) {
return "", errors.New("failed to resolve tenant")
}),
multiTenantCredentialProviderFunc(func(ctx context.Context, tid string) (azcore.TokenCredential, error) {
return &dummyCredential{}, nil
}),
)

_, err := provider.CredentialForSubscription(context.Background(), subscriptionId)
assert.Error(t, err)
assert.Contains(t, err.Error(), "AZURE_TENANT_ID")
assert.Contains(t, err.Error(), "manually set the subscription ID")
})
}

// subscriptionTenantResolverFunc implements [SubscriptionTenantResolver] using a provided function.
type subscriptionTenantResolverFunc func(ctx context.Context, subscriptionId string) (string, error)

Expand Down
4 changes: 4 additions & 0 deletions cli/azd/pkg/account/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,10 @@ func (b *BypassSubscriptionsCache) Save(ctx context.Context, key string, save []
return nil
}

func (b *BypassSubscriptionsCache) Merge(ctx context.Context, key string, save []Subscription) error {
return nil
}

func (b *BypassSubscriptionsCache) Clear(ctx context.Context) error {
return nil
}
Expand Down
64 changes: 64 additions & 0 deletions cli/azd/pkg/account/subscriptions_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,70 @@ func (s *subscriptionsCache) Save(ctx context.Context, key string, subscriptions
return err
}

// Merge merges the given subscriptions with the existing cache for the specified key.
// For each subscription in the new list:
// - If it already exists (by ID), it is updated with the new values
// - If it doesn't exist, it is added to the cache
//
// Subscriptions in the cache that are not present in the new list are preserved.
// This prevents losing tenant-to-subscription mappings when a tenant is temporarily inaccessible.
func (s *subscriptionsCache) Merge(ctx context.Context, key string, subscriptions []Subscription) error {
s.inMemoryLock.Lock()
defer s.inMemoryLock.Unlock()

// Read the file if it exists
cacheFile, err := os.ReadFile(filepath.Join(s.cacheDir, subscriptionsCacheFile))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}

// unmarshal cache, ignoring the error if the cache was upgraded or corrupted
cache := map[string][]Subscription{}
if cacheFile != nil {
err = json.Unmarshal(cacheFile, &cache)
if err != nil {
log.Printf("failed to unmarshal %s, ignoring: %v", subscriptionsCacheFile, err)
}
}

// Get existing subscriptions for this key
existing := cache[key]

// Build a map of existing subscriptions by ID for quick lookup
existingMap := make(map[string]Subscription, len(existing))
for _, sub := range existing {
existingMap[sub.Id] = sub
}

// Update or add new subscriptions
for _, sub := range subscriptions {
existingMap[sub.Id] = sub
}

// Convert map back to slice
merged := make([]Subscription, 0, len(existingMap))
for _, sub := range existingMap {
merged = append(merged, sub)
}

// Apply the merged result
cache[key] = merged

// save new cache
content, err := json.Marshal(cache)
if err != nil {
return fmt.Errorf("failed to marshal subscriptions: %w", err)
}

err = os.WriteFile(filepath.Join(s.cacheDir, subscriptionsCacheFile), content, osutil.PermissionFile)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}

s.inMemoryCopy = cache
return err
}

// Clear removes all stored cache items. Returns an error if a filesystem error other than ErrNotExist occurred.
func (s *subscriptionsCache) Clear(ctx context.Context) error {
s.inMemoryLock.Lock()
Expand Down
Loading
Loading