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"

Reply via email to