Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ These variables are used by the Terraform provider integration to authenticate w
| `AZD_DEBUG` | If true, enables debug mode. |
| `AZD_DEBUG_LOG` | If true, enables debug-level logging. |
| `AZD_DEBUG_TELEMETRY` | If true, enables debug-level telemetry output. |
| `AZD_DEBUG_MSAL_CACHE` | If true, logs MSAL cache metadata before and after login and around the first silent token acquisitions, including account identifiers and usernames, while hashing cache keys and token secrets. |
| `AZD_DEBUG_LOGIN_FORCE_SUBSCRIPTION_REFRESH` | If true, forces a refresh of the subscription list on login. |
| `AZD_DEBUG_SYNTHETIC_SUBSCRIPTION` | If set, provides a synthetic subscription for testing. |
| `AZD_DEBUG_NO_ALPHA_WARNINGS` | If true, suppresses alpha feature warnings. |
Expand Down
49 changes: 35 additions & 14 deletions cli/azd/pkg/account/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"regexp"
"strings"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/azure/azure-dev/cli/azd/internal"
Expand Down Expand Up @@ -65,29 +66,49 @@ func (p *subscriptionCredentialProvider) CredentialForSubscription(
if err != nil {
// If this is an AADSTS refresh token error, enhance it with tenant-specific login guidance
if aadRefreshTokenExpiredRegex.MatchString(err.Error()) {
message := fmt.Sprintf(
"Access to tenant '%s' requires re-authentication before azd can use this subscription.",
tenantId,
)

// Check if the error already has a suggestion (ErrorWithSuggestion from auth layer)
if errWithSuggestion, ok := errors.AsType[*internal.ErrorWithSuggestion](err); ok {
// 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,
)
if errWithSuggestion.Message != "" {
message = errWithSuggestion.Message
}

suggestion := strings.TrimSpace(errWithSuggestion.Suggestion)
// If the auth layer's suggestion doesn't already include --tenant-id,
// append tenant-specific login guidance.
if !strings.Contains(suggestion, "--tenant-id") {
tenantHint := fmt.Sprintf(
"Run `azd auth login --tenant-id %s` to re-authenticate to this tenant.",
tenantId,
)
if suggestion != "" {
suggestion = fmt.Sprintf("%s %s", suggestion, tenantHint)
} else {
suggestion = tenantHint
}
}

return nil, &internal.ErrorWithSuggestion{
Err: errWithSuggestion.Err,
Suggestion: enhancedSuggestion,
Message: message,
Suggestion: suggestion,
Links: errWithSuggestion.Links,
}
}

// If it's not wrapped yet, create a new ErrorWithSuggestion
tenantSpecificSuggestion := fmt.Sprintf(
"Run `azd auth login --tenant-id %s` to re-authenticate to this tenant.",
tenantId,
)
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,
),
Err: err,
Message: message,
Suggestion: tenantSpecificSuggestion,
}
}
return nil, err
Expand Down
73 changes: 67 additions & 6 deletions cli/azd/pkg/account/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ package account
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"

"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"
"github.com/stretchr/testify/require"
)

func TestSubscriptionCredentialProvider(t *testing.T) {
Expand Down Expand Up @@ -55,17 +58,17 @@ func TestSubscriptionCredentialProvider(t *testing.T) {
)

t.Run("Success", func(t *testing.T) {
cred1, err := provider.CredentialForSubscription(context.Background(), sub1)
cred1, err := provider.CredentialForSubscription(t.Context(), sub1)
assert.NoError(t, err)
assert.Equal(t, tenantToCred[tenant1], cred1)

cred2, err := provider.CredentialForSubscription(context.Background(), sub2)
cred2, err := provider.CredentialForSubscription(t.Context(), sub2)
assert.NoError(t, err)
assert.Equal(t, tenantToCred[tenant2], cred2)
})

t.Run("Failure", func(t *testing.T) {
_, err := provider.CredentialForSubscription(context.Background(), "11111111-1111-1111-1111-111111111111")
_, err := provider.CredentialForSubscription(t.Context(), "11111111-1111-1111-1111-111111111111")
assert.Error(t, err)
})
}
Expand All @@ -90,7 +93,7 @@ func TestSubscriptionCredentialProvider_AADSTSErrors(t *testing.T) {
}),
)

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

// The error should be wrapped in an ErrorWithSuggestion
Expand All @@ -100,6 +103,7 @@ func TestSubscriptionCredentialProvider_AADSTSErrors(t *testing.T) {
// Check that the suggestion includes tenant-specific guidance
assert.Contains(t, errWithSuggestion.Suggestion, tenantId)
assert.Contains(t, errWithSuggestion.Suggestion, "azd auth login --tenant-id")
assert.Contains(t, errWithSuggestion.Message, tenantId)

// The underlying error should contain AADSTS70043
assert.Contains(t, errWithSuggestion.Error(), "AADSTS70043")
Expand All @@ -119,7 +123,7 @@ func TestSubscriptionCredentialProvider_AADSTSErrors(t *testing.T) {
}),
)

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

// The error should be wrapped in an ErrorWithSuggestion
Expand All @@ -129,11 +133,68 @@ func TestSubscriptionCredentialProvider_AADSTSErrors(t *testing.T) {
// Check that the suggestion includes tenant-specific guidance
assert.Contains(t, errWithSuggestion.Suggestion, tenantId)
assert.Contains(t, errWithSuggestion.Suggestion, "azd auth login --tenant-id")
assert.Contains(t, errWithSuggestion.Message, tenantId)

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

t.Run("AADSTS700082_WithExistingSuggestion_PreservesWrappedFields", func(t *testing.T) {
provider := NewSubscriptionCredentialProvider(
subscriptionResolverFunc(func(ctx context.Context, subId string) (*Subscription, error) {
return &Subscription{
Id: subId,
TenantId: "resource-" + tenantId,
UserAccessTenantId: tenantId,
}, nil
}),
multiTenantCredentialProviderFunc(func(ctx context.Context, tid string) (azcore.TokenCredential, error) {
return nil, &internal.ErrorWithSuggestion{
Err: errors.New("AADSTS700082: The refresh token has expired"),
Message: "Login expired for the current account.",
Suggestion: "Run `azd auth login` to acquire a new token.",
}
}),
)

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

errWithSuggestion, ok := errors.AsType[*internal.ErrorWithSuggestion](err)
assert.True(t, ok, "error should be wrapped in ErrorWithSuggestion")
assert.Equal(t, "Login expired for the current account.", errWithSuggestion.Message)
assert.Contains(t, errWithSuggestion.Suggestion, "Run `azd auth login` to acquire a new token.")
assert.Contains(t, errWithSuggestion.Suggestion, tenantId)
})

t.Run("AADSTS700082_SuggestionAlreadyHasTenantID_NoRedundantAppend", func(t *testing.T) {
provider := NewSubscriptionCredentialProvider(
subscriptionResolverFunc(func(ctx context.Context, subId string) (*Subscription, error) {
return &Subscription{
Id: subId,
TenantId: "resource-" + tenantId,
UserAccessTenantId: tenantId,
}, nil
}),
multiTenantCredentialProviderFunc(func(ctx context.Context, tid string) (azcore.TokenCredential, error) {
return nil, &internal.ErrorWithSuggestion{
Err: errors.New("AADSTS700082: The refresh token has expired"),
Message: "Login expired for the current account.",
Suggestion: fmt.Sprintf(
"login expired, run `azd auth login --tenant-id %s` to acquire a new token.", tenantId),
}
}),
)

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

errWithSuggestion, ok := errors.AsType[*internal.ErrorWithSuggestion](err)
require.True(t, ok)
// Should NOT duplicate the --tenant-id guidance
assert.Equal(t, 1, strings.Count(errWithSuggestion.Suggestion, "--tenant-id"))
})

t.Run("TenantLookupFailure_EnhancedError", func(t *testing.T) {
provider := NewSubscriptionCredentialProvider(
subscriptionResolverFunc(func(ctx context.Context, subId string) (*Subscription, error) {
Expand All @@ -144,7 +205,7 @@ func TestSubscriptionCredentialProvider_AADSTSErrors(t *testing.T) {
}),
)

_, err := provider.CredentialForSubscription(context.Background(), subscriptionId)
_, err := provider.CredentialForSubscription(t.Context(), subscriptionId)
assert.Error(t, err)
assert.Contains(t, err.Error(), "AZURE_TENANT_ID")
assert.Contains(t, err.Error(), "manually set the subscription ID")
Expand Down
8 changes: 4 additions & 4 deletions cli/azd/pkg/auth/additional_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestAzdCredential_GetToken_Success(t *testing.T) {

account := public.Account{HomeAccountID: "h1"}
cred := newAzdCredential(
pc, &account, cloud.AzurePublic(), "",
pc, &account, cloud.AzurePublic(), "", nil,
)

tok, err := cred.GetToken(t.Context(), policy.TokenRequestOptions{
Expand All @@ -51,7 +51,7 @@ func TestAzdCredential_GetToken_WithTenantID(t *testing.T) {

account := public.Account{HomeAccountID: "h1"}
cred := newAzdCredential(
pc, &account, cloud.AzurePublic(), "default-t",
pc, &account, cloud.AzurePublic(), "default-t", nil,
)

// Override with request-level tenant
Expand All @@ -70,7 +70,7 @@ func TestAzdCredential_GetToken_GenericError(t *testing.T) {

account := public.Account{HomeAccountID: "h1"}
cred := newAzdCredential(
pc, &account, cloud.AzurePublic(), "",
pc, &account, cloud.AzurePublic(), "", nil,
)

_, err := cred.GetToken(t.Context(), policy.TokenRequestOptions{
Expand All @@ -90,7 +90,7 @@ func TestAzdCredential_GetToken_AuthFailedNotReLogin(t *testing.T) {

account := public.Account{HomeAccountID: "h1"}
cred := newAzdCredential(
pc, &account, cloud.AzurePublic(), "",
pc, &account, cloud.AzurePublic(), "", nil,
)

_, err := cred.GetToken(t.Context(), policy.TokenRequestOptions{
Expand Down
38 changes: 27 additions & 11 deletions cli/azd/pkg/auth/azd_credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,29 @@ import (
)

type azdCredential struct {
client publicClient
account *public.Account
cloud *cloud.Cloud
tenantID string
client publicClient
account *public.Account
cloud *cloud.Cloud
tenantID string
cacheTracer *msalCacheTracer
}

// newAzdCredential creates a credential that acquires tokens via MSAL's public client.
// tenantID, when non-empty, is forwarded to AcquireTokenSilent so MSAL issues tokens
// for that specific tenant instead of defaulting to the account's home tenant.
func newAzdCredential(
client publicClient, account *public.Account, cloud *cloud.Cloud, tenantID string,
client publicClient,
account *public.Account,
cloud *cloud.Cloud,
tenantID string,
cacheTracer *msalCacheTracer,
) *azdCredential {
return &azdCredential{
client: client,
account: account,
cloud: cloud,
tenantID: tenantID,
client: client,
account: account,
cloud: cloud,
tenantID: tenantID,
cacheTracer: cacheTracer,
}
}

Expand All @@ -57,11 +63,19 @@ func (c *azdCredential) GetToken(ctx context.Context, options policy.TokenReques
silentOpts = append(silentOpts, public.WithTenantID(tenantID))
}

res, err := c.client.AcquireTokenSilent(ctx, options.Scopes, silentOpts...)
phase := "after-first-acquire-token-silent"
failurePhase := "after-first-acquire-token-silent-failure"
if tenantID != "" {
phase = "after-first-tenant-acquire-token-silent"
failurePhase = "after-first-tenant-acquire-token-silent-failure"
}

res, err := c.client.AcquireTokenSilent(ctx, options.Scopes, silentOpts...)
if err != nil {
c.cacheTracer.LogSnapshotOnce(failurePhase)

if authFailed, ok := errors.AsType[*AuthFailedError](err); ok {
if loginErr, ok := newReLoginRequiredError(authFailed.Parsed, options.Scopes, c.cloud); ok {
if loginErr, ok := newReLoginRequiredError(authFailed.Parsed, options.Scopes, c.cloud, tenantID); ok {
log.Println(authFailed.httpErrorDetails())

if options.Claims != "" {
Expand All @@ -79,6 +93,8 @@ func (c *azdCredential) GetToken(ctx context.Context, options policy.TokenReques
return azcore.AccessToken{}, err
}

c.cacheTracer.LogSnapshotOnce(phase)

return azcore.AccessToken{
Token: res.AccessToken,
ExpiresOn: res.ExpiresOn.UTC(),
Expand Down
Loading
Loading