diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index a80bd7a..b565fb7 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -92,8 +92,10 @@ func main() { mux := http.NewServeMux() mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)( - chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL, - auth.CallerMiddleware(logger, mcpSrv), + auth.PassthroughMiddleware(giteaClient, mcpSrv, + chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL, + auth.CallerMiddleware(logger, mcpSrv), + ), ), )) mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr)) diff --git a/internal/auth/caller.go b/internal/auth/caller.go index 77a2005..a301428 100644 --- a/internal/auth/caller.go +++ b/internal/auth/caller.go @@ -36,11 +36,14 @@ func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler { "x_forwarded_user", fwdUser) } - ctx := context.WithValue(r.Context(), ctxKey{}, user) - next.ServeHTTP(w, r.WithContext(ctx)) + next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), user))) }) } +func withCaller(ctx context.Context, user string) context.Context { + return context.WithValue(ctx, ctxKey{}, user) +} + func Caller(ctx context.Context) string { if v, ok := ctx.Value(ctxKey{}).(string); ok { return v diff --git a/internal/auth/passthrough.go b/internal/auth/passthrough.go new file mode 100644 index 0000000..ba6efc0 --- /dev/null +++ b/internal/auth/passthrough.go @@ -0,0 +1,36 @@ +package auth + +import ( + "context" + "net/http" + "strings" + + "git.d-ma.be/mathias/gitea-mcp/internal/gitea" +) + +// TokenValidator asks the upstream service who a bearer token belongs to. +type TokenValidator interface { + ValidateToken(ctx context.Context, token string) (username string, ok bool) +} + +// PassthroughMiddleware lets a caller authenticate with their own Gitea PAT: +// if the request's bearer token validates directly against Gitea, it's used +// as-is for every upstream call this request makes (gitea-mcp#59), instead of +// the server's shared default token. Any other bearer (static token, JWT, or +// none) falls through to fallback unchanged — this only adds a capability, it +// never removes the existing auth paths. +func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authz := r.Header.Get("Authorization") + token, hasBearer := strings.CutPrefix(authz, "Bearer ") + if hasBearer && token != "" { + if login, ok := validator.ValidateToken(r.Context(), token); ok { + ctx := withCaller(r.Context(), login) + ctx = gitea.WithToken(ctx, token) + onValid.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + fallback.ServeHTTP(w, r) + }) +} diff --git a/internal/auth/passthrough_test.go b/internal/auth/passthrough_test.go new file mode 100644 index 0000000..0234022 --- /dev/null +++ b/internal/auth/passthrough_test.go @@ -0,0 +1,82 @@ +package auth_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "git.d-ma.be/mathias/gitea-mcp/internal/auth" + "git.d-ma.be/mathias/gitea-mcp/internal/gitea" + "github.com/stretchr/testify/assert" +) + +type fakeValidator struct { + login string + ok bool + calls int +} + +func (f *fakeValidator) ValidateToken(_ context.Context, _ string) (string, bool) { + f.calls++ + return f.login, f.ok +} + +func TestPassthroughMiddleware_ValidPATGoesStraightToOnValid(t *testing.T) { + validator := &fakeValidator{login: "alice", ok: true} + var gotCaller string + var gotToken string + var gotOK bool + onValid := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + gotCaller = auth.Caller(r.Context()) + gotToken, gotOK = gitea.TokenFromContext(r.Context()) + }) + fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("fallback should not be called for a valid PAT") + }) + + h := auth.PassthroughMiddleware(validator, onValid, fallback) + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer alices-pat") + h.ServeHTTP(httptest.NewRecorder(), req) + + assert.Equal(t, "alice", gotCaller) + assert.True(t, gotOK) + assert.Equal(t, "alices-pat", gotToken) +} + +func TestPassthroughMiddleware_InvalidTokenFallsThrough(t *testing.T) { + validator := &fakeValidator{ok: false} + fallbackCalled := false + onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("onValid should not be called for an invalid token") + }) + fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + fallbackCalled = true + }) + + h := auth.PassthroughMiddleware(validator, onValid, fallback) + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Authorization", "Bearer not-a-gitea-pat") + h.ServeHTTP(httptest.NewRecorder(), req) + + assert.True(t, fallbackCalled) +} + +func TestPassthroughMiddleware_NoBearerFallsThroughWithoutCallingValidator(t *testing.T) { + validator := &fakeValidator{ok: true, login: "alice"} + fallbackCalled := false + onValid := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("onValid should not be called with no Authorization header") + }) + fallback := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + fallbackCalled = true + }) + + h := auth.PassthroughMiddleware(validator, onValid, fallback) + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + h.ServeHTTP(httptest.NewRecorder(), req) + + assert.True(t, fallbackCalled) + assert.Equal(t, 0, validator.calls, "validator should not be invoked when there's no bearer to check") +} diff --git a/internal/gitea/client.go b/internal/gitea/client.go index fa8da6d..f467a13 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -3,6 +3,7 @@ package gitea import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -19,6 +20,21 @@ type Client struct { branchCache *expirable.LRU[string, string] } +type ctxTokenKey struct{} + +// WithToken overrides the token used for upstream Gitea calls made with the +// returned context, taking precedence over the Client's configured default +// token. Used for per-caller PAT pass-through (gitea-mcp#59). +func WithToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, ctxTokenKey{}, token) +} + +// TokenFromContext returns the token set by WithToken, if any. +func TokenFromContext(ctx context.Context) (string, bool) { + v, ok := ctx.Value(ctxTokenKey{}).(string) + return v, ok +} + func NewClient(baseURL, token string) *Client { return &Client{ baseURL: baseURL, @@ -28,6 +44,23 @@ func NewClient(baseURL, token string) *Client { } } +// ValidateToken asks Gitea who a given token belongs to (GET /api/v1/user +// using that token, not the client's configured default token) and returns +// its login name. Used for per-caller PAT pass-through (gitea-mcp#59). +func (c *Client) ValidateToken(ctx context.Context, token string) (string, bool) { + body, status, err := c.doOnce(WithToken(ctx, token), http.MethodGet, "/api/v1/user", nil) + if err != nil || status != http.StatusOK { + return "", false + } + var user struct { + Login string `json:"login"` + } + if err := json.Unmarshal(body, &user); err != nil || user.Login == "" { + return "", false + } + return user.Login, true +} + // DefaultBranch returns the default branch for a repo. Cached for 60s. func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) { key := owner + "/" + name @@ -66,6 +99,9 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ( return nil, 0, err } token := c.token + if override, ok := TokenFromContext(ctx); ok { + token = override + } if token != "" { req.Header.Set("Authorization", "token "+token) } @@ -135,6 +171,9 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (* return nil, err } token := c.token + if override, ok := TokenFromContext(ctx); ok { + token = override + } if token != "" { req.Header.Set("Authorization", "token "+token) } diff --git a/internal/gitea/client_test.go b/internal/gitea/client_test.go index 84ff7d7..adc2f93 100644 --- a/internal/gitea/client_test.go +++ b/internal/gitea/client_test.go @@ -50,6 +50,49 @@ func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) { assert.Equal(t, int32(2), atomic.LoadInt32(&attempts)) } +func TestClientPrefersTokenFromContextOverDefaultToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "default-token") + ctx := gitea.WithToken(context.Background(), "caller-token") + _, status, err := c.GetJSON(ctx, "/api/v1/user") + require.NoError(t, err) + assert.Equal(t, 200, status) + assert.Equal(t, "token caller-token", gotAuth) +} + +func TestValidateTokenReturnsLoginOnSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "token candidate-token", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"login":"alice"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "default-token") + login, ok := c.ValidateToken(context.Background(), "candidate-token") + assert.True(t, ok) + assert.Equal(t, "alice", login) +} + +func TestValidateTokenReturnsFalseOn401(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "default-token") + login, ok := c.ValidateToken(context.Background(), "bad-token") + assert.False(t, ok) + assert.Empty(t, login) +} + func TestRetryOnPostNotRetried(t *testing.T) { var attempts int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {