Skip to content

Commit 27932ad

Browse files
committed
add e2e test
Signed-off-by: Huabing Zhao <[email protected]>
1 parent 5ce572e commit 27932ad

File tree

7 files changed

+391
-2
lines changed

7 files changed

+391
-2
lines changed

api/v1alpha1/mcp_route.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ type MCPBackendAPIKey struct {
177177
}
178178

179179
// MCPRouteSecurityPolicy defines the security policy for a MCPRoute.
180+
//
181+
// +kubebuilder:validation:XValidation:rule="!has(self.authorization) || has(self.oauth)",message="oauth must be configured when authorization is set"
180182
type MCPRouteSecurityPolicy struct {
181183
// OAuth defines the configuration for the MCP spec compatible OAuth authentication.
182184
//

internal/mcpproxy/handlers_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,7 @@ func TestHandleToolCallRequest_UnknownBackend(t *testing.T) {
680680
params := &mcp.CallToolParams{Name: "unknown-backend__unknown-tool"}
681681
rr := httptest.NewRecorder()
682682

683-
err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil, nil)
683+
err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil, http.Header{})
684684
require.Error(t, err)
685685

686686
require.Equal(t, http.StatusNotFound, rr.Code)
@@ -710,7 +710,7 @@ func TestHandleToolCallRequest_BackendError(t *testing.T) {
710710
params := &mcp.CallToolParams{Name: "backend1__test-tool"}
711711
rr := httptest.NewRecorder()
712712

713-
err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil, nil)
713+
err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil, http.Header{})
714714
require.Error(t, err)
715715

716716
require.Equal(t, http.StatusInternalServerError, rr.Code)

manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4211,6 +4211,9 @@ spec:
42114211
- protectedResourceMetadata
42124212
type: object
42134213
type: object
4214+
x-kubernetes-validations:
4215+
- message: oauth must be configured when authorization is set
4216+
rule: '!has(self.authorization) || has(self.oauth)'
42144217
required:
42154218
- backendRefs
42164219
- parentRefs

tests/crdcel/main_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ func TestMCPRoutes(t *testing.T) {
250250
name: "jwks_both.yaml",
251251
expErr: "spec.securityPolicy.oauth.jwks: Invalid value: \"object\": remoteJWKS and localJWKS cannot both be specified.",
252252
},
253+
{
254+
name: "authorization_without_oauth.yaml",
255+
expErr: "spec.securityPolicy: Invalid value: \"object\": oauth must be configured when authorization is set",
256+
},
253257
} {
254258
t.Run(tc.name, func(t *testing.T) {
255259
data, err := testdata.ReadFile(path.Join("testdata/mcpgatewayroutes", tc.name))
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
apiVersion: aigateway.envoyproxy.io/v1alpha1
7+
kind: MCPRoute
8+
metadata:
9+
name: authorization-without-oauth
10+
namespace: default
11+
spec:
12+
parentRefs:
13+
- name: some-gateway
14+
kind: Gateway
15+
group: gateway.networking.k8s.io
16+
backendRefs:
17+
- name: mcp-service
18+
kind: Service
19+
port: 80
20+
securityPolicy:
21+
authorization:
22+
defaultAction: Deny
23+
rules:
24+
- source:
25+
jwtSource:
26+
scopes:
27+
- echo
28+
target:
29+
tools:
30+
- backendName: mcp-service
31+
toolName: echo
32+
action: Allow
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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 e2e
7+
8+
import (
9+
"context"
10+
"crypto/rsa"
11+
"encoding/base64"
12+
"fmt"
13+
"math/big"
14+
"net/http"
15+
"strings"
16+
"testing"
17+
"time"
18+
19+
"github.com/golang-jwt/jwt/v5"
20+
"github.com/modelcontextprotocol/go-sdk/mcp"
21+
"github.com/stretchr/testify/require"
22+
23+
"github.com/envoyproxy/ai-gateway/tests/internal/e2elib"
24+
"github.com/envoyproxy/ai-gateway/tests/internal/testmcp"
25+
)
26+
27+
// bearerTokenTransport injects a bearer token into outgoing requests.
28+
type bearerTokenTransport struct {
29+
token string
30+
base http.RoundTripper
31+
}
32+
33+
// RoundTrip implements [http.RoundTripper.RoundTrip].
34+
func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
35+
base := t.base
36+
if base == nil {
37+
base = http.DefaultTransport
38+
}
39+
req.Header.Set("Authorization", "Bearer "+t.token)
40+
return base.RoundTrip(req)
41+
}
42+
43+
func TestMCPRouteAuthorization(t *testing.T) {
44+
const manifest = "testdata/mcp_route_authorization.yaml"
45+
require.NoError(t, e2elib.KubectlApplyManifest(t.Context(), manifest))
46+
t.Cleanup(func() {
47+
_ = e2elib.KubectlDeleteManifest(context.Background(), manifest)
48+
})
49+
50+
const egSelector = "gateway.envoyproxy.io/owning-gateway-name=mcp-gateway-authorization"
51+
e2elib.RequireWaitForGatewayPodReady(t, egSelector)
52+
53+
fwd := e2elib.RequireNewHTTPPortForwarder(t, e2elib.EnvoyGatewayNamespace, egSelector, e2elib.EnvoyGatewayDefaultServicePort)
54+
defer fwd.Kill()
55+
56+
client := mcp.NewClient(&mcp.Implementation{Name: "demo-http-client", Version: "0.1.0"}, nil)
57+
58+
t.Run("allow rules with matching scopes", func(t *testing.T) {
59+
token := makeSignedJWT(t, "echo", "sum")
60+
authHTTPClient := &http.Client{
61+
Timeout: 10 * time.Second,
62+
Transport: &bearerTokenTransport{
63+
token: token,
64+
},
65+
}
66+
testMCPRouteTools(
67+
t.Context(),
68+
t,
69+
client,
70+
fwd.Address(),
71+
"/mcp-authorization",
72+
testMCPServerAllToolNames("mcp-backend-authorization__"),
73+
authHTTPClient,
74+
true,
75+
true,
76+
)
77+
})
78+
79+
t.Run("missing scopes fall back to deny", func(t *testing.T) {
80+
// Only includes the sum scope, so the echo tool should be denied.
81+
token := makeSignedJWT(t, "sum")
82+
authHTTPClient := &http.Client{
83+
Timeout: 10 * time.Second,
84+
Transport: &bearerTokenTransport{
85+
token: token,
86+
},
87+
}
88+
89+
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
90+
t.Cleanup(cancel)
91+
92+
var sess *mcp.ClientSession
93+
require.Eventually(t, func() bool {
94+
var err error
95+
sess, err = client.Connect(
96+
ctx,
97+
&mcp.StreamableClientTransport{
98+
Endpoint: fmt.Sprintf("%s/mcp-authorization", fwd.Address()),
99+
HTTPClient: authHTTPClient,
100+
}, nil)
101+
if err != nil {
102+
t.Logf("failed to connect to MCP server: %v", err)
103+
return false
104+
}
105+
return true
106+
}, 30*time.Second, 100*time.Millisecond, "failed to connect to MCP server")
107+
t.Cleanup(func() {
108+
if sess != nil {
109+
_ = sess.Close()
110+
}
111+
})
112+
113+
_, err := sess.CallTool(ctx, &mcp.CallToolParams{
114+
Name: "mcp-backend-authorization__" + testmcp.ToolEcho.Tool.Name,
115+
Arguments: testmcp.ToolEchoArgs{Text: "hello"},
116+
})
117+
require.Error(t, err)
118+
errMsg := strings.ToLower(err.Error())
119+
require.True(t, strings.Contains(errMsg, "401") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err)
120+
})
121+
}
122+
123+
func makeSignedJWT(t *testing.T, scopes ...string) string {
124+
t.Helper()
125+
126+
claims := jwt.MapClaims{
127+
"iss": "https://auth-server.example.com",
128+
"aud": "mcp-test",
129+
"sub": "robin",
130+
"client_id": "my_mcp_gateway",
131+
"scope": strings.Join(scopes, " "),
132+
"exp": time.Now().Add(30 * time.Minute).Unix(),
133+
"iat": time.Now().Unix(),
134+
"auth_time": time.Now().Unix(),
135+
"token_type": "Bearer",
136+
}
137+
138+
key := jwkPrivateKey(t)
139+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
140+
token.Header["kid"] = jwkPrivateKeyID
141+
signed, err := token.SignedString(key)
142+
require.NoError(t, err)
143+
return signed
144+
}
145+
146+
func jwkPrivateKey(t *testing.T) *rsa.PrivateKey {
147+
t.Helper()
148+
149+
mustBigInt := func(val string) *big.Int {
150+
bytes, err := base64.RawURLEncoding.DecodeString(val)
151+
require.NoError(t, err)
152+
return new(big.Int).SetBytes(bytes)
153+
}
154+
155+
key := &rsa.PrivateKey{
156+
PublicKey: rsa.PublicKey{
157+
N: mustBigInt(jwkN),
158+
E: 65537, // AQAB
159+
},
160+
D: mustBigInt(jwkD),
161+
Primes: []*big.Int{
162+
mustBigInt(jwkP),
163+
mustBigInt(jwkQ),
164+
},
165+
}
166+
key.Precompute()
167+
return key
168+
}
169+
170+
const (
171+
jwkPrivateKeyID = "8b267675394d7786f98ae29d8fddf905"
172+
jwkN = "lQiREO6mH0tV14KjhSz02zNaJ1SExS1QjlGoMSbG2-NUeURmvnwg-eY95sDCFpWuH3_ovFyRBGTi0e3j79mrQhM53PZQys-gr-rYGz8LeHp8G6H3XmeDxTvyemhB6uiN4EZkjOo6xT2ipmPEN3u315xPCR60Pwac2E0t4vZGxtU4LGYatIFOYtUvDdMPBLfGMKVapHBzbx9Ll4INEic1fNrAIUVtOn6i3sxzGHj4iGWsMrEUIXDOWEHzioXgPuRjJDRjhHRuEeA9i_Y-a9hY92q6P-dcPnCDLNF3349WDyw7jIMlU6TLM8lQ5ci_TS_0avovXPNECsuOObtT78LJJKLg58ghTnqrihwvSccVgW4M43Ntv7TOAgOsRl-NKY7QQJIbkxemvh14-gzwA6LijMvb0Tjrh6NynKfCIO0ASsMp3K3uks4cYhBALLJ1E41V-cYqdwg0c6Jam0Y4OXxNv_0FfmcsOk8iXdroNgWjBs3KaObMiMvNOKHNWZ4PsEll"
173+
jwkD = "CCv3lFeZmUautsntgGxmIqzOqTBrtUoWTC9zCvrm1YDCDYIwJgq1Xi5_P2tbWRSs_wIq90UWGIkVnNAv-uNTDiTyu8hvxqca1vqIDfpnfRwuOO-pGi6P3Z07XvXfg2tr-Bu0ALwJK-6EwB3hUO-CNZrXBJd_56LLr9qPhQ3e9KEVWu3gUfxzGV06HsZvYOFYxysR7MlTswiiwvR5FgE7YBS4izp80kPGV3QbbYCYlBYLGp52DZ1bWyCGo5ZSpPAt4Az9wdDTzJoTtflLymg8kZ-idQqk2_re214xQgeCuVAHujjC4r3GqSzbQGUqXicd-rbRLenyB22Ul8wyHqY8WtcFrGmHojK8b-W3M9m0-xYkMXmWcllYQuQ0LMP9K8Tl0uMpKsyd0AePItaWa_ft3dAzoBiUZA15X2_Nbbc9WbkmjN0Et8E1RWlrL5fzppbvLUl4mlSKHsLnwgmLx2OROjEnQsfzjMGxV2KhMZXzdvbRPTkaDtq3YT70ZiRIyvRD"
174+
jwkP = "yO5hho-83vQQ3t7HeVeinZClemDazWT5T7f2ZVMigcuyUNQjC69tyMzJ3I_UN5nUCwpKCw5wY8uCeT82o1j-OJC3irxWjAPHkkbsYTNxRnk8ShJ2UFdu5a7MEF82-QuRKciAv11cebEpk5ggf-jQrtTY2yQru0fW0WZB8hz19XywhFQ_mVMMahNHfycfXT2BMaV0wiBFKY8FXKqb5cErsCodcZ_STvqOTykWBaA4AWmJFRqd4i4enpf-MhgtkQK3"
175+
jwkQ = "veD3yFnEOZegVIpIxPqIsj7zazjKRn-io1s3KJxkgaz5ND1o1JwbxiLuUNL9ufkj6cPOVCEHRkjQ2GabHnA0NYci4qRHBWdHhCD7aisS2D60xZAiAVmNZlEGLxRS7gFnyD8uneLILFFMalvJdIccCXzN3c8vPlC_9FlEzaEyDUmWzT_1zZES2GpaYeC73fNg7h-mJ6m-96Y6Wwvlx6YlCRCIPLU7l4kA-jca37T0IMNhobWmg8u4yqvVaqdDhojD"
176+
)

0 commit comments

Comments
 (0)