From f5e7ab3599018dab212759e8792c146f96e279ed Mon Sep 17 00:00:00 2001 From: Eliya Sadan Date: Mon, 17 Nov 2025 12:12:40 +0200 Subject: [PATCH] Add CORS support documentation and OAuth 2.0 Protected Resource Metadata handler --- auth/auth.go | 43 +++++++++ auth/auth_test.go | 112 ++++++++++++++++++++++ docs/protocol.md | 29 +++++- examples/server/auth-middleware/README.md | 36 +++++++ examples/server/auth-middleware/go.mod | 1 + examples/server/auth-middleware/go.sum | 2 + examples/server/auth-middleware/main.go | 34 +++++-- internal/docs/protocol.src.md | 29 +++++- 8 files changed, 275 insertions(+), 11 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 0eea1d87..6754fbd6 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -6,11 +6,14 @@ package auth import ( "context" + "encoding/json" "errors" "net/http" "slices" "strings" "time" + + "github.com/modelcontextprotocol/go-sdk/oauthex" ) // TokenInfo holds information from a bearer token. @@ -118,3 +121,43 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO } return tokenInfo, "", 0 } + +// ProtectedResourceMetadataHandler returns an http.Handler that serves OAuth 2.0 +// protected resource metadata (RFC 9728) with CORS support. +// +// This handler allows cross-origin requests from any origin (Access-Control-Allow-Origin: *) +// because OAuth metadata is public information intended for client discovery (RFC 9728 §3.1). +// The metadata contains only non-sensitive configuration data about authorization servers +// and supported scopes. +// +// No validation of metadata fields is performed; ensure metadata accuracy at configuration time. +// +// For more sophisticated CORS policies or to restrict origins, wrap this handler with a +// CORS middleware like github.com/rs/cors or github.com/jub0bs/cors. +func ProtectedResourceMetadataHandler(metadata *oauthex.ProtectedResourceMetadata) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers for cross-origin client discovery. + // OAuth metadata is public information, so allowing any origin is safe. + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + // Handle CORS preflight requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + // Only GET allowed for metadata retrieval + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(metadata); err != nil { + http.Error(w, "Failed to encode metadata", http.StatusInternalServerError) + return + } + }) +} diff --git a/auth/auth_test.go b/auth/auth_test.go index ef8ea7b3..a943404c 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -6,10 +6,15 @@ package auth import ( "context" + "encoding/json" "errors" "net/http" + "net/http/httptest" + "strings" "testing" "time" + + "github.com/modelcontextprotocol/go-sdk/oauthex" ) func TestVerify(t *testing.T) { @@ -76,3 +81,110 @@ func TestVerify(t *testing.T) { }) } } + +func TestProtectedResourceMetadataHandler(t *testing.T) { + metadata := &oauthex.ProtectedResourceMetadata{ + Resource: "https://example.com/mcp", + AuthorizationServers: []string{ + "https://auth.example.com/.well-known/openid-configuration", + }, + ScopesSupported: []string{"read", "write"}, + } + + handler := ProtectedResourceMetadataHandler(metadata) + + tests := []struct { + name string + method string + wantStatus int + checkJSON bool + }{ + { + name: "GET returns metadata", + method: http.MethodGet, + wantStatus: http.StatusOK, + checkJSON: true, + }, + { + name: "OPTIONS for CORS preflight", + method: http.MethodOptions, + wantStatus: http.StatusNoContent, + }, + { + name: "POST not allowed", + method: http.MethodPost, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "PUT not allowed", + method: http.MethodPut, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "DELETE not allowed", + method: http.MethodDelete, + wantStatus: http.StatusMethodNotAllowed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, "/.well-known/oauth-protected-resource", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + // All responses should have CORS headers + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "*") + } + + if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, OPTIONS" { + t.Errorf("Access-Control-Allow-Methods = %q, want %q", got, "GET, OPTIONS") + } + + // Validate error response body for disallowed methods + if tt.wantStatus == http.StatusMethodNotAllowed { + if !strings.Contains(rec.Body.String(), "Method not allowed") { + t.Errorf("error body = %q, want to contain %q", rec.Body.String(), "Method not allowed") + } + } + + if tt.checkJSON { + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want %q", got, "application/json") + } + + var got oauthex.ProtectedResourceMetadata + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if got.Resource != metadata.Resource { + t.Errorf("Resource = %q, want %q", got.Resource, metadata.Resource) + } + + if len(got.AuthorizationServers) != len(metadata.AuthorizationServers) { + t.Errorf("AuthorizationServers length = %d, want %d", + len(got.AuthorizationServers), len(metadata.AuthorizationServers)) + } + + for i, server := range got.AuthorizationServers { + if server != metadata.AuthorizationServers[i] { + t.Errorf("AuthorizationServers[%d] = %q, want %q", + i, server, metadata.AuthorizationServers[i]) + } + } + + if len(got.ScopesSupported) != len(metadata.ScopesSupported) { + t.Errorf("ScopesSupported length = %d, want %d", + len(got.ScopesSupported), len(metadata.ScopesSupported)) + } + } + }) + } +} diff --git a/docs/protocol.md b/docs/protocol.md index 6d22c4b5..d96e26fb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -273,7 +273,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example, [`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).) HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext). - + +#### OAuth Protected Resource Metadata + +Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint +as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides +clients with information about the resource server's OAuth configuration, including which +authorization servers can be used and what scopes are supported. + +The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler) +to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`) +to support cross-origin client discovery, as the metadata contains only public configuration information. + +Example usage: + +```go +metadata := &oauthex.ProtectedResourceMetadata{ + Resource: "https://example.com/mcp", + AuthorizationServers: []string{ + "https://auth.example.com/.well-known/openid-configuration", + }, + ScopesSupported: []string{"read", "write"}, +} +http.Handle("/.well-known/oauth-protected-resource", + auth.ProtectedResourceMetadataHandler(metadata)) +``` + +For more sophisticated CORS policies, wrap the handler with a CORS middleware like +[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors). 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. diff --git a/examples/server/auth-middleware/README.md b/examples/server/auth-middleware/README.md index aaae767a..001237c0 100644 --- a/examples/server/auth-middleware/README.md +++ b/examples/server/auth-middleware/README.md @@ -44,6 +44,7 @@ go test -cover ### Public Endpoints (No Authentication Required) - `GET /health` - Health check +- `GET /.well-known/oauth-protected-resource` - OAuth 2.0 Protected Resource Metadata (RFC 9728) ### MCP Endpoints (Authentication Required) @@ -228,6 +229,41 @@ authenticatedHandler := authMiddleware(customMiddleware(mcpHandler)) 4. **Token Expiration**: Set appropriate token expiration times 5. **Principle of Least Privilege**: Grant only the minimum required scopes +## CORS Support + +The OAuth metadata endpoint (`/.well-known/oauth-protected-resource`) supports CORS to enable +cross-origin client discovery. It sets `Access-Control-Allow-Origin: *` by default because +OAuth metadata is public information meant for client discovery (RFC 9728 §3.1). + +### Custom CORS Policies + +For more sophisticated CORS requirements (origin validation, credentials, etc.), wrap the handler +with a CORS middleware library: + +**Using github.com/rs/cors:** +```go +import "github.com/rs/cors" + +c := cors.New(cors.Options{ + AllowedOrigins: []string{"https://example.com"}, + AllowedMethods: []string{"GET", "OPTIONS"}, +}) +http.Handle("/.well-known/oauth-protected-resource", + c.Handler(auth.ProtectedResourceMetadataHandler(metadata))) +``` + +**Using github.com/jub0bs/cors:** +```go +import "github.com/jub0bs/cors" + +corsMiddleware, err := cors.NewMiddleware(cors.Config{ + Origins: []string{"https://example.com"}, + Methods: []string{"GET", "OPTIONS"}, +}) +http.Handle("/.well-known/oauth-protected-resource", + corsMiddleware.Wrap(auth.ProtectedResourceMetadataHandler(metadata))) +``` + ## Use Cases **Ideal for:** diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index cbc89c78..9975879e 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -10,6 +10,7 @@ require ( require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect ) replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index 7b7a8e56..32ceedfe 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -6,5 +6,7 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/examples/server/auth-middleware/main.go b/examples/server/auth-middleware/main.go index 75a38b86..ca339dfb 100644 --- a/examples/server/auth-middleware/main.go +++ b/examples/server/auth-middleware/main.go @@ -20,6 +20,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" ) // This example demonstrates how to integrate auth.RequireBearerToken middleware @@ -237,7 +238,8 @@ func main() { // Create authentication middleware. jwtAuth := auth.RequireBearerToken(verifyJWT, &auth.RequireBearerTokenOptions{ - Scopes: []string{"read"}, // Require "read" permission + Scopes: []string{"read"}, // Require "read" permission + ResourceMetadataURL: "http://localhost:8080/.well-known/oauth-protected-resource", }) apiKeyAuth := auth.RequireBearerToken(verifyAPIKey, &auth.RequireBearerTokenOptions{ @@ -340,22 +342,36 @@ func main() { }) }) + // OAuth protected resource metadata endpoint. + // This endpoint provides OAuth configuration information to clients. + // CORS is enabled by default to support cross-origin client discovery. + metadata := &oauthex.ProtectedResourceMetadata{ + Resource: "http://localhost:8080/mcp/jwt", + AuthorizationServers: []string{ + "https://auth.example.com/.well-known/openid-configuration", + }, + ScopesSupported: []string{"read", "write"}, + } + http.Handle("/.well-known/oauth-protected-resource", + auth.ProtectedResourceMetadataHandler(metadata)) + // Start the HTTP server. log.Println("Authenticated MCP Server") log.Println("========================") log.Println("Server starting on", *httpAddr) log.Println() log.Println("Available endpoints:") - log.Println(" GET /health - Health check (no auth)") - log.Println(" GET /generate-token - Generate JWT token") - log.Println(" POST /generate-api-key - Generate API key") - log.Println(" POST /mcp/jwt - MCP endpoint (JWT auth)") - log.Println(" POST /mcp/apikey - MCP endpoint (API key auth)") + log.Println(" GET /health - Health check (no auth)") + log.Println(" GET /.well-known/oauth-protected-resource - OAuth resource metadata") + log.Println(" GET /generate-token - Generate JWT token") + log.Println(" POST /generate-api-key - Generate API key") + log.Println(" POST /mcp/jwt - MCP endpoint (JWT auth)") + log.Println(" POST /mcp/apikey - MCP endpoint (API key auth)") log.Println() log.Println("Available MCP Tools:") - log.Println(" - say_hi - Simple greeting (any auth)") - log.Println(" - get_user_info - Get user info (read scope)") - log.Println(" - create_resource - Create resource (write scope)") + log.Println(" - say_hi - Simple greeting (any auth)") + log.Println(" - get_user_info - Get user info (read scope)") + log.Println(" - create_resource - Create resource (write scope)") log.Println() log.Println("Example usage:") log.Println(" # Generate a token") diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index b0874ccf..d8d3006c 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -199,7 +199,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example, [`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).) HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext). - + +#### OAuth Protected Resource Metadata + +Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint +as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides +clients with information about the resource server's OAuth configuration, including which +authorization servers can be used and what scopes are supported. + +The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler) +to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`) +to support cross-origin client discovery, as the metadata contains only public configuration information. + +Example usage: + +```go +metadata := &oauthex.ProtectedResourceMetadata{ + Resource: "https://example.com/mcp", + AuthorizationServers: []string{ + "https://auth.example.com/.well-known/openid-configuration", + }, + ScopesSupported: []string{"read", "write"}, +} +http.Handle("/.well-known/oauth-protected-resource", + auth.ProtectedResourceMetadataHandler(metadata)) +``` + +For more sophisticated CORS policies, wrap the handler with a CORS middleware like +[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors). 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.