Skip to content

Commit bf067aa

Browse files
committed
add missing scopes in the response header
Signed-off-by: Huabing Zhao <[email protected]>
1 parent 56443ef commit bf067aa

File tree

8 files changed

+153
-19
lines changed

8 files changed

+153
-19
lines changed

internal/controller/gateway.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,10 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig {
476476
authorization := route.Spec.SecurityPolicy.Authorization
477477
mcpRoute.Authorization = &filterapi.MCPRouteAuthorization{}
478478

479+
if route.Spec.SecurityPolicy.OAuth != nil {
480+
mcpRoute.Authorization.ResourceMetadataURL = buildResourceMetadataURL(&route.Spec.SecurityPolicy.OAuth.ProtectedResourceMetadata)
481+
}
482+
479483
for _, rule := range authorization.Rules {
480484
scopes := make([]string, len(rule.Source.JWTSource.Scopes))
481485
for i, scope := range rule.Source.JWTSource.Scopes {

internal/controller/mcp_route_security_policy.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,13 +281,11 @@ func (c *MCPRouteController) ensureOAuthProtectedResourceMetadataBTP(ctx context
281281
return nil
282282
}
283283

284-
// buildWWWAuthenticateHeaderValue constructs the WWW-Authenticate header value according to RFC 9728.
284+
// buildResourceMetadataURL constructs the OAuth protected resource metadata URL using the resource identifier.
285285
// References:
286286
// * https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location
287287
// * https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
288-
func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata) string {
289-
// Build resource metadata URL using RFC 8414 compliant pattern.
290-
// Extract base URL and path from resource identifier.
288+
func buildResourceMetadataURL(metadata *aigv1a1.ProtectedResourceMetadata) string {
291289
resourceURL := strings.TrimSuffix(metadata.Resource, "/")
292290

293291
var (
@@ -316,7 +314,15 @@ func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata
316314
// they should honor hte value returned here.
317315
// We can't expose these resource at the root, because there may be multiple MCP routes with different OAuth settings, so we need
318316
// to rely on clients properly implementing the spec and using this value returned in the header.
319-
resourceMetadataURL := fmt.Sprintf("%s%s%s", baseURL, oauthWellKnownProtectedResourceMetadataPath, pathComponent)
317+
return fmt.Sprintf("%s%s%s", baseURL, oauthWellKnownProtectedResourceMetadataPath, pathComponent)
318+
}
319+
320+
// buildWWWAuthenticateHeaderValue constructs the WWW-Authenticate header value according to RFC 9728.
321+
// References:
322+
// * https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location
323+
// * https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
324+
func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata) string {
325+
resourceMetadataURL := buildResourceMetadataURL(metadata)
320326
// Build the basic Bearer challenge.
321327
headerValue := `Bearer error="invalid_request", error_description="No access token was provided in this request"`
322328

internal/filterapi/mcpconfig.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,14 @@ type MCPRouteName = string
6565
// MCPRouteAuthorization defines the authorization configuration for a MCPRoute.
6666
type MCPRouteAuthorization struct {
6767
// 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.
68+
// Requests that match any rule and satisfy the rule's conditions will be allowed.
69+
// Requests that do not match any rule or fail to satisfy the matched rule's conditions will be denied.
70+
// If no rules are defined, all requests will be denied.
7071
Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"`
72+
73+
// ResourceMetadataURL is the URI of the OAuth Protected Resource Metadata document for this route.
74+
// This is used to populate the WWW-Authenticate header when scope-based authorization fails.
75+
ResourceMetadataURL string `json:"resourceMetadataURL,omitempty"`
7176
}
7277

7378
// MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec.

internal/mcpproxy/authorization.go

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package mcpproxy
88
import (
99
"encoding/json"
1010
"errors"
11+
"fmt"
1112
"log/slog"
1213
"net/http"
1314
"regexp"
@@ -20,14 +21,15 @@ import (
2021
)
2122

2223
// authorizeRequest authorizes the request based on the given MCPRouteAuthorization configuration.
23-
func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string, argments any) bool {
24+
25+
func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string, argments any) (bool, []string) {
2426
if authorization == nil {
25-
return true
27+
return true, nil
2628
}
2729

2830
// If no rules are defined, deny all requests.
2931
if len(authorization.Rules) == 0 {
30-
return false
32+
return false, nil
3133
}
3234

3335
// If the rules are defined, a valid bearer token is required.
@@ -36,17 +38,18 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati
3638
// should always be present and valid.
3739
if err != nil {
3840
m.l.Info("missing or invalid bearer token", slog.String("error", err.Error()))
39-
return false
41+
return false, nil
4042
}
4143

4244
claims := jwt.MapClaims{}
4345
// JWT verification is performed by Envoy before reaching here. So we only need to parse the token without verification.
4446
if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil {
4547
m.l.Info("failed to parse JWT token", slog.String("error", err.Error()))
46-
return false
48+
return false, nil
4749
}
4850

4951
scopeSet := sets.New[string](extractScopes(claims)...)
52+
var missingScopesForChallenge []string
5053

5154
for _, rule := range authorization.Rules {
5255
var args map[string]any
@@ -58,12 +61,19 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati
5861
if !m.toolMatches(filterapi.ToolCall{BackendName: backendName, ToolName: toolName}, rule.Target.Tools, args) {
5962
continue
6063
}
61-
if scopesSatisfied(scopeSet, rule.Source.JWTSource.Scopes) {
62-
return true
64+
65+
requiredScopes := rule.Source.JWTSource.Scopes
66+
if scopesSatisfied(scopeSet, requiredScopes) {
67+
return true, nil
68+
}
69+
70+
// Keep track of the smallest set of missing scopes for challenge.
71+
if len(missingScopesForChallenge) == 0 || len(requiredScopes) < len(missingScopesForChallenge) {
72+
missingScopesForChallenge = requiredScopes
6373
}
6474
}
6575

66-
return false
76+
return false, missingScopesForChallenge
6777
}
6878

6979
func bearerToken(header string) (string, error) {
@@ -172,3 +182,16 @@ func scopesSatisfied(have sets.Set[string], required []string) bool {
172182
}
173183
return true
174184
}
185+
186+
// buildInsufficientScopeHeader builds the WWW-Authenticate header value for insufficient scope errors.
187+
// Reference: https://mcp.mintlify.app/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors
188+
func buildInsufficientScopeHeader(scopes []string, resourceMetadata string) string {
189+
parts := []string{`Bearer error="insufficient_scope"`}
190+
parts = append(parts, fmt.Sprintf(`scope="%s"`, strings.Join(scopes, " ")))
191+
if resourceMetadata != "" {
192+
parts = append(parts, fmt.Sprintf(`resource_metadata="%s"`, resourceMetadata))
193+
}
194+
parts = append(parts, `error_description="The token is missing required scopes"`)
195+
196+
return strings.Join(parts, ", ")
197+
}

internal/mcpproxy/authorization_test.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"log/slog"
1111
"net/http"
12+
"reflect"
1213
"testing"
1314

1415
"github.com/golang-jwt/jwt/v5"
@@ -38,6 +39,7 @@ func TestAuthorizeRequest(t *testing.T) {
3839
toolName string
3940
args map[string]any
4041
expectAllowed bool
42+
expectScopes []string
4143
}{
4244
{
4345
name: "matching tool and scope",
@@ -57,6 +59,7 @@ func TestAuthorizeRequest(t *testing.T) {
5759
backendName: "backend1",
5860
toolName: "tool1",
5961
expectAllowed: true,
62+
expectScopes: nil,
6063
},
6164
{
6265
name: "matching tool scope and arguments regex",
@@ -89,6 +92,7 @@ func TestAuthorizeRequest(t *testing.T) {
8992
"debug": "true",
9093
},
9194
expectAllowed: true,
95+
expectScopes: nil,
9296
},
9397
{
9498
name: "numeric argument matches via JSON string",
@@ -115,6 +119,7 @@ func TestAuthorizeRequest(t *testing.T) {
115119
toolName: "tool1",
116120
args: map[string]any{"count": 42},
117121
expectAllowed: true,
122+
expectScopes: nil,
118123
},
119124
{
120125
name: "object argument can be matched via JSON string",
@@ -149,6 +154,7 @@ func TestAuthorizeRequest(t *testing.T) {
149154
},
150155
},
151156
expectAllowed: true,
157+
expectScopes: nil,
152158
},
153159
{
154160
name: "matching tool but insufficient scopes not allowed",
@@ -168,6 +174,7 @@ func TestAuthorizeRequest(t *testing.T) {
168174
backendName: "backend1",
169175
toolName: "tool1",
170176
expectAllowed: false,
177+
expectScopes: []string{"read", "write"},
171178
},
172179
{
173180
name: "argument regex mismatch denied",
@@ -196,6 +203,7 @@ func TestAuthorizeRequest(t *testing.T) {
196203
"mode": "other",
197204
},
198205
expectAllowed: false,
206+
expectScopes: nil,
199207
},
200208
{
201209
name: "missing argument denies when required",
@@ -222,6 +230,7 @@ func TestAuthorizeRequest(t *testing.T) {
222230
toolName: "tool1",
223231
args: map[string]any{},
224232
expectAllowed: false,
233+
expectScopes: nil,
225234
},
226235
{
227236
name: "no matching rule falls back to default deny - tool mismatch",
@@ -241,6 +250,7 @@ func TestAuthorizeRequest(t *testing.T) {
241250
backendName: "backend1",
242251
toolName: "other-tool",
243252
expectAllowed: false,
253+
expectScopes: nil,
244254
},
245255
{
246256
name: "no matching rule falls back to default deny - scope mismatch",
@@ -260,6 +270,7 @@ func TestAuthorizeRequest(t *testing.T) {
260270
backendName: "backend1",
261271
toolName: "other-tool",
262272
expectAllowed: false,
273+
expectScopes: nil,
263274
},
264275
{
265276
name: "no rules falls back to default deny",
@@ -268,6 +279,7 @@ func TestAuthorizeRequest(t *testing.T) {
268279
backendName: "backend1",
269280
toolName: "tool1",
270281
expectAllowed: false,
282+
expectScopes: nil,
271283
},
272284
{
273285
name: "no bearer token not allowed when rules exist",
@@ -287,6 +299,7 @@ func TestAuthorizeRequest(t *testing.T) {
287299
backendName: "backend1",
288300
toolName: "tool1",
289301
expectAllowed: false,
302+
expectScopes: nil,
290303
},
291304
{
292305
name: "invalid bearer token not allowed when rules exist",
@@ -306,6 +319,27 @@ func TestAuthorizeRequest(t *testing.T) {
306319
backendName: "backend1",
307320
toolName: "tool1",
308321
expectAllowed: false,
322+
expectScopes: nil,
323+
},
324+
{
325+
name: "selects smallest required scope set when multiple rules match",
326+
auth: &filterapi.MCPRouteAuthorization{
327+
Rules: []filterapi.MCPRouteAuthorizationRule{
328+
{
329+
Source: filterapi.MCPAuthorizationSource{JWTSource: filterapi.JWTSource{Scopes: []string{"alpha", "beta", "gamma"}}},
330+
Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}},
331+
},
332+
{
333+
Source: filterapi.MCPAuthorizationSource{JWTSource: filterapi.JWTSource{Scopes: []string{"alpha", "beta"}}},
334+
Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}},
335+
},
336+
},
337+
},
338+
header: "Bearer " + makeToken("alpha"),
339+
backendName: "backend1",
340+
toolName: "tool1",
341+
expectAllowed: false,
342+
expectScopes: []string{"alpha", "beta"},
309343
},
310344
}
311345

@@ -315,10 +349,25 @@ func TestAuthorizeRequest(t *testing.T) {
315349
if tt.header != "" {
316350
headers.Set("Authorization", tt.header)
317351
}
318-
allowed := proxy.authorizeRequest(tt.auth, headers, tt.backendName, tt.toolName, tt.args)
352+
allowed, requiredScopes := proxy.authorizeRequest(tt.auth, headers, tt.backendName, tt.toolName, tt.args)
319353
if allowed != tt.expectAllowed {
320354
t.Fatalf("expected %v, got %v", tt.expectAllowed, allowed)
321355
}
356+
if !reflect.DeepEqual(requiredScopes, tt.expectScopes) {
357+
t.Fatalf("expected required scopes %v, got %v", tt.expectScopes, requiredScopes)
358+
}
322359
})
323360
}
324361
}
362+
363+
func TestBuildInsufficientScopeHeader(t *testing.T) {
364+
const resourceMetadata = "https://api.example.com/.well-known/oauth-protected-resource/mcp"
365+
366+
t.Run("with scopes and resource metadata", func(t *testing.T) {
367+
header := buildInsufficientScopeHeader([]string{"read", "write"}, resourceMetadata)
368+
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"`
369+
if header != expected {
370+
t.Fatalf("expected %q, got %q", expected, header)
371+
}
372+
})
373+
}

internal/mcpproxy/handlers.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,15 @@ func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http
542542

543543
// Enforce authentication if required by the route.
544544
if route.authorization != nil {
545-
if !m.authorizeRequest(route.authorization, headers, backendName, toolName, p.Arguments) {
545+
allowed, requiredScopes := m.authorizeRequest(route.authorization, headers, backendName, toolName, p.Arguments)
546+
if !allowed {
547+
// Specify the minimum required scopes in the WWW-Authenticate header.
548+
// Reference: https://mcp.mintlify.app/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors
549+
if len(requiredScopes) > 0 {
550+
if challenge := buildInsufficientScopeHeader(requiredScopes, route.authorization.ResourceMetadataURL); challenge != "" {
551+
w.Header().Set("WWW-Authenticate", challenge)
552+
}
553+
}
546554
onErrorResponse(w, http.StatusForbidden, "authorization failed")
547555
return fmt.Errorf("authorization failed")
548556
}

tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ spec:
1919
port: 80
2020
securityPolicy:
2121
authorization:
22-
defaultAction: Deny
2322
rules:
2423
- source:
2524
jwtSource:
@@ -29,4 +28,3 @@ spec:
2928
tools:
3029
- backendName: mcp-service
3130
toolName: echo
32-
action: Allow

tests/e2e/mcp_route_authorization_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package e2e
77

88
import (
9+
"bytes"
910
"context"
1011
"crypto/rsa"
1112
"encoding/base64"
@@ -167,6 +168,46 @@ func TestMCPRouteAuthorization(t *testing.T) {
167168
errMsg := strings.ToLower(err.Error())
168169
require.True(t, strings.Contains(errMsg, "403") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err)
169170
})
171+
172+
t.Run("WWW-Authenticate on insufficient scope", func(t *testing.T) {
173+
token := makeSignedJWT(t, "sum") // only sum scope; echo requires echo
174+
authHTTPClient := &http.Client{
175+
Timeout: 10 * time.Second,
176+
Transport: &bearerTokenTransport{
177+
token: token,
178+
},
179+
}
180+
181+
routeHeader := "default/mcp-route-authorization-default-deny"
182+
183+
// First, initialize a session to obtain a session ID header.
184+
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
185+
t.Cleanup(cancel)
186+
187+
sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient)
188+
t.Cleanup(func() {
189+
_ = sess.Close()
190+
})
191+
192+
// Now call a tool that requires a missing scope to trigger insufficient_scope.
193+
reqBody := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"mcp-backend-authorization__echo","arguments":{"text":"Hello, world!"}}}`)
194+
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), bytes.NewReader(reqBody))
195+
require.NoError(t, err)
196+
req.Header.Set("Authorization", "Bearer "+token)
197+
req.Header.Set("Content-Type", "application/json")
198+
req.Header.Set("mcp-session-id", sess.ID())
199+
req.Header.Set("x-ai-eg-mcp-route", routeHeader)
200+
201+
resp, err := http.DefaultClient.Do(req)
202+
require.NoError(t, err)
203+
defer resp.Body.Close()
204+
205+
require.Equal(t, http.StatusForbidden, resp.StatusCode)
206+
wwwAuth := resp.Header.Get("WWW-Authenticate")
207+
require.Contains(t, wwwAuth, `error="insufficient_scope"`)
208+
require.Contains(t, wwwAuth, `scope="echo"`) // expected missing scope
209+
require.Contains(t, wwwAuth, `resource_metadata="https://foo.bar.com/.well-known/oauth-protected-resource/mcp"`)
210+
})
170211
}
171212

172213
func requireConnectMCP(ctx context.Context, t *testing.T, client *mcp.Client, endpoint string, httpClient *http.Client) *mcp.ClientSession {

0 commit comments

Comments
 (0)