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.
This commit is contained in:
Eliya Sadan 2025-12-08 21:54:52 +02:00 • committed by GitHub
commit d6d6edd6d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 275 additions and 11 deletions

View file

@ -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.
@ -123,3 +126,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
}
})
}

View file

@ -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))
}
}
})
}
}