diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index ae5a838..550d1d9 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -73,7 +73,7 @@ func main() { mux := http.NewServeMux() mux.Handle("/mcp", logReq(mcp.OriginAllowlist(cfg.OriginAllowlist)( - auth.BearerMiddleware(cfg.GiteaBaseURL, + auth.BearerMiddleware(cfg.GiteaBaseURL, cfg.DefaultToken, auth.CallerMiddleware(mcpSrv), )), )) diff --git a/internal/auth/bearer.go b/internal/auth/bearer.go index 20bae73..01d2f3c 100644 --- a/internal/auth/bearer.go +++ b/internal/auth/bearer.go @@ -12,13 +12,19 @@ type tokenKey struct{} // BearerMiddleware validates the incoming bearer token as a Gitea PAT by // calling GET /api/v1/user. The validated token is stored in context for // downstream use by the Gitea client. -func BearerMiddleware(giteaBaseURL string, next http.Handler) http.Handler { +// +// defaultToken, if non-empty, is used when no Authorization header is present +// (e.g. claude.ai connectors which do not inject Bearer tokens). +func BearerMiddleware(giteaBaseURL, defaultToken string, next http.Handler) http.Handler { hc := &http.Client{Timeout: 5 * time.Second} return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || token == "" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return + if defaultToken == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + token = defaultToken } req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, giteaBaseURL+"/api/v1/user", nil) if err != nil { diff --git a/internal/auth/bearer_test.go b/internal/auth/bearer_test.go index 5a2303d..01cc943 100644 --- a/internal/auth/bearer_test.go +++ b/internal/auth/bearer_test.go @@ -11,7 +11,7 @@ import ( ) func TestBearerMiddleware_NoAuthHeader(t *testing.T) { - srv := httptest.NewServer(auth.BearerMiddleware("https://gitea.example.com", + srv := httptest.NewServer(auth.BearerMiddleware("https://gitea.example.com", "", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -24,6 +24,32 @@ func TestBearerMiddleware_NoAuthHeader(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } +func TestBearerMiddleware_NoAuthHeaderWithDefault(t *testing.T) { + const defaultToken = "default-pat" + + giteaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "token "+defaultToken, r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + })) + defer giteaMock.Close() + + called := false + srv := httptest.NewServer(auth.BearerMiddleware(giteaMock.URL, defaultToken, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + assert.Equal(t, defaultToken, auth.TokenFromContext(r.Context())) + w.WriteHeader(http.StatusOK) + }), + )) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/mcp", "application/json", nil) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, called) +} + func TestBearerMiddleware_InvalidToken(t *testing.T) { // Mock Gitea that rejects the token giteaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -31,7 +57,7 @@ func TestBearerMiddleware_InvalidToken(t *testing.T) { })) defer giteaMock.Close() - srv := httptest.NewServer(auth.BearerMiddleware(giteaMock.URL, + srv := httptest.NewServer(auth.BearerMiddleware(giteaMock.URL, "", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -57,7 +83,7 @@ func TestBearerMiddleware_ValidToken(t *testing.T) { defer giteaMock.Close() called := false - srv := httptest.NewServer(auth.BearerMiddleware(giteaMock.URL, + srv := httptest.NewServer(auth.BearerMiddleware(giteaMock.URL, "", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true // Token must be available in context for downstream Gitea client diff --git a/internal/config/config.go b/internal/config/config.go index 6818555..c97add0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( type Config struct { Port string // GITEA_MCP_PORT, default 8080 GiteaBaseURL string // GITEA_BASE_URL, e.g. https://gitea.d-ma.be + DefaultToken string // GITEA_MCP_DEFAULT_TOKEN, fallback PAT when no Bearer header present (e.g. claude.ai) AllowedOwners []string // GITEA_MCP_ALLOWED_OWNERS, comma-separated, default "mathias" OriginAllowlist []string // GITEA_MCP_ORIGIN_ALLOWLIST, comma-separated } @@ -16,6 +17,7 @@ func Load() (Config, error) { cfg := Config{ Port: envOr("GITEA_MCP_PORT", "8080"), GiteaBaseURL: os.Getenv("GITEA_BASE_URL"), + DefaultToken: os.Getenv("GITEA_MCP_DEFAULT_TOKEN"), AllowedOwners: splitCSV(envOr("GITEA_MCP_ALLOWED_OWNERS", "mathias")), OriginAllowlist: splitCSV(os.Getenv("GITEA_MCP_ORIGIN_ALLOWLIST")), }