Skip to content

Commit 6dd81dd

Browse files
waveywavesclaude
andcommitted
fix(cli): stop masking auth errors as token expiry
The Kratos errors.Is() function matches only on Code and Reason (not Message). Since all JWT middleware errors share Code=401 and Reason="UNAUTHORIZED", every authentication error was incorrectly matching ErrTokenExpired and showing "your authentication token has expired" regardless of the actual cause. Fix by removing the errors.Is() short-circuit in isWrappedErr and keeping only the Code+Message comparison, which carries the distinguishing text for each JWT error type. Also add explicit case branches for ErrTokenInvalid and ErrTokenParseFail, and a fallback for unmatched 401 errors that surfaces the server's original message. Closes #2922 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
1 parent 56c5a3f commit 6dd81dd

2 files changed

Lines changed: 273 additions & 6 deletions

File tree

app/cli/main.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package main
1717

1818
import (
19+
"fmt"
1920
"os"
2021

2122
"github.com/chainloop-dev/chainloop/app/cli/cmd"
@@ -87,13 +88,24 @@ func errorInfo(err error, logger zerolog.Logger) (string, int) {
8788
case v1.IsCasBackendErrorReasonInvalid(err):
8889
msg = "the CAS backend you provided is invalid. Refer to `chainloop cas-backend update` command or contact your administrator."
8990
case isWrappedErr(st, jwtMiddleware.ErrTokenExpired):
90-
msg = "your authentication token has expired, please run chainloop auth login again"
91+
msg = "your authentication token has expired, please run \"chainloop auth login\" again"
92+
case isWrappedErr(st, jwtMiddleware.ErrTokenInvalid):
93+
msg = "your authentication token is invalid, please run \"chainloop auth login\" again"
94+
case isWrappedErr(st, jwtMiddleware.ErrTokenParseFail):
95+
msg = "failed to parse authentication token, please run \"chainloop auth login\" again"
9196
case isWrappedErr(st, jwtMiddleware.ErrMissingJwtToken):
9297
msg = "authentication required, please run \"chainloop auth login\""
9398
case v1.IsUserNotMemberOfOrgErrorNotInOrg(err):
9499
msg = "the organization you are trying to access does not exist or you are not part of it, please run \"chainloop auth login\""
95100
case v1.IsUserWithNoMembershipErrorNotInOrg(err):
96101
msg = "you are not part of any organization, please run \"chainloop organization create --name ORG_NAME\" to create one"
102+
case isUnmatchedAuthErr(st):
103+
// Fallback for any other 401/Unauthenticated errors not matched above.
104+
// Org-membership errors (IsUserNotMemberOfOrgErrorNotInOrg,
105+
// IsUserWithNoMembershipErrorNotInOrg) use gRPC code 7 (PermissionDenied),
106+
// not code 16 (Unauthenticated), so shadowing is structurally impossible.
107+
// We keep this case ordered after them purely for readability/clarity.
108+
msg = fmt.Sprintf("authentication error: %s", st.Message())
97109
case errors.As(err, &cmd.GracefulError{}):
98110
// Graceful recovery if the flag is set and the received error is marked as recoverable
99111
if cmd.GracefulExit {
@@ -110,12 +122,21 @@ func errorInfo(err error, logger zerolog.Logger) (string, int) {
110122

111123
// target is the expected error
112124
// grpcStatus is the actual error that might be wrapped in both the status and the error
125+
//
126+
// NOTE: We intentionally do NOT use kratos errors.Is() here because it only
127+
// compares Code and Reason. Since all JWT errors share the same Code (401) and
128+
// Reason ("UNAUTHORIZED"), errors.Is() would match any 401 error against any
129+
// JWT sentinel — causing e.g. "token invalid" to be reported as "token expired".
130+
// Instead we compare Code and Message, which carry the distinguishing text.
113131
func isWrappedErr(grpcStatus *status.Status, target *errors.Error) bool {
114132
err := errors.FromError(grpcStatus.Err())
115-
// The error might be wrapped since the CLI sometimes returns a wrapped error
116-
if errors.Is(err, target) {
117-
return true
118-
}
119-
120133
return target.Code == err.Code && err.Message == target.Message
121134
}
135+
136+
// isUnmatchedAuthErr returns true when the gRPC status represents an
137+
// Unauthenticated (401-equivalent) error that was not matched by any of the
138+
// specific JWT sentinel checks above. This lets us surface the server's
139+
// original message instead of silently dropping it.
140+
func isUnmatchedAuthErr(grpcStatus *status.Status) bool {
141+
return grpcStatus.Code() == codes.Unauthenticated
142+
}

app/cli/main_test.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
//
2+
// Copyright 2023-2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package main
17+
18+
import (
19+
"testing"
20+
21+
kratosErrors "github.com/go-kratos/kratos/v2/errors"
22+
jwtMiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt"
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
"google.golang.org/grpc/codes"
26+
"google.golang.org/grpc/status"
27+
)
28+
29+
// toGRPCStatus converts a Kratos *errors.Error into a *status.Status the same
30+
// way it would travel over the wire: Kratos Error -> GRPCStatus() -> gRPC Status.
31+
func toGRPCStatus(err *kratosErrors.Error) *status.Status {
32+
return err.GRPCStatus()
33+
}
34+
35+
func TestIsWrappedErr(t *testing.T) {
36+
tests := []struct {
37+
name string
38+
actual *kratosErrors.Error // the error that "came over the wire"
39+
target *kratosErrors.Error // the sentinel we are checking against
40+
want bool
41+
}{
42+
{
43+
name: "ErrTokenExpired matches itself",
44+
actual: jwtMiddleware.ErrTokenExpired,
45+
target: jwtMiddleware.ErrTokenExpired,
46+
want: true,
47+
},
48+
{
49+
name: "ErrTokenInvalid matches itself",
50+
actual: jwtMiddleware.ErrTokenInvalid,
51+
target: jwtMiddleware.ErrTokenInvalid,
52+
want: true,
53+
},
54+
{
55+
name: "ErrTokenParseFail matches itself",
56+
actual: jwtMiddleware.ErrTokenParseFail,
57+
target: jwtMiddleware.ErrTokenParseFail,
58+
want: true,
59+
},
60+
{
61+
name: "ErrMissingJwtToken matches itself",
62+
actual: jwtMiddleware.ErrMissingJwtToken,
63+
target: jwtMiddleware.ErrMissingJwtToken,
64+
want: true,
65+
},
66+
{
67+
name: "ErrTokenExpired does NOT match ErrTokenInvalid",
68+
actual: jwtMiddleware.ErrTokenExpired,
69+
target: jwtMiddleware.ErrTokenInvalid,
70+
want: false,
71+
},
72+
{
73+
name: "ErrTokenInvalid does NOT match ErrTokenExpired",
74+
actual: jwtMiddleware.ErrTokenInvalid,
75+
target: jwtMiddleware.ErrTokenExpired,
76+
want: false,
77+
},
78+
{
79+
name: "ErrTokenParseFail does NOT match ErrTokenExpired",
80+
actual: jwtMiddleware.ErrTokenParseFail,
81+
target: jwtMiddleware.ErrTokenExpired,
82+
want: false,
83+
},
84+
{
85+
name: "ErrTokenExpired does NOT match ErrMissingJwtToken",
86+
actual: jwtMiddleware.ErrTokenExpired,
87+
target: jwtMiddleware.ErrMissingJwtToken,
88+
want: false,
89+
},
90+
}
91+
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
st := toGRPCStatus(tt.actual)
95+
got := isWrappedErr(st, tt.target)
96+
assert.Equal(t, tt.want, got, "isWrappedErr(%v, %v)", tt.actual.Message, tt.target.Message)
97+
})
98+
}
99+
}
100+
101+
func TestIsUnmatchedAuthErr(t *testing.T) {
102+
tests := []struct {
103+
name string
104+
st *status.Status
105+
want bool
106+
}{
107+
{
108+
name: "generic 401/Unauthenticated error is caught",
109+
st: status.New(codes.Unauthenticated, "some auth error"),
110+
want: true,
111+
},
112+
{
113+
name: "JWT token expired (Unauthenticated) is caught",
114+
st: toGRPCStatus(jwtMiddleware.ErrTokenExpired),
115+
want: true,
116+
},
117+
{
118+
name: "PermissionDenied is NOT caught",
119+
st: status.New(codes.PermissionDenied, "forbidden"),
120+
want: false,
121+
},
122+
{
123+
name: "OK status is NOT caught",
124+
st: status.New(codes.OK, ""),
125+
want: false,
126+
},
127+
{
128+
name: "Internal error is NOT caught",
129+
st: status.New(codes.Internal, "internal server error"),
130+
want: false,
131+
},
132+
{
133+
name: "NotFound is NOT caught",
134+
st: status.New(codes.NotFound, "not found"),
135+
want: false,
136+
},
137+
}
138+
139+
for _, tt := range tests {
140+
t.Run(tt.name, func(t *testing.T) {
141+
got := isUnmatchedAuthErr(tt.st)
142+
assert.Equal(t, tt.want, got, "isUnmatchedAuthErr()")
143+
})
144+
}
145+
}
146+
147+
// TestKratosErrorsIsMasksJWTErrors demonstrates the bug that motivates our
148+
// Message-based comparison: Kratos errors.Is() only compares Code and Reason.
149+
// Since all JWT errors share Code=401 and Reason="UNAUTHORIZED", errors.Is()
150+
// incorrectly matches ANY JWT error against ANY other JWT sentinel.
151+
func TestKratosErrorsIsMasksJWTErrors(t *testing.T) {
152+
// This test proves that the naive errors.Is approach is broken:
153+
// ErrTokenExpired would incorrectly match ErrTokenInvalid via Kratos errors.Is.
154+
if !kratosErrors.Is(jwtMiddleware.ErrTokenExpired, jwtMiddleware.ErrTokenInvalid) {
155+
t.Skip("Kratos errors.Is behavior has changed; this test documents the original masking bug")
156+
}
157+
158+
// Now verify that our isWrappedErr correctly distinguishes them
159+
st := toGRPCStatus(jwtMiddleware.ErrTokenExpired)
160+
assert.False(t, isWrappedErr(st, jwtMiddleware.ErrTokenInvalid),
161+
"isWrappedErr should NOT match ErrTokenExpired against ErrTokenInvalid")
162+
assert.True(t, isWrappedErr(st, jwtMiddleware.ErrTokenExpired),
163+
"isWrappedErr should match ErrTokenExpired against itself")
164+
}
165+
166+
// TestIsWrappedErrGRPCWireRoundTrip verifies that isWrappedErr works after a
167+
// full gRPC wire round-trip: KratosError -> GRPCStatus -> proto bytes -> gRPC
168+
// status.FromError -> isWrappedErr. This simulates the actual path an error
169+
// takes from the server through a gRPC transport to the CLI client.
170+
func TestIsWrappedErrGRPCWireRoundTrip(t *testing.T) {
171+
sentinels := []*kratosErrors.Error{
172+
jwtMiddleware.ErrTokenExpired,
173+
jwtMiddleware.ErrTokenInvalid,
174+
jwtMiddleware.ErrTokenParseFail,
175+
jwtMiddleware.ErrMissingJwtToken,
176+
}
177+
178+
for _, sentinel := range sentinels {
179+
t.Run(sentinel.Message, func(t *testing.T) {
180+
// Step 1: Convert Kratos error to gRPC status (server side)
181+
grpcSt := sentinel.GRPCStatus()
182+
183+
// Step 2: Serialize to the wire format (proto bytes)
184+
proto := grpcSt.Proto()
185+
require.NotNil(t, proto, "gRPC status proto should not be nil")
186+
187+
// Step 3: Deserialize from proto back into a gRPC status (client side)
188+
roundTripped := status.FromProto(proto)
189+
require.NotNil(t, roundTripped, "round-tripped status should not be nil")
190+
191+
// Step 4: Verify isWrappedErr still matches after the full round-trip
192+
assert.True(t, isWrappedErr(roundTripped, sentinel),
193+
"isWrappedErr should match %q after gRPC wire round-trip", sentinel.Message)
194+
195+
// Step 5: Verify it does NOT match a different sentinel after round-trip
196+
for _, other := range sentinels {
197+
if other == sentinel {
198+
continue
199+
}
200+
assert.False(t, isWrappedErr(roundTripped, other),
201+
"isWrappedErr should NOT match %q against %q after round-trip",
202+
sentinel.Message, other.Message)
203+
}
204+
})
205+
}
206+
}
207+
208+
// TestJWTSentinelMessageCanary asserts the exact Message strings of the JWT
209+
// sentinel errors we depend on. If a Kratos update changes these strings
210+
// (e.g. ErrTokenParseFail's trailing space), this test will fail and alert us
211+
// that our Message-based matching in isWrappedErr needs updating.
212+
func TestJWTSentinelMessageCanary(t *testing.T) {
213+
tests := []struct {
214+
name string
215+
err *kratosErrors.Error
216+
wantMsg string
217+
}{
218+
{
219+
name: "ErrTokenExpired",
220+
err: jwtMiddleware.ErrTokenExpired,
221+
wantMsg: "JWT token has expired",
222+
},
223+
{
224+
name: "ErrTokenInvalid",
225+
err: jwtMiddleware.ErrTokenInvalid,
226+
wantMsg: "Token is invalid",
227+
},
228+
{
229+
name: "ErrTokenParseFail (note trailing space)",
230+
err: jwtMiddleware.ErrTokenParseFail,
231+
wantMsg: "Fail to parse JWT token ", // trailing space is intentional
232+
},
233+
{
234+
name: "ErrMissingJwtToken",
235+
err: jwtMiddleware.ErrMissingJwtToken,
236+
wantMsg: "JWT token is missing",
237+
},
238+
}
239+
240+
for _, tt := range tests {
241+
t.Run(tt.name, func(t *testing.T) {
242+
require.Equal(t, tt.wantMsg, tt.err.Message,
243+
"Kratos sentinel %s Message has changed — update isWrappedErr matching and case branches in errorInfo", tt.name)
244+
})
245+
}
246+
}

0 commit comments

Comments
 (0)