Skip to content

Commit 2dd862c

Browse files
committed
authorization impl draft
Signed-off-by: Huabing Zhao <[email protected]>
1 parent 41f189a commit 2dd862c

File tree

12 files changed

+694
-60
lines changed

12 files changed

+694
-60
lines changed

api/v1alpha1/mcp_route.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,6 @@ type MCPAuthorizationTarget struct {
267267
// +kubebuilder:validation:MinItems=1
268268
// +kubebuilder:validation:MaxItems=16
269269
Tools []ToolCall `json:"tools"`
270-
271-
// TODO: we can add resources, prompts, etc. in the future.
272270
}
273271

274272
type MCPAuthorizationSource struct {
@@ -286,21 +284,27 @@ type JWTSource struct {
286284
// +kubebuilder:validation:MaxItems=16
287285
Scopes []egv1a1.JWTScope `json:"scopes"`
288286

289-
//TODO : we can add more fields in the future, e.g., audiences, claims, etc.
287+
// TODO : we can add more fields in the future, e.g., audiences, claims, etc.
290288
}
291289

292290
type ToolCall struct {
293-
// Tools defines the list of tool names this rule applies to. The name must be a fully qualified tool name including the backend name.
294-
// For example, "mcp-backend-name__tool-name".
295-
Name string `json:"name"`
291+
// BackendName is the name of the backend this tool belongs to.
292+
//
293+
// +kubebuilder:validation:Required
294+
BackendName string `json:"backendName"`
295+
296+
// ToolName is the name of the tool.
297+
//
298+
// +kubebuilder:validation:Required
299+
ToolName string `json:"toolName"`
296300

297301
// Arguments defines the arguments that must be present in the tool call for this rule to match.
298302
//
299303
// +optional
300-
Arguments map[string]string `json:"arguments,omitempty"`
304+
// Arguments map[string]string `json:"arguments,omitempty"`
301305
}
302306

303-
type ToolArgument struct {
307+
/*type ToolArgument struct {
304308
// Name is the name of the argument.
305309
Name string `json:"name"`
306310
@@ -312,7 +316,7 @@ type ArgumentValues struct {
312316
Include []string `json:"include,omitempty"`
313317
314318
IncludeRegex []string `json:"includeRegex,omitempty"`
315-
}
319+
}*/
316320

317321
// JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HTTPS endpoint or from a local source.
318322
// +kubebuilder:validation:XValidation:rule="has(self.remoteJWKS) || has(self.localJWKS)", message="either remoteJWKS or localJWKS must be specified."

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 77 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

go.mod

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ require (
2323
github.com/envoyproxy/go-control-plane v0.14.0
2424
github.com/envoyproxy/go-control-plane/envoy v1.36.0
2525
github.com/go-logr/logr v1.4.3
26-
github.com/golang-jwt/jwt/v4 v4.5.2
26+
github.com/golang-jwt/jwt/v5 v5.3.0
2727
github.com/google/cel-go v0.26.1
2828
github.com/google/go-cmp v0.7.0
2929
github.com/google/jsonschema-go v0.3.0
@@ -158,7 +158,6 @@ require (
158158
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
159159
github.com/goccy/go-yaml v1.18.0 // indirect
160160
github.com/gogo/protobuf v1.3.2 // indirect
161-
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
162161
github.com/golang/protobuf v1.5.4 // indirect
163162
github.com/google/btree v1.1.3 // indirect
164163
github.com/google/gnostic-models v0.7.0 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,6 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
235235
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
236236
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
237237
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
238-
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
239-
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
240238
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
241239
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
242240
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=

internal/filterapi/mcpconfig.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ type MCPRoute struct {
2727

2828
// Backends is the list of backends that this route can route to.
2929
Backends []MCPBackend `json:"backends"`
30+
31+
// Authorization is the authorization configuration for this route.
32+
Authorization *MCPRouteAuthorization `json:"authorization,omitempty"`
3033
}
3134

3235
// MCPBackend is the MCP backend configuration.
@@ -58,3 +61,62 @@ type MCPToolSelector struct {
5861

5962
// MCPRouteName is the name of the MCP route.
6063
type MCPRouteName = string
64+
65+
// MCPRouteAuthorization defines the authorization configuration for a MCPRoute.
66+
type MCPRouteAuthorization struct {
67+
// Rules defines a list of authorization rules.
68+
// These rules are evaluated in order, the first matching rule will be applied,
69+
// and the rest will be skipped.
70+
Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"`
71+
72+
// DefaultAction defines the action to take when no rules match.
73+
// If unset, the default is Deny.
74+
DefaultAction *AuthorizationAction `json:"defaultAction,omitempty"`
75+
}
76+
77+
// MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec.
78+
// Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling
79+
type MCPRouteAuthorizationRule struct {
80+
// Source defines the authorization source for this rule.
81+
Source MCPAuthorizationSource `json:"source"`
82+
83+
// Target defines the authorization target for this rule.
84+
Target MCPAuthorizationTarget `json:"target"`
85+
86+
// Action defines whether to allow or deny requests that match this rule.
87+
Action AuthorizationAction `json:"action"`
88+
}
89+
90+
// AuthorizationAction represents an authorization decision.
91+
type AuthorizationAction string
92+
93+
const (
94+
// AuthorizationActionAllow allows the request.
95+
AuthorizationActionAllow AuthorizationAction = "Allow"
96+
// AuthorizationActionDeny denies the request.
97+
AuthorizationActionDeny AuthorizationAction = "Deny"
98+
)
99+
100+
type MCPAuthorizationTarget struct {
101+
// Tools defines the list of tools this rule applies to.
102+
Tools []ToolCall `json:"tools"`
103+
}
104+
105+
type MCPAuthorizationSource struct {
106+
// JWTSource defines the JWT scopes required for this rule to match.
107+
JWTSource JWTSource `json:"jwtSource,omitempty"`
108+
}
109+
110+
type JWTSource struct {
111+
// Scopes defines the list of JWT scopes required for the rule.
112+
// If multiple scopes are specified, all scopes must be present in the JWT for the rule to match.
113+
Scopes []string `json:"scopes"`
114+
}
115+
116+
type ToolCall struct {
117+
// BackendName is the name of the backend this tool belongs to.
118+
BackendName string `json:"backendName"`
119+
120+
// ToolName is the name of the tool.
121+
ToolName string `json:"toolName"`
122+
}

internal/mcpproxy/authorization.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright Envoy AI Gateway Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
// The full text of the Apache license is available in the LICENSE file at
4+
// the root of the repo.
5+
6+
package mcpproxy
7+
8+
import (
9+
"errors"
10+
"log/slog"
11+
"net/http"
12+
"strings"
13+
14+
"github.com/golang-jwt/jwt/v5"
15+
"k8s.io/utils/ptr"
16+
17+
"github.com/envoyproxy/ai-gateway/internal/filterapi"
18+
)
19+
20+
func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string) bool {
21+
defaultAction := ptr.Deref(authorization.DefaultAction, filterapi.AuthorizationActionDeny) == filterapi.AuthorizationActionAllow
22+
23+
// If there are no rules, return the default action.
24+
if len(authorization.Rules) == 0 {
25+
return defaultAction
26+
}
27+
28+
// If the rules are defined, a valid bearer token is required.
29+
token, err := bearerToken(headers.Get("Authorization"))
30+
if err != nil {
31+
m.l.Info("missing or invalid bearer token", slog.String("error", err.Error()))
32+
return false
33+
}
34+
35+
claims := jwt.MapClaims{}
36+
// JWT verification is performed by Envoy before reaching here. So we only need to parse the token without verification.
37+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
38+
if _, _, err := parser.ParseUnverified(token, claims); err != nil {
39+
m.l.Info("failed to parse JWT token", slog.String("error", err.Error()))
40+
return false
41+
}
42+
43+
scopeSet := make(map[string]struct{})
44+
for _, scope := range extractScopes(claims) {
45+
scopeSet[scope] = struct{}{}
46+
}
47+
48+
target := filterapi.ToolCall{BackendName: backendName, ToolName: toolName}
49+
for _, rule := range authorization.Rules {
50+
if !toolTargetMatches(target, rule.Target.Tools) {
51+
continue
52+
}
53+
if scopesSatisfied(scopeSet, rule.Source.JWTSource.Scopes) {
54+
return rule.Action == filterapi.AuthorizationActionAllow
55+
}
56+
}
57+
58+
return defaultAction
59+
}
60+
61+
func bearerToken(header string) (string, error) {
62+
if header == "" {
63+
return "", errors.New("missing Authorization header")
64+
}
65+
66+
parts := strings.SplitN(header, " ", 2)
67+
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
68+
return "", errors.New("invalid Authorization header")
69+
}
70+
71+
token := strings.TrimSpace(parts[1])
72+
if token == "" {
73+
return "", errors.New("missing bearer token")
74+
}
75+
return token, nil
76+
}
77+
78+
func extractScopes(claims jwt.MapClaims) []string {
79+
raw, ok := claims["scope"]
80+
if !ok {
81+
return nil
82+
}
83+
84+
switch v := raw.(type) {
85+
case string:
86+
return strings.Fields(v)
87+
case []string:
88+
return v
89+
case []interface{}:
90+
scopes := make([]string, 0, len(v))
91+
for _, item := range v {
92+
if s, ok := item.(string); ok && s != "" {
93+
scopes = append(scopes, s)
94+
}
95+
}
96+
return scopes
97+
default:
98+
return nil
99+
}
100+
}
101+
102+
func toolTargetMatches(target filterapi.ToolCall, tools []filterapi.ToolCall) bool {
103+
if len(tools) == 0 {
104+
return true
105+
}
106+
for _, t := range tools {
107+
if t.BackendName == target.BackendName && t.ToolName == target.ToolName {
108+
return true
109+
}
110+
}
111+
return false
112+
}
113+
114+
func scopesSatisfied(have map[string]struct{}, required []string) bool {
115+
if len(required) == 0 {
116+
return true
117+
}
118+
for _, scope := range required {
119+
if _, ok := have[scope]; !ok {
120+
return false
121+
}
122+
}
123+
return true
124+
}

0 commit comments

Comments
 (0)