Skip to content

Commit 0597762

Browse files
Dumbrisclaude
andauthored
fix(upstream): stop misclassifying transport errors as auth failures (#464)
The auth-strategy loop in connection_http.go used `isAuthError` to decide whether to fall back to the next strategy. The substring list was too permissive — `"auth"` matched the strategy-name wrapper itself ("...headers-auth strategy: ...", "...no-auth strategy: ..."), so any wrapped transport/parse error was treated as an auth failure and silently triggered the OAuth fallback. The visible symptom: a server configured with a static Bearer token whose upstream returns HTTP 502 (Cloudflare error body, not JSON) failed with "OAuth authentication required" — pointing the user at the wrong fix. Tighten the substring list to genuine 403 / "authentication failed" / "authorization failed" markers. 401/Unauthorized continues to be routed through isOAuthError, unchanged. Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent aaec117 commit 0597762

2 files changed

Lines changed: 75 additions & 3 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package core
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
// TestIsAuthError_DoesNotMatchStrategyWrapper verifies that the substring "auth"
11+
// inside the strategy-name wrapper ("headers-auth strategy", "no-auth strategy")
12+
// does NOT cause a non-auth transport/parse error to be misclassified as an
13+
// authentication failure. Misclassification triggered an unintended OAuth
14+
// fallback and surfaced a misleading "OAuth authentication required" message
15+
// to users whose static Bearer token was actually fine (e.g. when the upstream
16+
// returned HTTP 502 with a non-JSON body).
17+
func TestIsAuthError_DoesNotMatchStrategyWrapper(t *testing.T) {
18+
c := &Client{}
19+
20+
// Real scenario: upstream returns 502 with non-JSON body; initialize parse
21+
// fails; tryHeadersAuth wraps with "headers-auth strategy".
22+
upstream502 := errors.New("MCP initialize failed during headers-auth strategy: failed to unmarshal response: unexpected end of JSON input")
23+
assert.False(t, c.isAuthError(upstream502),
24+
"a JSON-parse failure wrapped by the headers-auth strategy must not be classified as an auth error")
25+
26+
noAuthWrapper := errors.New("MCP initialize failed during no-auth strategy: failed to unmarshal response: unexpected end of JSON input")
27+
assert.False(t, c.isAuthError(noAuthWrapper),
28+
"a JSON-parse failure wrapped by the no-auth strategy must not be classified as an auth error")
29+
30+
sseWrapper := errors.New("MCP initialize failed during SSE headers-auth strategy: context deadline exceeded")
31+
assert.False(t, c.isAuthError(sseWrapper),
32+
"a timeout wrapped by the SSE headers-auth strategy must not be classified as an auth error")
33+
}
34+
35+
// TestIsAuthError_MatchesGenuineAuthFailures verifies that real 403 / explicit
36+
// authentication-failure responses still trigger the fallback chain.
37+
// Note: 401/Unauthorized is intentionally classified as an OAuth error (see
38+
// isOAuthError), so it is exercised via TestIsAuthError_IgnoresOAuthErrors.
39+
func TestIsAuthError_MatchesGenuineAuthFailures(t *testing.T) {
40+
c := &Client{}
41+
42+
cases := []string{
43+
"server returned status 403 Forbidden",
44+
"403: forbidden",
45+
"authentication failed for user",
46+
"authorization failed",
47+
}
48+
for _, msg := range cases {
49+
assert.True(t, c.isAuthError(errors.New(msg)),
50+
"expected isAuthError to match genuine auth failure: %q", msg)
51+
}
52+
}
53+
54+
// TestIsAuthError_IgnoresOAuthErrors ensures OAuth-classified errors are not
55+
// double-classified as generic auth errors (the OAuth branch handles them).
56+
func TestIsAuthError_IgnoresOAuthErrors(t *testing.T) {
57+
c := &Client{}
58+
err := errors.New("no valid token available, authorization required")
59+
assert.True(t, c.isOAuthError(err), "sanity: should be an OAuth error")
60+
assert.False(t, c.isAuthError(err),
61+
"OAuth errors must not also be classified as generic auth errors")
62+
}

internal/upstream/core/connection_http.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,16 @@ func (c *Client) trySSENoAuth(ctx context.Context) error {
251251
return nil
252252
}
253253

254-
// isAuthError checks if error indicates authentication failure (non-OAuth)
254+
// isAuthError checks if error indicates authentication failure (non-OAuth).
255+
//
256+
// The substring list is intentionally narrow: the strategy wrappers
257+
// ("MCP initialize failed during headers-auth strategy", "no-auth strategy",
258+
// "SSE headers-auth strategy") contain the literal token "auth", so any
259+
// substring as permissive as "auth" or "authentication" would misclassify a
260+
// wrapped transport/parse error (e.g. upstream HTTP 502 → JSON parse failure)
261+
// as an auth failure and trigger an unwanted OAuth fallback. The patterns
262+
// below match only genuine HTTP 403 responses and explicit "*failed"/"*failure"
263+
// phrasings that upstreams emit, never the wrapper text.
255264
func (c *Client) isAuthError(err error) bool {
256265
if err == nil {
257266
return false
@@ -264,8 +273,9 @@ func (c *Client) isAuthError(err error) bool {
264273

265274
errStr := err.Error()
266275
return containsAny(errStr, []string{
267-
"403", "Forbidden",
268-
"authentication", "auth",
276+
"403", "Forbidden", "forbidden",
277+
"authentication failed", "authentication failure",
278+
"authorization failed", "authorization failure",
269279
})
270280
}
271281

0 commit comments

Comments
 (0)