This is an automated email from the ASF dual-hosted git repository.
alexstocks pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git
The following commit(s) were added to refs/heads/develop by this push:
new f5869b36 feat(MCP): add MCP authorization (#740)
f5869b36 is described below
commit f5869b360374c8491d50e2825142a58d64368c80
Author: Zerui Yang <[email protected]>
AuthorDate: Sun Oct 12 20:36:04 2025 +0800
feat(MCP): add MCP authorization (#740)
* feat: Add MCP executor filter with configuration and request handling
* feat: Implement MCP server filter with configuration, request handling,
and tool registry
* feat: Enhance MCP server filter with new context management and tool
registry features
* feat: Enhance MCP server filter with new context management and tool
registry features
* feat: Simplify MCP server configuration and response handling with
optimized structures and error management
* fix: fix some problems from copilot
* feat: Add MCP Auth filter with JWT validation and resource metadata
support
* feat: Refactor JWT validator with enhanced provider management and JWKS
loading
* feat(mcp): implement MCP Auth filter with JWT validation and resource
metadata support
* feat(mcp): enhance MCP Auth filter with improved provider validation and
error handling
* fix(mcp): organize import statements for improved readability
* fix: delete PLAN.md
* Update pkg/filter/auth/mcp/config.go
Co-authored-by: Copilot <[email protected]>
* fix(mcp): correct metaURL construction for OAuth error handling
* refactor(mcp): simplify Providers method using maps.Keys and slices.Sorted
* chore: update dependencies in go.mod and go.sum
---------
Co-authored-by: Copilot <[email protected]>
---
go.mod | 7 +
go.sum | 14 +
pkg/common/constant/http.go | 1 +
pkg/common/constant/key.go | 1 +
pkg/filter/auth/mcp/config.go | 114 +++++++
pkg/filter/auth/mcp/filter.go | 239 ++++++++++++++
pkg/filter/auth/mcp/filter_test.go | 357 ++++++++++++++++++++
pkg/filter/auth/mcp/internal/validator/config.go | 42 +++
pkg/filter/auth/mcp/internal/validator/loader.go | 73 +++++
.../auth/mcp/internal/validator/validator.go | 358 +++++++++++++++++++++
.../auth/mcp/internal/validator/validator_test.go | 166 ++++++++++
pkg/pluginregistry/registry.go | 1 +
12 files changed, 1373 insertions(+)
diff --git a/go.mod b/go.mod
index d5dc8cfe..90197565 100644
--- a/go.mod
+++ b/go.mod
@@ -30,6 +30,8 @@ require (
github.com/golang/protobuf v1.5.4
github.com/jhump/protoreflect v1.17.0
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
+ github.com/lestrrat-go/httprc/v3 v3.0.0-beta1
+ github.com/lestrrat-go/jwx/v3 v3.0.0
github.com/mark3labs/mcp-go v0.32.0
github.com/mitchellh/mapstructure v1.5.0
github.com/nacos-group/nacos-sdk-go v1.1.3
@@ -96,6 +98,7 @@ require (
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eapache/go-resiliency v1.7.0 // indirect
@@ -152,6 +155,9 @@ require (
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/knadh/koanf v1.5.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/lestrrat-go/blackmagic v1.0.2 // indirect
+ github.com/lestrrat-go/httpcc v1.0.1 // indirect
+ github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/strftime v1.1.1 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 //
indirect
github.com/magiconair/properties v1.8.5 // indirect
@@ -182,6 +188,7 @@ require (
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 //
indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/segmentio/asm v1.2.0 // indirect
github.com/shirou/gopsutil/v3 v3.22.2 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/smartystreets/assertions v1.2.0 // indirect
diff --git a/go.sum b/go.sum
index ce3d42db..0111bac4 100644
--- a/go.sum
+++ b/go.sum
@@ -565,6 +565,8 @@ github.com/creasty/defaults v1.5.2/go.mod
h1:FPZ+Y0WNrbqOVw+c6av63eyHUAl6pMHZwqL
github.com/davecgh/go-spew v1.1.0/go.mod
h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1
h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod
h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod
h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgraph-io/badger/v3 v3.2103.2
h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod
h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
github.com/dgraph-io/ristretto v0.1.0
h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
@@ -1061,10 +1063,20 @@ github.com/kylelemons/godebug v1.1.0/go.mod
h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+
github.com/leodido/go-urn v1.2.2/go.mod
h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
github.com/leodido/go-urn v1.4.0
h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod
h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lestrrat-go/blackmagic v1.0.2
h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
+github.com/lestrrat-go/blackmagic v1.0.2/go.mod
h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc
h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod
h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is=
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
h1:Y6sqxHMyB1D2YSzWkLibYKgg+SwmyFU9dF2hn6MdTj4=
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible/go.mod
h1:ZQnN8lSECaebrkQytbHj4xNgtg8CR7RYXnPok8e0EHA=
+github.com/lestrrat-go/httpcc v1.0.1
h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
+github.com/lestrrat-go/httpcc v1.0.1/go.mod
h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
+github.com/lestrrat-go/httprc/v3 v3.0.0-beta1
h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
+github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod
h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
+github.com/lestrrat-go/jwx/v3 v3.0.0
h1:IRnFNdZx5dJHjTpPVkYqP6TRahJI2Z9v43UwEDJcj6U=
+github.com/lestrrat-go/jwx/v3 v3.0.0/go.mod
h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
+github.com/lestrrat-go/option v1.0.1
h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
+github.com/lestrrat-go/option v1.0.1/go.mod
h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/strftime v1.1.1
h1:zgf8QCsgj27GlKBy3SU9/8MMgegZ8UCzlCyHYrUF0QU=
github.com/lestrrat-go/strftime v1.1.1/go.mod
h1:YDrzHJAODYQ+xxvrn5SG01uFIQAeDTzpxNVppCz7Nmw=
github.com/lestrrat/go-envload v0.0.0-20180220120943-6ed08b54a570/go.mod
h1:BLt8L9ld7wVsvEWQbuLrUZnCMnUmLZ+CGDzKtclrTlE=
@@ -1304,6 +1316,8 @@ github.com/ryanuber/columnize v2.1.0+incompatible/go.mod
h1:sm1tb6uqfes/u+d4ooFo
github.com/ryanuber/go-glob v1.0.0/go.mod
h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod
h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod
h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
+github.com/segmentio/asm v1.2.0/go.mod
h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil v3.20.11+incompatible/go.mod
h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shirou/gopsutil/v3 v3.21.6/go.mod
h1:JfVbDpIBLVzT8oKbvMg9P3wEIMDDpVn+LwHTKj0ST88=
github.com/shirou/gopsutil/v3 v3.22.2
h1:wCrArWFkHYIdDxx/FSfF5RB4dpJYW6t7rcp3+zL8uks=
diff --git a/pkg/common/constant/http.go b/pkg/common/constant/http.go
index bbb98dd4..287ac3b9 100644
--- a/pkg/common/constant/http.go
+++ b/pkg/common/constant/http.go
@@ -87,6 +87,7 @@ const (
const (
Host = "Host"
Authorization = "Authorization"
+ WWWAuthenticate = "WWW-Authenticate"
XForwardedFor = "X-Forwarded-For"
AccessControlRequestMethod = "Access-Control-Request-Method"
Origin = "Origin"
diff --git a/pkg/common/constant/key.go b/pkg/common/constant/key.go
index feeb8f7b..a08ecd93 100644
--- a/pkg/common/constant/key.go
+++ b/pkg/common/constant/key.go
@@ -42,6 +42,7 @@ const (
HTTPWasmFilter = "dgp.filter.http.webassembly"
HTTPCircuitBreakerFilter = "dgp.filter.http.circuitbreaker"
HTTPAuthJwtFilter = "dgp.filter.http.auth.jwt"
+ HTTPMCPAuthFilter = "dgp.filter.http.auth.mcp"
HTTPCorsFilter = "dgp.filter.http.cors"
HTTPCsrfFilter = "dgp.filter.http.csrf"
HTTPProxyRewriteFilter = "dgp.filter.http.proxyrewrite"
diff --git a/pkg/filter/auth/mcp/config.go b/pkg/filter/auth/mcp/config.go
new file mode 100644
index 00000000..41044f6e
--- /dev/null
+++ b/pkg/filter/auth/mcp/config.go
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package mcp
+
+import (
+ "fmt"
+)
+
+import (
+
"github.com/apache/dubbo-go-pixiu/pkg/filter/auth/mcp/internal/validator"
+ "github.com/apache/dubbo-go-pixiu/pkg/logger"
+)
+
+// Config defines the MCP auth filter configuration.
+// It wires resource metadata (RFC9728), JWT providers, and path-based auth
rules.
+type Config struct {
+ // ResourceMetadata controls /.well-known/oauth-protected-resource
exposure and values.
+ ResourceMetadata ResourceMetadata `yaml:"resource_metadata"
json:"resource_metadata" mapstructure:"resource_metadata"`
+
+ // Providers declares JWT validation providers reused by rules.
+ Providers []validator.Provider `yaml:"providers" json:"providers"
mapstructure:"providers"`
+
+ // Rules binds request paths to a provider and required scopes.
+ Rules []Rule `yaml:"rules" json:"rules" mapstructure:"rules"`
+}
+
+// ResourceMetadata represents OAuth 2.0 Protected Resource Metadata (RFC9728)
+// that MCP clients discover via /.well-known/oauth-protected-resource
+type ResourceMetadata struct {
+ // Path is the well-known endpoint path to serve metadata from.
+ // Default: "/.well-known/oauth-protected-resource"
+ Path string `yaml:"path" json:"path" mapstructure:"path"`
+
+ // Resource is the canonical resource identifier (RFC8707) that clients
+ // should request tokens for (e.g. "https://mcp.example.com").
+ Resource string `yaml:"resource" json:"resource"
mapstructure:"resource"`
+
+ // AuthorizationServers lists candidate Authorization Server metadata
endpoints
+ // (e.g.
"https://auth.example.com/.well-known/oauth-authorization-server").
+ AuthorizationServers []string `yaml:"authorization_servers"
json:"authorization_servers" mapstructure:"authorization_servers"`
+}
+
+// Rule describes how to protect requests under a given path prefix.
+type Rule struct {
+ // Cluster is the route cluster name matched by the framework router.
+ // The MCP filter will protect routes that resolve to this cluster.
+ Cluster string `yaml:"cluster" json:"cluster" mapstructure:"cluster"`
+}
+
+// Validate performs basic semantic checks on the configuration.
+func (c *Config) Validate() error {
+ // Resource metadata
+ if c.ResourceMetadata.Path == "" {
+ c.ResourceMetadata.Path =
"/.well-known/oauth-protected-resource"
+ logger.Warnf("[dubbo-go-pixiu] resource_metadata.path is not
set, using default value: %s", c.ResourceMetadata.Path)
+ }
+ if c.ResourceMetadata.Resource == "" {
+ return fmt.Errorf("resource_metadata.resource must be set to
the canonical MCP server URI")
+ }
+ if len(c.ResourceMetadata.AuthorizationServers) == 0 {
+ return fmt.Errorf("resource_metadata.authorization_servers must
not be empty")
+ }
+
+ // Providers presence
+ if len(c.Providers) == 0 {
+ return fmt.Errorf("providers must not be empty")
+ }
+
+ // Validate provider entries and index names to detect duplicates
+ providerNames := make(map[string]struct{}, len(c.Providers))
+ for _, p := range c.Providers {
+ if p.Name == "" {
+ return fmt.Errorf("provider name must not be empty")
+ }
+ if p.Audience == "" {
+ p.Audience = c.ResourceMetadata.Resource
+ logger.Warnf("[dubbo-go-pixiu] provider '%s' has no
audience; defaulting to resource_metadata.resource '%s'", p.Name,
c.ResourceMetadata.Resource)
+ }
+ if p.Issuer == "" {
+ return fmt.Errorf("provider '%s': issuer must not be
empty", p.Name)
+ }
+ if p.JWKS == "" {
+ return fmt.Errorf("provider '%s': jwks must not be
empty", p.Name)
+ }
+ if _, exists := providerNames[p.Name]; exists {
+ return fmt.Errorf("duplicated provider name '%s'",
p.Name)
+ }
+ providerNames[p.Name] = struct{}{}
+ }
+
+ // Rules
+ for idx, r := range c.Rules {
+ if r.Cluster == "" {
+ return fmt.Errorf("rules[%d].cluster must not be
empty", idx)
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/filter/auth/mcp/filter.go b/pkg/filter/auth/mcp/filter.go
new file mode 100644
index 00000000..00c595f1
--- /dev/null
+++ b/pkg/filter/auth/mcp/filter.go
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package mcp
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+)
+
+import (
+ "github.com/apache/dubbo-go-pixiu/pkg/common/constant"
+ "github.com/apache/dubbo-go-pixiu/pkg/common/extension/filter"
+ contexthttp "github.com/apache/dubbo-go-pixiu/pkg/context/http"
+
"github.com/apache/dubbo-go-pixiu/pkg/filter/auth/mcp/internal/validator"
+ "github.com/apache/dubbo-go-pixiu/pkg/logger"
+)
+
+const (
+ // Kind is the filter kind key for MCP auth
+ Kind = constant.HTTPMCPAuthFilter
+)
+
+func init() {
+ filter.RegisterHttpFilter(&Plugin{})
+}
+
+type (
+ // Plugin is http filter plugin.
+ Plugin struct{}
+
+ // runtimeState holds read-only runtime data for filters
+ runtimeState struct {
+ validator *validator.Validator
+ metaPath string
+ metaBody []byte
+ rules []Rule
+ }
+
+ // FilterFactory holds immutable state for creating filters
+ FilterFactory struct {
+ cfg *Config
+ state *runtimeState
+ }
+
+ // Filter is the runtime decode filter
+ Filter struct {
+ state *runtimeState
+ }
+)
+
+func (p *Plugin) Kind() string { return Kind }
+
+func (p *Plugin) CreateFilterFactory() (filter.HttpFilterFactory, error) {
+ return &FilterFactory{cfg: &Config{}}, nil
+}
+
+func (factory *FilterFactory) Config() any { return factory.cfg }
+
+// Apply initializes the validator and prebuilds resource metadata body
+func (factory *FilterFactory) Apply() error {
+ if err := factory.cfg.Validate(); err != nil {
+ return err
+ }
+
+ v, err := validator.NewValidator(validator.Config{Providers:
factory.cfg.Providers})
+ if err != nil {
+ return fmt.Errorf("init validator: %w", err)
+ }
+ metaPath := factory.cfg.ResourceMetadata.Path
+ // Minimal RFC9728 document: resource + authorization_servers
+ meta := struct {
+ AuthorizationServers []string `json:"authorization_servers"`
+ Resource string `json:"resource"`
+ }{
+ AuthorizationServers:
factory.cfg.ResourceMetadata.AuthorizationServers,
+ Resource: factory.cfg.ResourceMetadata.Resource,
+ }
+ body, err := json.Marshal(meta)
+ if err != nil {
+ return fmt.Errorf("marshal resource metadata: %w", err)
+ }
+ rules := factory.cfg.Rules
+
+ factory.state = &runtimeState{
+ validator: v,
+ metaPath: metaPath,
+ metaBody: body,
+ rules: rules,
+ }
+ return nil
+}
+
+// PrepareFilterChain appends the decode filter to chain
+func (factory *FilterFactory) PrepareFilterChain(ctx *contexthttp.HttpContext,
chain filter.FilterChain) error {
+ f := &Filter{state: factory.state}
+ chain.AppendDecodeFilters(f)
+ return nil
+}
+
+// Decode implements MCP auth flow
+func (f *Filter) Decode(hc *contexthttp.HttpContext) filter.FilterStatus {
+ path := hc.GetUrl()
+
+ // Well-known metadata endpoint
+ if path == f.state.metaPath {
+ logger.Debugf("[dubbo-go-pixiu] mcp auth filter meta path: %s",
path)
+ hc.SendLocalReply(http.StatusOK, f.state.metaBody)
+ return filter.Stop
+ }
+
+ // Resolve rule by framework route entry's cluster
+ var rule *Rule
+ if rEntry := hc.GetRouteEntry(); rEntry != nil {
+ for i := range f.state.rules {
+ if rEntry.Cluster == f.state.rules[i].Cluster {
+ rule = &f.state.rules[i]
+ break
+ }
+ }
+ }
+ if rule == nil {
+ return filter.Continue
+ }
+
+ // Extract bearer token
+ token := extractBearer(hc.GetHeader(constant.Authorization))
+ if token == "" {
+ f.unauthorized(hc, "invalid_token", "missing bearer token")
+ return filter.Stop
+ }
+
+ // Determine provider by token issuer (do not trust token issuer
blindly)
+ providerName, err := f.state.validator.ProviderByTokenIssuer(token)
+ if err != nil {
+ logger.Warnf("[dubbo-go-pixiu] provider lookup by token issuer
failed: %v", err)
+ f.unauthorized(hc, "invalid_token", "untrusted token issuer")
+ return filter.Stop
+ }
+
+ // Validate token using provider derived from issuer
+ _, err = f.state.validator.Validate(providerName, token)
+ if err != nil {
+ // Map validator.ValidationError if possible
+ verr := validator.ValidationError{}
+ code := "invalid_token"
+ msg := "invalid token"
+ if ok := asValidationError(err, &verr); ok {
+ if verr.Code != "" {
+ code = verr.Code
+ }
+ if verr.Message != "" {
+ msg = verr.Message
+ }
+ } else {
+ msg = err.Error()
+ }
+ f.unauthorized(hc, code, msg)
+ return filter.Stop
+ }
+
+ // remove Authorization header to avoid leaking token to downstream
services
+ hc.Request.Header.Del(constant.Authorization)
+
+ return filter.Continue
+}
+
+// unauthorized writes 401 with WWW-Authenticate including resource metadata
URL
+func (f *Filter) unauthorized(hc *contexthttp.HttpContext, code, desc string) {
+ // Build absolute metadata URL from request
+ scheme := "http"
+ if hc.Request.TLS != nil {
+ scheme = "https"
+ }
+ metaURL := scheme + constant.ProtocolSlash + hc.Request.Host +
f.state.metaPath
+ // Per RFC9728, include resource_metadata parameter; include OAuth
error fields
+ header := fmt.Sprintf("Bearer resource_metadata=\"%s\", error=\"%s\",
error_description=\"%s\"", metaURL, escapeParam(code), escapeParam(desc))
+ hc.AddHeader(constant.WWWAuthenticate, header)
+ writeOAuthError(hc, http.StatusUnauthorized, code, desc)
+}
+
+// writeOAuthError responds with JSON {error, error_description}
+func writeOAuthError(hc *contexthttp.HttpContext, status int, code, desc
string) {
+ resp := map[string]string{
+ "error": code,
+ "error_description": desc,
+ }
+ b, _ := json.Marshal(resp)
+ hc.SendLocalReply(status, b)
+}
+
+// extractBearer pulls the token from Authorization header value
+func extractBearer(v string) string {
+ if v == "" {
+ return ""
+ }
+ if len(v) < 7 {
+ return ""
+ }
+ if strings.HasPrefix(strings.ToLower(v), "bearer ") {
+ return strings.TrimSpace(v[7:])
+ }
+ return ""
+}
+
+// asValidationError performs a typed unwrap without importing errors in
callers
+func asValidationError(err error, target *validator.ValidationError) bool {
+ // local re-implementation to avoid importing errors here again
+ // keep it straightforward using standard errors.As
+ return errorsAs(err, target)
+}
+
+// errorsAs isolates usage to enable unit testing in this file easily
+var errorsAs = func(err error, target any) bool { return errors.As(err,
target) }
+
+// escapeParam performs minimal escaping suitable for WWW-Authenticate param
values
+func escapeParam(s string) string {
+ // Replace embedded quotes and backslashes per RFC6750 guidance
+ s = strings.ReplaceAll(s, "\\", "\\\\")
+ s = strings.ReplaceAll(s, "\"", "\\\"")
+ return s
+}
diff --git a/pkg/filter/auth/mcp/filter_test.go
b/pkg/filter/auth/mcp/filter_test.go
new file mode 100644
index 00000000..d974bff5
--- /dev/null
+++ b/pkg/filter/auth/mcp/filter_test.go
@@ -0,0 +1,357 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package mcp
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+import (
+ "github.com/lestrrat-go/jwx/v3/jwa"
+ "github.com/lestrrat-go/jwx/v3/jws"
+)
+
+import (
+ "github.com/apache/dubbo-go-pixiu/pkg/common/constant"
+ dgpfilter "github.com/apache/dubbo-go-pixiu/pkg/common/extension/filter"
+ contexthttp "github.com/apache/dubbo-go-pixiu/pkg/context/http"
+
"github.com/apache/dubbo-go-pixiu/pkg/filter/auth/mcp/internal/validator"
+ "github.com/apache/dubbo-go-pixiu/pkg/model"
+)
+
+//
=============================================================================
+// Test Helper Functions
+//
=============================================================================
+// writeTempJWKS creates a temporary JWKS file with empty keys for testing
+func writeTempJWKS(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ p := filepath.Join(dir, "jwks.json")
+ if err := os.WriteFile(p, []byte(`{"keys":[]}`), 0o644); err != nil {
+ t.Fatalf("write temp jwks: %v", err)
+ }
+ return "file://" + p
+}
+
+// writeHS256JWKS creates a temporary JWKS file with HS256 key for testing
+func writeHS256JWKS(t *testing.T, secret []byte) string {
+ t.Helper()
+ dir := t.TempDir()
+ p := filepath.Join(dir, "jwks.json")
+ k := base64.RawURLEncoding.EncodeToString(secret)
+ jwks := []byte(`{"keys":[{"kty":"oct","k":"` + k +
`","alg":"HS256","use":"sig","kid":"test"}]}`)
+ if err := os.WriteFile(p, jwks, 0o644); err != nil {
+ t.Fatalf("write jwks: %v", err)
+ }
+ return "file://" + p
+}
+
+// buildFactory creates a FilterFactory for testing with default configuration
+func buildFactory(t *testing.T) *FilterFactory {
+ t.Helper()
+ cfg := &Config{
+ ResourceMetadata: ResourceMetadata{
+ Path:
"/.well-known/oauth-protected-resource",
+ Resource: "https://mcp.example.com",
+ AuthorizationServers:
[]string{"https://auth.example.com/.well-known/oauth-authorization-server"},
+ },
+ Providers: []validator.Provider{
+ {
+ Name: "p1",
+ Issuer: "https://issuer.example.com",
+ Audience: "mcp-aud",
+ JWKS: writeTempJWKS(t),
+ },
+ },
+ Rules: []Rule{{Cluster: "protected-cluster"}},
+ }
+
+ ff := &FilterFactory{cfg: cfg}
+ if err := ff.Apply(); err != nil {
+ t.Fatalf("apply factory: %v", err)
+ }
+ return ff
+}
+
+// newHttpContext creates a test HTTP context with request and response
recorder
+func newHttpContext(method, url, host string) (*contexthttp.HttpContext,
*httptest.ResponseRecorder) {
+ req := httptest.NewRequest(method, url, nil)
+ if host != "" {
+ req.Host = host
+ }
+ rr := httptest.NewRecorder()
+ hc := &contexthttp.HttpContext{Request: req, Writer: rr}
+ return hc, rr
+}
+
+// makeHS256JWT creates a signed JWT token for testing
+func makeHS256JWT(t *testing.T, secret []byte, iss, aud, scope string) string {
+ t.Helper()
+ now := time.Now()
+ payload := map[string]any{
+ "iss": iss,
+ "aud": []string{aud},
+ "iat": now.Add(-time.Minute).Unix(),
+ "exp": now.Add(10 * time.Minute).Unix(),
+ }
+ if scope != "" {
+ payload["scope"] = scope
+ }
+ pb, _ := json.Marshal(payload)
+ hdr := jws.NewHeaders()
+ _ = hdr.Set(jws.AlgorithmKey, jwa.HS256())
+ _ = hdr.Set(jws.TypeKey, "JWT")
+ _ = hdr.Set(jws.KeyIDKey, "test")
+ signed, err := jws.Sign(pb, jws.WithKey(jwa.HS256(), secret,
jws.WithProtectedHeaders(hdr)))
+ if err != nil {
+ t.Fatalf("sign jws: %v", err)
+ }
+ return string(signed)
+}
+
+// buildFactoryWithHS256 creates a FilterFactory with HS256 JWKS for testing
+func buildFactoryWithHS256(t *testing.T, secret []byte) *FilterFactory {
+ t.Helper()
+ jwksURI := writeHS256JWKS(t, secret)
+
+ cfg := &Config{
+ ResourceMetadata: ResourceMetadata{
+ Path:
"/.well-known/oauth-protected-resource",
+ Resource: "https://mcp.example.com",
+ AuthorizationServers:
[]string{"https://auth.example.com/.well-known/oauth-authorization-server"},
+ },
+ Providers: []validator.Provider{{
+ Name: "p1",
+ Issuer: "https://issuer.example.com",
+ Audience: "mcp-aud",
+ JWKS: jwksURI,
+ }},
+ Rules: []Rule{{Cluster: "protected-cluster"}},
+ }
+
+ ff := &FilterFactory{cfg: cfg}
+ if err := ff.Apply(); err != nil {
+ t.Fatalf("apply factory: %v", err)
+ }
+ return ff
+}
+
+//
=============================================================================
+// Core MCP Filter Tests
+//
=============================================================================
+
+func TestMCPAuth_MetadataEndpoint(t *testing.T) {
+ ff := buildFactory(t)
+ hc, rr := newHttpContext(http.MethodGet, ff.state.metaPath,
"mcp.example.com")
+
+ chain := dgpfilter.NewDefaultFilterChain()
+ _ = ff.PrepareFilterChain(hc, chain)
+ chain.OnDecode(hc)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+ var got struct {
+ AuthorizationServers []string `json:"authorization_servers"`
+ Resource string `json:"resource"`
+ }
+ if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal body: %v", err)
+ }
+ if got.Resource != ff.cfg.ResourceMetadata.Resource {
+ t.Fatalf("resource = %q, want %q", got.Resource,
ff.cfg.ResourceMetadata.Resource)
+ }
+ if len(got.AuthorizationServers) !=
len(ff.cfg.ResourceMetadata.AuthorizationServers) {
+ t.Fatalf("authorization_servers len = %d, want %d",
len(got.AuthorizationServers),
len(ff.cfg.ResourceMetadata.AuthorizationServers))
+ }
+}
+
+func TestMCPAuth_MissingToken_Unauthorized(t *testing.T) {
+ ff := buildFactory(t)
+ // path matches rule, but no Authorization header
+ hc, rr := newHttpContext(http.MethodGet, "/api/hello",
"mcp.example.com")
+ // simulate router matched cluster for protected route
+ hc.RouteEntry(&model.RouteAction{Cluster: "protected-cluster"})
+
+ chain := dgpfilter.NewDefaultFilterChain()
+ _ = ff.PrepareFilterChain(hc, chain)
+ chain.OnDecode(hc)
+
+ if rr.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", rr.Code)
+ }
+ // WWW-Authenticate should include resource_metadata
+ wa := rr.Header().Get(constant.WWWAuthenticate)
+ if wa == "" {
+ t.Fatalf("missing WWW-Authenticate header")
+ }
+ // response body should be oauth error json
+ var body map[string]string
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal oauth error: %v", err)
+ }
+ if body["error"] == "" {
+ t.Fatalf("missing error in body")
+ }
+}
+
+//
=============================================================================
+// JWT Token Tests
+//
=============================================================================
+
+func TestMCPAuth_NoScopeEnforcement_AllowsRequest(t *testing.T) {
+ secret := []byte("secret123")
+ ff := buildFactoryWithHS256(t, secret)
+
+ // Token has only read scope but scope is not enforced
+ token := makeHS256JWT(t, secret, "https://issuer.example.com",
"mcp-aud", "read")
+ hc, rr := newHttpContext(http.MethodGet, "/api/hello",
"mcp.example.com")
+ hc.RouteEntry(&model.RouteAction{Cluster: "protected-cluster"})
+ hc.Request.Header.Set("Authorization", "Bearer "+token)
+
+ chain := dgpfilter.NewDefaultFilterChain()
+ _ = ff.PrepareFilterChain(hc, chain)
+ chain.OnDecode(hc)
+
+ if rr.Code != 0 && rr.Code != http.StatusOK { // no local reply expected
+ t.Fatalf("unexpected status = %d", rr.Code)
+ }
+ if v := hc.Request.Header.Get("Authorization"); v != "" {
+ t.Fatalf("Authorization header not removed on success")
+ }
+}
+
+func TestMCPAuth_Success_RemoveAuthorizationHeader(t *testing.T) {
+ secret := []byte("secret123")
+ ff := buildFactoryWithHS256(t, secret)
+
+ token := makeHS256JWT(t, secret, "https://issuer.example.com",
"mcp-aud", "read write")
+ hc, rr := newHttpContext(http.MethodGet, "/api/hello",
"mcp.example.com")
+ hc.RouteEntry(&model.RouteAction{Cluster: "protected-cluster"})
+ hc.Request.Header.Set("Authorization", "Bearer "+token)
+
+ chain := dgpfilter.NewDefaultFilterChain()
+ _ = ff.PrepareFilterChain(hc, chain)
+ chain.OnDecode(hc)
+
+ if rr.Code != 0 && rr.Code != http.StatusOK { // no local reply expected
+ t.Fatalf("unexpected status code: %d", rr.Code)
+ }
+ if v := hc.Request.Header.Get(constant.Authorization); v != "" {
+ t.Fatalf("Authorization header not removed on success")
+ }
+}
+
+//
=============================================================================
+// Unit Tests for asValidationError Function
+//
=============================================================================
+
+func TestAsValidationError_WithValidationError(t *testing.T) {
+ // Save original errorsAs function
+ originalErrorsAs := errorsAs
+ defer func() { errorsAs = originalErrorsAs }()
+
+ // Mock errorsAs to return true (simulating successful type assertion)
+ errorsAs = func(err error, target any) bool {
+ if verr, ok := target.(*validator.ValidationError); ok {
+ verr.Code = "test_code"
+ verr.Message = "test message"
+ }
+ return true
+ }
+
+ // Test the function
+ testErr := errors.New("some error")
+ var verr validator.ValidationError
+ result := asValidationError(testErr, &verr)
+
+ if !result {
+ t.Fatalf("expected asValidationError to return true")
+ }
+ if verr.Code != "test_code" {
+ t.Fatalf("expected Code to be 'test_code', got %q", verr.Code)
+ }
+ if verr.Message != "test message" {
+ t.Fatalf("expected Message to be 'test message', got %q",
verr.Message)
+ }
+}
+
+func TestAsValidationError_WithNonValidationError(t *testing.T) {
+ // Save original errorsAs function
+ originalErrorsAs := errorsAs
+ defer func() { errorsAs = originalErrorsAs }()
+
+ // Mock errorsAs to return false (simulating failed type assertion)
+ errorsAs = func(err error, target any) bool {
+ return false
+ }
+
+ // Test the function
+ testErr := errors.New("some error")
+ var verr validator.ValidationError
+ result := asValidationError(testErr, &verr)
+
+ if result {
+ t.Fatalf("expected asValidationError to return false")
+ }
+ // verr should remain unchanged
+ if verr.Code != "" {
+ t.Fatalf("expected Code to remain empty, got %q", verr.Code)
+ }
+ if verr.Message != "" {
+ t.Fatalf("expected Message to remain empty, got %q",
verr.Message)
+ }
+}
+
+func TestAsValidationError_MockBehavior(t *testing.T) {
+ // Save original errorsAs function
+ originalErrorsAs := errorsAs
+ defer func() { errorsAs = originalErrorsAs }()
+
+ // Mock errorsAs to always return true with specific values
+ errorsAs = func(err error, target any) bool {
+ if verr, ok := target.(*validator.ValidationError); ok {
+ verr.Code = "mocked_code"
+ verr.Message = "mocked message"
+ return true
+ }
+ return false
+ }
+
+ regularErr := errors.New("any error")
+ var target validator.ValidationError
+ result := asValidationError(regularErr, &target)
+
+ if !result {
+ t.Fatalf("expected mocked asValidationError to return true")
+ }
+ if target.Code != "mocked_code" {
+ t.Fatalf("expected mocked Code 'mocked_code', got %q",
target.Code)
+ }
+ if target.Message != "mocked message" {
+ t.Fatalf("expected mocked Message 'mocked message', got %q",
target.Message)
+ }
+}
diff --git a/pkg/filter/auth/mcp/internal/validator/config.go
b/pkg/filter/auth/mcp/internal/validator/config.go
new file mode 100644
index 00000000..58d50c82
--- /dev/null
+++ b/pkg/filter/auth/mcp/internal/validator/config.go
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package validator
+
+// Config represents the configuration for the JWT validator
+type Config struct {
+ // Providers is the list of JWT providers (using external Provider type)
+ Providers []Provider `yaml:"providers" json:"providers"`
+}
+
+// Provider represents a JWT provider configuration (internal use)
+type Provider struct {
+ // Name is the unique identifier for this provider
+ Name string `yaml:"name" json:"name" mapstructure:"name"`
+
+ // Issuer is the JWT issuer identifier
+ Issuer string `yaml:"issuer" json:"issuer" mapstructure:"issuer"`
+
+ // Audience is the single valid audience value
+ Audience string `yaml:"audience" json:"audience"
mapstructure:"audience"`
+
+ // JWKS is a single URI-like string that specifies how to obtain JWKS
+ // Supported schemes:
+ // - http(s)://... (remote JWKS, uses default timeout)
+ // - file:///abs/path/jwks.json (local file)
+ JWKS string `yaml:"jwks" json:"jwks" mapstructure:"jwks"`
+}
diff --git a/pkg/filter/auth/mcp/internal/validator/loader.go
b/pkg/filter/auth/mcp/internal/validator/loader.go
new file mode 100644
index 00000000..c8d5b003
--- /dev/null
+++ b/pkg/filter/auth/mcp/internal/validator/loader.go
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package validator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+)
+
+import (
+ "github.com/lestrrat-go/jwx/v3/jwk"
+)
+
+// JWKSLoader loads a jwk.Set for verification without performing network I/O
+// during request validation.
+type JWKSLoader interface {
+ Load(ctx context.Context) (jwk.Set, error)
+}
+
+// StaticLoader loads a pre-parsed jwk.Set
+type StaticLoader struct{ set jwk.Set }
+
+func newStaticLoaderFromBytes(data []byte) (JWKSLoader, error) {
+ keySet, err := jwk.Parse(data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse JWKS: %w", err)
+ }
+ return &StaticLoader{set: keySet}, nil
+}
+
+func newStaticLoaderFromFile(path string) (JWKSLoader, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read JWKS file %s: %w", path,
err)
+ }
+ return newStaticLoaderFromBytes(data)
+}
+
+func (l *StaticLoader) Load(_ context.Context) (jwk.Set, error) { return
l.set, nil }
+
+// HTTPLoader loads a jwk.Set from a prepared jwk.Cache by lookup only.
+type HTTPLoader struct {
+ uri string
+ cache *jwk.Cache
+}
+
+func newHTTPLoader(cache *jwk.Cache, uri string) JWKSLoader {
+ return &HTTPLoader{uri: uri, cache: cache}
+}
+
+func (r *HTTPLoader) Load(ctx context.Context) (jwk.Set, error) {
+ if r.cache == nil || r.uri == "" {
+ return nil, errors.New("remote loader not properly initialized")
+ }
+ return r.cache.Lookup(ctx, r.uri)
+}
diff --git a/pkg/filter/auth/mcp/internal/validator/validator.go
b/pkg/filter/auth/mcp/internal/validator/validator.go
new file mode 100644
index 00000000..40ba726b
--- /dev/null
+++ b/pkg/filter/auth/mcp/internal/validator/validator.go
@@ -0,0 +1,358 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package validator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "maps"
+ "net/http"
+ "net/url"
+ "slices"
+ "sync"
+ "time"
+)
+
+import (
+ "github.com/lestrrat-go/httprc/v3"
+
+ "github.com/lestrrat-go/jwx/v3/jwa"
+ "github.com/lestrrat-go/jwx/v3/jwk"
+ "github.com/lestrrat-go/jwx/v3/jwt"
+)
+
+import (
+ "github.com/apache/dubbo-go-pixiu/pkg/logger"
+)
+
+// Error code constants to avoid magic strings in responses.
+const (
+ ErrCodeInvalidProvider = "invalid_provider"
+ ErrCodeJWKS = "jwks_error"
+ ErrCodeInvalidToken = "invalid_token"
+ ErrCodeTokenExpired = "token_expired"
+ ErrCodeTokenNotYet = "token_not_yet_valid"
+)
+
+// TODO(validator): dynamic provider update (Add/Update/Remove) via atomic
snapshot or RWMutex
+
+const (
+ defaultAcceptableSkew = 60 * time.Second
+ defaultRemoteJWKSHTTPTimeout = 5 * time.Second
+)
+
+// allowedSignatureAlgorithms defines the whitelist of acceptable JWS
algorithms
+// for verifying access tokens. This mitigates algorithm confusion/downgrade.
+var allowedSignatureAlgorithms = map[string]struct{}{
+ // Asymmetric RSA (recommended)
+ "RS256": {},
+ "RS384": {},
+ "RS512": {},
+ // RSASSA-PSS (recommended)
+ "PS256": {},
+ "PS384": {},
+ "PS512": {},
+ // ECDSA (recommended)
+ "ES256": {},
+ "ES384": {},
+ "ES512": {},
+ // Edwards (modern)
+ "EdDSA": {},
+ // Optionally allow HMAC for compatible deployments. If your AS never
uses HMAC,
+ // remove HS* to further tighten security.
+ "HS256": {},
+ "HS384": {},
+ "HS512": {},
+}
+
+// filterKeySetByAllowedAlgorithms filters a JWK set to only include keys
+func filterKeySetByAllowedAlgorithms(source jwk.Set) (jwk.Set, int) {
+ if source == nil {
+ return nil, 0
+ }
+ filtered := jwk.NewSet()
+ kept := 0
+ for i := 0; i < source.Len(); i++ {
+ key, ok := source.Key(i)
+ if !ok {
+ continue
+ }
+ var algStr string
+ if err := key.Get("alg", &algStr); err != nil || algStr == "" {
+ // Try retrieving as jwa.SignatureAlgorithm, then
stringify
+ var sa jwa.SignatureAlgorithm
+ if err2 := key.Get("alg", &sa); err2 == nil {
+ algStr = sa.String()
+ }
+ }
+ if algStr == "" {
+ continue
+ }
+ if _, ok := allowedSignatureAlgorithms[algStr]; !ok {
+ continue
+ }
+ if err := filtered.AddKey(key); err == nil {
+ kept++
+ }
+ }
+ return filtered, kept
+}
+
+// Validator represents a JWT validator instance
+// Remote providers use jwk.Cache for JWKS auto-refresh; local providers use a
static key set.
+type Validator struct {
+ providers map[string]*providerInfo
+ mu sync.RWMutex
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// providerInfo contains the provider configuration and its JWKS loader
+type providerInfo struct {
+ config Provider
+ loader JWKSLoader
+}
+
+// ValidationError represents a JWT validation error
+type ValidationError struct {
+ Code string `json:"error"`
+ Message string `json:"error_description"`
+ Err error `json:"-"`
+}
+
+func (e ValidationError) Error() string {
+ return fmt.Sprintf("%s: %s", e.Code, e.Message)
+}
+
+// Unwrap exposes the underlying error for errors.Is / errors.As without
leaking to clients
+func (e ValidationError) Unwrap() error { return e.Err }
+
+// categorizeJWKSLoadError maps loader errors to standardized error
code/message
+func categorizeJWKSLoadError(err error) (code, msg string) {
+ if err == nil {
+ return ErrCodeJWKS, "jwks error"
+ }
+ return ErrCodeJWKS, err.Error()
+}
+
+// categorizeJWTError categorizes JWT validation errors into standard error
codes
+func categorizeJWTError(err error) (code, msg string) {
+ if err == nil {
+ return ErrCodeInvalidToken, "invalid token"
+ }
+ // jwx v3 exposes sentinel errors; Validate wraps with
fmt.Errorf("validation failed: %w", err)
+ if errors.Is(err, jwt.TokenExpiredError()) {
+ return ErrCodeTokenExpired, jwt.TokenExpiredError().Error()
+ }
+ if errors.Is(err, jwt.TokenNotYetValidError()) {
+ return ErrCodeTokenNotYet, jwt.TokenNotYetValidError().Error()
+ }
+ return ErrCodeInvalidToken, err.Error()
+}
+
+// NewValidator creates a new JWT validator instance
+func NewValidator(config Config) (*Validator, error) {
+ if len(config.Providers) == 0 {
+ return nil, errors.New("at least one provider must be
configured")
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ v := &Validator{
+ providers: make(map[string]*providerInfo),
+ ctx: ctx,
+ cancel: cancel,
+ }
+
+ // Initialize each provider
+ for _, provider := range config.Providers {
+ if err := v.addProvider(provider); err != nil {
+ cancel()
+ return nil, fmt.Errorf("failed to add provider %s: %w",
provider.Name, err)
+ }
+ }
+
+ return v, nil
+}
+
+// addProvider adds a provider to the validator
+func (v *Validator) addProvider(provider Provider) error {
+ entry := &providerInfo{config: provider}
+
+ loader, err := v.buildLoaderFromJWKS(provider.JWKS)
+ if err != nil {
+ logger.Errorf("[dubbo-go-pixiu] jwt validator build loader
failed: provider=%s jwks=%s err=%v", provider.Name, provider.JWKS, err)
+ return fmt.Errorf("failed to init JWKS loader: %w", err)
+ }
+ entry.loader = loader
+
+ v.mu.Lock()
+ v.providers[provider.Name] = entry
+ v.mu.Unlock()
+ return nil
+}
+
+// ProviderByTokenIssuer parses token without validation to extract the issuer
+// and returns the provider name configured for that issuer.
+func (v *Validator) ProviderByTokenIssuer(tokenString string) (string, error) {
+ // Parse token without validation to read claims
+ tok, err := jwt.Parse([]byte(tokenString), jwt.WithValidate(false),
jwt.WithVerify(false))
+ if err != nil {
+ return "", fmt.Errorf("failed to parse token for issuer
extraction: %w", err)
+ }
+
+ var iss string
+ if err := tok.Get("iss", &iss); err != nil || iss == "" {
+ // fallback to Issuer() accessor (returns issuer string and ok
bool)
+ if iss2, ok := tok.Issuer(); ok {
+ iss = iss2
+ }
+ if iss == "" {
+ return "", fmt.Errorf("issuer claim not found in token")
+ }
+ }
+
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+ for name, entry := range v.providers {
+ if entry.config.Issuer == iss {
+ return name, nil
+ }
+ }
+ return "", fmt.Errorf("no provider found for issuer %s", iss)
+}
+
+// buildLoaderFromJWKS parses provider.JWKS and constructs an appropriate
loader.
+func (v *Validator) buildLoaderFromJWKS(jwks string) (JWKSLoader, error) {
+ if jwks == "" {
+ return nil, errors.New("jwks must be specified")
+ }
+ u, err := url.Parse(jwks)
+ if err != nil {
+ return nil, fmt.Errorf("invalid jwks uri: %w", err)
+ }
+ switch u.Scheme {
+ case "http", "https":
+ timeout := defaultRemoteJWKSHTTPTimeout
+ // Build http client with resolved timeout
+ httpClient := &http.Client{Timeout: timeout}
+ client := httprc.NewClient(httprc.WithHTTPClient(httpClient))
+ c, err := jwk.NewCache(v.ctx, client)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create jwk cache:
%w", err)
+ }
+ if err := c.Register(v.ctx, jwks); err != nil {
+ return nil, fmt.Errorf("failed to register JWKS uri %s:
%w", jwks, err)
+ }
+ return newHTTPLoader(c, jwks), nil
+ case "file":
+ return newStaticLoaderFromFile(u.Path)
+ default:
+ return nil, fmt.Errorf("unsupported jwks scheme: %s", u.Scheme)
+ }
+}
+
+// Validate validates a JWT token using the specified provider
+func (v *Validator) Validate(providerName, tokenString string) (jwt.Token,
error) {
+ v.mu.RLock()
+ provider, exists := v.providers[providerName]
+ v.mu.RUnlock()
+
+ if !exists {
+ logger.Warnf("[dubbo-go-pixiu] jwt validator provider not
found: name=%s", providerName)
+ return nil, ValidationError{Code: ErrCodeInvalidProvider,
Message: fmt.Sprintf("provider '%s' not found", providerName)}
+ }
+
+ // Resolve key set via loader (no network IO in validation path)
+ var (
+ keySet jwk.Set
+ err error
+ )
+ keySet, err = provider.loader.Load(v.ctx)
+ if err != nil {
+ code, msg := categorizeJWKSLoadError(err)
+ logger.Errorf("[dubbo-go-pixiu] jwt validator jwks load failed:
provider=%s code=%s err=%v", providerName, code, err)
+ return nil, ValidationError{Code: code, Message: msg, Err: err}
+ }
+ if keySet == nil {
+ logger.Warnf("[dubbo-go-pixiu] jwt validator jwks not
available: provider=%s", providerName)
+ return nil, ValidationError{Code: ErrCodeJWKS, Message: "no
JWKS available for provider"}
+ }
+
+ // Enforce algorithm whitelist by filtering the key set. Tokens signed
with algorithms
+ // outside this list will be rejected because no matching key remains.
+ filteredKeySet, kept := filterKeySetByAllowedAlgorithms(keySet)
+ if kept == 0 {
+ logger.Warnf("[dubbo-go-pixiu] jwt validator no acceptable jwk
after alg filter: provider=%s", providerName)
+ return nil, ValidationError{Code: ErrCodeJWKS, Message: "no
acceptable JWKs with allowed algorithms"}
+ }
+
+ // Build parse options
+ opts := make([]jwt.ParseOption, 0, 5)
+ opts = append(opts,
+ jwt.WithKeySet(filteredKeySet),
+ jwt.WithIssuer(provider.config.Issuer),
+ jwt.WithValidate(true),
+ jwt.WithAcceptableSkew(defaultAcceptableSkew),
+ )
+ if provider.config.Audience != "" {
+ opts = append(opts, jwt.WithAudience(provider.config.Audience))
+ }
+
+ // Parse and validate the token (iss/exp/nbf etc.)
+ token, err := jwt.Parse([]byte(tokenString), opts...)
+ if err != nil {
+ code, msg := categorizeJWTError(err)
+ logger.Warnf("[dubbo-go-pixiu] jwt validator token validate
failed: provider=%s iss=%s code=%s err=%v", providerName,
provider.config.Issuer, code, err)
+ return nil, ValidationError{Code: code, Message: msg, Err: err}
+ }
+
+ return token, nil
+}
+
+// Provider returns the provider configuration by name
+func (v *Validator) Provider(name string) (*Provider, bool) {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+ p, ok := v.providers[name]
+ if !ok {
+ return nil, false
+ }
+ cp := p.config
+ return &cp, true
+}
+
+// Providers returns the list of provider names
+func (v *Validator) Providers() []string {
+ v.mu.RLock()
+ defer v.mu.RUnlock()
+
+ // Return sorted names for consistency, using maps.Keys + slices.Sorted
+ names := slices.Sorted(maps.Keys(v.providers))
+
+ return names
+}
+
+// Close shuts down background resources
+func (v *Validator) Close() error {
+ if v.cancel != nil {
+ logger.Infof("[dubbo-go-pixiu] jwt validator shutting down")
+ v.cancel()
+ }
+ return nil
+}
diff --git a/pkg/filter/auth/mcp/internal/validator/validator_test.go
b/pkg/filter/auth/mcp/internal/validator/validator_test.go
new file mode 100644
index 00000000..7f2f3a29
--- /dev/null
+++ b/pkg/filter/auth/mcp/internal/validator/validator_test.go
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package validator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+import (
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func tempJWKSFileURL(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ p := filepath.Join(dir, "jwks.json")
+ if err := os.WriteFile(p, []byte(`{"keys":[]}`), 0644); err != nil {
+ t.Fatalf("write temp jwks: %v", err)
+ }
+ return "file://" + p
+}
+
+func TestNewValidator(t *testing.T) {
+ tests := []struct {
+ name string
+ config Config
+ wantErr bool
+ }{
+ {
+ name: "empty providers",
+ config: Config{Providers: []Provider{}},
+ wantErr: true,
+ },
+ {
+ name: "valid provider with local JWKS",
+ config: Config{
+ Providers: []Provider{
+ {
+ Name: "test-provider",
+ Issuer:
"https://test.issuer.com",
+ Audience: "test-audience",
+ JWKS: tempJWKSFileURL(t),
+ },
+ },
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ validator, err := NewValidator(tt.config)
+ if tt.wantErr {
+ assert.Error(t, err)
+ assert.Nil(t, validator)
+ } else {
+ assert.NoError(t, err)
+ assert.NotNil(t, validator)
+ }
+ })
+ }
+}
+
+func TestValidator_ListProviders(t *testing.T) {
+ config := Config{
+ Providers: []Provider{
+ {
+ Name: "provider1",
+ Issuer: "https://issuer1.com",
+ Audience: "audience1",
+ JWKS: tempJWKSFileURL(t),
+ },
+ {
+ Name: "provider2",
+ Issuer: "https://issuer2.com",
+ Audience: "audience2",
+ JWKS: tempJWKSFileURL(t),
+ },
+ },
+ }
+
+ validator, err := NewValidator(config)
+ require.NoError(t, err)
+
+ providers := validator.Providers()
+ assert.Len(t, providers, 2)
+ assert.Contains(t, providers, "provider1")
+ assert.Contains(t, providers, "provider2")
+}
+
+func TestValidator_GetProvider(t *testing.T) {
+ config := Config{
+ Providers: []Provider{
+ {
+ Name: "test-provider",
+ Issuer: "https://test.issuer.com",
+ Audience: "test-audience",
+ JWKS: tempJWKSFileURL(t),
+ },
+ },
+ }
+
+ validator, err := NewValidator(config)
+ require.NoError(t, err)
+
+ // Test existing provider
+ provider, exists := validator.Provider("test-provider")
+ assert.True(t, exists)
+ assert.Equal(t, "test-provider", provider.Name)
+ assert.Equal(t, "https://test.issuer.com", provider.Issuer)
+
+ // Test non-existing provider
+ provider, exists = validator.Provider("non-existing")
+ assert.False(t, exists)
+ assert.Nil(t, provider)
+}
+
+func TestValidationError_Error(t *testing.T) {
+ err := ValidationError{
+ Code: "invalid_token",
+ Message: "token is expired",
+ }
+
+ expected := "invalid_token: token is expired"
+ assert.Equal(t, expected, err.Error())
+}
+
+func TestProvider_Configuration(t *testing.T) {
+ config := Config{
+ Providers: []Provider{
+ {
+ Name: "test-provider",
+ Issuer: "https://test.issuer.com",
+ Audience: "test-audience",
+ JWKS: tempJWKSFileURL(t),
+ },
+ },
+ }
+
+ validator, err := NewValidator(config)
+ require.NoError(t, err)
+
+ provider, exists := validator.Provider("test-provider")
+ assert.True(t, exists)
+ assert.Equal(t, "test-provider", provider.Name)
+ assert.Equal(t, "https://test.issuer.com", provider.Issuer)
+ assert.Equal(t, "test-audience", provider.Audience)
+}
diff --git a/pkg/pluginregistry/registry.go b/pkg/pluginregistry/registry.go
index 2d114367..9d7c181c 100644
--- a/pkg/pluginregistry/registry.go
+++ b/pkg/pluginregistry/registry.go
@@ -30,6 +30,7 @@ import (
_ "github.com/apache/dubbo-go-pixiu/pkg/cluster/retry/noretry"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/accesslog"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/auth/jwt"
+ _ "github.com/apache/dubbo-go-pixiu/pkg/filter/auth/mcp"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/authority"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/cors"
_ "github.com/apache/dubbo-go-pixiu/pkg/filter/csrf"