fix(grpc): return 401 for unauthenticated v1 gateway errors (#11786)

Fixes #11730

The v1 gRPC middleware can return native gRPC status errors such as
`codes.Unauthenticated` when the auth header is missing.

Those errors were being passed through the generic gRPC error converter,
which only handled ZITADEL errors and downgraded raw gRPC statuses to
`codes.Unknown`.

Through grpc-gateway that caused `/auth/v1/users/me` to return HTTP 500
instead of HTTP 401.

So this change preserves native gRPC status errors in
`ZITADELToGRPCError`, keeps `ExtractZITADELError` aligned with those
transport codes for activity reporting, and adds regression coverage for
the converter, middleware, and gateway HTTP behavior.

---------

Co-authored-by: Gayathri Vijayan <[email protected]>
This commit is contained in:
Oluwatobi Mustapha
2026-04-10 12:22:40 +00:00
committed by GitHub
co-authored by Gayathri Vijayan
parent 035ac4b9a2
commit fbd43ced6e
4 changed files with 80 additions and 0 deletions
@@ -23,6 +23,11 @@ func ZITADELToGRPCError(ctx context.Context, err error) error {
if err == nil {
return nil
}
if grpcStatus, ok := status.FromError(err); ok {
// Native gRPC status errors are already transport-safe and may carry details.
logging.Log(ctx, grpcStatusLevel(grpcStatus.Code()), grpcStatus.Message(), "err", err, "code", grpcStatus.Code())
return err
}
code, key, id, lvl := extractError(err)
msg := key
@@ -78,6 +83,9 @@ func extractError(err error) (c codes.Code, msg, id string, lvl slog.Level) {
if ok := errors.As(err, &connErr); ok {
return codes.Internal, "db connection error", "", slog.LevelError
}
if grpcStatus, ok := status.FromError(err); ok {
return grpcStatus.Code(), grpcStatus.Message(), "", grpcStatusLevel(grpcStatus.Code())
}
zitadelErr, ok := zerrors.AsZitadelError(err)
if !ok {
return codes.Unknown, err.Error(), "", slog.LevelError
@@ -126,6 +134,24 @@ func extractError(err error) (c codes.Code, msg, id string, lvl slog.Level) {
return c, msg, id, lvl
}
func grpcStatusLevel(code codes.Code) slog.Level {
switch code { //nolint:exhaustive
case codes.AlreadyExists,
codes.Canceled,
codes.FailedPrecondition,
codes.InvalidArgument,
codes.NotFound,
codes.OutOfRange,
codes.PermissionDenied,
codes.Unauthenticated:
return slog.LevelWarn
case codes.Unimplemented:
return slog.LevelInfo
default:
return slog.LevelError
}
}
func getErrorInfo(id, key string, err error) protoadapt.MessageV1 {
var errorInfo protoadapt.MessageV1
@@ -7,7 +7,9 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/protoadapt"
commandErrors "github.com/zitadel/zitadel/internal/command/errors"
@@ -49,6 +51,14 @@ func TestCaosToGRPCError(t *testing.T) {
}
}
func TestZITADELToGRPCError_PreservesGRPCStatus(t *testing.T) {
err := ZITADELToGRPCError(t.Context(), status.Error(codes.Unauthenticated, "auth header missing"))
grpcStatus, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.Unauthenticated, grpcStatus.Code())
assert.Equal(t, "auth header missing", grpcStatus.Message())
}
func Test_getErrorInfo(t *testing.T) {
tests := []struct {
name string
@@ -205,6 +215,14 @@ func Test_extractError(t *testing.T) {
"",
slog.LevelError,
},
{
"grpc unauthenticated",
args{status.Error(codes.Unauthenticated, "auth header missing")},
codes.Unauthenticated,
"auth header missing",
"",
slog.LevelWarn,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+24
View File
@@ -0,0 +1,24 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestErrorHandler_PreservesUnauthenticatedStatus(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/v1/users/me", nil)
resp := httptest.NewRecorder()
errorHandler(context.Background(), runtime.NewServeMux(), jsonMarshaler, resp, req, status.Error(codes.Unauthenticated, "auth header missing"))
assert.Equal(t, http.StatusUnauthorized, resp.Code)
assert.Contains(t, resp.Body.String(), `"code":16`)
assert.Contains(t, resp.Body.String(), `"message":"auth header missing"`)
}
@@ -43,6 +43,18 @@ func Test_toGRPCError(t *testing.T) {
want: nil,
wantCode: codes.FailedPrecondition,
},
{
name: "grpc status error",
args: args{
ctx: context.Background(),
req: &mockReq{},
handler: func(context.Context, any) (any, error) {
return nil, status.Error(codes.Unauthenticated, "auth header missing")
},
},
want: nil,
wantCode: codes.Unauthenticated,
},
{
name: "panic with string",
args: args{