Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions api/v1alpha1/mcp_route.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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.
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason for this limitation?

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"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd just call it jwt to make the YAML API less verbose.

Suggested change
JWTSource JWTSource `json:"jwtSource"`
JWT JWTSource `json:"jwt"`


// 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"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplify the names for a less verbose and easier to write/read YAML API?

Suggested change
BackendName string `json:"backendName"`
Backend string `json:"backend"`


// 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"`
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After discussing this with @nacx offline, we chose CEL for argument evaluation.

CEL is easier to write and understand than regex evaluation when the arguments are complex object types.

For example:

To check against this argument:

{
  "key-level1": {
    "key-level2": {
      "key-level3": "value"
    }
  }
}

The regex match:

/"key-level1"\s*:\s*\{\s*"key-level2"\s*:\s*\{\s*"key-level3"\s*:\s*"value"\s*\}\s*\}/gm

The CEL match:

args["key-level1"]["key-level2"]["key-level3"] == "value"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about renaming this to condition? If we do this, we could also easily support meta in addition to args to have policies based on the contents of the metadata.

}

// 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."
Expand Down
122 changes: 122 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
37 changes: 37 additions & 0 deletions internal/controller/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions internal/controller/mcp_route_security_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"`

Expand Down
Loading
Loading