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:
parent
18cd63599d
commit
d6d6edd6d4
1 changed files with 275 additions and 11 deletions
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue