feat(auth): per-caller Gitea PAT pass-through (#59)
Replaces the shared GITEA_MCP_DEFAULT_TOKEN for all callers. When a request's bearer validates directly against Gitea's own /api/v1/user, that token is used for every upstream call this request makes instead of the service PAT, and the caller identity comes from Gitea's own login rather than the proxy header. Any other bearer (static token, JWT, none) falls through unchanged to the existing chassis auth. Prep: Authentik now SSOs into Gitea (infra a32801c), so each real user can mint their own PAT from their own linked account. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -92,9 +92,11 @@ func main() {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||
auth.PassthroughMiddleware(giteaClient, mcpSrv,
|
||||
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||
auth.CallerMiddleware(logger, mcpSrv),
|
||||
),
|
||||
),
|
||||
))
|
||||
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
||||
if cfg.DexIssuerURL != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user