From 19c6b880f785c9cc38550516be6f19e3f75c3bbd Mon Sep 17 00:00:00 2001 From: John Miller Date: Mon, 6 Apr 2026 16:31:19 -0400 Subject: [PATCH] fix(agents): return actionable auth error when Graph token is missing (#7514) When azd auth login caches only an ARM token, the postdeploy RBAC hook fails with a cryptic 'AzureDeveloperCLICredential: please run azd auth login' message because the Graph API scope was never consented/cached. Changes: - Detect credential auth errors in ensureAgentIdentityRBACWithCred and return an exterrors.Auth with a suggestion to re-login with the Graph scope - Preserve structured errors in postdeployHandler so the message, code, and suggestion survive gRPC serialization - Add isCredentialAuthError helper with table-driven tests - Add CodeRbacAuthFailed error code Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/cspell.yaml | 1 + .../azure.ai.agents/internal/cmd/listen.go | 11 ++++ .../internal/exterrors/codes.go | 1 + .../internal/project/agent_identity_rbac.go | 25 +++++++++ .../project/agent_identity_rbac_test.go | 55 +++++++++++++++++++ 5 files changed, 93 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 8973ab87723..71a82e4e909 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -1,4 +1,5 @@ words: + - aadsts - aiservices - agentserver - anonymousconnection diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index a8b08c9e7e0..87d4dea8fd2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -6,6 +6,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -112,6 +113,16 @@ func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *a // Ensure agent identity RBAC is configured when vnext is enabled. // Runs post-deploy because the platform provisions the identity during agent deployment. if err := project.EnsureAgentIdentityRBAC(ctx, azdClient); err != nil { + // If the error is already structured, return it unchanged so gRPC + // serialization preserves the message, code, and suggestion. + var localErr *azdext.LocalError + if errors.As(err, &localErr) { + return err + } + var svcErr *azdext.ServiceError + if errors.As(err, &svcErr) { + return err + } return fmt.Errorf("agent identity RBAC setup failed: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index fe700c880e6..0f3dc1b3b12 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -62,6 +62,7 @@ const ( CodeNotLoggedIn = "not_logged_in" CodeLoginExpired = "login_expired" CodeAuthFailed = "auth_failed" + CodeRbacAuthFailed = "rbac_auth_failed" ) // Error codes for compatibility errors. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_rbac.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_rbac.go index 458553740d2..eba4fc18d3c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_rbac.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_rbac.go @@ -5,6 +5,7 @@ package project import ( "context" + "errors" "fmt" "os" "strconv" @@ -12,6 +13,8 @@ import ( "sync" "time" + "azureaiagent/internal/exterrors" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -183,6 +186,15 @@ func ensureAgentIdentityRBACWithCred( for attempt := range identityLookupMaxAttempts { agentIdentities, err = discoverAgentIdentity(ctx, graphClient, displayName) if err != nil { + if isCredentialAuthError(err) { + return exterrors.Auth( + exterrors.CodeRbacAuthFailed, + "agent identity RBAC setup failed: "+ + "could not acquire a Microsoft Graph token", + "run 'azd auth login --scope https://graph.microsoft.com/.default' "+ + "and re-run 'azd deploy'", + ) + } return fmt.Errorf("failed to discover agent identity: %w", err) } if len(agentIdentities) > 0 { @@ -453,3 +465,16 @@ func extractSubscriptionID(resourceID string) string { } return "" } + +// isCredentialAuthError returns true when an error originates from the Azure Identity +// library failing to acquire a token (e.g. expired login, missing scope consent). +func isCredentialAuthError(err error) bool { + if err == nil { + return false + } + if _, ok := errors.AsType[*azidentity.AuthenticationFailedError](err); ok { + return true + } + // Fallback: AADSTS codes may appear in errors not wrapped as AuthenticationFailedError. + return strings.Contains(err.Error(), "AADSTS") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_rbac_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_rbac_test.go index 2500bbaef4f..9e538229718 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_rbac_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_identity_rbac_test.go @@ -4,8 +4,12 @@ package project import ( + "errors" + "fmt" + "net/http" "testing" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -198,3 +202,54 @@ func TestConstants(t *testing.T) { assert.Equal(t, "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd", roleCognitiveServicesOpenAIUser) assert.Equal(t, "3913510d-42f4-4e42-8a64-420c390055eb", roleMonitoringMetricsPublisher) } + +func TestIsCredentialAuthError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "unrelated error", + err: fmt.Errorf("network timeout"), + want: false, + }, + { + name: "typed AuthenticationFailedError", + err: &azidentity.AuthenticationFailedError{ + RawResponse: &http.Response{StatusCode: 401}, + }, + want: true, + }, + { + name: "wrapped AuthenticationFailedError", + err: fmt.Errorf("graph query failed: %w", + &azidentity.AuthenticationFailedError{ + RawResponse: &http.Response{StatusCode: 401}, + }), + want: true, + }, + { + name: "AADSTS error code fallback", + err: fmt.Errorf("AADSTS70043: The refresh token has expired"), + want: true, + }, + { + name: "string-only error without AADSTS not matched", + err: errors.New("AzureDeveloperCLICredential: please run azd auth login"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isCredentialAuthError(tt.err) + assert.Equal(t, tt.want, got) + }) + } +}