Skip to content

Commit 0c0ac79

Browse files
committed
http: classify auth and scope failures (#2213)
1 parent f93e526 commit 0c0ac79

File tree

8 files changed

+175
-6
lines changed

8 files changed

+175
-6
lines changed

docs/error-handling.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@ Used for REST API errors from the GitHub API:
2020
```go
2121
type GitHubAPIError struct {
2222
Message string `json:"message"`
23+
Code string `json:"code"`
2324
Response *github.Response `json:"-"`
2425
Err error `json:"-"`
2526
}
2627
```
2728

29+
For HTTP-auth related failures, `Code` is populated with a machine-readable classifier so callers and middleware can distinguish:
30+
31+
- `missing_token`: no bearer token was provided
32+
- `invalid_token`: the token was malformed or GitHub rejected it with `401`
33+
- `insufficient_scope`: the token was valid but lacked the required permission or scope
34+
2835
### GitHubGraphQLError
2936

3037
Used for GraphQL API errors from the GitHub API:

pkg/errors/error.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"strings"
78

89
"github.com/github/github-mcp-server/pkg/utils"
910
"github.com/google/go-github/v82/github"
@@ -12,6 +13,7 @@ import (
1213

1314
type GitHubAPIError struct {
1415
Message string `json:"message"`
16+
Code string `json:"code,omitempty"`
1517
Response *github.Response `json:"-"`
1618
Err error `json:"-"`
1719
}
@@ -20,6 +22,7 @@ type GitHubAPIError struct {
2022
func newGitHubAPIError(message string, resp *github.Response, err error) *GitHubAPIError {
2123
return &GitHubAPIError{
2224
Message: message,
25+
Code: classifyHTTPErrorCode(resp.Response, message),
2326
Response: resp,
2427
Err: err,
2528
}
@@ -47,18 +50,37 @@ func (e *GitHubGraphQLError) Error() string {
4750

4851
type GitHubRawAPIError struct {
4952
Message string `json:"message"`
53+
Code string `json:"code,omitempty"`
5054
Response *http.Response `json:"-"`
5155
Err error `json:"-"`
5256
}
5357

5458
func newGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError {
5559
return &GitHubRawAPIError{
5660
Message: message,
61+
Code: classifyHTTPErrorCode(resp, message),
5762
Response: resp,
5863
Err: err,
5964
}
6065
}
6166

67+
func classifyHTTPErrorCode(resp *http.Response, message string) string {
68+
if resp == nil {
69+
return ""
70+
}
71+
72+
switch resp.StatusCode {
73+
case http.StatusUnauthorized:
74+
return "invalid_token"
75+
case http.StatusForbidden:
76+
if strings.Contains(strings.ToLower(message), "scope") || strings.Contains(strings.ToLower(message), "permission") {
77+
return "insufficient_scope"
78+
}
79+
}
80+
81+
return ""
82+
}
83+
6284
func (e *GitHubRawAPIError) Error() string {
6385
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
6486
}

pkg/errors/error_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func TestGitHubErrorContext(t *testing.T) {
3636

3737
apiError := apiErrors[0]
3838
assert.Equal(t, "failed to fetch resource", apiError.Message)
39+
assert.Empty(t, apiError.Code)
3940
assert.Equal(t, resp, apiError.Response)
4041
assert.Equal(t, originalErr, apiError.Err)
4142
assert.Equal(t, "failed to fetch resource: resource not found", apiError.Error())
@@ -86,6 +87,7 @@ func TestGitHubErrorContext(t *testing.T) {
8687

8788
rawError := rawErrors[0]
8889
assert.Equal(t, "failed to fetch raw content", rawError.Message)
90+
assert.Empty(t, rawError.Code)
8991
assert.Equal(t, resp, rawError.Response)
9092
assert.Equal(t, originalErr, rawError.Err)
9193
})
@@ -361,6 +363,24 @@ func TestGitHubErrorContext(t *testing.T) {
361363
assert.NoError(t, err, "NewGitHubAPIErrorToCtx should handle nil context gracefully")
362364
assert.Nil(t, updatedCtx, "Context should remain nil when passed as nil")
363365
})
366+
367+
t.Run("API errors classify invalid token and insufficient scope codes from HTTP status", func(t *testing.T) {
368+
ctx := ContextWithGitHubErrors(context.Background())
369+
370+
unauthorized := &github.Response{Response: &http.Response{StatusCode: http.StatusUnauthorized}}
371+
forbidden := &github.Response{Response: &http.Response{StatusCode: http.StatusForbidden}}
372+
373+
_, err := NewGitHubAPIErrorToCtx(ctx, "token rejected", unauthorized, fmt.Errorf("unauthorized"))
374+
require.NoError(t, err)
375+
_, err = NewGitHubAPIErrorToCtx(ctx, "insufficient permissions", forbidden, fmt.Errorf("forbidden"))
376+
require.NoError(t, err)
377+
378+
apiErrors, err := GetGitHubAPIErrors(ctx)
379+
require.NoError(t, err)
380+
require.Len(t, apiErrors, 2)
381+
assert.Equal(t, "invalid_token", apiErrors[0].Code)
382+
assert.Equal(t, "insufficient_scope", apiErrors[1].Code)
383+
})
364384
}
365385

366386
func TestGitHubErrorTypes(t *testing.T) {

pkg/http/middleware/auth_error.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package middleware
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
)
7+
8+
type authErrorBody struct {
9+
Error string `json:"error"`
10+
Code string `json:"code"`
11+
}
12+
13+
func writeAuthError(w http.ResponseWriter, status int, code string, message string, wwwAuthenticate string) {
14+
if wwwAuthenticate != "" {
15+
w.Header().Set("WWW-Authenticate", wwwAuthenticate)
16+
}
17+
w.Header().Set("Content-Type", "application/json")
18+
w.WriteHeader(status)
19+
_ = json.NewEncoder(w).Encode(authErrorBody{
20+
Error: message,
21+
Code: code,
22+
})
23+
}

pkg/http/middleware/scope_challenge.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,13 @@ func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInter
137137
)
138138

139139
// Send scope challenge response with the superset of existing and required scopes
140-
w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader)
141-
http.Error(w, "Forbidden: insufficient scopes", http.StatusForbidden)
140+
writeAuthError(
141+
w,
142+
http.StatusForbidden,
143+
"insufficient_scope",
144+
"Forbidden: insufficient scopes",
145+
wwwAuthenticateHeader,
146+
)
142147
}
143148
return http.HandlerFunc(fn)
144149
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package middleware
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
ghcontext "github.com/github/github-mcp-server/pkg/context"
11+
"github.com/github/github-mcp-server/pkg/http/oauth"
12+
"github.com/github/github-mcp-server/pkg/scopes"
13+
"github.com/github/github-mcp-server/pkg/utils"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
type scopeChallengeFetcher struct {
19+
scopes []string
20+
err error
21+
}
22+
23+
func (m *scopeChallengeFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) {
24+
return m.scopes, m.err
25+
}
26+
27+
func TestWithScopeChallenge_ReturnsMachineReadableInsufficientScopeCode(t *testing.T) {
28+
scopes.SetGlobalToolScopeMap(scopes.ToolScopeMap{
29+
"create_or_update_file": {
30+
RequiredScopes: []string{"repo"},
31+
AcceptedScopes: []string{"repo"},
32+
},
33+
})
34+
t.Cleanup(func() {
35+
scopes.SetGlobalToolScopeMap(nil)
36+
})
37+
38+
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
39+
w.WriteHeader(http.StatusOK)
40+
})
41+
42+
handler := WithScopeChallenge(
43+
&oauth.Config{BaseURL: "https://example.com"},
44+
&scopeChallengeFetcher{scopes: []string{}},
45+
)(nextHandler)
46+
47+
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
48+
req = req.WithContext(ghcontext.WithTokenInfo(req.Context(), &ghcontext.TokenInfo{
49+
Token: "gho_test",
50+
TokenType: utils.TokenTypeOAuthAccessToken,
51+
}))
52+
req = req.WithContext(ghcontext.WithMCPMethodInfo(req.Context(), &ghcontext.MCPMethodInfo{
53+
Method: "tools/call",
54+
ItemName: "create_or_update_file",
55+
}))
56+
rr := httptest.NewRecorder()
57+
58+
handler.ServeHTTP(rr, req)
59+
60+
assert.Equal(t, http.StatusForbidden, rr.Code)
61+
assert.Contains(t, rr.Header().Get("WWW-Authenticate"), `error="insufficient_scope"`)
62+
63+
var body struct {
64+
Code string `json:"code"`
65+
}
66+
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
67+
assert.Equal(t, "insufficient_scope", body.Code)
68+
}

pkg/http/middleware/token.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handl
3030
sendAuthChallenge(w, r, oauthCfg)
3131
return
3232
}
33-
// For other auth errors (bad format, unsupported), return 400
34-
http.Error(w, err.Error(), http.StatusBadRequest)
33+
// For other auth errors (bad format, unsupported), keep the existing 400
34+
// but expose a machine-readable invalid_token classification.
35+
writeAuthError(w, http.StatusBadRequest, "invalid_token", err.Error(), "")
3536
return
3637
}
3738

@@ -51,6 +52,11 @@ func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handl
5152
func sendAuthChallenge(w http.ResponseWriter, r *http.Request, oauthCfg *oauth.Config) {
5253
resourcePath := oauth.ResolveResourcePath(r, oauthCfg)
5354
resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath)
54-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, resourceMetadataURL))
55-
http.Error(w, "Unauthorized", http.StatusUnauthorized)
55+
writeAuthError(
56+
w,
57+
http.StatusUnauthorized,
58+
"missing_token",
59+
"Unauthorized",
60+
fmt.Sprintf(`Bearer resource_metadata=%q`, resourceMetadataURL),
61+
)
5662
}

pkg/http/middleware/token_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package middleware
22

33
import (
4+
"encoding/json"
45
"net/http"
56
"net/http/httptest"
67
"testing"
@@ -23,6 +24,7 @@ func TestExtractUserToken(t *testing.T) {
2324
name string
2425
authHeader string
2526
expectedStatusCode int
27+
expectedCode string
2628
expectedTokenType utils.TokenType
2729
expectedToken string
2830
expectTokenInfo bool
@@ -33,6 +35,7 @@ func TestExtractUserToken(t *testing.T) {
3335
name: "missing Authorization header returns 401 with WWW-Authenticate",
3436
authHeader: "",
3537
expectedStatusCode: http.StatusUnauthorized,
38+
expectedCode: "missing_token",
3639
expectTokenInfo: false,
3740
expectWWWAuth: true,
3841
},
@@ -151,18 +154,21 @@ func TestExtractUserToken(t *testing.T) {
151154
name: "unsupported GitHub-Bearer header returns 400",
152155
authHeader: "GitHub-Bearer some_encrypted_token",
153156
expectedStatusCode: http.StatusBadRequest,
157+
expectedCode: "invalid_token",
154158
expectTokenInfo: false,
155159
},
156160
{
157161
name: "invalid token format returns 400",
158162
authHeader: "Bearer invalid_token_format",
159163
expectedStatusCode: http.StatusBadRequest,
164+
expectedCode: "invalid_token",
160165
expectTokenInfo: false,
161166
},
162167
{
163168
name: "unrecognized prefix returns 400",
164169
authHeader: "Bearer xyz_notavalidprefix",
165170
expectedStatusCode: http.StatusBadRequest,
171+
expectedCode: "invalid_token",
166172
expectTokenInfo: false,
167173
},
168174
}
@@ -189,6 +195,13 @@ func TestExtractUserToken(t *testing.T) {
189195
handler.ServeHTTP(rr, req)
190196

191197
assert.Equal(t, tt.expectedStatusCode, rr.Code)
198+
if tt.expectedCode != "" {
199+
var body struct {
200+
Code string `json:"code"`
201+
}
202+
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
203+
assert.Equal(t, tt.expectedCode, body.Code)
204+
}
192205

193206
if tt.expectWWWAuth {
194207
wwwAuth := rr.Header().Get("WWW-Authenticate")
@@ -253,6 +266,11 @@ func TestExtractUserToken_MissingAuthHeader_WWWAuthenticateFormat(t *testing.T)
253266
handler.ServeHTTP(rr, req)
254267

255268
assert.Equal(t, http.StatusUnauthorized, rr.Code)
269+
var body struct {
270+
Code string `json:"code"`
271+
}
272+
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
273+
assert.Equal(t, "missing_token", body.Code)
256274
wwwAuth := rr.Header().Get("WWW-Authenticate")
257275
assert.NotEmpty(t, wwwAuth)
258276
assert.Contains(t, wwwAuth, "Bearer")

0 commit comments

Comments
 (0)