Skip to content

Commit a87ada5

Browse files
committed
Add dangerouslyAcceptIssuerAudience config option
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 a87ada5

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)