diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index 5c037f89e4..fa4d26dabe 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -177,6 +177,8 @@ type MCPBackendAPIKey struct { } // MCPRouteSecurityPolicy defines the security policy for a MCPRoute. +// +// +kubebuilder:validation:XValidation:rule="!has(self.authorization) || has(self.oauth)",message="oauth must be configured when authorization is set" type MCPRouteSecurityPolicy struct { // OAuth defines the configuration for the MCP spec compatible OAuth authentication. // @@ -192,6 +194,11 @@ type MCPRouteSecurityPolicy struct { // // +optional ExtAuth *egv1a1.ExtAuth `json:"extAuth,omitempty"` + + // Authorization defines the configuration for the MCP spec compatible authorization. + // + // +optional + Authorization *MCPRouteAuthorization `json:"authorization,omitempty"` } // MCPRouteOAuth defines a MCP spec compatible OAuth authentication configuration for a MCPRoute. @@ -227,6 +234,87 @@ type MCPRouteOAuth struct { ProtectedResourceMetadata ProtectedResourceMetadata `json:"protectedResourceMetadata"` } +// MCPRouteAuthorization defines the authorization configuration for a MCPRoute. +type MCPRouteAuthorization struct { + // Rules defines a list of authorization rules. + // + // Requests that match any rule and satisfy the rule's conditions will be allowed. + // Requests that do not match any rule or fail to satisfy the matched rule's conditions will be denied. + // If no rules are defined, all requests will be denied. + // + // +optional + Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"` +} + +// MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec. +// Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling +type MCPRouteAuthorizationRule struct { + // Source defines the authorization source for this rule. + // + // +kubebuilder:validation:Required + Source MCPAuthorizationSource `json:"source"` + + // Target defines the authorization target for this rule. + // + // +kubebuilder:validation:Required + Target MCPAuthorizationTarget `json:"target"` +} + +// MCPAuthorizationTarget defines the target of an authorization rule. +type MCPAuthorizationTarget struct { + // Tools defines the list of tools this rule applies to. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + Tools []ToolCall `json:"tools"` +} + +// MCPAuthorizationSource defines the source of an authorization rule. +type MCPAuthorizationSource struct { + // JWTSource defines the JWT scopes required for this rule to match. + // + // +kubebuilder:validation:Required + JWTSource JWTSource `json:"jwtSource"` + + // TODO: JWTSource can be optional in the future when we support more source types. +} + +// JWTSource defines the MCP authorization source for JWT tokens. +type JWTSource struct { + // Scopes defines the list of JWT scopes required for the rule. + // If multiple scopes are specified, all scopes must be present in the JWT for the rule to match. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + Scopes []egv1a1.JWTScope `json:"scopes"` + + // TODO : we can add more fields in the future, e.g., audiences, claims, etc. +} + +// ToolCall represents a tool call in the MCP authorization target. +type ToolCall struct { + // BackendName is the name of the backend this tool belongs to. + // + // +kubebuilder:validation:Required + BackendName string `json:"backendName"` + + // ToolName is the name of the tool. + // + // +kubebuilder:validation:Required + ToolName string `json:"toolName"` + + // Arguments is a CEL expression that must evaluate to true for the rule to match. + // The expression is evaluated with a single variable "args" bound to the tool call arguments as a dynamic object. + // Guard against missing fields with null checks (e.g., args["foo"] != null && args["foo"]["bar"] == "val"). + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxLength=4096 + // +optional + Arguments *string `json:"arguments,omitempty"` +} + // JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HTTPS endpoint or from a local source. // +kubebuilder:validation:XValidation:rule="has(self.remoteJWKS) || has(self.localJWKS)", message="either remoteJWKS or localJWKS must be specified." // +kubebuilder:validation:XValidation:rule="!(has(self.remoteJWKS) && has(self.localJWKS))", message="remoteJWKS and localJWKS cannot both be specified." diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d2d42ae93a..e98c8872b7 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -915,6 +915,26 @@ func (in *JWKS) DeepCopy() *JWKS { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JWTSource) DeepCopyInto(out *JWTSource) { + *out = *in + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]apiv1alpha1.JWTScope, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTSource. +func (in *JWTSource) DeepCopy() *JWTSource { + if in == nil { + return nil + } + out := new(JWTSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LLMRequestCost) DeepCopyInto(out *LLMRequestCost) { *out = *in @@ -935,6 +955,44 @@ func (in *LLMRequestCost) DeepCopy() *LLMRequestCost { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPAuthorizationSource) DeepCopyInto(out *MCPAuthorizationSource) { + *out = *in + in.JWTSource.DeepCopyInto(&out.JWTSource) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthorizationSource. +func (in *MCPAuthorizationSource) DeepCopy() *MCPAuthorizationSource { + if in == nil { + return nil + } + out := new(MCPAuthorizationSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPAuthorizationTarget) DeepCopyInto(out *MCPAuthorizationTarget) { + *out = *in + if in.Tools != nil { + in, out := &in.Tools, &out.Tools + *out = make([]ToolCall, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthorizationTarget. +func (in *MCPAuthorizationTarget) DeepCopy() *MCPAuthorizationTarget { + if in == nil { + return nil + } + out := new(MCPAuthorizationTarget) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPBackendAPIKey) DeepCopyInto(out *MCPBackendAPIKey) { *out = *in @@ -1012,6 +1070,45 @@ func (in *MCPRoute) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRouteAuthorization) DeepCopyInto(out *MCPRouteAuthorization) { + *out = *in + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]MCPRouteAuthorizationRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRouteAuthorization. +func (in *MCPRouteAuthorization) DeepCopy() *MCPRouteAuthorization { + if in == nil { + return nil + } + out := new(MCPRouteAuthorization) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPRouteAuthorizationRule) DeepCopyInto(out *MCPRouteAuthorizationRule) { + *out = *in + in.Source.DeepCopyInto(&out.Source) + in.Target.DeepCopyInto(&out.Target) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRouteAuthorizationRule. +func (in *MCPRouteAuthorizationRule) DeepCopy() *MCPRouteAuthorizationRule { + if in == nil { + return nil + } + out := new(MCPRouteAuthorizationRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPRouteBackendRef) DeepCopyInto(out *MCPRouteBackendRef) { *out = *in @@ -1119,6 +1216,11 @@ func (in *MCPRouteSecurityPolicy) DeepCopyInto(out *MCPRouteSecurityPolicy) { *out = new(apiv1alpha1.ExtAuth) (*in).DeepCopyInto(*out) } + if in.Authorization != nil { + in, out := &in.Authorization, &out.Authorization + *out = new(MCPRouteAuthorization) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRouteSecurityPolicy. @@ -1264,6 +1366,26 @@ func (in *ProtectedResourceMetadata) DeepCopy() *ProtectedResourceMetadata { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolCall) DeepCopyInto(out *ToolCall) { + *out = *in + if in.Arguments != nil { + in, out := &in.Arguments, &out.Arguments + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolCall. +func (in *ToolCall) DeepCopy() *ToolCall { + if in == nil { + return nil + } + out := new(ToolCall) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VersionedAPISchema) DeepCopyInto(out *VersionedAPISchema) { *out = *in diff --git a/go.mod b/go.mod index 8f0f9b7e8e..efa4bb04cc 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/envoyproxy/go-control-plane v0.14.0 github.com/envoyproxy/go-control-plane/envoy v1.36.0 github.com/go-logr/logr v1.4.3 - github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/cel-go v0.26.1 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.3.0 @@ -157,7 +157,6 @@ require ( github.com/go-openapi/validate v0.25.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect diff --git a/go.sum b/go.sum index d650f618c1..1262163e38 100644 --- a/go.sum +++ b/go.sum @@ -233,8 +233,6 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index b1aa5bcb58..8873762184 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -471,6 +471,43 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { mcpRoute.Backends = append( mcpRoute.Backends, mcpBackend) } + // Add authorization configuration for the route. + if route.Spec.SecurityPolicy != nil && route.Spec.SecurityPolicy.Authorization != nil { + authorization := route.Spec.SecurityPolicy.Authorization + mcpRoute.Authorization = &filterapi.MCPRouteAuthorization{} + + if route.Spec.SecurityPolicy.OAuth != nil { + mcpRoute.Authorization.ResourceMetadataURL = buildResourceMetadataURL(&route.Spec.SecurityPolicy.OAuth.ProtectedResourceMetadata) + } + + for _, rule := range authorization.Rules { + scopes := make([]string, len(rule.Source.JWTSource.Scopes)) + for i, scope := range rule.Source.JWTSource.Scopes { + scopes[i] = string(scope) + } + + tools := make([]filterapi.ToolCall, len(rule.Target.Tools)) + for i, tool := range rule.Target.Tools { + tools[i] = filterapi.ToolCall{ + BackendName: tool.BackendName, + ToolName: tool.ToolName, + Arguments: tool.Arguments, + } + } + + mcpRule := filterapi.MCPRouteAuthorizationRule{ + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{ + Scopes: scopes, + }, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: tools, + }, + } + mcpRoute.Authorization.Rules = append(mcpRoute.Authorization.Rules, mcpRule) + } + } mc.Routes = append(mc.Routes, mcpRoute) } return mc diff --git a/internal/controller/mcp_route_security_policy.go b/internal/controller/mcp_route_security_policy.go index 1d2685c93a..82f7d2be25 100644 --- a/internal/controller/mcp_route_security_policy.go +++ b/internal/controller/mcp_route_security_policy.go @@ -281,13 +281,11 @@ func (c *MCPRouteController) ensureOAuthProtectedResourceMetadataBTP(ctx context return nil } -// buildWWWAuthenticateHeaderValue constructs the WWW-Authenticate header value according to RFC 9728. +// buildResourceMetadataURL constructs the OAuth protected resource metadata URL using the resource identifier. // References: // * https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location // * https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response -func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata) string { - // Build resource metadata URL using RFC 8414 compliant pattern. - // Extract base URL and path from resource identifier. +func buildResourceMetadataURL(metadata *aigv1a1.ProtectedResourceMetadata) string { resourceURL := strings.TrimSuffix(metadata.Resource, "/") var ( @@ -316,7 +314,15 @@ func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata // they should honor hte value returned here. // We can't expose these resource at the root, because there may be multiple MCP routes with different OAuth settings, so we need // to rely on clients properly implementing the spec and using this value returned in the header. - resourceMetadataURL := fmt.Sprintf("%s%s%s", baseURL, oauthWellKnownProtectedResourceMetadataPath, pathComponent) + return fmt.Sprintf("%s%s%s", baseURL, oauthWellKnownProtectedResourceMetadataPath, pathComponent) +} + +// buildWWWAuthenticateHeaderValue constructs the WWW-Authenticate header value according to RFC 9728. +// References: +// * https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location +// * https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response +func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata) string { + resourceMetadataURL := buildResourceMetadataURL(metadata) // Build the basic Bearer challenge. headerValue := `Bearer error="invalid_request", error_description="No access token was provided in this request"` diff --git a/internal/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index 852831f592..49a4c91321 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -27,6 +27,9 @@ type MCPRoute struct { // Backends is the list of backends that this route can route to. Backends []MCPBackend `json:"backends"` + + // Authorization is the authorization configuration for this route. + Authorization *MCPRouteAuthorization `json:"authorization,omitempty"` } // MCPBackend is the MCP backend configuration. @@ -58,3 +61,54 @@ type MCPToolSelector struct { // MCPRouteName is the name of the MCP route. type MCPRouteName = string + +// MCPRouteAuthorization defines the authorization configuration for a MCPRoute. +type MCPRouteAuthorization struct { + // Rules defines a list of authorization rules. + // Requests that match any rule and satisfy the rule's conditions will be allowed. + // Requests that do not match any rule or fail to satisfy the matched rule's conditions will be denied. + // If no rules are defined, all requests will be denied. + Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"` + + // ResourceMetadataURL is the URI of the OAuth Protected Resource Metadata document for this route. + // This is used to populate the WWW-Authenticate header when scope-based authorization fails. + ResourceMetadataURL string `json:"resourceMetadataURL,omitempty"` +} + +// MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec. +// Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling +type MCPRouteAuthorizationRule struct { + // Source defines the authorization source for this rule. + Source MCPAuthorizationSource `json:"source"` + + // Target defines the authorization target for this rule. + Target MCPAuthorizationTarget `json:"target"` +} + +type MCPAuthorizationTarget struct { + // Tools defines the list of tools this rule applies to. + Tools []ToolCall `json:"tools"` +} + +type MCPAuthorizationSource struct { + // JWTSource defines the JWT scopes required for this rule to match. + JWTSource JWTSource `json:"jwtSource,omitempty"` +} + +type JWTSource struct { + // Scopes defines the list of JWT scopes required for the rule. + // If multiple scopes are specified, all scopes must be present in the JWT for the rule to match. + Scopes []string `json:"scopes"` +} + +type ToolCall struct { + // BackendName is the name of the backend this tool belongs to. + BackendName string `json:"backendName"` + + // ToolName is the name of the tool. + ToolName string `json:"toolName"` + + // Arguments is a CEL expression evaluated against the tool call arguments map. + // The expression must evaluate to true for the rule to apply. + Arguments *string `json:"arguments,omitempty"` +} diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go new file mode 100644 index 0000000000..d1d7e6d769 --- /dev/null +++ b/internal/mcpproxy/authorization.go @@ -0,0 +1,239 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package mcpproxy + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/envoyproxy/ai-gateway/internal/filterapi" +) + +type compiledAuthorization struct { + ResourceMetadataURL string + Rules []compiledAuthorizationRule +} + +type compiledAuthorizationRule struct { + Source filterapi.MCPAuthorizationSource + Target []compiledToolCall +} + +type compiledToolCall struct { + BackendName string + ToolName string + Expression string + program cel.Program +} + +// compileAuthorization compiles the MCPRouteAuthorization into a compiledAuthorization for efficient CEL evaluation. +func compileAuthorization(auth *filterapi.MCPRouteAuthorization) (*compiledAuthorization, error) { + if auth == nil { + return nil, nil + } + + env, err := cel.NewEnv( + cel.Variable("args", cel.DynType), + cel.OptionalTypes(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create CEL environment: %w", err) + } + + compiled := &compiledAuthorization{ + ResourceMetadataURL: auth.ResourceMetadataURL, + } + + for _, rule := range auth.Rules { + cr := compiledAuthorizationRule{ + Source: rule.Source, + } + for _, tool := range rule.Target.Tools { + ct := compiledToolCall{ + BackendName: tool.BackendName, + ToolName: tool.ToolName, + } + if tool.Arguments != nil && strings.TrimSpace(*tool.Arguments) != "" { + expr := strings.TrimSpace(*tool.Arguments) + ast, issues := env.Compile(expr) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("failed to compile arguments CEL for tool %s/%s: %w", tool.BackendName, tool.ToolName, issues.Err()) + } + program, err := env.Program(ast, cel.CostLimit(10000), cel.EvalOptions(cel.OptOptimize)) + if err != nil { + return nil, fmt.Errorf("failed to build arguments CEL program for tool %s/%s: %w", tool.BackendName, tool.ToolName, err) + } + ct.Expression = expr + ct.program = program + } + cr.Target = append(cr.Target, ct) + } + compiled.Rules = append(compiled.Rules, cr) + } + + return compiled, nil +} + +// authorizeRequest authorizes the request based on the given MCPRouteAuthorization configuration. + +func (m *MCPProxy) authorizeRequest(authorization *compiledAuthorization, headers http.Header, backendName, toolName string, arguments any) (bool, []string) { + if authorization == nil { + return true, nil + } + + // If no rules are defined, deny all requests. + if len(authorization.Rules) == 0 { + return false, nil + } + + // If the rules are defined, a valid bearer token is required. + token, err := bearerToken(headers.Get("Authorization")) + // This is just a sanity check. The actual JWT verification is performed by Envoy before reaching here, and the token + // should always be present and valid. + if err != nil { + m.l.Info("missing or invalid bearer token", slog.String("error", err.Error())) + return false, nil + } + + claims := jwt.MapClaims{} + // JWT verification is performed by Envoy before reaching here. So we only need to parse the token without verification. + if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil { + m.l.Info("failed to parse JWT token", slog.String("error", err.Error())) + return false, nil + } + + scopeSet := sets.New(extractScopes(claims)...) + var requiredScopesForChallenge []string + + for _, rule := range authorization.Rules { + if !m.toolMatches(backendName, toolName, rule.Target, arguments) { + continue + } + + requiredScopes := rule.Source.JWTSource.Scopes + if scopesSatisfied(scopeSet, requiredScopes) { + return true, nil + } + + // Keep track of the smallest set of required scopes for challenge. + if len(requiredScopesForChallenge) == 0 || len(requiredScopes) < len(requiredScopesForChallenge) { + requiredScopesForChallenge = requiredScopes + } + } + + return false, requiredScopesForChallenge +} + +func bearerToken(header string) (string, error) { + if header == "" { + return "", errors.New("missing Authorization header") + } + + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "", errors.New("invalid Authorization header") + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return "", errors.New("missing bearer token") + } + return token, nil +} + +func extractScopes(claims jwt.MapClaims) []string { + raw, ok := claims["scope"] + if !ok { + return nil + } + + switch v := raw.(type) { + case string: + return strings.Fields(v) + case []string: + return v + case []interface{}: + scopes := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + scopes = append(scopes, s) + } + } + return scopes + default: + return nil + } +} + +func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToolCall, args any) bool { + if len(tools) == 0 { + return true + } + + for _, t := range tools { + if t.BackendName != backendName || t.ToolName != toolName { + continue + } + if t.program == nil { + return true + } + + result, _, err := t.program.Eval(map[string]any{"args": args}) + if err != nil { + m.l.Error("failed to evaluate arguments CEL", slog.String("backend", t.BackendName), slog.String("tool", t.ToolName), slog.String("error", err.Error())) + continue + } + + switch v := result.Value().(type) { + case bool: + if v { + return true + } + case types.Bool: + if bool(v) { + return true + } + default: + m.l.Error("arguments CEL did not return a boolean", slog.String("backend", t.BackendName), slog.String("tool", t.ToolName), slog.String("expression", t.Expression)) + } + } + // If no matching tool entry or no arguments matched, fail. + return false +} + +func scopesSatisfied(have sets.Set[string], required []string) bool { + if len(required) == 0 { + return true + } + // All required scopes must be present for authorization to succeed. + for _, scope := range required { + if _, ok := have[scope]; !ok { + return false + } + } + return true +} + +// buildInsufficientScopeHeader builds the WWW-Authenticate header value for insufficient scope errors. +// Reference: https://mcp.mintlify.app/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors +func buildInsufficientScopeHeader(scopes []string, resourceMetadata string) string { + parts := []string{`Bearer error="insufficient_scope"`} + parts = append(parts, fmt.Sprintf(`scope="%s"`, strings.Join(scopes, " "))) + if resourceMetadata != "" { + parts = append(parts, fmt.Sprintf(`resource_metadata="%s"`, resourceMetadata)) + } + parts = append(parts, `error_description="The token is missing required scopes"`) + + return strings.Join(parts, ", ") +} diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go new file mode 100644 index 0000000000..11a3512281 --- /dev/null +++ b/internal/mcpproxy/authorization_test.go @@ -0,0 +1,472 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package mcpproxy + +import ( + "io" + "log/slog" + "net/http" + "reflect" + "testing" + + "github.com/golang-jwt/jwt/v5" + + "github.com/envoyproxy/ai-gateway/internal/filterapi" +) + +func strPtr(s string) *string { return &s } + +func TestAuthorizeRequest(t *testing.T) { + makeToken := func(scopes ...string) string { + claims := jwt.MapClaims{} + if len(scopes) > 0 { + claims["scope"] = scopes + } + token := jwt.NewWithClaims(jwt.SigningMethodNone, claims) + signed, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType) + return signed + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + proxy := &MCPProxy{l: logger} + + tests := []struct { + name string + auth *filterapi.MCPRouteAuthorization + header string + backendName string + toolName string + args map[string]any + expectError bool + expectAllowed bool + expectScopes []string + }{ + { + name: "matching tool and scope", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read", "write"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read", "write"), + backendName: "backend1", + toolName: "tool1", + expectAllowed: true, + expectScopes: nil, + }, + { + name: "matching tool scope and arguments CEL", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args.mode in ["fast", "slow"] && args.user.matches("u-[0-9]+") && args.debug == true`), + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "mode": "fast", + "user": "u-123", + "debug": true, + }, + expectAllowed: true, + expectScopes: nil, + }, + { + name: "numeric argument matches via CEL", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: strPtr(`int(args.count) >= 40 && int(args.count) < 50`), + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{"count": 42}, + expectAllowed: true, + expectScopes: nil, + }, + { + name: "object argument can be matched via CEL safe navigation", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args["payload"] != null && args["payload"]["kind"] == "test" && args["payload"]["value"] == 123`), + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "payload": map[string]any{ + "kind": "test", + "value": 123, + }, + }, + expectAllowed: true, + expectScopes: nil, + }, + { + name: "matching tool but insufficient scopes not allowed", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read", "write"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + expectScopes: []string{"read", "write"}, + }, + { + name: "arguments CEL mismatch denied", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args.mode in ["fast", "slow"]`), + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "mode": "other", + }, + expectAllowed: false, + expectScopes: nil, + }, + { + name: "arguments CEL failed evaluation denies", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args.nonExistingField in ["fast", "slow"]`), + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "mode": "other", + }, + expectAllowed: false, + expectScopes: nil, + }, + { + name: "arguments CEL returns non-boolean denies", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args.mode`), + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "mode": "other", + }, + expectAllowed: false, + expectScopes: nil, + }, + { + name: "arguments invalid CEL denies", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: strPtr(`invalid syntax here`), + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "mode": "other", + }, + expectError: true, + expectAllowed: false, + expectScopes: nil, + }, + { + name: "missing argument denies when required", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args["mode"] == "fast"`), + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{}, + expectAllowed: false, + expectScopes: nil, + }, + { + name: "no matching rule falls back to default deny - tool mismatch", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read", "write"), + backendName: "backend1", + toolName: "other-tool", + expectAllowed: false, + expectScopes: nil, + }, + { + name: "no matching rule falls back to default deny - scope mismatch", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + }, + }, + }, + header: "Bearer " + makeToken("foo", "bar"), + backendName: "backend1", + toolName: "other-tool", + expectAllowed: false, + expectScopes: nil, + }, + { + name: "no rules falls back to default deny", + auth: &filterapi.MCPRouteAuthorization{}, + header: "", + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + expectScopes: nil, + }, + { + name: "no bearer token not allowed when rules exist", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + }, + }, + }, + header: "", + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + expectScopes: nil, + }, + { + name: "invalid bearer token not allowed when rules exist", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + }, + }, + }, + header: "Bearer invalid.token.here", + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + expectScopes: nil, + }, + { + name: "selects smallest required scope set when multiple rules match", + auth: &filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{JWTSource: filterapi.JWTSource{Scopes: []string{"alpha", "beta", "gamma"}}}, + Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}}, + }, + { + Source: filterapi.MCPAuthorizationSource{JWTSource: filterapi.JWTSource{Scopes: []string{"alpha", "beta"}}}, + Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}}, + }, + }, + }, + header: "Bearer " + makeToken("alpha"), + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + expectScopes: []string{"alpha", "beta"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := http.Header{} + if tt.header != "" { + headers.Set("Authorization", tt.header) + } + compiled, err := compileAuthorization(tt.auth) + if (err != nil) != tt.expectError { + t.Fatalf("expected error: %v, got: %v", tt.expectError, err) + } + if err != nil { + return + } + allowed, requiredScopes := proxy.authorizeRequest(compiled, headers, tt.backendName, tt.toolName, tt.args) + if allowed != tt.expectAllowed { + t.Fatalf("expected %v, got %v", tt.expectAllowed, allowed) + } + if !reflect.DeepEqual(requiredScopes, tt.expectScopes) { + t.Fatalf("expected required scopes %v, got %v", tt.expectScopes, requiredScopes) + } + }) + } +} + +func TestBuildInsufficientScopeHeader(t *testing.T) { + const resourceMetadata = "https://api.example.com/.well-known/oauth-protected-resource/mcp" + + t.Run("with scopes and resource metadata", func(t *testing.T) { + header := buildInsufficientScopeHeader([]string{"read", "write"}, resourceMetadata) + expected := `Bearer error="insufficient_scope", scope="read write", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp", error_description="The token is missing required scopes"` + if header != expected { + t.Fatalf("expected %q, got %q", expected, header) + } + }) +} + +func TestCompileAuthorizationInvalidExpression(t *testing.T) { + _, err := compileAuthorization(&filterapi.MCPRouteAuthorization{ + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: strPtr("args."), + }}, + }, + }, + }, + }) + if err == nil { + t.Fatalf("expected compile error for invalid CEL expression") + } +} diff --git a/internal/mcpproxy/handlers.go b/internal/mcpproxy/handlers.go index 34504cf1ab..aad3abf0b9 100644 --- a/internal/mcpproxy/handlers.go +++ b/internal/mcpproxy/handlers.go @@ -22,7 +22,7 @@ import ( "strings" "time" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -282,7 +282,7 @@ func (m *MCPProxy) servePOST(w http.ResponseWriter, r *http.Request) { onErrorResponse(w, http.StatusBadRequest, "invalid params") return } - err = m.handleToolCallRequest(ctx, s, w, msg, params.(*mcp.CallToolParams), span) + err = m.handleToolCallRequest(ctx, s, w, msg, params.(*mcp.CallToolParams), span, r.Header) case "tools/list": params = &mcp.ListToolsParams{} span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header) @@ -514,7 +514,7 @@ func (m *MCPProxy) handleClientToServerResponse(ctx context.Context, s *session, return nil } -func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http.ResponseWriter, req *jsonrpc.Request, p *mcp.CallToolParams, span tracing.MCPSpan) error { +func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http.ResponseWriter, req *jsonrpc.Request, p *mcp.CallToolParams, span tracing.MCPSpan, headers http.Header) error { backendName, toolName, err := upstreamResourceName(p.Name) if err != nil { onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid tool name %s: %v", p.Name, err)) @@ -540,6 +540,22 @@ func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http return fmt.Errorf("%w: %s", errInvalidToolName, toolName) } + // Enforce authentication if required by the route. + if route.authorization != nil { + allowed, requiredScopes := m.authorizeRequest(route.authorization, headers, backendName, toolName, p.Arguments) + if !allowed { + // Specify the minimum required scopes in the WWW-Authenticate header. + // Reference: https://mcp.mintlify.app/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors + if len(requiredScopes) > 0 { + if challenge := buildInsufficientScopeHeader(requiredScopes, route.authorization.ResourceMetadataURL); challenge != "" { + w.Header().Set("WWW-Authenticate", challenge) + } + } + onErrorResponse(w, http.StatusForbidden, "authorization failed") + return fmt.Errorf("authorization failed") + } + } + cse := s.getCompositeSessionEntry(backendName) if cse == nil { onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("no MCP session found for backend %s", backendName)) diff --git a/internal/mcpproxy/handlers_test.go b/internal/mcpproxy/handlers_test.go index f7f362dce2..956e7ea45d 100644 --- a/internal/mcpproxy/handlers_test.go +++ b/internal/mcpproxy/handlers_test.go @@ -680,7 +680,7 @@ func TestHandleToolCallRequest_UnknownBackend(t *testing.T) { params := &mcp.CallToolParams{Name: "unknown-backend__unknown-tool"} rr := httptest.NewRecorder() - err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil) + err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil, http.Header{}) require.Error(t, err) require.Equal(t, http.StatusNotFound, rr.Code) @@ -710,7 +710,7 @@ func TestHandleToolCallRequest_BackendError(t *testing.T) { params := &mcp.CallToolParams{Name: "backend1__test-tool"} rr := httptest.NewRecorder() - err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil) + err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil, http.Header{}) require.Error(t, err) require.Equal(t, http.StatusInternalServerError, rr.Code) diff --git a/internal/mcpproxy/mcpproxy.go b/internal/mcpproxy/mcpproxy.go index 818d3918e6..861dc47135 100644 --- a/internal/mcpproxy/mcpproxy.go +++ b/internal/mcpproxy/mcpproxy.go @@ -53,6 +53,7 @@ type ( mcpProxyConfigRoute struct { backends map[filterapi.MCPBackendName]filterapi.MCPBackend toolSelectors map[filterapi.MCPBackendName]*toolSelector + authorization *compiledAuthorization } // toolSelector filters tools using include patterns with exact matches or regular expressions. @@ -132,9 +133,15 @@ func (p *ProxyConfig) LoadConfig(_ context.Context, config *filterapi.Config) er newConfig.routes = make(map[filterapi.MCPRouteName]*mcpProxyConfigRoute, len(mcpConfig.Routes)) for _, route := range mcpConfig.Routes { + compiledAuth, err := compileAuthorization(route.Authorization) + if err != nil { + return fmt.Errorf("failed to compile authorization rules for route %s: %w", route.Name, err) + } + r := &mcpProxyConfigRoute{ backends: make(map[filterapi.MCPBackendName]filterapi.MCPBackend, len(route.Backends)), toolSelectors: make(map[filterapi.MCPBackendName]*toolSelector, len(route.Backends)), + authorization: compiledAuth, } for _, backend := range route.Backends { r.backends[backend.Name] = backend diff --git a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml index 1734e611be..a0e24582ee 100644 --- a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml +++ b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml @@ -581,6 +581,88 @@ spec: - credentialRefs - extractFrom type: object + authorization: + description: Authorization defines the configuration for the MCP + spec compatible authorization. + properties: + rules: + description: |- + Rules defines a list of authorization rules. + + Requests that match any rule and satisfy the rule's conditions will be allowed. + Requests that do not match any rule or fail to satisfy the matched rule's conditions will be denied. + If no rules are defined, all requests will be denied. + items: + description: |- + MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec. + Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling + properties: + source: + description: Source defines the authorization source + for this rule. + properties: + jwtSource: + description: JWTSource defines the JWT scopes required + for this rule to match. + properties: + scopes: + description: |- + Scopes defines the list of JWT scopes required for the rule. + If multiple scopes are specified, all scopes must be present in the JWT for the rule to match. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 16 + minItems: 1 + type: array + required: + - scopes + type: object + required: + - jwtSource + type: object + target: + description: Target defines the authorization target + for this rule. + properties: + tools: + description: Tools defines the list of tools this + rule applies to. + items: + description: ToolCall represents a tool call in + the MCP authorization target. + properties: + arguments: + description: |- + Arguments is a CEL expression that must evaluate to true for the rule to match. + The expression is evaluated with a single variable "args" bound to the tool call arguments as a dynamic object. + Guard against missing fields with null checks (e.g., args["foo"] != null && args["foo"]["bar"] == "val"). + maxLength: 4096 + type: string + backendName: + description: BackendName is the name of the + backend this tool belongs to. + type: string + toolName: + description: ToolName is the name of the tool. + type: string + required: + - backendName + - toolName + type: object + maxItems: 16 + minItems: 1 + type: array + required: + - tools + type: object + required: + - source + - target + type: object + type: array + type: object extAuth: description: ExtAuth defines the configuration for External Authorization. properties: @@ -4126,6 +4208,9 @@ spec: - protectedResourceMetadata type: object type: object + x-kubernetes-validations: + - message: oauth must be configured when authorization is set + rule: '!has(self.authorization) || has(self.oauth)' required: - backendRefs - parentRefs diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 66fb5147ca..da92294ad4 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -404,10 +404,15 @@ MCPRouteList contains a list of MCPRoute. - [HTTPBodyMutation](#httpbodymutation) - [HTTPHeaderMutation](#httpheadermutation) - [JWKS](#jwks) +- [JWTSource](#jwtsource) - [LLMRequestCost](#llmrequestcost) - [LLMRequestCostType](#llmrequestcosttype) +- [MCPAuthorizationSource](#mcpauthorizationsource) +- [MCPAuthorizationTarget](#mcpauthorizationtarget) - [MCPBackendAPIKey](#mcpbackendapikey) - [MCPBackendSecurityPolicy](#mcpbackendsecuritypolicy) +- [MCPRouteAuthorization](#mcprouteauthorization) +- [MCPRouteAuthorizationRule](#mcprouteauthorizationrule) - [MCPRouteBackendRef](#mcproutebackendref) - [MCPRouteOAuth](#mcprouteoauth) - [MCPRouteSecurityPolicy](#mcproutesecuritypolicy) @@ -415,6 +420,7 @@ MCPRouteList contains a list of MCPRoute. - [MCPRouteStatus](#mcproutestatus) - [MCPToolFilter](#mcptoolfilter) - [ProtectedResourceMetadata](#protectedresourcemetadata) +- [ToolCall](#toolcall) - [VersionedAPISchema](#versionedapischema) ### Type Definitions @@ -1443,6 +1449,27 @@ JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HT /> +#### JWTSource + + + +**Appears in:** +- [MCPAuthorizationSource](#mcpauthorizationsource) + +JWTSource defines the MCP authorization source for JWT tokens. + +##### Fields + + + + + + #### LLMRequestCost @@ -1513,6 +1540,48 @@ LLMRequestCostType specifies the type of the LLMRequestCost. required="false" description="LLMRequestCostTypeCEL is for calculating the cost using the CEL expression.
" /> +#### MCPAuthorizationSource + + + +**Appears in:** +- [MCPRouteAuthorizationRule](#mcprouteauthorizationrule) + +MCPAuthorizationSource defines the source of an authorization rule. + +##### Fields + + + + + + +#### MCPAuthorizationTarget + + + +**Appears in:** +- [MCPRouteAuthorizationRule](#mcprouteauthorizationrule) + +MCPAuthorizationTarget defines the target of an authorization rule. + +##### Fields + + + + + + #### MCPBackendAPIKey @@ -1565,6 +1634,54 @@ MCPBackendSecurityPolicy defines the security policy for a sp /> +#### MCPRouteAuthorization + + + +**Appears in:** +- [MCPRouteSecurityPolicy](#mcproutesecuritypolicy) + +MCPRouteAuthorization defines the authorization configuration for a MCPRoute. + +##### Fields + + + + + + +#### MCPRouteAuthorizationRule + + + +**Appears in:** +- [MCPRouteAuthorization](#mcprouteauthorization) + +MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec. +Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling + +##### Fields + + + + + + #### MCPRouteBackendRef @@ -1688,6 +1805,11 @@ MCPRouteSecurityPolicy defines the security policy for a MCPRoute. type="[ExtAuth](#extauth)" required="false" description="ExtAuth defines the configuration for External Authorization." +/> @@ -1830,6 +1952,37 @@ References: /> +#### ToolCall + + + +**Appears in:** +- [MCPAuthorizationTarget](#mcpauthorizationtarget) + +ToolCall represents a tool call in the MCP authorization target. + +##### Fields + + + + + + #### VersionedAPISchema diff --git a/tests/crdcel/main_test.go b/tests/crdcel/main_test.go index ced9b33998..04e9fb9951 100644 --- a/tests/crdcel/main_test.go +++ b/tests/crdcel/main_test.go @@ -250,6 +250,10 @@ func TestMCPRoutes(t *testing.T) { name: "jwks_both.yaml", expErr: "spec.securityPolicy.oauth.jwks: Invalid value: \"object\": remoteJWKS and localJWKS cannot both be specified.", }, + { + name: "authorization_without_oauth.yaml", + expErr: "spec.securityPolicy: Invalid value: \"object\": oauth must be configured when authorization is set", + }, } { t.Run(tc.name, func(t *testing.T) { data, err := testdata.ReadFile(path.Join("testdata/mcpgatewayroutes", tc.name)) diff --git a/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml b/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml new file mode 100644 index 0000000000..e5b26ce6a8 --- /dev/null +++ b/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml @@ -0,0 +1,30 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: MCPRoute +metadata: + name: authorization-without-oauth + namespace: default +spec: + parentRefs: + - name: some-gateway + kind: Gateway + group: gateway.networking.k8s.io + backendRefs: + - name: mcp-service + kind: Service + port: 80 + securityPolicy: + authorization: + rules: + - source: + jwtSource: + scopes: + - echo + target: + tools: + - backendName: mcp-service + toolName: echo diff --git a/tests/e2e/mcp_route_authorization_test.go b/tests/e2e/mcp_route_authorization_test.go new file mode 100644 index 0000000000..e660631828 --- /dev/null +++ b/tests/e2e/mcp_route_authorization_test.go @@ -0,0 +1,285 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package e2e + +import ( + "bytes" + "context" + "crypto/rsa" + "encoding/base64" + "fmt" + "math/big" + "net/http" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + + "github.com/envoyproxy/ai-gateway/tests/internal/e2elib" + "github.com/envoyproxy/ai-gateway/tests/internal/testmcp" +) + +// bearerTokenTransport injects a bearer token into outgoing requests. +type bearerTokenTransport struct { + token string + base http.RoundTripper +} + +// RoundTrip implements [http.RoundTripper.RoundTrip]. +func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.base + if base == nil { + base = http.DefaultTransport + } + req.Header.Set("Authorization", "Bearer "+t.token) + return base.RoundTrip(req) +} + +func TestMCPRouteAuthorization(t *testing.T) { + const manifest = "testdata/mcp_route_authorization.yaml" + require.NoError(t, e2elib.KubectlApplyManifest(t.Context(), manifest)) + t.Cleanup(func() { + _ = e2elib.KubectlDeleteManifest(context.Background(), manifest) + }) + + const egSelector = "gateway.envoyproxy.io/owning-gateway-name=mcp-gateway-authorization" + e2elib.RequireWaitForGatewayPodReady(t, egSelector) + + fwd := e2elib.RequireNewHTTPPortForwarder(t, e2elib.EnvoyGatewayNamespace, egSelector, e2elib.EnvoyGatewayDefaultServicePort) + defer fwd.Kill() + + client := mcp.NewClient(&mcp.Implementation{Name: "demo-http-client", Version: "0.1.0"}, nil) + + t.Run("allow rules with matching scopes", func(t *testing.T) { + token := makeSignedJWT(t, "sum") + authHTTPClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &bearerTokenTransport{ + token: token, + }, + } + + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + t.Cleanup(cancel) + + sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient) + t.Cleanup(func() { + _ = sess.Close() + }) + + res, err := sess.CallTool(ctx, &mcp.CallToolParams{ + Name: "mcp-backend-authorization__" + testmcp.ToolSum.Tool.Name, + Arguments: testmcp.ToolSumArgs{A: 41, B: 1}, + }) + require.NoError(t, err) + require.False(t, res.IsError) + require.Len(t, res.Content, 1) + txt, ok := res.Content[0].(*mcp.TextContent) + require.True(t, ok) + require.Equal(t, "42", txt.Text) + }) + + t.Run("matching scopes and arguments", func(t *testing.T) { + token := makeSignedJWT(t, "echo") + authHTTPClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &bearerTokenTransport{ + token: token, + }, + } + + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + t.Cleanup(cancel) + + sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient) + t.Cleanup(func() { + _ = sess.Close() + }) + + const hello = "Hello, world!" // Should match the argument regex "^Hello, .*!$" + res, err := sess.CallTool(ctx, &mcp.CallToolParams{ + Name: "mcp-backend-authorization__" + testmcp.ToolEcho.Tool.Name, + Arguments: testmcp.ToolEchoArgs{Text: hello}, + }) + require.NoError(t, err) + require.False(t, res.IsError) + require.Len(t, res.Content, 1) + txt, ok := res.Content[0].(*mcp.TextContent) + require.True(t, ok) + require.Equal(t, hello, txt.Text) + }) + + t.Run("matching scopes and mismatched arguments", func(t *testing.T) { + token := makeSignedJWT(t, "echo") + authHTTPClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &bearerTokenTransport{ + token: token, + }, + } + + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + t.Cleanup(cancel) + + sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient) + t.Cleanup(func() { + _ = sess.Close() + }) + + const hello = "hello, world!" // Should match the argument regex "^Hello, .*!$" + _, err := sess.CallTool(ctx, &mcp.CallToolParams{ + Name: "mcp-backend-authorization__" + testmcp.ToolEcho.Tool.Name, + Arguments: testmcp.ToolEchoArgs{Text: hello}, + }) + require.Error(t, err) + errMsg := strings.ToLower(err.Error()) + require.True(t, strings.Contains(errMsg, "403") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err) + }) + + t.Run("missing scopes fall back to deny", func(t *testing.T) { + // Only includes the sum scope, so the echo tool should be denied. + token := makeSignedJWT(t, "sum") + authHTTPClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &bearerTokenTransport{ + token: token, + }, + } + + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + t.Cleanup(cancel) + + sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient) + t.Cleanup(func() { + _ = sess.Close() + }) + + _, err := sess.CallTool(ctx, &mcp.CallToolParams{ + Name: "mcp-backend-authorization__" + testmcp.ToolEcho.Tool.Name, + Arguments: testmcp.ToolEchoArgs{Text: "hello"}, + }) + require.Error(t, err) + errMsg := strings.ToLower(err.Error()) + require.True(t, strings.Contains(errMsg, "403") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err) + }) + + t.Run("WWW-Authenticate on insufficient scope", func(t *testing.T) { + token := makeSignedJWT(t, "sum") // only sum scope; echo requires echo + authHTTPClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &bearerTokenTransport{ + token: token, + }, + } + + routeHeader := "default/mcp-route-authorization-default-deny" + + // First, initialize a session to obtain a session ID header. + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + t.Cleanup(cancel) + + sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient) + t.Cleanup(func() { + _ = sess.Close() + }) + + // Now call a tool that requires a missing scope to trigger insufficient_scope. + reqBody := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"mcp-backend-authorization__echo","arguments":{"text":"Hello, world!"}}}`) + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), bytes.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("mcp-session-id", sess.ID()) + req.Header.Set("x-ai-eg-mcp-route", routeHeader) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusForbidden, resp.StatusCode) + wwwAuth := resp.Header.Get("WWW-Authenticate") + require.Contains(t, wwwAuth, `error="insufficient_scope"`) + require.Contains(t, wwwAuth, `scope="echo"`) // expected missing scope + require.Contains(t, wwwAuth, `resource_metadata="https://foo.bar.com/.well-known/oauth-protected-resource/mcp"`) + }) +} + +func requireConnectMCP(ctx context.Context, t *testing.T, client *mcp.Client, endpoint string, httpClient *http.Client) *mcp.ClientSession { + var sess *mcp.ClientSession + require.Eventually(t, func() bool { + var err error + sess, err = client.Connect( + ctx, + &mcp.StreamableClientTransport{ + Endpoint: endpoint, + HTTPClient: httpClient, + }, nil) + if err != nil { + t.Logf("failed to connect to MCP server: %v", err) + return false + } + return true + }, 30*time.Second, 100*time.Millisecond, "failed to connect to MCP server") + return sess +} + +func makeSignedJWT(t *testing.T, scopes ...string) string { + t.Helper() + + claims := jwt.MapClaims{ + "iss": "https://auth-server.example.com", + "aud": "mcp-test", + "sub": "robin", + "client_id": "my_mcp_gateway", + "scope": strings.Join(scopes, " "), + "exp": time.Now().Add(30 * time.Minute).Unix(), + "iat": time.Now().Unix(), + "auth_time": time.Now().Unix(), + "token_type": "Bearer", + } + + key := jwkPrivateKey(t) + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = jwkPrivateKeyID + signed, err := token.SignedString(key) + require.NoError(t, err) + return signed +} + +func jwkPrivateKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + + mustBigInt := func(val string) *big.Int { + bytes, err := base64.RawURLEncoding.DecodeString(val) + require.NoError(t, err) + return new(big.Int).SetBytes(bytes) + } + + key := &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{ + N: mustBigInt(jwkN), + E: 65537, // AQAB + }, + D: mustBigInt(jwkD), + Primes: []*big.Int{ + mustBigInt(jwkP), + mustBigInt(jwkQ), + }, + } + key.Precompute() + return key +} + +const ( + jwkPrivateKeyID = "8b267675394d7786f98ae29d8fddf905" + jwkN = "lQiREO6mH0tV14KjhSz02zNaJ1SExS1QjlGoMSbG2-NUeURmvnwg-eY95sDCFpWuH3_ovFyRBGTi0e3j79mrQhM53PZQys-gr-rYGz8LeHp8G6H3XmeDxTvyemhB6uiN4EZkjOo6xT2ipmPEN3u315xPCR60Pwac2E0t4vZGxtU4LGYatIFOYtUvDdMPBLfGMKVapHBzbx9Ll4INEic1fNrAIUVtOn6i3sxzGHj4iGWsMrEUIXDOWEHzioXgPuRjJDRjhHRuEeA9i_Y-a9hY92q6P-dcPnCDLNF3349WDyw7jIMlU6TLM8lQ5ci_TS_0avovXPNECsuOObtT78LJJKLg58ghTnqrihwvSccVgW4M43Ntv7TOAgOsRl-NKY7QQJIbkxemvh14-gzwA6LijMvb0Tjrh6NynKfCIO0ASsMp3K3uks4cYhBALLJ1E41V-cYqdwg0c6Jam0Y4OXxNv_0FfmcsOk8iXdroNgWjBs3KaObMiMvNOKHNWZ4PsEll" + jwkD = "CCv3lFeZmUautsntgGxmIqzOqTBrtUoWTC9zCvrm1YDCDYIwJgq1Xi5_P2tbWRSs_wIq90UWGIkVnNAv-uNTDiTyu8hvxqca1vqIDfpnfRwuOO-pGi6P3Z07XvXfg2tr-Bu0ALwJK-6EwB3hUO-CNZrXBJd_56LLr9qPhQ3e9KEVWu3gUfxzGV06HsZvYOFYxysR7MlTswiiwvR5FgE7YBS4izp80kPGV3QbbYCYlBYLGp52DZ1bWyCGo5ZSpPAt4Az9wdDTzJoTtflLymg8kZ-idQqk2_re214xQgeCuVAHujjC4r3GqSzbQGUqXicd-rbRLenyB22Ul8wyHqY8WtcFrGmHojK8b-W3M9m0-xYkMXmWcllYQuQ0LMP9K8Tl0uMpKsyd0AePItaWa_ft3dAzoBiUZA15X2_Nbbc9WbkmjN0Et8E1RWlrL5fzppbvLUl4mlSKHsLnwgmLx2OROjEnQsfzjMGxV2KhMZXzdvbRPTkaDtq3YT70ZiRIyvRD" + jwkP = "yO5hho-83vQQ3t7HeVeinZClemDazWT5T7f2ZVMigcuyUNQjC69tyMzJ3I_UN5nUCwpKCw5wY8uCeT82o1j-OJC3irxWjAPHkkbsYTNxRnk8ShJ2UFdu5a7MEF82-QuRKciAv11cebEpk5ggf-jQrtTY2yQru0fW0WZB8hz19XywhFQ_mVMMahNHfycfXT2BMaV0wiBFKY8FXKqb5cErsCodcZ_STvqOTykWBaA4AWmJFRqd4i4enpf-MhgtkQK3" + jwkQ = "veD3yFnEOZegVIpIxPqIsj7zazjKRn-io1s3KJxkgaz5ND1o1JwbxiLuUNL9ufkj6cPOVCEHRkjQ2GabHnA0NYci4qRHBWdHhCD7aisS2D60xZAiAVmNZlEGLxRS7gFnyD8uneLILFFMalvJdIccCXzN3c8vPlC_9FlEzaEyDUmWzT_1zZES2GpaYeC73fNg7h-mJ6m-96Y6Wwvlx6YlCRCIPLU7l4kA-jca37T0IMNhobWmg8u4yqvVaqdDhojD" +) diff --git a/tests/e2e/testdata/mcp_route_authorization.yaml b/tests/e2e/testdata/mcp_route_authorization.yaml new file mode 100644 index 0000000000..ba9565d4fb --- /dev/null +++ b/tests/e2e/testdata/mcp_route_authorization.yaml @@ -0,0 +1,170 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: mcp-gateway-class-authorization +spec: + controllerName: gateway.envoyproxy.io/gatewayclass-controller +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: mcp-gateway-authorization + namespace: default +spec: + gatewayClassName: mcp-gateway-class-authorization + listeners: + - name: http + protocol: HTTP + port: 80 + infrastructure: + parametersRef: + group: gateway.envoyproxy.io + kind: EnvoyProxy + name: envoy-mcp-gateway-authorization +--- +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: MCPRoute +metadata: + name: mcp-route-authorization-default-deny + namespace: default +spec: + path: "/mcp-authorization" + parentRefs: + - name: mcp-gateway-authorization + kind: Gateway + group: gateway.networking.k8s.io + backendRefs: + - name: mcp-backend-authorization + port: 1063 + securityPolicy: + oauth: + issuer: "https://auth-server.example.com" + jwks: + localJWKS: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: jwks-configmap + protectedResourceMetadata: + resource: "https://foo.bar.com/mcp" + resourceName: "example-resource" + scopesSupported: + - "echo" + - "sum" + - "countdown" + authorization: + rules: + - source: + jwtSource: + scopes: + - echo + target: + tools: + - backendName: mcp-backend-authorization + toolName: echo + arguments: args.text.matches("^Hello, .*!$") + - source: + jwtSource: + scopes: + - sum + target: + tools: + - backendName: mcp-backend-authorization + toolName: sum +--- +# https://www.scottbrady.io/tools/jwt +apiVersion: v1 +kind: ConfigMap +metadata: + name: jwks-configmap + namespace: default +data: + jwks: | + { + "keys": [ + { + "alg": "RS256", + "d": "CCv3lFeZmUautsntgGxmIqzOqTBrtUoWTC9zCvrm1YDCDYIwJgq1Xi5_P2tbWRSs_wIq90UWGIkVnNAv-uNTDiTyu8hvxqca1vqIDfpnfRwuOO-pGi6P3Z07XvXfg2tr-Bu0ALwJK-6EwB3hUO-CNZrXBJd_56LLr9qPhQ3e9KEVWu3gUfxzGV06HsZvYOFYxysR7MlTswiiwvR5FgE7YBS4izp80kPGV3QbbYCYlBYLGp52DZ1bWyCGo5ZSpPAt4Az9wdDTzJoTtflLymg8kZ-idQqk2_re214xQgeCuVAHujjC4r3GqSzbQGUqXicd-rbRLenyB22Ul8wyHqY8WtcFrGmHojK8b-W3M9m0-xYkMXmWcllYQuQ0LMP9K8Tl0uMpKsyd0AePItaWa_ft3dAzoBiUZA15X2_Nbbc9WbkmjN0Et8E1RWlrL5fzppbvLUl4mlSKHsLnwgmLx2OROjEnQsfzjMGxV2KhMZXzdvbRPTkaDtq3YT70ZiRIyvRD", + "dp": "w2Z3NznPTe6B_Yse51UfEiXZlxJblgTNIwZeupjHZPwns80pK7L1i6ID6NeCZHPXLslZyjjHeXUutCSSSPZBe9bYdzXC4LTIPutz8u7pCMTbqZkcr_LnKLv9PSqrNjRWfhC7i94KEVoFecAmUt2hG3RoU2xwjtdFBCxykzYwxwP0USvxEXUfDIUlMXVlXfJzEkm6KxLgz5KDf2N26k8Z4l6Cdb4b8qxc-oSVIvF1pYHxSrGwuoVpR4e-Lw8uOOgv", + "dq": "Czchi5z5wSkamEO-vpvJvTWIrTmigP2C_sEhhe2O5jXwVkyWR5Cc91wS1YVQ5U4499LP-holUtp0M4QD_41DGDJONjLb2w7Zo41LLF808r7pcI3t5ESE3JlGkztRFqvQlHxe5YaCqlN2_wVC5fYhCtJrWoGlWbntTKKFNNwjl7NUC_WOMEE0asIFaqiakCaTAB2wc8FL_Va9NamDPgKrR1jJo0RVK8M04pKkrLgEf3bq6mFPX5OF67qwlWchzu1_", + "e": "AQAB", + "key_ops": [ + "sign" + ], + "kty": "RSA", + "n": "lQiREO6mH0tV14KjhSz02zNaJ1SExS1QjlGoMSbG2-NUeURmvnwg-eY95sDCFpWuH3_ovFyRBGTi0e3j79mrQhM53PZQys-gr-rYGz8LeHp8G6H3XmeDxTvyemhB6uiN4EZkjOo6xT2ipmPEN3u315xPCR60Pwac2E0t4vZGxtU4LGYatIFOYtUvDdMPBLfGMKVapHBzbx9Ll4INEic1fNrAIUVtOn6i3sxzGHj4iGWsMrEUIXDOWEHzioXgPuRjJDRjhHRuEeA9i_Y-a9hY92q6P-dcPnCDLNF3349WDyw7jIMlU6TLM8lQ5ci_TS_0avovXPNECsuOObtT78LJJKLg58ghTnqrihwvSccVgW4M43Ntv7TOAgOsRl-NKY7QQJIbkxemvh14-gzwA6LijMvb0Tjrh6NynKfCIO0ASsMp3K3uks4cYhBALLJ1E41V-cYqdwg0c6Jam0Y4OXxNv_0FfmcsOk8iXdroNgWjBs3KaObMiMvNOKHNWZ4PsEll", + "p": "yO5hho-83vQQ3t7HeVeinZClemDazWT5T7f2ZVMigcuyUNQjC69tyMzJ3I_UN5nUCwpKCw5wY8uCeT82o1j-OJC3irxWjAPHkkbsYTNxRnk8ShJ2UFdu5a7MEF82-QuRKciAv11cebEpk5ggf-jQrtTY2yQru0fW0WZB8hz19XywhFQ_mVMMahNHfycfXT2BMaV0wiBFKY8FXKqb5cErsCodcZ_STvqOTykWBaA4AWmJFRqd4i4enpf-MhgtkQK3", + "q": "veD3yFnEOZegVIpIxPqIsj7zazjKRn-io1s3KJxkgaz5ND1o1JwbxiLuUNL9ufkj6cPOVCEHRkjQ2GabHnA0NYci4qRHBWdHhCD7aisS2D60xZAiAVmNZlEGLxRS7gFnyD8uneLILFFMalvJdIccCXzN3c8vPlC_9FlEzaEyDUmWzT_1zZES2GpaYeC73fNg7h-mJ6m-96Y6Wwvlx6YlCRCIPLU7l4kA-jca37T0IMNhobWmg8u4yqvVaqdDhojD", + "qi": "TDrZ2CE6uHay64K4f9sSDN0QANTxk60SDVALWCz8apB66q5g2XuaTak2LvIAMBw758rIM_DhpCH7gs9sEc1UoFbs6KLR-6cb1WtsoTxNtXbXFQDuVtDRbjGjVWXRuRe8rY5Hca2Kx2-Tl1iXT4tBkMPcr-1TGHY8A5uiRzldY5u2Qu2W-wlGNwLe87CSzoo3QT0l1h9aMCNH6T6q1cD1ZiUktPWAmGtZl88YzC6dHrxIoYAz4BSVIdbGoV82vvuI", + "use": "sig", + "kid": "8b267675394d7786f98ae29d8fddf905" + }, + { + "alg": "RS256", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "kty": "RSA", + "n": "lQiREO6mH0tV14KjhSz02zNaJ1SExS1QjlGoMSbG2-NUeURmvnwg-eY95sDCFpWuH3_ovFyRBGTi0e3j79mrQhM53PZQys-gr-rYGz8LeHp8G6H3XmeDxTvyemhB6uiN4EZkjOo6xT2ipmPEN3u315xPCR60Pwac2E0t4vZGxtU4LGYatIFOYtUvDdMPBLfGMKVapHBzbx9Ll4INEic1fNrAIUVtOn6i3sxzGHj4iGWsMrEUIXDOWEHzioXgPuRjJDRjhHRuEeA9i_Y-a9hY92q6P-dcPnCDLNF3349WDyw7jIMlU6TLM8lQ5ci_TS_0avovXPNECsuOObtT78LJJKLg58ghTnqrihwvSccVgW4M43Ntv7TOAgOsRl-NKY7QQJIbkxemvh14-gzwA6LijMvb0Tjrh6NynKfCIO0ASsMp3K3uks4cYhBALLJ1E41V-cYqdwg0c6Jam0Y4OXxNv_0FfmcsOk8iXdroNgWjBs3KaObMiMvNOKHNWZ4PsEll", + "use": "sig", + "kid": "8b267675394d7786f98ae29d8fddf905" + } + ] + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mcp-backend-authorization + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: mcp-backend-authorization + template: + metadata: + labels: + app: mcp-backend-authorization + spec: + containers: + - name: mcp-backend-authorization + image: docker.io/envoyproxy/ai-gateway-testmcpserver:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 1063 +--- +apiVersion: v1 +kind: Service +metadata: + name: mcp-backend-authorization + namespace: default +spec: + selector: + app: mcp-backend-authorization + ports: + - protocol: TCP + port: 1063 + targetPort: 1063 + type: ClusterIP +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyProxy +metadata: + name: envoy-mcp-gateway-authorization + namespace: default +spec: + provider: + type: Kubernetes + kubernetes: + envoyDeployment: + container: + # Clear the default memory/cpu requirements for local tests. + resources: {}