From ec7316b27226865a1a01751e78aaec6523fcaabb Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Wed, 21 Oct 2026 20:30:51 -0700 Subject: [PATCH] fix(public): show the message of the Public API's 401 The Public API's auth middleware answers 401 with {"error":{"code","message"}}, and its panic recovery answers 500 the same way, unlike the top-level message of the errors its handlers return. responseError only read the top level, so an expired or revoked session printed the raw JSON before the sign-in hint. It now reads the nested message too, so a server panic also shows "internal server error" instead of the raw body. --- cmd/lk/testdata/script/rejected-session.txtar | 1 - pkg/public/client.go | 21 +++++++++++++++---- pkg/public/client_test.go | 20 ++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/cmd/lk/testdata/script/rejected-session.txtar b/cmd/lk/testdata/script/rejected-session.txtar index 436e58893..ebafb163f 100644 --- a/cmd/lk/testdata/script/rejected-session.txtar +++ b/cmd/lk/testdata/script/rejected-session.txtar @@ -9,7 +9,6 @@ cp stderr lk.stderr requests cmp stdout want-requests -skip 'known bug: lk shows the raw body of the Public API''s 401 instead of its message' cmp lk.stderr want-stderr -- home/.livekit/cli-config.yaml -- diff --git a/pkg/public/client.go b/pkg/public/client.go index 33204c093..1e0a08102 100644 --- a/pkg/public/client.go +++ b/pkg/public/client.go @@ -176,15 +176,28 @@ func IsNotFound(err error) bool { } // responseError builds an APIError from a non-2xx response, preferring the -// API's error envelope ({"code","message","details"}) and falling back -// to the raw body. +// API's error envelope ({"code","message","details"}, or the +// {"error":{"code","message"}} its auth and panic-recovery middleware answer +// with) and falling back to the raw body. func responseError(status int, body []byte) error { var env struct { Message string `json:"message"` Details []json.RawMessage `json:"details"` + Error json.RawMessage `json:"error"` } - if err := json.Unmarshal(body, &env); err == nil && env.Message != "" { - return &APIError{Status: status, Message: env.Message, details: env.Details} + if err := json.Unmarshal(body, &env); err == nil { + msg := env.Message + if msg == "" && len(env.Error) > 0 { + var nested struct { + Message string `json:"message"` + } + if json.Unmarshal(env.Error, &nested) == nil { + msg = nested.Message + } + } + if msg != "" { + return &APIError{Status: status, Message: msg, details: env.Details} + } } msg := strings.TrimSpace(string(body)) if msg == "" { diff --git a/pkg/public/client_test.go b/pkg/public/client_test.go index 09e3d0009..8d28810ee 100644 --- a/pkg/public/client_test.go +++ b/pkg/public/client_test.go @@ -36,6 +36,26 @@ func TestResponseError(t *testing.T) { assert.Equal(t, http.StatusForbidden, apiErr.Status) assert.Equal(t, "forbidden", apiErr.Message) + // The auth layer's 401 nests its message under "error". + err = responseError(http.StatusUnauthorized, []byte(`{"error":{"code":"unauthenticated","message":"authentication required"}}`)) + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusUnauthorized, apiErr.Status) + assert.Equal(t, "authentication required", apiErr.Message) + + // A top-level message wins over a nested one. + err = responseError(http.StatusBadRequest, []byte(`{"message":"top","error":{"message":"nested"}}`)) + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "top", apiErr.Message) + + // A nested error without a message, or an "error" that isn't an object, + // doesn't hide the top-level message or the raw body. + err = responseError(http.StatusBadRequest, []byte(`{"error":{"code":"x"}}`)) + require.ErrorAs(t, err, &apiErr) + assert.Contains(t, apiErr.Message, "400") + err = responseError(http.StatusBadRequest, []byte(`{"message":"top","error":"a string"}`)) + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, "top", apiErr.Message) + // Non-envelope body falls back to the raw text (with the status). err = responseError(http.StatusInternalServerError, []byte("boom")) require.ErrorAs(t, err, &apiErr)