feat(32.1-01): publish one MCP scope list and call context

- pact gains MemberTokenScopeCatalog, MCPAdvertisedScopes and MCPCall
- an empty MCP ScopesSupported resolves to the five fixed names including backend
- pier copies advertised scopes and sets MCPCall beside WithDispatch
This commit is contained in:
2026-10-28 14:32:31 +01:00
parent 2cea3127cc
commit 63d05e8acb
11 changed files with 322 additions and 27 deletions

View File

@@ -18,22 +18,17 @@ import (
"strings"
)
// authorizeAllowedScopes mirrors PHP OAuthAuthorizeController::ALLOWED_SCOPES:
// the allow-list of a server whose Options.ScopesSupported is empty.
var authorizeAllowedScopes = map[string]bool{
"read": true, "write": true, "ai": true, "offline_access": true,
}
// allowedAuthorizeScopes is the set parseAuthorizeScopes accepts on this
// server: exactly Options.ScopesSupported, the values the metadata document
// advertises, so a host that lists an extra scope (such as backend for the
// MCP module) accepts it at authorize; an empty list keeps the PHP default.
// server: the same resolvedScopes slice Metadata advertises. A non-empty
// Options.ScopesSupported is used as given, so a host that lists an extra
// scope accepts it and a list without backend still refuses it. An empty
// list on the MCP flavour resolves to the five fixed names, backend
// included. Scope resolution runs after the client, redirect and PKCE
// checks in Authorize.
func (s *Server) allowedAuthorizeScopes() map[string]bool {
if s == nil || len(s.opts.ScopesSupported) == 0 {
return authorizeAllowedScopes
}
allowed := make(map[string]bool, len(s.opts.ScopesSupported))
for _, sc := range s.opts.ScopesSupported {
scopes := s.resolvedScopes()
allowed := make(map[string]bool, len(scopes))
for _, sc := range scopes {
if sc = strings.TrimSpace(sc); sc != "" {
allowed[sc] = true
}

View File

@@ -8,10 +8,11 @@ import (
"testing"
)
// TestMCPScopesSmoke covers the configurable authorize scope set of phase
// 19.7 (RESEARCH Pitfall 5, D-21 framework side): the allow-list is exactly
// Options.ScopesSupported, so a host that lists backend accepts it, while
// the default server keeps refusing it.
// TestMCPScopesSmoke covers the configurable authorize scope set: a non-empty
// Options.ScopesSupported is the allow-list and the metadata list, so a host
// that lists backend accepts it, while DefaultOptions (the four PHP names,
// which do not include backend) still refuses it. An empty list on the MCP
// flavour resolves to read, write, ai, offline_access and backend.
func TestMCPScopesSmoke(t *testing.T) {
backend := newMemoryBackend()
opts := DefaultOptions()
@@ -69,10 +70,25 @@ func TestMCPScopesSmoke(t *testing.T) {
t.Fatalf("default server accepted backend: %v", q)
}
// An empty ScopesSupported keeps the PHP default allow-list.
// An empty ScopesSupported resolves to the five fixed names and accepts backend.
emptyBackend := newMemoryBackend()
empty := DefaultOptions()
empty.Issuer, empty.Resource, empty.ScopesSupported = opts.Issuer, opts.Resource, nil
if got := NewServer(empty).allowedAuthorizeScopes(); !got["read"] || !got["offline_access"] || got["backend"] || len(got) != 4 {
emptySrv := NewServer(empty)
emptySrv.SetBackend(emptyBackend)
insertAuthorizeTestClient(emptyBackend, "cli-empty", []string{authorizeTestRedirect}, nil)
if got := emptySrv.allowedAuthorizeScopes(); !got["read"] || !got["write"] || !got["ai"] || !got["offline_access"] || !got["backend"] || len(got) != 5 {
t.Fatalf("allowed scopes with empty ScopesSupported = %v", got)
}
if q := authorize(emptySrv, "cli-empty"); q["error"] != "" {
t.Fatalf("empty ScopesSupported refused backend: %v", q)
}
rec = httptest.NewRecorder()
emptySrv.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if !slices.Equal(doc.Scopes, []string{"read", "write", "ai", "offline_access", "backend"}) {
t.Fatalf("empty scopes_supported = %v", doc.Scopes)
}
}

View File

@@ -34,7 +34,9 @@ type Options struct {
ServiceDocumentationPath string
// ScopesSupported is the RFC 8414 scopes_supported list. PHP default:
// ["read","write","ai","offline_access"].
// ["read","write","ai","offline_access"]. On the MCP flavour an empty
// or nil list resolves to those four plus backend; a non-empty list is
// used as given. OIDC discovery does not read this field.
ScopesSupported []string
// TokenEndpointAuthMethodsSupported is the RFC 8414
@@ -189,13 +191,32 @@ func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) {
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
CodeChallengeMethodsSupported: []string{"S256"},
TokenEndpointAuthMethodsSupported: s.opts.TokenEndpointAuthMethodsSupported,
ScopesSupported: s.opts.ScopesSupported,
ScopesSupported: s.resolvedScopes(),
ServiceDocumentation: s.opts.Issuer + s.opts.ServiceDocumentationPath,
AuthorizationResponseIssParameterSupported: s.opts.AuthorizationResponseIssParameterSupported,
}
writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": "no-cache, private"})
}
// mcpEmptyScopes is the MCP flavour's scope list when Options.ScopesSupported
// is empty or nil. A non-empty list is not widened to include backend.
var mcpEmptyScopes = []string{"read", "write", "ai", "offline_access", "backend"}
// resolvedScopes is the one scope slice Metadata advertises and
// allowedAuthorizeScopes accepts. A non-empty Options.ScopesSupported is
// cloned as given. An empty or nil list on the MCP flavour (NewServer, the
// flavour zero value) resolves to mcpEmptyScopes. The OIDC flavour is not
// given that list: its discovery document is separate.
func (s *Server) resolvedScopes() []string {
if s != nil && len(s.opts.ScopesSupported) > 0 {
return append([]string(nil), s.opts.ScopesSupported...)
}
if s != nil && s.flavour == flavourOIDC {
return append([]string(nil), s.opts.ScopesSupported...)
}
return append([]string(nil), mcpEmptyScopes...)
}
// writeExactJSON writes v as an unwrapped, no-trailing-newline JSON document
// (matching the wire/response.go WriteJSON technique) but never falls back to
// the house opaque-500 envelope: raw RFC responses must never acquire a