Skip to content

Commit d6d6edd

Browse files
authored
auth: add OAuth 2.0 Protected Resource Metadata handler
This change adds support for RFC 9728 (OAuth 2.0 Protected Resource Metadata) by introducing a `ProtectedResourceMetadataHandler`. The handler includes built-in CORS support with `Access-Control-Allow-Origin: *` by default, as OAuth metadata is public information meant for client discovery. Documentation includes examples for using custom CORS policies with popular middleware libraries (github.com/rs/cors and github.com/jub0bs/cors). The implementation follows RFC 9728 §3.1 for OAuth 2.0 Authorization Server Metadata discovery, enabling clients to discover protected resource capabilities and authentication requirements.
1 parent 18cd635 commit d6d6edd

File tree

8 files changed

+275
-11
lines changed

8 files changed

+275
-11
lines changed

auth/auth.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ package auth
66

77
import (
88
"context"
9+
"encoding/json"
910
"errors"
1011
"net/http"
1112
"slices"
1213
"strings"
1314
"time"
15+
16+
"github.com/modelcontextprotocol/go-sdk/oauthex"
1417
)
1518

1619
// TokenInfo holds information from a bearer token.
@@ -123,3 +126,43 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO
123126
}
124127
return tokenInfo, "", 0
125128
}
129+
130+
// ProtectedResourceMetadataHandler returns an http.Handler that serves OAuth 2.0
131+
// protected resource metadata (RFC 9728) with CORS support.
132+
//
133+
// This handler allows cross-origin requests from any origin (Access-Control-Allow-Origin: *)
134+
// because OAuth metadata is public information intended for client discovery (RFC 9728 §3.1).
135+
// The metadata contains only non-sensitive configuration data about authorization servers
136+
// and supported scopes.
137+
//
138+
// No validation of metadata fields is performed; ensure metadata accuracy at configuration time.
139+
//
140+
// For more sophisticated CORS policies or to restrict origins, wrap this handler with a
141+
// CORS middleware like github.com/rs/cors or github.com/jub0bs/cors.
142+
func ProtectedResourceMetadataHandler(metadata *oauthex.ProtectedResourceMetadata) http.Handler {
143+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144+
// Set CORS headers for cross-origin client discovery.
145+
// OAuth metadata is public information, so allowing any origin is safe.
146+
w.Header().Set("Access-Control-Allow-Origin", "*")
147+
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
148+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
149+
150+
// Handle CORS preflight requests
151+
if r.Method == http.MethodOptions {
152+
w.WriteHeader(http.StatusNoContent)
153+
return
154+
}
155+
156+
// Only GET allowed for metadata retrieval
157+
if r.Method != http.MethodGet {
158+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
159+
return
160+
}
161+
162+
w.Header().Set("Content-Type", "application/json")
163+
if err := json.NewEncoder(w).Encode(metadata); err != nil {
164+
http.Error(w, "Failed to encode metadata", http.StatusInternalServerError)
165+
return
166+
}
167+
})
168+
}

auth/auth_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ package auth
66

77
import (
88
"context"
9+
"encoding/json"
910
"errors"
1011
"net/http"
12+
"net/http/httptest"
13+
"strings"
1114
"testing"
1215
"time"
16+
17+
"github.com/modelcontextprotocol/go-sdk/oauthex"
1318
)
1419

1520
func TestVerify(t *testing.T) {
@@ -76,3 +81,110 @@ func TestVerify(t *testing.T) {
7681
})
7782
}
7883
}
84+
85+
func TestProtectedResourceMetadataHandler(t *testing.T) {
86+
metadata := &oauthex.ProtectedResourceMetadata{
87+
Resource: "https://example.com/mcp",
88+
AuthorizationServers: []string{
89+
"https://auth.example.com/.well-known/openid-configuration",
90+
},
91+
ScopesSupported: []string{"read", "write"},
92+
}
93+
94+
handler := ProtectedResourceMetadataHandler(metadata)
95+
96+
tests := []struct {
97+
name string
98+
method string
99+
wantStatus int
100+
checkJSON bool
101+
}{
102+
{
103+
name: "GET returns metadata",
104+
method: http.MethodGet,
105+
wantStatus: http.StatusOK,
106+
checkJSON: true,
107+
},
108+
{
109+
name: "OPTIONS for CORS preflight",
110+
method: http.MethodOptions,
111+
wantStatus: http.StatusNoContent,
112+
},
113+
{
114+
name: "POST not allowed",
115+
method: http.MethodPost,
116+
wantStatus: http.StatusMethodNotAllowed,
117+
},
118+
{
119+
name: "PUT not allowed",
120+
method: http.MethodPut,
121+
wantStatus: http.StatusMethodNotAllowed,
122+
},
123+
{
124+
name: "DELETE not allowed",
125+
method: http.MethodDelete,
126+
wantStatus: http.StatusMethodNotAllowed,
127+
},
128+
}
129+
130+
for _, tt := range tests {
131+
t.Run(tt.name, func(t *testing.T) {
132+
req := httptest.NewRequest(tt.method, "/.well-known/oauth-protected-resource", nil)
133+
rec := httptest.NewRecorder()
134+
135+
handler.ServeHTTP(rec, req)
136+
137+
if rec.Code != tt.wantStatus {
138+
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
139+
}
140+
141+
// All responses should have CORS headers
142+
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
143+
t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "*")
144+
}
145+
146+
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, OPTIONS" {
147+
t.Errorf("Access-Control-Allow-Methods = %q, want %q", got, "GET, OPTIONS")
148+
}
149+
150+
// Validate error response body for disallowed methods
151+
if tt.wantStatus == http.StatusMethodNotAllowed {
152+
if !strings.Contains(rec.Body.String(), "Method not allowed") {
153+
t.Errorf("error body = %q, want to contain %q", rec.Body.String(), "Method not allowed")
154+
}
155+
}
156+
157+
if tt.checkJSON {
158+
if got := rec.Header().Get("Content-Type"); got != "application/json" {
159+
t.Errorf("Content-Type = %q, want %q", got, "application/json")
160+
}
161+
162+
var got oauthex.ProtectedResourceMetadata
163+
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
164+
t.Fatalf("failed to decode response: %v", err)
165+
}
166+
167+
if got.Resource != metadata.Resource {
168+
t.Errorf("Resource = %q, want %q", got.Resource, metadata.Resource)
169+
}
170+
171+
if len(got.AuthorizationServers) != len(metadata.AuthorizationServers) {
172+
t.Errorf("AuthorizationServers length = %d, want %d",
173+
len(got.AuthorizationServers), len(metadata.AuthorizationServers))
174+
}
175+
176+
for i, server := range got.AuthorizationServers {
177+
if server != metadata.AuthorizationServers[i] {
178+
t.Errorf("AuthorizationServers[%d] = %q, want %q",
179+
i, server, metadata.AuthorizationServers[i])
180+
}
181+
}
182+
183+
if len(got.ScopesSupported) != len(metadata.ScopesSupported) {
184+
t.Errorf("ScopesSupported length = %d, want %d",
185+
len(got.ScopesSupported), len(metadata.ScopesSupported))
186+
}
187+
}
188+
})
189+
}
190+
}

docs/protocol.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example,
273273
[`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).)
274274
HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context
275275
with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext).
276-
276+
277+
#### OAuth Protected Resource Metadata
278+
279+
Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint
280+
as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides
281+
clients with information about the resource server's OAuth configuration, including which
282+
authorization servers can be used and what scopes are supported.
283+
284+
The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler)
285+
to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`)
286+
to support cross-origin client discovery, as the metadata contains only public configuration information.
287+
288+
Example usage:
289+
290+
```go
291+
metadata := &oauthex.ProtectedResourceMetadata{
292+
Resource: "https://example.com/mcp",
293+
AuthorizationServers: []string{
294+
"https://auth.example.com/.well-known/openid-configuration",
295+
},
296+
ScopesSupported: []string{"read", "write"},
297+
}
298+
http.Handle("/.well-known/oauth-protected-resource",
299+
auth.ProtectedResourceMetadataHandler(metadata))
300+
```
301+
302+
For more sophisticated CORS policies, wrap the handler with a CORS middleware like
303+
[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors).
277304

278305
The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys.
279306

examples/server/auth-middleware/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ go test -cover
4444
### Public Endpoints (No Authentication Required)
4545

4646
- `GET /health` - Health check
47+
- `GET /.well-known/oauth-protected-resource` - OAuth 2.0 Protected Resource Metadata (RFC 9728)
4748

4849
### MCP Endpoints (Authentication Required)
4950

@@ -228,6 +229,41 @@ authenticatedHandler := authMiddleware(customMiddleware(mcpHandler))
228229
4. **Token Expiration**: Set appropriate token expiration times
229230
5. **Principle of Least Privilege**: Grant only the minimum required scopes
230231

232+
## CORS Support
233+
234+
The OAuth metadata endpoint (`/.well-known/oauth-protected-resource`) supports CORS to enable
235+
cross-origin client discovery. It sets `Access-Control-Allow-Origin: *` by default because
236+
OAuth metadata is public information meant for client discovery (RFC 9728 §3.1).
237+
238+
### Custom CORS Policies
239+
240+
For more sophisticated CORS requirements (origin validation, credentials, etc.), wrap the handler
241+
with a CORS middleware library:
242+
243+
**Using github.com/rs/cors:**
244+
```go
245+
import "github.com/rs/cors"
246+
247+
c := cors.New(cors.Options{
248+
AllowedOrigins: []string{"https://example.com"},
249+
AllowedMethods: []string{"GET", "OPTIONS"},
250+
})
251+
http.Handle("/.well-known/oauth-protected-resource",
252+
c.Handler(auth.ProtectedResourceMetadataHandler(metadata)))
253+
```
254+
255+
**Using github.com/jub0bs/cors:**
256+
```go
257+
import "github.com/jub0bs/cors"
258+
259+
corsMiddleware, err := cors.NewMiddleware(cors.Config{
260+
Origins: []string{"https://example.com"},
261+
Methods: []string{"GET", "OPTIONS"},
262+
})
263+
http.Handle("/.well-known/oauth-protected-resource",
264+
corsMiddleware.Wrap(auth.ProtectedResourceMetadataHandler(metadata)))
265+
```
266+
231267
## Use Cases
232268

233269
**Ideal for:**

examples/server/auth-middleware/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
require (
1111
github.com/google/jsonschema-go v0.3.0 // indirect
1212
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
13+
golang.org/x/oauth2 v0.30.0 // indirect
1314
)
1415

1516
replace github.com/modelcontextprotocol/go-sdk => ../../../

examples/server/auth-middleware/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy
66
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
77
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
88
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
9+
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
10+
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
911
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
1012
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=

examples/server/auth-middleware/main.go

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/golang-jwt/jwt/v5"
2121
"github.com/modelcontextprotocol/go-sdk/auth"
2222
"github.com/modelcontextprotocol/go-sdk/mcp"
23+
"github.com/modelcontextprotocol/go-sdk/oauthex"
2324
)
2425

2526
// This example demonstrates how to integrate auth.RequireBearerToken middleware
@@ -237,7 +238,8 @@ func main() {
237238

238239
// Create authentication middleware.
239240
jwtAuth := auth.RequireBearerToken(verifyJWT, &auth.RequireBearerTokenOptions{
240-
Scopes: []string{"read"}, // Require "read" permission
241+
Scopes: []string{"read"}, // Require "read" permission
242+
ResourceMetadataURL: "http://localhost:8080/.well-known/oauth-protected-resource",
241243
})
242244

243245
apiKeyAuth := auth.RequireBearerToken(verifyAPIKey, &auth.RequireBearerTokenOptions{
@@ -340,22 +342,36 @@ func main() {
340342
})
341343
})
342344

345+
// OAuth protected resource metadata endpoint.
346+
// This endpoint provides OAuth configuration information to clients.
347+
// CORS is enabled by default to support cross-origin client discovery.
348+
metadata := &oauthex.ProtectedResourceMetadata{
349+
Resource: "http://localhost:8080/mcp/jwt",
350+
AuthorizationServers: []string{
351+
"https://auth.example.com/.well-known/openid-configuration",
352+
},
353+
ScopesSupported: []string{"read", "write"},
354+
}
355+
http.Handle("/.well-known/oauth-protected-resource",
356+
auth.ProtectedResourceMetadataHandler(metadata))
357+
343358
// Start the HTTP server.
344359
log.Println("Authenticated MCP Server")
345360
log.Println("========================")
346361
log.Println("Server starting on", *httpAddr)
347362
log.Println()
348363
log.Println("Available endpoints:")
349-
log.Println(" GET /health - Health check (no auth)")
350-
log.Println(" GET /generate-token - Generate JWT token")
351-
log.Println(" POST /generate-api-key - Generate API key")
352-
log.Println(" POST /mcp/jwt - MCP endpoint (JWT auth)")
353-
log.Println(" POST /mcp/apikey - MCP endpoint (API key auth)")
364+
log.Println(" GET /health - Health check (no auth)")
365+
log.Println(" GET /.well-known/oauth-protected-resource - OAuth resource metadata")
366+
log.Println(" GET /generate-token - Generate JWT token")
367+
log.Println(" POST /generate-api-key - Generate API key")
368+
log.Println(" POST /mcp/jwt - MCP endpoint (JWT auth)")
369+
log.Println(" POST /mcp/apikey - MCP endpoint (API key auth)")
354370
log.Println()
355371
log.Println("Available MCP Tools:")
356-
log.Println(" - say_hi - Simple greeting (any auth)")
357-
log.Println(" - get_user_info - Get user info (read scope)")
358-
log.Println(" - create_resource - Create resource (write scope)")
372+
log.Println(" - say_hi - Simple greeting (any auth)")
373+
log.Println(" - get_user_info - Get user info (read scope)")
374+
log.Println(" - create_resource - Create resource (write scope)")
359375
log.Println()
360376
log.Println("Example usage:")
361377
log.Println(" # Generate a token")

internal/docs/protocol.src.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example,
199199
[`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).)
200200
HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context
201201
with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext).
202-
202+
203+
#### OAuth Protected Resource Metadata
204+
205+
Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint
206+
as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides
207+
clients with information about the resource server's OAuth configuration, including which
208+
authorization servers can be used and what scopes are supported.
209+
210+
The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler)
211+
to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`)
212+
to support cross-origin client discovery, as the metadata contains only public configuration information.
213+
214+
Example usage:
215+
216+
```go
217+
metadata := &oauthex.ProtectedResourceMetadata{
218+
Resource: "https://example.com/mcp",
219+
AuthorizationServers: []string{
220+
"https://auth.example.com/.well-known/openid-configuration",
221+
},
222+
ScopesSupported: []string{"read", "write"},
223+
}
224+
http.Handle("/.well-known/oauth-protected-resource",
225+
auth.ProtectedResourceMetadataHandler(metadata))
226+
```
227+
228+
For more sophisticated CORS policies, wrap the handler with a CORS middleware like
229+
[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors).
203230

204231
The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys.
205232

0 commit comments

Comments
 (0)