Skip to content
Merged
6 changes: 4 additions & 2 deletions cli/azd/cmd/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,10 +314,12 @@ func (la *loginAction) Run(ctx context.Context) (*actions.ActionResult, error) {
// We print any non-setup related errors to stderr.
// We always return a zero exit code.
token, err := la.verifyLoggedIn(ctx)
_, loginExpiryError := errors.AsType[*auth.ReLoginRequiredError](err)
// An *internal.ErrorWithSuggestion already carries actionable, user-facing guidance
// surfaced through the login result; avoid double-printing the raw error.
_, hasSuggestion := errors.AsType[*internal.ErrorWithSuggestion](err)
if err != nil &&
!errors.Is(err, auth.ErrNoCurrentUser) &&
!loginExpiryError {
!hasSuggestion {
fmt.Fprintln(la.console.Handles().Stderr, err.Error())
}

Expand Down
6 changes: 4 additions & 2 deletions cli/azd/cmd/auth_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,12 @@ func (a *authStatusAction) Run(ctx context.Context) (*actions.ActionResult, erro

// get user account information
details, err := a.authManager.LogInDetails(ctx)
_, loginExpiryError := errors.AsType[*auth.ReLoginRequiredError](err)
// An *internal.ErrorWithSuggestion already carries actionable, user-facing guidance
// surfaced through the status result; avoid double-printing the raw error.
_, hasSuggestion := errors.AsType[*internal.ErrorWithSuggestion](err)
if err != nil {
if !errors.Is(err, auth.ErrNoCurrentUser) &&
!loginExpiryError {
!hasSuggestion {
// print a useful message for unknown errors
fmt.Fprintln(a.console.Handles().Stderr, err.Error())
}
Expand Down
37 changes: 24 additions & 13 deletions cli/azd/internal/cmd/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ func MapError(err error, span tracing.Span) {
errDetails = append(errDetails, fields.ErrCategory.String("auth"))
} else if errWithSuggestion, ok := errors.AsType[*internal.ErrorWithSuggestion](err); ok {
errCode = "error.suggestion"
span.SetAttributes(fields.ErrType.String(classifySuggestionType(errWithSuggestion.Unwrap())))
innerErr := errWithSuggestion.Unwrap()
span.SetAttributes(fields.ErrType.String(classifySuggestionType(innerErr)))
if authFailedErr, ok := errors.AsType[*auth.AuthFailedError](innerErr); ok {
errDetails = append(errDetails, authFailedTelemetryDetails(authFailedErr)...)
}
} else if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok {
serviceName := "other"
statusCode := -1
Expand Down Expand Up @@ -173,18 +177,7 @@ func MapError(err error, span tracing.Span) {
errDetails = append(errDetails, fields.ToolName.String(strings.Join(toolCheckErr.ToolNames, ",")))
}
} else if authFailedErr, ok := errors.AsType[*auth.AuthFailedError](err); ok {
errDetails = append(errDetails, fields.ServiceName.String("aad"))
if authFailedErr.Parsed != nil {
codes := make([]string, 0, len(authFailedErr.Parsed.ErrorCodes))
for _, code := range authFailedErr.Parsed.ErrorCodes {
codes = append(codes, fmt.Sprintf("%d", code))
}
serviceErr := strings.Join(codes, ",")
errDetails = append(errDetails,
fields.ServiceStatusCode.String(authFailedErr.Parsed.Error),
fields.ServiceErrorCode.String(serviceErr),
fields.ServiceCorrelationId.String(authFailedErr.Parsed.CorrelationId))
}
errDetails = append(errDetails, authFailedTelemetryDetails(authFailedErr)...)
errCode = "service.aad.failed"
} else if errors.Is(err, auth.ErrNoCurrentUser) {
errCode = "auth.not_logged_in"
Expand Down Expand Up @@ -216,6 +209,24 @@ func MapError(err error, span tracing.Span) {
span.SetStatus(codes.Error, errCode)
}

func authFailedTelemetryDetails(authFailedErr *auth.AuthFailedError) []attribute.KeyValue {
errDetails := []attribute.KeyValue{fields.ServiceName.String("aad")}
if authFailedErr == nil || authFailedErr.Parsed == nil {
return errDetails
}

codes := make([]string, 0, len(authFailedErr.Parsed.ErrorCodes))
for _, code := range authFailedErr.Parsed.ErrorCodes {
codes = append(codes, fmt.Sprintf("%d", code))
}

return append(errDetails,
fields.ServiceStatusCode.String(authFailedErr.Parsed.Error),
fields.ServiceErrorCode.String(strings.Join(codes, ",")),
fields.ServiceCorrelationId.String(authFailedErr.Parsed.CorrelationId),
)
}

// classifySentinel checks if the error matches a known sentinel
// and returns the corresponding telemetry code, or "" if no match.
func classifySentinel(err error) string {
Expand Down
22 changes: 22 additions & 0 deletions cli/azd/internal/cmd/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,28 @@ func Test_MapError(t *testing.T) {
fields.ErrorKey(fields.ErrCategory.Key).String("auth"),
},
},
{
name: "WithTokenProtectionBlockedAuthFailedError",
err: &internal.ErrorWithSuggestion{
Err: &auth.AuthFailedError{
Parsed: &auth.AadErrorResponse{
Error: "invalid_grant",
ErrorCodes: []int{530084},
CorrelationId: "blocked-token-protection",
},
},
},
// AADSTS530084 surfaced via the suggestion wrapper still classifies as the generic
// AAD service failure for telemetry, and should preserve the wrapped AAD details.
wantErrReason: "error.suggestion",
wantErrDetails: []attribute.KeyValue{
fields.ErrType.String("service.aad.failed"),
fields.ErrorKey(fields.ServiceName.Key).String("aad"),
fields.ErrorKey(fields.ServiceErrorCode.Key).String("530084"),
fields.ErrorKey(fields.ServiceStatusCode.Key).String("invalid_grant"),
fields.ErrorKey(fields.ServiceCorrelationId.Key).String("blocked-token-protection"),
},
},
{
name: "WithAzidentityAuthenticationFailedError",
err: &azidentity.AuthenticationFailedError{},
Expand Down
66 changes: 60 additions & 6 deletions cli/azd/internal/grpcserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/auth"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
Expand Down Expand Up @@ -268,31 +269,84 @@ func (s *authenticatedStream) Context() context.Context {
// This ensures that helpful suggestions (like "run azd auth login") are preserved
// when errors are transmitted over gRPC, where only the error message string is sent.
//
// Auth-related errors (ReLoginRequiredError, ErrNoCurrentUser) are returned with
// codes.Unauthenticated so that extensions can detect auth failures via gRPC status code.
// Auth-related errors are returned with codes.Unauthenticated and a structured
// ErrorInfo reason so extensions can reliably classify them as auth failures
// while still distinguishing the remediation path.
func wrapErrorWithSuggestion(err error) error {
if err == nil {
return nil
}

_, loginErr := errors.AsType[*auth.ReLoginRequiredError](err)
isAuthErr := errors.Is(err, auth.ErrNoCurrentUser) || loginErr
isAuthErr := isAuthError(err)

if suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err); ok {
msg := fmt.Sprintf("%s\n%s", err.Error(), suggestionErr.Suggestion)
if isAuthErr {
return status.Error(codes.Unauthenticated, msg)
return grpcAuthStatus(err, msg)
}
return fmt.Errorf("%w\n%s", err, suggestionErr.Suggestion)
}

if isAuthErr {
return status.Error(codes.Unauthenticated, err.Error())
return grpcAuthStatus(err, err.Error())
}

return err
}

// isAuthError reports whether err's chain contains a known auth-failure type that should be
// surfaced over gRPC as codes.Unauthenticated.
func isAuthError(err error) bool {
if errors.Is(err, auth.ErrNoCurrentUser) {
return true
}
if _, ok := errors.AsType[*auth.ReLoginRequiredError](err); ok {
return true
}
if _, ok := errors.AsType[*auth.AuthFailedError](err); ok {
return true
}
return false
}

func grpcAuthStatus(err error, msg string) error {
st := status.New(codes.Unauthenticated, msg)
reason := grpcAuthReason(err)
if reason == "" {
return st.Err()
}

withDetails, detailErr := st.WithDetails(&errdetails.ErrorInfo{
Reason: reason,
Domain: azdext.AuthErrorDomain,
})
if detailErr != nil {
return st.Err()
}

return withDetails.Err()
}

func grpcAuthReason(err error) string {
if errors.Is(err, auth.ErrNoCurrentUser) {
return azdext.AuthErrorReasonNotLoggedIn
}

// Pass through the originating AAD error code (e.g., "AADSTS530084") when available.
// This preserves Entra's own semantics rather than redefining them on azd's side.
if authFailed, ok := errors.AsType[*auth.AuthFailedError](err); ok {
if authFailed.Parsed != nil && len(authFailed.Parsed.ErrorCodes) > 0 {
return fmt.Sprintf("AADSTS%d", authFailed.Parsed.ErrorCodes[0])
}
}

if _, ok := errors.AsType[*auth.ReLoginRequiredError](err); ok {
return azdext.AuthErrorReasonLoginRequired
}

return ""
}

func generateSigningKey() ([]byte, error) {
bytes := make([]byte, 32) // 256-bit HMAC signing key
if _, err := rand.Read(bytes); err != nil {
Expand Down
29 changes: 29 additions & 0 deletions cli/azd/internal/grpcserver/server_coverage3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import (

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/auth"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
Expand Down Expand Up @@ -77,6 +79,33 @@ func TestWrapErrorWithSuggestion_ReLoginRequired(t *testing.T) {
require.Equal(t, codes.Unauthenticated, st.Code())
}

func TestWrapErrorWithSuggestion_TokenProtectionBlocked(t *testing.T) {
t.Parallel()
authFailed := &auth.AuthFailedError{
Parsed: &auth.AadErrorResponse{
Error: "invalid_grant",
ErrorCodes: []int{530084},
},
}
// In production the wrapper is built by newActionableAuthError; mirror that shape here so
// wrapErrorWithSuggestion classifies the wrapped *AuthFailedError as an auth interaction.
err := fmt.Errorf("token protection: %w", &internal.ErrorWithSuggestion{
Err: authFailed,
Suggestion: "Contact your IT administrator or request a policy exception.",
})

wrapped := wrapErrorWithSuggestion(err)
st, ok := status.FromError(wrapped)
require.True(t, ok)
require.Equal(t, codes.Unauthenticated, st.Code())
details := st.Details()
require.Len(t, details, 1)
info, ok := details[0].(*errdetails.ErrorInfo)
require.True(t, ok)
require.Equal(t, azdext.AuthErrorDomain, info.Domain)
require.Equal(t, "AADSTS530084", info.Reason)
}

func TestWrapErrorWithSuggestion_AuthErrorWithSuggestion(t *testing.T) {
t.Parallel()
inner := auth.ErrNoCurrentUser
Expand Down
48 changes: 38 additions & 10 deletions cli/azd/internal/grpcserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
Expand Down Expand Up @@ -237,6 +238,7 @@ func Test_wrapErrorWithSuggestion(t *testing.T) {
wantContain string
wantSameInstance bool
wantGrpcCode codes.Code
wantAuthReason string
}{
{
name: "nil error returns nil",
Expand Down Expand Up @@ -266,25 +268,43 @@ func Test_wrapErrorWithSuggestion(t *testing.T) {
wantContain: "azd auth login",
},
{
name: "ErrNoCurrentUser returns Unauthenticated",
err: auth.ErrNoCurrentUser,
wantContain: "not logged in",
wantGrpcCode: codes.Unauthenticated,
name: "ErrNoCurrentUser returns Unauthenticated",
err: auth.ErrNoCurrentUser,
wantContain: "not logged in",
wantGrpcCode: codes.Unauthenticated,
wantAuthReason: azdext.AuthErrorReasonNotLoggedIn,
},
{
name: "wrapped ErrNoCurrentUser returns Unauthenticated",
err: fmt.Errorf("failed to list subscriptions: %w", auth.ErrNoCurrentUser),
wantContain: "not logged in",
wantGrpcCode: codes.Unauthenticated,
name: "wrapped ErrNoCurrentUser returns Unauthenticated",
err: fmt.Errorf("failed to list subscriptions: %w", auth.ErrNoCurrentUser),
wantContain: "not logged in",
wantGrpcCode: codes.Unauthenticated,
wantAuthReason: azdext.AuthErrorReasonNotLoggedIn,
},
{
name: "ReLoginRequiredError with suggestion returns Unauthenticated",
err: &internal.ErrorWithSuggestion{
Err: &auth.ReLoginRequiredError{},
Suggestion: "login expired, run `azd auth login` to acquire a new token.",
},
wantContain: "azd auth login",
wantGrpcCode: codes.Unauthenticated,
wantContain: "azd auth login",
wantGrpcCode: codes.Unauthenticated,
wantAuthReason: azdext.AuthErrorReasonLoginRequired,
},
{
name: "TokenProtectionBlockedError with suggestion returns Unauthenticated",
err: &internal.ErrorWithSuggestion{
Err: &auth.AuthFailedError{
Parsed: &auth.AadErrorResponse{
Error: "invalid_grant",
ErrorCodes: []int{530084},
},
},
Suggestion: "Contact your IT administrator or request a policy exception.",
},
wantContain: "policy exception",
wantGrpcCode: codes.Unauthenticated,
wantAuthReason: "AADSTS530084",
},
}

Expand All @@ -304,6 +324,14 @@ func Test_wrapErrorWithSuggestion(t *testing.T) {
st, ok := status.FromError(result)
require.True(t, ok, "expected gRPC status error")
require.Equal(t, tt.wantGrpcCode, st.Code())
if tt.wantAuthReason != "" {
details := st.Details()
require.Len(t, details, 1)
info, ok := details[0].(*errdetails.ErrorInfo)
require.True(t, ok, "expected ErrorInfo detail")
require.Equal(t, azdext.AuthErrorDomain, info.Domain)
require.Equal(t, tt.wantAuthReason, info.Reason)
}
}
})
}
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/pkg/auth/azd_credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func (c *azdCredential) GetToken(ctx context.Context, options policy.TokenReques
c.cacheTracer.LogSnapshotOnce(failurePhase)

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

if options.Claims != "" {
Expand Down
Loading
Loading