chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/github/github-mcp-server/pkg/http/headers"
|
||||
)
|
||||
|
||||
// SetCorsHeaders is middleware that sets CORS headers to allow browser-based
|
||||
// MCP clients to connect from any origin. This is safe because the server
|
||||
// authenticates via bearer tokens (not cookies), so cross-origin requests
|
||||
// cannot exploit ambient credentials.
|
||||
func SetCorsHeaders(h http.Handler) http.Handler {
|
||||
allowHeaders := strings.Join([]string{
|
||||
"Content-Type",
|
||||
"Mcp-Session-Id",
|
||||
"Mcp-Protocol-Version",
|
||||
"Last-Event-ID",
|
||||
headers.AuthorizationHeader,
|
||||
headers.MCPReadOnlyHeader,
|
||||
headers.MCPToolsetsHeader,
|
||||
headers.MCPToolsHeader,
|
||||
headers.MCPExcludeToolsHeader,
|
||||
headers.MCPFeaturesHeader,
|
||||
headers.MCPLockdownHeader,
|
||||
headers.MCPInsidersHeader,
|
||||
}, ", ")
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate")
|
||||
w.Header().Set("Access-Control-Allow-Headers", allowHeaders)
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/github/github-mcp-server/pkg/http/middleware"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSetCorsHeaders(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := middleware.SetCorsHeaders(inner)
|
||||
|
||||
t.Run("OPTIONS preflight returns 200 with CORS headers", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodOptions, "/", nil)
|
||||
req.Header.Set("Origin", "http://localhost:6274")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Methods"), "POST")
|
||||
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Authorization")
|
||||
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Content-Type")
|
||||
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Mcp-Session-Id")
|
||||
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Lockdown")
|
||||
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Insiders")
|
||||
assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "Mcp-Session-Id")
|
||||
assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "WWW-Authenticate")
|
||||
})
|
||||
|
||||
t.Run("POST request includes CORS headers", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("Origin", "http://localhost:6274")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
)
|
||||
|
||||
// mcpJSONRPCRequest represents the structure of an MCP JSON-RPC request.
|
||||
// We only parse the fields needed for routing and optimization.
|
||||
type mcpJSONRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
// For tools/call
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
// For prompts/get
|
||||
// Name is shared with tools/call
|
||||
// For resources/read
|
||||
URI string `json:"uri,omitempty"`
|
||||
} `json:"params"`
|
||||
}
|
||||
|
||||
// WithMCPParse creates a middleware that parses MCP JSON-RPC requests early in the
|
||||
// request lifecycle and stores the parsed information in the request context.
|
||||
// This enables:
|
||||
// - Registry filtering via ForMCPRequest (only register needed tools/resources/prompts)
|
||||
// - Avoiding duplicate JSON parsing in downstream middlewares
|
||||
// - Access to owner/repo for secret-scanning middleware
|
||||
//
|
||||
// The middleware reads the request body, parses it, restores the body for downstream
|
||||
// handlers, and stores the parsed MCPMethodInfo in the request context.
|
||||
func WithMCPParse() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Skip health check endpoints
|
||||
if r.URL.Path == "/_ping" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Only parse POST requests (MCP uses JSON-RPC over POST)
|
||||
if r.Method != http.MethodPost {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Read the request body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
// Log but continue - don't block requests on parse errors
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the body for downstream handlers
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
// Skip empty bodies
|
||||
if len(body) == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the JSON-RPC request
|
||||
var mcpReq mcpJSONRPCRequest
|
||||
err = json.Unmarshal(body, &mcpReq)
|
||||
if err != nil {
|
||||
// Log but continue - could be a non-MCP request or malformed JSON
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if not a valid JSON-RPC 2.0 request
|
||||
if mcpReq.JSONRPC != "2.0" || mcpReq.Method == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Build the MCPMethodInfo
|
||||
methodInfo := &ghcontext.MCPMethodInfo{
|
||||
Method: mcpReq.Method,
|
||||
}
|
||||
|
||||
// Extract item name based on method type
|
||||
|
||||
switch mcpReq.Method {
|
||||
case "tools/call":
|
||||
methodInfo.ItemName = mcpReq.Params.Name
|
||||
// Parse arguments if present
|
||||
if len(mcpReq.Params.Arguments) > 0 {
|
||||
var args map[string]any
|
||||
err := json.Unmarshal(mcpReq.Params.Arguments, &args)
|
||||
if err == nil {
|
||||
methodInfo.Arguments = args
|
||||
// Extract owner and repo if present
|
||||
if owner, ok := args["owner"].(string); ok {
|
||||
methodInfo.Owner = owner
|
||||
}
|
||||
if repo, ok := args["repo"].(string); ok {
|
||||
methodInfo.Repo = repo
|
||||
}
|
||||
}
|
||||
}
|
||||
case "prompts/get":
|
||||
methodInfo.ItemName = mcpReq.Params.Name
|
||||
case "resources/read":
|
||||
methodInfo.ItemName = mcpReq.Params.URI
|
||||
default:
|
||||
// Whatever
|
||||
}
|
||||
|
||||
// Store the parsed info in context
|
||||
ctx = ghcontext.WithMCPMethodInfo(ctx, methodInfo)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithMCPParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
expectInfo bool
|
||||
expectedMethod string
|
||||
expectedItem string
|
||||
expectedOwner string
|
||||
expectedRepo string
|
||||
expectedArgs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "health check path is skipped",
|
||||
method: http.MethodPost,
|
||||
path: "/_ping",
|
||||
body: `{"jsonrpc":"2.0","method":"tools/list"}`,
|
||||
expectInfo: false,
|
||||
},
|
||||
{
|
||||
name: "GET request is skipped",
|
||||
method: http.MethodGet,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"2.0","method":"tools/list"}`,
|
||||
expectInfo: false,
|
||||
},
|
||||
{
|
||||
name: "empty body is skipped",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: "",
|
||||
expectInfo: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON is skipped",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: "not valid json",
|
||||
expectInfo: false,
|
||||
},
|
||||
{
|
||||
name: "non-JSON-RPC 2.0 is skipped",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"1.0","method":"tools/list"}`,
|
||||
expectInfo: false,
|
||||
},
|
||||
{
|
||||
name: "empty method is skipped",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"2.0","method":""}`,
|
||||
expectInfo: false,
|
||||
},
|
||||
{
|
||||
name: "tools/list parses method only",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"2.0","method":"tools/list"}`,
|
||||
expectInfo: true,
|
||||
expectedMethod: "tools/list",
|
||||
},
|
||||
{
|
||||
name: "tools/call parses name",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents"}}`,
|
||||
expectInfo: true,
|
||||
expectedMethod: "tools/call",
|
||||
expectedItem: "get_file_contents",
|
||||
},
|
||||
{
|
||||
name: "tools/call parses owner and repo from arguments",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":{"owner":"github","repo":"github-mcp-server","path":"README.md"}}}`,
|
||||
expectInfo: true,
|
||||
expectedMethod: "tools/call",
|
||||
expectedItem: "get_file_contents",
|
||||
expectedOwner: "github",
|
||||
expectedRepo: "github-mcp-server",
|
||||
expectedArgs: map[string]any{"owner": "github", "repo": "github-mcp-server", "path": "README.md"},
|
||||
},
|
||||
{
|
||||
name: "tools/call with invalid arguments JSON continues without args",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":"not an object"}}`,
|
||||
expectInfo: true,
|
||||
expectedMethod: "tools/call",
|
||||
expectedItem: "get_file_contents",
|
||||
},
|
||||
{
|
||||
name: "prompts/get parses name",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"2.0","method":"prompts/get","params":{"name":"my_prompt"}}`,
|
||||
expectInfo: true,
|
||||
expectedMethod: "prompts/get",
|
||||
expectedItem: "my_prompt",
|
||||
},
|
||||
{
|
||||
name: "resources/read parses URI as item name",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"repo://github/github-mcp-server"}}`,
|
||||
expectInfo: true,
|
||||
expectedMethod: "resources/read",
|
||||
expectedItem: "repo://github/github-mcp-server",
|
||||
},
|
||||
{
|
||||
name: "initialize method parses correctly",
|
||||
method: http.MethodPost,
|
||||
path: "/mcp",
|
||||
body: `{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}}}`,
|
||||
expectInfo: true,
|
||||
expectedMethod: "initialize",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedInfo *ghcontext.MCPMethodInfo
|
||||
var infoCaptured bool
|
||||
|
||||
// Create a handler that captures the MCPMethodInfo from context
|
||||
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
capturedInfo, infoCaptured = ghcontext.MCPMethod(r.Context())
|
||||
})
|
||||
|
||||
middleware := WithMCPParse()
|
||||
handler := middleware(nextHandler)
|
||||
|
||||
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if tt.expectInfo {
|
||||
require.True(t, infoCaptured, "MCPMethodInfo should be present in context")
|
||||
require.NotNil(t, capturedInfo)
|
||||
assert.Equal(t, tt.expectedMethod, capturedInfo.Method)
|
||||
assert.Equal(t, tt.expectedItem, capturedInfo.ItemName)
|
||||
assert.Equal(t, tt.expectedOwner, capturedInfo.Owner)
|
||||
assert.Equal(t, tt.expectedRepo, capturedInfo.Repo)
|
||||
if tt.expectedArgs != nil {
|
||||
assert.Equal(t, tt.expectedArgs, capturedInfo.Arguments)
|
||||
}
|
||||
} else {
|
||||
assert.False(t, infoCaptured, "MCPMethodInfo should not be present in context")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMCPParse_BodyRestoration(t *testing.T) {
|
||||
originalBody := `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test_tool"}}`
|
||||
|
||||
var capturedBody string
|
||||
|
||||
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
capturedBody = string(body)
|
||||
})
|
||||
|
||||
middleware := WithMCPParse()
|
||||
handler := middleware(nextHandler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(originalBody))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, originalBody, capturedBody, "body should be restored for downstream handlers")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/scopes"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
)
|
||||
|
||||
// WithPATScopes is a middleware that fetches and stores scopes for classic Personal Access Tokens (PATs) in the request context.
|
||||
func WithPATScopes(logger *slog.Logger, scopeFetcher scopes.FetcherInterface) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
|
||||
if !ok || tokenInfo == nil {
|
||||
logger.Warn("no token info found in context")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch token scopes for scope-based tool filtering (PAT tokens only)
|
||||
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
|
||||
// Fine-grained PATs and other token types don't support this, so we skip filtering.
|
||||
if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken {
|
||||
existingScopes, ok := ghcontext.GetTokenScopes(ctx)
|
||||
if ok {
|
||||
logger.Debug("using existing scopes from context", "scopes", existingScopes)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
scopesList, err := scopeFetcher.FetchTokenScopes(ctx, tokenInfo.Token)
|
||||
if err != nil {
|
||||
logger.Warn("failed to fetch PAT scopes", "error", err)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Store fetched scopes in context for downstream use
|
||||
ctx = ghcontext.WithTokenScopes(ctx, scopesList)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockScopeFetcher is a mock implementation of scopes.FetcherInterface
|
||||
type mockScopeFetcher struct {
|
||||
scopes []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockScopeFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) {
|
||||
return m.scopes, m.err
|
||||
}
|
||||
|
||||
func TestWithPATScopes(t *testing.T) {
|
||||
logger := slog.Default()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenInfo *ghcontext.TokenInfo
|
||||
fetcherScopes []string
|
||||
fetcherErr error
|
||||
expectScopesFetched bool
|
||||
expectedScopes []string
|
||||
expectNextHandlerCalled bool
|
||||
}{
|
||||
{
|
||||
name: "no token info in context calls next handler",
|
||||
tokenInfo: nil,
|
||||
expectScopesFetched: false,
|
||||
expectedScopes: nil,
|
||||
expectNextHandlerCalled: true,
|
||||
},
|
||||
{
|
||||
name: "non-PAT token type skips scope fetching",
|
||||
tokenInfo: &ghcontext.TokenInfo{
|
||||
Token: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
TokenType: utils.TokenTypeOAuthAccessToken,
|
||||
},
|
||||
expectScopesFetched: false,
|
||||
expectedScopes: nil,
|
||||
expectNextHandlerCalled: true,
|
||||
},
|
||||
{
|
||||
name: "fine-grained PAT skips scope fetching",
|
||||
tokenInfo: &ghcontext.TokenInfo{
|
||||
Token: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
||||
TokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
|
||||
},
|
||||
expectScopesFetched: false,
|
||||
expectedScopes: nil,
|
||||
expectNextHandlerCalled: true,
|
||||
},
|
||||
{
|
||||
name: "classic PAT fetches and stores scopes",
|
||||
tokenInfo: &ghcontext.TokenInfo{
|
||||
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
TokenType: utils.TokenTypePersonalAccessToken,
|
||||
},
|
||||
fetcherScopes: []string{"repo", "user", "read:org"},
|
||||
expectScopesFetched: true,
|
||||
expectedScopes: []string{"repo", "user", "read:org"},
|
||||
expectNextHandlerCalled: true,
|
||||
},
|
||||
{
|
||||
name: "classic PAT with empty scopes",
|
||||
tokenInfo: &ghcontext.TokenInfo{
|
||||
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
TokenType: utils.TokenTypePersonalAccessToken,
|
||||
},
|
||||
fetcherScopes: []string{},
|
||||
expectScopesFetched: true,
|
||||
expectedScopes: []string{},
|
||||
expectNextHandlerCalled: true,
|
||||
},
|
||||
{
|
||||
name: "fetcher error calls next handler without scopes",
|
||||
tokenInfo: &ghcontext.TokenInfo{
|
||||
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
TokenType: utils.TokenTypePersonalAccessToken,
|
||||
},
|
||||
fetcherErr: errors.New("network error"),
|
||||
expectScopesFetched: false,
|
||||
expectedScopes: nil,
|
||||
expectNextHandlerCalled: true,
|
||||
},
|
||||
{
|
||||
name: "old-style PAT (40 hex chars) fetches scopes",
|
||||
tokenInfo: &ghcontext.TokenInfo{
|
||||
Token: "0123456789abcdef0123456789abcdef01234567",
|
||||
TokenType: utils.TokenTypePersonalAccessToken,
|
||||
},
|
||||
fetcherScopes: []string{"repo"},
|
||||
expectScopesFetched: true,
|
||||
expectedScopes: []string{"repo"},
|
||||
expectNextHandlerCalled: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedScopes []string
|
||||
var scopesFound bool
|
||||
var nextHandlerCalled bool
|
||||
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextHandlerCalled = true
|
||||
capturedScopes, scopesFound = ghcontext.GetTokenScopes(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
fetcher := &mockScopeFetcher{
|
||||
scopes: tt.fetcherScopes,
|
||||
err: tt.fetcherErr,
|
||||
}
|
||||
|
||||
middleware := WithPATScopes(logger, fetcher)
|
||||
handler := middleware(nextHandler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
|
||||
// Set up context with token info if provided
|
||||
if tt.tokenInfo != nil {
|
||||
ctx := ghcontext.WithTokenInfo(req.Context(), tt.tokenInfo)
|
||||
req = req.WithContext(ctx)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, tt.expectNextHandlerCalled, nextHandlerCalled, "next handler called mismatch")
|
||||
|
||||
if tt.expectNextHandlerCalled {
|
||||
assert.Equal(t, tt.expectScopesFetched, scopesFound, "scopes found mismatch")
|
||||
assert.Equal(t, tt.expectedScopes, capturedScopes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithPATScopes_PreservesExistingTokenInfo(t *testing.T) {
|
||||
logger := slog.Default()
|
||||
|
||||
var capturedTokenInfo *ghcontext.TokenInfo
|
||||
var capturedScopes []string
|
||||
var scopesFound bool
|
||||
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedTokenInfo, _ = ghcontext.GetTokenInfo(r.Context())
|
||||
capturedScopes, scopesFound = ghcontext.GetTokenScopes(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
fetcher := &mockScopeFetcher{
|
||||
scopes: []string{"repo", "user"},
|
||||
}
|
||||
|
||||
originalTokenInfo := &ghcontext.TokenInfo{
|
||||
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
TokenType: utils.TokenTypePersonalAccessToken,
|
||||
}
|
||||
|
||||
middleware := WithPATScopes(logger, fetcher)
|
||||
handler := middleware(nextHandler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
ctx := ghcontext.WithTokenInfo(req.Context(), originalTokenInfo)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
require.NotNil(t, capturedTokenInfo)
|
||||
assert.Equal(t, originalTokenInfo.Token, capturedTokenInfo.Token)
|
||||
assert.Equal(t, originalTokenInfo.TokenType, capturedTokenInfo.TokenType)
|
||||
assert.True(t, scopesFound)
|
||||
assert.Equal(t, []string{"repo", "user"}, capturedScopes)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/http/headers"
|
||||
)
|
||||
|
||||
// WithRequestConfig is a middleware that extracts MCP-related headers and sets them in the request context.
|
||||
// This includes readonly mode, toolsets, tools, lockdown mode, insiders mode, and feature flags.
|
||||
func WithRequestConfig(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Readonly mode
|
||||
if relaxedParseBool(r.Header.Get(headers.MCPReadOnlyHeader)) {
|
||||
ctx = ghcontext.WithReadonly(ctx, true)
|
||||
}
|
||||
|
||||
// Toolsets
|
||||
if toolsets := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsetsHeader)); len(toolsets) > 0 {
|
||||
ctx = ghcontext.WithToolsets(ctx, toolsets)
|
||||
}
|
||||
|
||||
// Tools
|
||||
if tools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsHeader)); len(tools) > 0 {
|
||||
ctx = ghcontext.WithTools(ctx, tools)
|
||||
}
|
||||
|
||||
// Lockdown mode
|
||||
if relaxedParseBool(r.Header.Get(headers.MCPLockdownHeader)) {
|
||||
ctx = ghcontext.WithLockdownMode(ctx, true)
|
||||
}
|
||||
|
||||
// Excluded tools
|
||||
if excludeTools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPExcludeToolsHeader)); len(excludeTools) > 0 {
|
||||
ctx = ghcontext.WithExcludeTools(ctx, excludeTools)
|
||||
}
|
||||
|
||||
// Insiders mode
|
||||
if relaxedParseBool(r.Header.Get(headers.MCPInsidersHeader)) {
|
||||
ctx = ghcontext.WithInsidersMode(ctx, true)
|
||||
}
|
||||
|
||||
// Feature flags
|
||||
if features := headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader)); len(features) > 0 {
|
||||
ctx = ghcontext.WithHeaderFeatures(ctx, features)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// relaxedParseBool parses a string into a boolean value, treating various
|
||||
// common false values or empty strings as false, and everything else as true.
|
||||
// It is case-insensitive and trims whitespace.
|
||||
func relaxedParseBool(s string) bool {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
falseValues := []string{"", "false", "0", "no", "off", "n", "f"}
|
||||
return !slices.Contains(falseValues, s)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/http/oauth"
|
||||
"github.com/github/github-mcp-server/pkg/scopes"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
)
|
||||
|
||||
// WithScopeChallenge creates a new middleware that determines if an OAuth request contains sufficient scopes to
|
||||
// complete the request and returns a scope challenge if not.
|
||||
func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInterface) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Skip health check endpoints
|
||||
if r.URL.Path == "/_ping" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from context
|
||||
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
|
||||
if !ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Only check OAuth tokens - scope challenge allows OAuth apps to request additional scopes
|
||||
if tokenInfo.TokenType != utils.TokenTypeOAuthAccessToken {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to use pre-parsed MCP method info first (performance optimization)
|
||||
// This avoids re-parsing the JSON body if WithMCPParse middleware ran earlier
|
||||
var toolName string
|
||||
if methodInfo, ok := ghcontext.MCPMethod(ctx); ok && methodInfo != nil {
|
||||
// Only check tools/call requests
|
||||
if methodInfo.Method != "tools/call" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
toolName = methodInfo.ItemName
|
||||
} else {
|
||||
// Fallback: parse the request body directly
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
var mcpRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments map[string]any `json:"arguments,omitempty"`
|
||||
} `json:"params"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &mcpRequest)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Only check tools/call requests
|
||||
if mcpRequest.Method != "tools/call" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
toolName = mcpRequest.Params.Name
|
||||
}
|
||||
toolScopeInfo, err := scopes.GetToolScopeInfo(toolName)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// If tool not found in scope map, allow the request
|
||||
if toolScopeInfo == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get OAuth scopes for Token. First check if scopes are already in context, then fetch from GitHub if not present.
|
||||
// This allows Remote Server to pass scope info to avoid redundant GitHub API calls.
|
||||
activeScopes, ok := ghcontext.GetTokenScopes(ctx)
|
||||
if !ok || (len(activeScopes) == 0 && tokenInfo.Token != "") {
|
||||
activeScopes, err = scopeFetcher.FetchTokenScopes(ctx, tokenInfo.Token)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Store active scopes in context for downstream use
|
||||
ctx = ghcontext.WithTokenScopes(ctx, activeScopes)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Check if user has the required scopes
|
||||
if toolScopeInfo.HasAcceptedScope(activeScopes...) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// User lacks required scopes - get the scopes they need
|
||||
requiredScopes := toolScopeInfo.GetRequiredScopesSlice()
|
||||
|
||||
// Build the resource metadata URL using the shared utility
|
||||
// GetEffectiveResourcePath returns the original path (e.g., /mcp or /mcp/x/all)
|
||||
// which is used to construct the well-known OAuth protected resource URL
|
||||
resourcePath := oauth.ResolveResourcePath(r, oauthCfg)
|
||||
resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath)
|
||||
|
||||
// Build recommended scopes: existing scopes + required scopes
|
||||
recommendedScopes := make([]string, 0, len(activeScopes)+len(requiredScopes))
|
||||
recommendedScopes = append(recommendedScopes, activeScopes...)
|
||||
recommendedScopes = append(recommendedScopes, requiredScopes...)
|
||||
|
||||
// Build the WWW-Authenticate header value
|
||||
wwwAuthenticateHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q, resource_metadata=%q, error_description=%q`,
|
||||
strings.Join(recommendedScopes, " "),
|
||||
resourceMetadataURL,
|
||||
"Additional scopes required: "+strings.Join(requiredScopes, ", "),
|
||||
)
|
||||
|
||||
// Send scope challenge response with the superset of existing and required scopes
|
||||
w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader)
|
||||
http.Error(w, "Forbidden: insufficient scopes", http.StatusForbidden)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/http/oauth"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
)
|
||||
|
||||
func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Check if token info already exists in context, if it does, skip extraction.
|
||||
// In remote setup, we may have already extracted token info earlier.
|
||||
if _, ok := ghcontext.GetTokenInfo(ctx); ok {
|
||||
// Token info already exists in context, skip extraction
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tokenType, token, err := utils.ParseAuthorizationHeader(r)
|
||||
if err != nil {
|
||||
// For missing Authorization header, return 401 with WWW-Authenticate header per MCP spec
|
||||
if errors.Is(err, utils.ErrMissingAuthorizationHeader) {
|
||||
sendAuthChallenge(w, r, oauthCfg)
|
||||
return
|
||||
}
|
||||
// For other auth errors (bad format, unsupported), return 400
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx = ghcontext.WithTokenInfo(ctx, &ghcontext.TokenInfo{
|
||||
Token: token,
|
||||
TokenType: tokenType,
|
||||
})
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sendAuthChallenge sends a 401 Unauthorized response with WWW-Authenticate header
|
||||
// containing the OAuth protected resource metadata URL as per RFC 6750 and MCP spec.
|
||||
func sendAuthChallenge(w http.ResponseWriter, r *http.Request, oauthCfg *oauth.Config) {
|
||||
resourcePath := oauth.ResolveResourcePath(r, oauthCfg)
|
||||
resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath)
|
||||
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, resourceMetadataURL))
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/http/headers"
|
||||
"github.com/github/github-mcp-server/pkg/http/oauth"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractUserToken(t *testing.T) {
|
||||
oauthCfg := &oauth.Config{
|
||||
BaseURL: "https://example.com",
|
||||
AuthorizationServer: "https://github.com/login/oauth",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
authHeader string
|
||||
expectedStatusCode int
|
||||
expectedTokenType utils.TokenType
|
||||
expectedToken string
|
||||
expectTokenInfo bool
|
||||
expectWWWAuth bool
|
||||
}{
|
||||
// Missing authorization header
|
||||
{
|
||||
name: "missing Authorization header returns 401 with WWW-Authenticate",
|
||||
authHeader: "",
|
||||
expectedStatusCode: http.StatusUnauthorized,
|
||||
expectTokenInfo: false,
|
||||
expectWWWAuth: true,
|
||||
},
|
||||
// Personal Access Token (classic) - ghp_ prefix
|
||||
{
|
||||
name: "personal access token (classic) with Bearer prefix",
|
||||
authHeader: "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
||||
expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
{
|
||||
name: "personal access token (classic) with bearer lowercase",
|
||||
authHeader: "bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
||||
expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
{
|
||||
name: "personal access token (classic) without Bearer prefix",
|
||||
authHeader: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
||||
expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
// Fine-grained Personal Access Token - github_pat_ prefix
|
||||
{
|
||||
name: "fine-grained personal access token with Bearer prefix",
|
||||
authHeader: "Bearer github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
|
||||
expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
{
|
||||
name: "fine-grained personal access token without Bearer prefix",
|
||||
authHeader: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
|
||||
expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
// OAuth Access Token - gho_ prefix
|
||||
{
|
||||
name: "OAuth access token with Bearer prefix",
|
||||
authHeader: "Bearer gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypeOAuthAccessToken,
|
||||
expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
{
|
||||
name: "OAuth access token without Bearer prefix",
|
||||
authHeader: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypeOAuthAccessToken,
|
||||
expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
// User-to-Server GitHub App Token - ghu_ prefix
|
||||
{
|
||||
name: "user-to-server GitHub App token with Bearer prefix",
|
||||
authHeader: "Bearer ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken,
|
||||
expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
{
|
||||
name: "user-to-server GitHub App token without Bearer prefix",
|
||||
authHeader: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken,
|
||||
expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
// Server-to-Server GitHub App Token (installation token) - ghs_ prefix
|
||||
{
|
||||
name: "server-to-server GitHub App token with Bearer prefix",
|
||||
authHeader: "Bearer ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken,
|
||||
expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
{
|
||||
name: "server-to-server GitHub App token without Bearer prefix",
|
||||
authHeader: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken,
|
||||
expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
// Old-style Personal Access Token (40 hex characters, pre-2021)
|
||||
{
|
||||
name: "old-style personal access token (40 hex chars) with Bearer prefix",
|
||||
authHeader: "Bearer 0123456789abcdef0123456789abcdef01234567",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
||||
expectedToken: "0123456789abcdef0123456789abcdef01234567",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
{
|
||||
name: "old-style personal access token (40 hex chars) without Bearer prefix",
|
||||
authHeader: "0123456789abcdef0123456789abcdef01234567",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedTokenType: utils.TokenTypePersonalAccessToken,
|
||||
expectedToken: "0123456789abcdef0123456789abcdef01234567",
|
||||
expectTokenInfo: true,
|
||||
},
|
||||
// Error cases
|
||||
{
|
||||
name: "unsupported GitHub-Bearer header returns 400",
|
||||
authHeader: "GitHub-Bearer some_encrypted_token",
|
||||
expectedStatusCode: http.StatusBadRequest,
|
||||
expectTokenInfo: false,
|
||||
},
|
||||
{
|
||||
name: "invalid token format returns 400",
|
||||
authHeader: "Bearer invalid_token_format",
|
||||
expectedStatusCode: http.StatusBadRequest,
|
||||
expectTokenInfo: false,
|
||||
},
|
||||
{
|
||||
name: "unrecognized prefix returns 400",
|
||||
authHeader: "Bearer xyz_notavalidprefix",
|
||||
expectedStatusCode: http.StatusBadRequest,
|
||||
expectTokenInfo: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedTokenInfo *ghcontext.TokenInfo
|
||||
var tokenInfoCaptured bool
|
||||
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
middleware := ExtractUserToken(oauthCfg)
|
||||
handler := middleware(nextHandler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
if tt.authHeader != "" {
|
||||
req.Header.Set(headers.AuthorizationHeader, tt.authHeader)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatusCode, rr.Code)
|
||||
|
||||
if tt.expectWWWAuth {
|
||||
wwwAuth := rr.Header().Get("WWW-Authenticate")
|
||||
assert.NotEmpty(t, wwwAuth, "expected WWW-Authenticate header")
|
||||
assert.Contains(t, wwwAuth, "Bearer resource_metadata=")
|
||||
}
|
||||
|
||||
if tt.expectTokenInfo {
|
||||
require.True(t, tokenInfoCaptured, "expected TokenInfo to be present in context")
|
||||
require.NotNil(t, capturedTokenInfo)
|
||||
assert.Equal(t, tt.expectedTokenType, capturedTokenInfo.TokenType)
|
||||
assert.Equal(t, tt.expectedToken, capturedTokenInfo.Token)
|
||||
} else {
|
||||
assert.False(t, tokenInfoCaptured, "expected no TokenInfo in context")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUserToken_NilOAuthConfig(t *testing.T) {
|
||||
var capturedTokenInfo *ghcontext.TokenInfo
|
||||
var tokenInfoCaptured bool
|
||||
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
middleware := ExtractUserToken(nil)
|
||||
handler := middleware(nextHandler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
require.True(t, tokenInfoCaptured)
|
||||
require.NotNil(t, capturedTokenInfo)
|
||||
assert.Equal(t, utils.TokenTypePersonalAccessToken, capturedTokenInfo.TokenType)
|
||||
}
|
||||
|
||||
func TestExtractUserToken_MissingAuthHeader_WWWAuthenticateFormat(t *testing.T) {
|
||||
oauthCfg := &oauth.Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
AuthorizationServer: "https://github.com/login/oauth",
|
||||
ResourcePath: "/mcp",
|
||||
}
|
||||
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
middleware := ExtractUserToken(oauthCfg)
|
||||
handler := middleware(nextHandler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
// No Authorization header
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
wwwAuth := rr.Header().Get("WWW-Authenticate")
|
||||
assert.NotEmpty(t, wwwAuth)
|
||||
assert.Contains(t, wwwAuth, "Bearer")
|
||||
assert.Contains(t, wwwAuth, "resource_metadata=")
|
||||
assert.Contains(t, wwwAuth, "/.well-known/oauth-protected-resource")
|
||||
}
|
||||
|
||||
func TestSendAuthChallenge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oauthCfg *oauth.Config
|
||||
requestPath string
|
||||
expectedContains []string
|
||||
}{
|
||||
{
|
||||
name: "with base URL configured",
|
||||
oauthCfg: &oauth.Config{
|
||||
BaseURL: "https://mcp.example.com",
|
||||
},
|
||||
requestPath: "/api/test",
|
||||
expectedContains: []string{
|
||||
"Bearer",
|
||||
"resource_metadata=",
|
||||
"https://mcp.example.com/.well-known/oauth-protected-resource",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with nil config uses request host",
|
||||
oauthCfg: nil,
|
||||
requestPath: "/api/test",
|
||||
expectedContains: []string{
|
||||
"Bearer",
|
||||
"resource_metadata=",
|
||||
"/.well-known/oauth-protected-resource",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with resource path configured",
|
||||
oauthCfg: &oauth.Config{
|
||||
BaseURL: "https://mcp.example.com",
|
||||
ResourcePath: "/mcp",
|
||||
},
|
||||
requestPath: "/api/test",
|
||||
expectedContains: []string{
|
||||
"Bearer",
|
||||
"resource_metadata=",
|
||||
"/mcp",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil)
|
||||
|
||||
sendAuthChallenge(rr, req, tt.oauthCfg)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
wwwAuth := rr.Header().Get("WWW-Authenticate")
|
||||
for _, expected := range tt.expectedContains {
|
||||
assert.Contains(t, wwwAuth, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user