Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5601927dc8 |
@@ -92,8 +92,10 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||||
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
auth.PassthroughMiddleware(giteaClient, mcpSrv,
|
||||||
auth.CallerMiddleware(logger, mcpSrv),
|
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||||
|
auth.CallerMiddleware(logger, mcpSrv),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ func CallerMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
|||||||
"x_forwarded_user", fwdUser)
|
"x_forwarded_user", fwdUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), ctxKey{}, user)
|
next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), user)))
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withCaller(ctx context.Context, user string) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKey{}, user)
|
||||||
|
}
|
||||||
|
|
||||||
func Caller(ctx context.Context) string {
|
func Caller(ctx context.Context) string {
|
||||||
if v, ok := ctx.Value(ctxKey{}).(string); ok {
|
if v, ok := ctx.Value(ctxKey{}).(string); ok {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,6 +20,21 @@ type Client struct {
|
|||||||
branchCache *expirable.LRU[string, string]
|
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 {
|
func NewClient(baseURL, token string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
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.
|
// DefaultBranch returns the default branch for a repo. Cached for 60s.
|
||||||
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
|
||||||
key := owner + "/" + name
|
key := owner + "/" + name
|
||||||
@@ -66,6 +99,9 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) (
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
token := c.token
|
token := c.token
|
||||||
|
if override, ok := TokenFromContext(ctx); ok {
|
||||||
|
token = override
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "token "+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
|
return nil, err
|
||||||
}
|
}
|
||||||
token := c.token
|
token := c.token
|
||||||
|
if override, ok := TokenFromContext(ctx); ok {
|
||||||
|
token = override
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "token "+token)
|
req.Header.Set("Authorization", "token "+token)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,49 @@ func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
|
|||||||
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
|
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) {
|
func TestRetryOnPostNotRetried(t *testing.T) {
|
||||||
var attempts int32
|
var attempts int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
Reference in New Issue
Block a user