From 923689afa5331c9cf6b6f8725729eb8e1357375b Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Thu, 7 May 2026 21:04:47 +0200 Subject: [PATCH] feat: replace static API token with per-request Gitea PAT pass-through Callers now supply their own Gitea PAT as a Bearer token; the server validates it against GET /api/v1/user and threads it through context to all downstream Gitea API calls. GITEA_API_TOKEN env var and the GiteaAPIToken config field are removed. --- cmd/gitea-mcp/main.go | 8 +++- internal/auth/bearer.go | 49 ++++++++++++++++++++ internal/auth/bearer_test.go | 82 ++++++++++++++++++++++++++++++++++ internal/config/config.go | 2 - internal/config/config_test.go | 3 -- internal/gitea/client.go | 17 +++++-- 6 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 internal/auth/bearer.go create mode 100644 internal/auth/bearer_test.go diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index 7eeaefa..d57884a 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -23,7 +23,7 @@ func main() { os.Exit(1) } - giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken) + giteaClient := gitea.NewClient(cfg.GiteaBaseURL, "") ownerAllow := allowlist.New(cfg.AllowedOwners) reg := registry.New() @@ -58,7 +58,11 @@ func main() { }) mux := http.NewServeMux() - mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(auth.CallerMiddleware(mcpSrv))) + mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)( + auth.BearerMiddleware(cfg.GiteaBaseURL, + auth.CallerMiddleware(mcpSrv), + ), + )) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) diff --git a/internal/auth/bearer.go b/internal/auth/bearer.go new file mode 100644 index 0000000..20bae73 --- /dev/null +++ b/internal/auth/bearer.go @@ -0,0 +1,49 @@ +package auth + +import ( + "context" + "net/http" + "strings" + "time" +) + +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 { + 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 + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, giteaBaseURL+"/api/v1/user", nil) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + req.Header.Set("Authorization", "token "+token) + resp, err := hc.Do(req) + if err != nil || resp.StatusCode != http.StatusOK { + if resp != nil { + _ = resp.Body.Close() + } + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _ = resp.Body.Close() + ctx := context.WithValue(r.Context(), tokenKey{}, token) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// TokenFromContext returns the validated Gitea PAT stored by BearerMiddleware. +func TokenFromContext(ctx context.Context) string { + if v, ok := ctx.Value(tokenKey{}).(string); ok { + return v + } + return "" +} diff --git a/internal/auth/bearer_test.go b/internal/auth/bearer_test.go new file mode 100644 index 0000000..25d5580 --- /dev/null +++ b/internal/auth/bearer_test.go @@ -0,0 +1,82 @@ +package auth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBearerMiddleware_NoAuthHeader(t *testing.T) { + srv := httptest.NewServer(auth.BearerMiddleware("https://gitea.example.com", + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + )) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/mcp", "application/json", nil) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestBearerMiddleware_InvalidToken(t *testing.T) { + // Mock Gitea that rejects the token + giteaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer giteaMock.Close() + + srv := httptest.NewServer(auth.BearerMiddleware(giteaMock.URL, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + )) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil) + req.Header.Set("Authorization", "Bearer bad-token") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestBearerMiddleware_ValidToken(t *testing.T) { + const token = "valid-pat" + + // Mock Gitea that accepts the token and returns a user + giteaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "token "+token, r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + })) + defer giteaMock.Close() + + called := false + 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 + assert.Equal(t, token, auth.TokenFromContext(r.Context())) + w.WriteHeader(http.StatusOK) + }), + )) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, called) +} + +func TestTokenFromContext_Empty(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + assert.Equal(t, "", auth.TokenFromContext(req.Context())) +} diff --git a/internal/config/config.go b/internal/config/config.go index 9b0defe..6818555 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,7 +8,6 @@ import ( type Config struct { Port string // GITEA_MCP_PORT, default 8080 GiteaBaseURL string // GITEA_BASE_URL, e.g. https://gitea.d-ma.be - GiteaAPIToken string // GITEA_API_TOKEN — bot user token AllowedOwners []string // GITEA_MCP_ALLOWED_OWNERS, comma-separated, default "mathias" OriginAllowlist []string // GITEA_MCP_ORIGIN_ALLOWLIST, comma-separated } @@ -17,7 +16,6 @@ func Load() (Config, error) { cfg := Config{ Port: envOr("GITEA_MCP_PORT", "8080"), GiteaBaseURL: os.Getenv("GITEA_BASE_URL"), - GiteaAPIToken: os.Getenv("GITEA_API_TOKEN"), AllowedOwners: splitCSV(envOr("GITEA_MCP_ALLOWED_OWNERS", "mathias")), OriginAllowlist: splitCSV(os.Getenv("GITEA_MCP_ORIGIN_ALLOWLIST")), } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 32ae3dc..1c2a148 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,7 +10,6 @@ import ( func TestLoadDefaults(t *testing.T) { t.Setenv("GITEA_BASE_URL", "") - t.Setenv("GITEA_API_TOKEN", "") t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "") t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "") t.Setenv("GITEA_MCP_PORT", "") @@ -23,7 +22,6 @@ func TestLoadDefaults(t *testing.T) { func TestLoadFromEnv(t *testing.T) { t.Setenv("GITEA_BASE_URL", "https://gitea.d-ma.be") - t.Setenv("GITEA_API_TOKEN", "test-token") t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "mathias,acme") t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "https://claude.ai,https://api.anthropic.com") t.Setenv("GITEA_MCP_PORT", "9000") @@ -31,7 +29,6 @@ func TestLoadFromEnv(t *testing.T) { cfg, err := config.Load() require.NoError(t, err) assert.Equal(t, "https://gitea.d-ma.be", cfg.GiteaBaseURL) - assert.Equal(t, "test-token", cfg.GiteaAPIToken) assert.Equal(t, []string{"mathias", "acme"}, cfg.AllowedOwners) assert.Equal(t, []string{"https://claude.ai", "https://api.anthropic.com"}, cfg.OriginAllowlist) assert.Equal(t, "9000", cfg.Port) diff --git a/internal/gitea/client.go b/internal/gitea/client.go index 4957c3d..861e382 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + "gitea.d-ma.be/mathias/gitea-mcp/internal/auth" "github.com/hashicorp/golang-lru/v2/expirable" ) @@ -49,8 +50,12 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ( if err != nil { return nil, 0, err } - if c.token != "" { - req.Header.Set("Authorization", "token "+c.token) + token := auth.TokenFromContext(ctx) + if token == "" { + token = c.token + } + if token != "" { + req.Header.Set("Authorization", "token "+token) } if body != nil { req.Header.Set("Content-Type", "application/json") @@ -114,8 +119,12 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (* if err != nil { return nil, err } - if c.token != "" { - req.Header.Set("Authorization", "token "+c.token) + token := auth.TokenFromContext(ctx) + if token == "" { + token = c.token + } + if token != "" { + req.Header.Set("Authorization", "token "+token) } if body != nil { req.Header.Set("Content-Type", "application/json")