Skip to content

Commit 8e5f919

Browse files
authored
Implement per-service protected resource metadata (RFC 9728) (#34)
401 responses now include service-specific metadata URIs that advertise the correct per-service resource, so clients request tokens with the right audience claim.
1 parent 9839592 commit 8e5f919

File tree

7 files changed

+434
-12
lines changed

7 files changed

+434
-12
lines changed

integration/oauth_test.go

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,22 +1403,50 @@ func TestRFC8707ResourceIndicators(t *testing.T) {
14031403

14041404
waitForMCPFront(t)
14051405

1406-
t.Run("ProtectedResourceMetadataEndpoint", func(t *testing.T) {
1406+
t.Run("BaseProtectedResourceMetadataReturns404", func(t *testing.T) {
1407+
// Base metadata endpoint should return 404, directing clients to per-service endpoints
14071408
resp, err := http.Get("http://localhost:8080/.well-known/oauth-protected-resource")
14081409
require.NoError(t, err)
14091410
defer resp.Body.Close()
14101411

1411-
assert.Equal(t, 200, resp.StatusCode, "Protected resource metadata endpoint should exist")
1412+
assert.Equal(t, 404, resp.StatusCode, "Base protected resource metadata endpoint should return 404")
1413+
1414+
var errResp map[string]any
1415+
err = json.NewDecoder(resp.Body).Decode(&errResp)
1416+
require.NoError(t, err)
1417+
1418+
assert.Contains(t, errResp["message"], "per-service", "Error message should direct to per-service endpoints")
1419+
})
1420+
1421+
t.Run("PerServiceProtectedResourceMetadataEndpoint", func(t *testing.T) {
1422+
// Per-service metadata endpoint should return service-specific resource URI
1423+
resp, err := http.Get("http://localhost:8080/.well-known/oauth-protected-resource/test-sse")
1424+
require.NoError(t, err)
1425+
defer resp.Body.Close()
1426+
1427+
assert.Equal(t, 200, resp.StatusCode, "Per-service protected resource metadata endpoint should exist")
14121428

14131429
var metadata map[string]any
14141430
err = json.NewDecoder(resp.Body).Decode(&metadata)
14151431
require.NoError(t, err)
14161432

1417-
assert.Equal(t, "http://localhost:8080", metadata["resource"])
1433+
// Resource should be service-specific, not base URL
1434+
assert.Equal(t, "http://localhost:8080/test-sse", metadata["resource"],
1435+
"Resource should be service-specific URL")
14181436

14191437
authzServers, ok := metadata["authorization_servers"].([]any)
14201438
require.True(t, ok, "Should have authorization_servers array")
14211439
require.NotEmpty(t, authzServers)
1440+
assert.Equal(t, "http://localhost:8080", authzServers[0],
1441+
"Authorization server should be base issuer")
1442+
})
1443+
1444+
t.Run("UnknownServiceReturns404", func(t *testing.T) {
1445+
resp, err := http.Get("http://localhost:8080/.well-known/oauth-protected-resource/nonexistent-service")
1446+
require.NoError(t, err)
1447+
defer resp.Body.Close()
1448+
1449+
assert.Equal(t, 404, resp.StatusCode, "Unknown service should return 404")
14221450
})
14231451

14241452
t.Run("TokenWithResourceParameter", func(t *testing.T) {
@@ -1517,5 +1545,33 @@ func TestRFC8707ResourceIndicators(t *testing.T) {
15171545
wwwAuth := streamableResp.Header.Get("WWW-Authenticate")
15181546
assert.Contains(t, wwwAuth, "Bearer resource_metadata=",
15191547
"401 response should include RFC 9728 WWW-Authenticate header")
1548+
// Per RFC 9728 Section 5.2, the metadata URI should be service-specific
1549+
assert.Contains(t, wwwAuth, "/.well-known/oauth-protected-resource/test-streamable",
1550+
"401 response should point to per-service metadata endpoint")
1551+
})
1552+
1553+
t.Run("401ResponseIncludesServiceSpecificMetadataURI", func(t *testing.T) {
1554+
// Request to a protected endpoint without token should get 401
1555+
// with service-specific metadata URI in WWW-Authenticate header
1556+
client := &http.Client{
1557+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
1558+
return http.ErrUseLastResponse
1559+
},
1560+
}
1561+
1562+
req, _ := http.NewRequest("GET", "http://localhost:8080/test-sse/sse", nil)
1563+
req.Header.Set("Accept", "text/event-stream")
1564+
1565+
resp, err := client.Do(req)
1566+
require.NoError(t, err)
1567+
defer resp.Body.Close()
1568+
1569+
assert.Equal(t, 401, resp.StatusCode, "Request without token should return 401")
1570+
1571+
wwwAuth := resp.Header.Get("WWW-Authenticate")
1572+
assert.Contains(t, wwwAuth, "Bearer resource_metadata=",
1573+
"401 response should include RFC 9728 WWW-Authenticate header")
1574+
assert.Contains(t, wwwAuth, "/.well-known/oauth-protected-resource/test-sse",
1575+
"401 response should point to test-sse specific metadata endpoint")
15201576
})
15211577
}

internal/mcpfront.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ 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"
1819
"github.com/dgellow/mcp-front/internal/log"
1920
"github.com/dgellow/mcp-front/internal/oauth"
2021
"github.com/dgellow/mcp-front/internal/server"
@@ -345,7 +346,13 @@ func buildHTTPHandler(
345346

346347
// Register OAuth endpoints
347348
mux.Handle(route("/.well-known/oauth-authorization-server"), server.ChainMiddleware(http.HandlerFunc(authHandlers.WellKnownHandler), oauthMiddleware...))
348-
mux.Handle(route("/.well-known/oauth-protected-resource"), server.ChainMiddleware(http.HandlerFunc(authHandlers.ProtectedResourceMetadataHandler), oauthMiddleware...))
349+
// Per-service protected resource metadata (RFC 9728 Section 5.2)
350+
// Clients discover service-specific resource URIs for per-service audience validation (RFC 8707)
351+
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...))
349356
mux.Handle(route("/authorize"), server.ChainMiddleware(http.HandlerFunc(authHandlers.AuthorizeHandler), oauthMiddleware...))
350357
mux.Handle(route("/oauth/callback"), server.ChainMiddleware(http.HandlerFunc(authHandlers.GoogleCallbackHandler), oauthMiddleware...))
351358
mux.Handle(route("/token"), server.ChainMiddleware(http.HandlerFunc(authHandlers.TokenHandler), oauthMiddleware...))

internal/oauth/metadata.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ func AuthorizationServerMetadata(issuer string) (map[string]any, error) {
5555

5656
// ProtectedResourceMetadata builds OAuth 2.0 Protected Resource Metadata per RFC 9728
5757
// https://datatracker.ietf.org/doc/html/rfc9728
58+
//
59+
// Deprecated: Use ServiceProtectedResourceMetadata for per-service metadata endpoints.
60+
// This function returns the base issuer as the resource, which doesn't support
61+
// per-service audience validation required by RFC 8707.
5862
func ProtectedResourceMetadata(issuer string) (map[string]any, error) {
5963
authzServerURL, err := urlutil.JoinPath(issuer, ".well-known", "oauth-authorization-server")
6064
if err != nil {
@@ -74,6 +78,50 @@ func ProtectedResourceMetadata(issuer string) (map[string]any, error) {
7478
}, nil
7579
}
7680

81+
// ServiceProtectedResourceMetadata builds OAuth 2.0 Protected Resource Metadata per RFC 9728
82+
// for a specific service. Per RFC 9728 Section 5.2, multiple resources on a single host
83+
// use path-based differentiation, with each resource having its own metadata endpoint.
84+
//
85+
// Example:
86+
//
87+
// ServiceProtectedResourceMetadata("https://mcp.company.com", "postgres")
88+
// Returns: {"resource": "https://mcp.company.com/postgres", ...}
89+
func ServiceProtectedResourceMetadata(issuer string, serviceName string) (map[string]any, error) {
90+
resourceURI, err := urlutil.JoinPath(issuer, serviceName)
91+
if err != nil {
92+
return nil, err
93+
}
94+
95+
authzServerURL, err := urlutil.JoinPath(issuer, ".well-known", "oauth-authorization-server")
96+
if err != nil {
97+
return nil, err
98+
}
99+
100+
return map[string]any{
101+
"resource": resourceURI,
102+
"authorization_servers": []string{
103+
issuer,
104+
},
105+
"_links": map[string]any{
106+
"oauth-authorization-server": map[string]string{
107+
"href": authzServerURL,
108+
},
109+
},
110+
}, nil
111+
}
112+
113+
// ServiceProtectedResourceMetadataURI builds the URI for a service-specific protected
114+
// resource metadata endpoint per RFC 9728. Used in WWW-Authenticate headers to direct
115+
// clients to the correct per-service metadata endpoint.
116+
//
117+
// Example:
118+
//
119+
// ServiceProtectedResourceMetadataURI("https://mcp.company.com", "postgres")
120+
// Returns: "https://mcp.company.com/.well-known/oauth-protected-resource/postgres"
121+
func ServiceProtectedResourceMetadataURI(issuer string, serviceName string) (string, error) {
122+
return urlutil.JoinPath(issuer, ".well-known", "oauth-protected-resource", serviceName)
123+
}
124+
77125
// ClientMetadata represents OAuth 2.0 client metadata
78126
type ClientMetadata struct {
79127
ClientID string `json:"client_id"`

internal/oauth/metadata_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,143 @@ func TestProtectedResourceMetadata(t *testing.T) {
150150
})
151151
}
152152
}
153+
154+
func TestServiceProtectedResourceMetadata(t *testing.T) {
155+
tests := []struct {
156+
name string
157+
issuer string
158+
serviceName string
159+
wantResource string
160+
wantErr bool
161+
}{
162+
{
163+
name: "standard case",
164+
issuer: "https://mcp.company.com",
165+
serviceName: "postgres",
166+
wantResource: "https://mcp.company.com/postgres",
167+
},
168+
{
169+
name: "issuer with base path",
170+
issuer: "https://mcp.company.com/api",
171+
serviceName: "postgres",
172+
wantResource: "https://mcp.company.com/api/postgres",
173+
},
174+
{
175+
name: "issuer with trailing slash",
176+
issuer: "https://mcp.company.com/",
177+
serviceName: "linear",
178+
wantResource: "https://mcp.company.com/linear",
179+
},
180+
{
181+
name: "different service",
182+
issuer: "https://mcp.company.com",
183+
serviceName: "gong",
184+
wantResource: "https://mcp.company.com/gong",
185+
},
186+
{
187+
name: "invalid issuer",
188+
issuer: "://invalid",
189+
serviceName: "postgres",
190+
wantErr: true,
191+
},
192+
}
193+
194+
for _, tt := range tests {
195+
t.Run(tt.name, func(t *testing.T) {
196+
metadata, err := ServiceProtectedResourceMetadata(tt.issuer, tt.serviceName)
197+
if (err != nil) != tt.wantErr {
198+
t.Errorf("ServiceProtectedResourceMetadata() error = %v, wantErr %v", err, tt.wantErr)
199+
return
200+
}
201+
202+
if tt.wantErr {
203+
return
204+
}
205+
206+
// Verify resource field is service-specific
207+
resource := metadata["resource"].(string)
208+
if resource != tt.wantResource {
209+
t.Errorf("resource = %v, want %v", resource, tt.wantResource)
210+
}
211+
212+
// Verify authorization_servers array contains issuer (not service-specific)
213+
authzServers, ok := metadata["authorization_servers"].([]string)
214+
if !ok || len(authzServers) == 0 {
215+
t.Error("authorization_servers is missing or empty")
216+
}
217+
218+
// Authorization server should not be empty
219+
if authzServers[0] == "" {
220+
t.Error("authorization_servers[0] is empty")
221+
}
222+
223+
// Verify _links structure
224+
links, ok := metadata["_links"].(map[string]any)
225+
if !ok {
226+
t.Error("_links is missing or wrong type")
227+
}
228+
229+
authzServerLink, ok := links["oauth-authorization-server"].(map[string]string)
230+
if !ok {
231+
t.Error("oauth-authorization-server link is missing or wrong type")
232+
}
233+
234+
if authzServerLink["href"] == "" {
235+
t.Error("oauth-authorization-server href is empty")
236+
}
237+
})
238+
}
239+
}
240+
241+
func TestServiceProtectedResourceMetadataURI(t *testing.T) {
242+
tests := []struct {
243+
name string
244+
issuer string
245+
serviceName string
246+
want string
247+
wantErr bool
248+
}{
249+
{
250+
name: "standard case",
251+
issuer: "https://mcp.company.com",
252+
serviceName: "postgres",
253+
want: "https://mcp.company.com/.well-known/oauth-protected-resource/postgres",
254+
},
255+
{
256+
name: "issuer with base path",
257+
issuer: "https://mcp.company.com/mcp",
258+
serviceName: "linear",
259+
want: "https://mcp.company.com/mcp/.well-known/oauth-protected-resource/linear",
260+
},
261+
{
262+
name: "issuer with trailing slash",
263+
issuer: "https://mcp.company.com/",
264+
serviceName: "gong",
265+
want: "https://mcp.company.com/.well-known/oauth-protected-resource/gong",
266+
},
267+
{
268+
name: "invalid issuer",
269+
issuer: "://invalid",
270+
serviceName: "postgres",
271+
wantErr: true,
272+
},
273+
}
274+
275+
for _, tt := range tests {
276+
t.Run(tt.name, func(t *testing.T) {
277+
got, err := ServiceProtectedResourceMetadataURI(tt.issuer, tt.serviceName)
278+
if (err != nil) != tt.wantErr {
279+
t.Errorf("ServiceProtectedResourceMetadataURI() error = %v, wantErr %v", err, tt.wantErr)
280+
return
281+
}
282+
283+
if tt.wantErr {
284+
return
285+
}
286+
287+
if got != tt.want {
288+
t.Errorf("ServiceProtectedResourceMetadataURI() = %v, want %v", got, tt.want)
289+
}
290+
})
291+
}
292+
}

0 commit comments

Comments
 (0)