Skip to content

Commit 8e589d5

Browse files
authored
Add annoying workaround (#35)
Workaround for MCP clients (like Claude Code) that don't properly implement RFC 8707 resource indicators. When enabled, tokens with just the base issuer as audience are accepted for any service.
1 parent 8e5f919 commit 8e589d5

File tree

6 files changed

+135
-57
lines changed

6 files changed

+135
-57
lines changed

internal/config/types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@ type OAuthAuthConfig struct {
216216
GoogleRedirectURI string `json:"googleRedirectUri"`
217217
JWTSecret Secret `json:"jwtSecret"`
218218
EncryptionKey Secret `json:"encryptionKey"`
219+
// DangerouslyAcceptIssuerAudience allows tokens with just the base issuer as audience
220+
// to be accepted for any service. This is a workaround for MCP clients that don't
221+
// properly implement RFC 8707 resource indicators, but it defeats per-service token
222+
// isolation. Only enable this if you understand the security implications.
223+
DangerouslyAcceptIssuerAudience bool `json:"dangerouslyAcceptIssuerAudience,omitempty"`
219224
}
220225

221226
// ProxyConfig represents the proxy configuration with resolved values

internal/mcpfront.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"github.com/dgellow/mcp-front/internal/config"
1616
"github.com/dgellow/mcp-front/internal/crypto"
1717
"github.com/dgellow/mcp-front/internal/inline"
18-
jsonwriter "github.com/dgellow/mcp-front/internal/json"
1918
"github.com/dgellow/mcp-front/internal/log"
2019
"github.com/dgellow/mcp-front/internal/oauth"
2120
"github.com/dgellow/mcp-front/internal/server"
@@ -349,10 +348,9 @@ func buildHTTPHandler(
349348
// Per-service protected resource metadata (RFC 9728 Section 5.2)
350349
// Clients discover service-specific resource URIs for per-service audience validation (RFC 8707)
351350
mux.Handle(route("/.well-known/oauth-protected-resource/{service}"), server.ChainMiddleware(http.HandlerFunc(authHandlers.ServiceProtectedResourceMetadataHandler), oauthMiddleware...))
352-
// Base protected resource metadata endpoint - returns 404 directing clients to per-service endpoints
353-
mux.Handle(route("/.well-known/oauth-protected-resource"), server.ChainMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
354-
jsonwriter.WriteNotFound(w, "Use /.well-known/oauth-protected-resource/{service} for per-service metadata")
355-
}), oauthMiddleware...))
351+
// Base protected resource metadata endpoint
352+
// Returns 404 by default, or base issuer metadata if dangerouslyAcceptIssuerAudience is enabled
353+
mux.Handle(route("/.well-known/oauth-protected-resource"), server.ChainMiddleware(http.HandlerFunc(authHandlers.ProtectedResourceMetadataHandler), oauthMiddleware...))
356354
mux.Handle(route("/authorize"), server.ChainMiddleware(http.HandlerFunc(authHandlers.AuthorizeHandler), oauthMiddleware...))
357355
mux.Handle(route("/oauth/callback"), server.ChainMiddleware(http.HandlerFunc(authHandlers.GoogleCallbackHandler), oauthMiddleware...))
358356
mux.Handle(route("/token"), server.ChainMiddleware(http.HandlerFunc(authHandlers.TokenHandler), oauthMiddleware...))
@@ -439,7 +437,7 @@ func buildHTTPHandler(
439437

440438
// Add OAuth validation if OAuth is enabled
441439
if oauthProvider != nil {
442-
mcpMiddlewares = append(mcpMiddlewares, oauth.NewValidateTokenMiddleware(oauthProvider, authConfig.Issuer))
440+
mcpMiddlewares = append(mcpMiddlewares, oauth.NewValidateTokenMiddleware(oauthProvider, authConfig.Issuer, authConfig.DangerouslyAcceptIssuerAudience))
443441
}
444442

445443
// Add service auth middleware if configured

internal/oauth/provider.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,11 @@ func GenerateJWTSecret(providedSecret string) ([]byte, error) {
118118
// The middleware dynamically builds the service-specific metadata URI based on the
119119
// request path, ensuring 401 responses point clients to the correct per-service
120120
// protected resource metadata endpoint.
121-
func NewValidateTokenMiddleware(provider fosite.OAuth2Provider, issuer string) func(http.Handler) http.Handler {
121+
//
122+
// If acceptIssuerAudience is true, tokens with just the base issuer as audience are
123+
// accepted for any service. This is a workaround for MCP clients that don't properly
124+
// implement RFC 8707 resource indicators.
125+
func NewValidateTokenMiddleware(provider fosite.OAuth2Provider, issuer string, acceptIssuerAudience bool) func(http.Handler) http.Handler {
122126
return func(next http.Handler) http.Handler {
123127
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124128
ctx := r.Context()
@@ -162,7 +166,7 @@ func NewValidateTokenMiddleware(provider fosite.OAuth2Provider, issuer string) f
162166
}
163167

164168
// Validate audience claim matches requested service (RFC 8707)
165-
if err := ValidateAudienceForService(r.URL.Path, accessRequest.GetGrantedAudience(), issuer); err != nil {
169+
if err := ValidateAudienceForService(r.URL.Path, accessRequest.GetGrantedAudience(), issuer, acceptIssuerAudience); err != nil {
166170
log.LogErrorWithFields("oauth", "Audience validation failed", map[string]any{
167171
"path": r.URL.Path,
168172
"audience": accessRequest.GetGrantedAudience(),

internal/oauth/resource_indicators.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,21 @@ func BuildResourceURI(issuer string, serviceName string) (string, error) {
157157
// in the token's audience claim. This prevents confused deputy attacks where a token
158158
// intended for one service is misused to access another.
159159
//
160+
// If acceptIssuerAudience is true, the base issuer URI is also accepted as a valid audience
161+
// for any service. This is a workaround for MCP clients that don't properly implement
162+
// RFC 8707 resource indicators, but it defeats per-service token isolation.
163+
//
160164
// Example:
161165
//
162-
// ValidateAudienceForService("/postgres/sse", []string{"https://mcp.company.com/postgres"}, "https://mcp.company.com")
166+
// ValidateAudienceForService("/postgres/sse", []string{"https://mcp.company.com/postgres"}, "https://mcp.company.com", false)
163167
// Returns: nil (valid - postgres is in audience)
164168
//
165-
// ValidateAudienceForService("/linear/sse", []string{"https://mcp.company.com/postgres"}, "https://mcp.company.com")
169+
// ValidateAudienceForService("/linear/sse", []string{"https://mcp.company.com/postgres"}, "https://mcp.company.com", false)
166170
// Returns: error (invalid - linear not in audience)
167-
func ValidateAudienceForService(requestPath string, tokenAudience []string, issuer string) error {
171+
//
172+
// ValidateAudienceForService("/linear/sse", []string{"https://mcp.company.com"}, "https://mcp.company.com", true)
173+
// Returns: nil (valid - issuer is in audience and acceptIssuerAudience is true)
174+
func ValidateAudienceForService(requestPath string, tokenAudience []string, issuer string, acceptIssuerAudience bool) error {
168175
u, err := url.Parse(issuer)
169176
if err != nil {
170177
return fmt.Errorf("invalid issuer URL: %w", err)
@@ -193,10 +200,16 @@ func ValidateAudienceForService(requestPath string, tokenAudience []string, issu
193200
return fmt.Errorf("failed to build resource URI for service %s: %w", serviceName, err)
194201
}
195202

203+
// Check for per-service audience (preferred)
196204
if slices.Contains(tokenAudience, expectedResource) {
197205
return nil
198206
}
199207

208+
// Workaround: accept base issuer as audience if enabled
209+
if acceptIssuerAudience && slices.Contains(tokenAudience, issuer) {
210+
return nil
211+
}
212+
200213
return fmt.Errorf("token audience %v does not include required resource %s for service %s",
201214
tokenAudience, expectedResource, serviceName)
202215
}

internal/oauth/resource_indicators_test.go

Lines changed: 90 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -253,70 +253,117 @@ func TestValidateAudienceForService(t *testing.T) {
253253
issuer := "https://mcp.company.com"
254254

255255
tests := []struct {
256-
name string
257-
requestPath string
258-
tokenAudience []string
259-
wantErr bool
260-
errContains string
256+
name string
257+
requestPath string
258+
tokenAudience []string
259+
acceptIssuerAudience bool
260+
wantErr bool
261+
errContains string
261262
}{
262263
{
263-
name: "matching audience",
264-
requestPath: "/postgres/sse",
265-
tokenAudience: []string{"https://mcp.company.com/postgres"},
266-
wantErr: false,
264+
name: "matching audience",
265+
requestPath: "/postgres/sse",
266+
tokenAudience: []string{"https://mcp.company.com/postgres"},
267+
acceptIssuerAudience: false,
268+
wantErr: false,
267269
},
268270
{
269-
name: "matching audience with message endpoint",
270-
requestPath: "/postgres/message",
271-
tokenAudience: []string{"https://mcp.company.com/postgres"},
272-
wantErr: false,
271+
name: "matching audience with message endpoint",
272+
requestPath: "/postgres/message",
273+
tokenAudience: []string{"https://mcp.company.com/postgres"},
274+
acceptIssuerAudience: false,
275+
wantErr: false,
273276
},
274277
{
275-
name: "matching audience in multi-audience token",
276-
requestPath: "/postgres/sse",
277-
tokenAudience: []string{"https://mcp.company.com/linear", "https://mcp.company.com/postgres"},
278-
wantErr: false,
278+
name: "matching audience in multi-audience token",
279+
requestPath: "/postgres/sse",
280+
tokenAudience: []string{"https://mcp.company.com/linear", "https://mcp.company.com/postgres"},
281+
acceptIssuerAudience: false,
282+
wantErr: false,
279283
},
280284
{
281-
name: "wrong audience",
282-
requestPath: "/postgres/sse",
283-
tokenAudience: []string{"https://mcp.company.com/linear"},
284-
wantErr: true,
285-
errContains: "does not include required resource",
285+
name: "wrong audience",
286+
requestPath: "/postgres/sse",
287+
tokenAudience: []string{"https://mcp.company.com/linear"},
288+
acceptIssuerAudience: false,
289+
wantErr: true,
290+
errContains: "does not include required resource",
286291
},
287292
{
288-
name: "empty audience",
289-
requestPath: "/postgres/sse",
290-
tokenAudience: []string{},
291-
wantErr: true,
292-
errContains: "does not include required resource",
293+
name: "empty audience",
294+
requestPath: "/postgres/sse",
295+
tokenAudience: []string{},
296+
acceptIssuerAudience: false,
297+
wantErr: true,
298+
errContains: "does not include required resource",
293299
},
294300
{
295-
name: "nil audience",
296-
requestPath: "/postgres/sse",
297-
tokenAudience: nil,
298-
wantErr: true,
299-
errContains: "does not include required resource",
301+
name: "nil audience",
302+
requestPath: "/postgres/sse",
303+
tokenAudience: nil,
304+
acceptIssuerAudience: false,
305+
wantErr: true,
306+
errContains: "does not include required resource",
300307
},
301308
{
302-
name: "malformed request path",
303-
requestPath: "/",
304-
tokenAudience: []string{"https://mcp.company.com/postgres"},
305-
wantErr: true,
306-
errContains: "does not contain service name",
309+
name: "malformed request path",
310+
requestPath: "/",
311+
tokenAudience: []string{"https://mcp.company.com/postgres"},
312+
acceptIssuerAudience: false,
313+
wantErr: true,
314+
errContains: "does not contain service name",
307315
},
308316
{
309-
name: "empty request path",
310-
requestPath: "",
311-
tokenAudience: []string{"https://mcp.company.com/postgres"},
312-
wantErr: true,
313-
errContains: "does not contain service name",
317+
name: "empty request path",
318+
requestPath: "",
319+
tokenAudience: []string{"https://mcp.company.com/postgres"},
320+
acceptIssuerAudience: false,
321+
wantErr: true,
322+
errContains: "does not contain service name",
323+
},
324+
// Tests for acceptIssuerAudience workaround
325+
{
326+
name: "issuer audience accepted when enabled",
327+
requestPath: "/postgres/sse",
328+
tokenAudience: []string{"https://mcp.company.com"},
329+
acceptIssuerAudience: true,
330+
wantErr: false,
331+
},
332+
{
333+
name: "issuer audience rejected when disabled",
334+
requestPath: "/postgres/sse",
335+
tokenAudience: []string{"https://mcp.company.com"},
336+
acceptIssuerAudience: false,
337+
wantErr: true,
338+
errContains: "does not include required resource",
339+
},
340+
{
341+
name: "per-service audience still works when issuer fallback enabled",
342+
requestPath: "/postgres/sse",
343+
tokenAudience: []string{"https://mcp.company.com/postgres"},
344+
acceptIssuerAudience: true,
345+
wantErr: false,
346+
},
347+
{
348+
name: "issuer audience works for any service when enabled",
349+
requestPath: "/linear/sse",
350+
tokenAudience: []string{"https://mcp.company.com"},
351+
acceptIssuerAudience: true,
352+
wantErr: false,
353+
},
354+
{
355+
name: "wrong issuer still rejected even when fallback enabled",
356+
requestPath: "/postgres/sse",
357+
tokenAudience: []string{"https://other.company.com"},
358+
acceptIssuerAudience: true,
359+
wantErr: true,
360+
errContains: "does not include required resource",
314361
},
315362
}
316363

317364
for _, tt := range tests {
318365
t.Run(tt.name, func(t *testing.T) {
319-
err := ValidateAudienceForService(tt.requestPath, tt.tokenAudience, issuer)
366+
err := ValidateAudienceForService(tt.requestPath, tt.tokenAudience, issuer, tt.acceptIssuerAudience)
320367
if (err != nil) != tt.wantErr {
321368
t.Errorf("ValidateAudienceForService() error = %v, wantErr %v", err, tt.wantErr)
322369
return

internal/server/auth_handlers.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,23 @@ func (h *AuthHandlers) WellKnownHandler(w http.ResponseWriter, r *http.Request)
8686
// ProtectedResourceMetadataHandler serves OAuth 2.0 Protected Resource Metadata (RFC 9728)
8787
// This endpoint helps clients discover which authorization servers this resource server trusts
8888
//
89-
// Deprecated: Use ServiceProtectedResourceMetadataHandler for per-service metadata.
90-
// This handler returns the base issuer as the resource, which doesn't support per-service
91-
// audience validation required by RFC 8707.
89+
// By default, this returns 404 directing clients to per-service metadata endpoints.
90+
// When DangerouslyAcceptIssuerAudience is enabled, it returns base issuer metadata as a
91+
// workaround for MCP clients that don't properly implement RFC 8707 resource indicators.
9292
func (h *AuthHandlers) ProtectedResourceMetadataHandler(w http.ResponseWriter, r *http.Request) {
9393
log.Logf("Protected resource metadata handler called: %s %s", r.Method, r.URL.Path)
9494

95+
// By default, return 404 to direct clients to per-service metadata
96+
if !h.authConfig.DangerouslyAcceptIssuerAudience {
97+
jsonwriter.WriteNotFound(w, "Use /.well-known/oauth-protected-resource/{service} for per-service metadata")
98+
return
99+
}
100+
101+
// Workaround mode: return base issuer metadata for broken clients
102+
log.LogWarnWithFields("oauth", "Serving base protected resource metadata (dangerouslyAcceptIssuerAudience enabled)", map[string]any{
103+
"issuer": h.authConfig.Issuer,
104+
})
105+
95106
metadata, err := oauth.ProtectedResourceMetadata(h.authConfig.Issuer)
96107
if err != nil {
97108
log.LogError("Failed to build protected resource metadata: %v", err)

0 commit comments

Comments
 (0)