feat(auth): JWT-or-static middleware + /.well-known/oauth-protected-resource (issue #5)
Some checks failed
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped

- internal/auth/jwt.go: JWTValidator via lestrrat-go/jwx/v2, JWKS auto-refresh
- internal/auth/bearer.go: replace Gitea PAT validation with JWT->static->default chain
- internal/gitea/client.go: always use service PAT; remove TokenFromContext lookup
- internal/config/config.go: add DexIssuerURL, MCPAudience, MCPResourceURL, StaticToken
- cmd/gitea-mcp/main.go: wire validator, fix /.well-known to return real AS list
- bearer_test.go: rewrite for new API
This commit is contained in:
Mathias Bergqvist
2026-05-12 11:30:52 +02:00
parent efbbd37882
commit 91be18c100
20 changed files with 1745 additions and 114 deletions

View File

@@ -1,55 +1,43 @@
package auth
import (
"context"
"crypto/subtle"
"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.
// BearerMiddleware authenticates requests via one of three paths (in order):
//
// 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}
// 1. Bearer token is a valid JWT issued by the configured Dex OIDC server.
// 2. Bearer token matches staticToken (constant-time compare).
// 3. No Authorization header and defaultToken is set — allow through; the
// Gitea client will use its service PAT for upstream calls.
//
// Any other case returns 401.
func BearerMiddleware(jwtValidator *JWTValidator, staticToken, defaultToken string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || token == "" {
if defaultToken == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
bearer, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
hasBearer = hasBearer && bearer != ""
if !hasBearer {
if defaultToken != "" {
next.ServeHTTP(w, r)
return
}
token = defaultToken
}
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)
if jwtValidator.Validate(r.Context(), bearer) {
next.ServeHTTP(w, r)
return
}
_ = resp.Body.Close()
ctx := context.WithValue(r.Context(), tokenKey{}, token)
next.ServeHTTP(w, r.WithContext(ctx))
if staticToken != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(staticToken)) == 1 {
next.ServeHTTP(w, r)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
})
}
// 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 ""
}