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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user