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:
@@ -817,11 +817,14 @@ type HasTokenScopes interface {
|
|||||||
// member token screens show. Label and Description are phrase keys
|
// member token screens show. Label and Description are phrase keys
|
||||||
// (containing "::") or literal text. It is the frontend counterpart of
|
// (containing "::") or literal text. It is the frontend counterpart of
|
||||||
// TokenScope and carries no permission patterns or method filter: the route
|
// TokenScope and carries no permission patterns or method filter: the route
|
||||||
// middleware alone decides what a member scope allows.
|
// middleware is what enforces a member scope. Implies is only "", "read" or
|
||||||
|
// "write", for a later OAuth MCP check; the zero value means the scope is
|
||||||
|
// not implied. This package does not perform that check.
|
||||||
type FrontendTokenScope struct {
|
type FrontendTokenScope struct {
|
||||||
Name string
|
Name string
|
||||||
Label string
|
Label string
|
||||||
Description string
|
Description string
|
||||||
|
Implies string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasFrontendTokenScopes is implemented by plugins that declare member
|
// HasFrontendTokenScopes is implemented by plugins that declare member
|
||||||
|
|||||||
@@ -139,6 +139,40 @@ type MCPSubject struct {
|
|||||||
TokenID uint
|
TokenID uint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mcpCallKey is the unexported context key of an MCPCall. A plain HTTP
|
||||||
|
// request never carries one.
|
||||||
|
type mcpCallKey struct{}
|
||||||
|
|
||||||
|
// MCPCall is one in-process MCP tool call. Subject is the subject pier
|
||||||
|
// resolved for the tool's guard, not an expansion of its scopes. SessionID
|
||||||
|
// is the MCP session id, empty on the stateless path and when the call was
|
||||||
|
// served without a session. Tool is the exposed tool name.
|
||||||
|
type MCPCall struct {
|
||||||
|
Subject MCPSubject
|
||||||
|
SessionID string
|
||||||
|
Tool string
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMCPCall stores call on ctx. A nil ctx is returned as nil: the
|
||||||
|
// dispatch context is never nil, and this does not invent a background
|
||||||
|
// context.
|
||||||
|
func WithMCPCall(ctx context.Context, call MCPCall) context.Context {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, mcpCallKey{}, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPCallFrom returns the MCPCall on ctx. ok is false when ctx is nil or
|
||||||
|
// carries none, including a plain HTTP request.
|
||||||
|
func MCPCallFrom(ctx context.Context) (MCPCall, bool) {
|
||||||
|
if ctx == nil {
|
||||||
|
return MCPCall{}, false
|
||||||
|
}
|
||||||
|
call, ok := ctx.Value(mcpCallKey{}).(MCPCall)
|
||||||
|
return call, ok
|
||||||
|
}
|
||||||
|
|
||||||
// MCPDispatch is a guard provider's answer for one in-process call: the
|
// MCPDispatch is a guard provider's answer for one in-process call: the
|
||||||
// principal (a *bouncer.Principal, carried as any so pact stays free of
|
// principal (a *bouncer.Principal, carried as any so pact stays free of
|
||||||
// bouncer), the optional credential stored next to it, and the bouncer
|
// bouncer), the optional credential stored next to it, and the bouncer
|
||||||
@@ -224,6 +258,22 @@ type MCPTokenVerifier interface {
|
|||||||
MCPResource() string
|
MCPResource() string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCPAdvertisedScopes is implemented by a verifier that publishes the scopes
|
||||||
|
// the MCP authorization server advertises. pier copies MCPScopesSupported
|
||||||
|
// into protected-resource metadata when the slice is non-empty, and
|
||||||
|
// otherwise keeps its own fallback list. It is a separate interface so
|
||||||
|
// MCPTokenVerifier stays MCPVerifyToken and MCPResource.
|
||||||
|
type MCPAdvertisedScopes interface {
|
||||||
|
MCPScopesSupported() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberTokenScopeCatalog is the compiled member personal-token scope list,
|
||||||
|
// published by the plugin that owns member tokens. The method is not named
|
||||||
|
// FrontendTokenScopes: HasFrontendTokenScopes already has that method.
|
||||||
|
type MemberTokenScopeCatalog interface {
|
||||||
|
MemberTokenScopes() []FrontendTokenScope
|
||||||
|
}
|
||||||
|
|
||||||
// MCPGrant is a verified OAuth access token: the frontend user, the client,
|
// MCPGrant is a verified OAuth access token: the frontend user, the client,
|
||||||
// the token row, the granted scopes, the expiry and, for a grant carrying
|
// the token row, the granted scopes, the expiry and, for a grant carrying
|
||||||
// the backend scope, the backend account the user consented to.
|
// the backend scope, the backend account the user consented to.
|
||||||
|
|||||||
32
modules/pact/mcp_call_test.go
Normal file
32
modules/pact/mcp_call_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package pact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMCPCallRoundTrip proves the context accessor stores and reads one
|
||||||
|
// call, and that a context without one answers ok false.
|
||||||
|
func TestMCPCallRoundTrip(t *testing.T) {
|
||||||
|
want := MCPCall{
|
||||||
|
Subject: MCPSubject{Guard: "user", UserID: 7, Scopes: []string{"read"}, ClientID: "c1", TokenID: 3},
|
||||||
|
SessionID: "sess-1",
|
||||||
|
Tool: "acme_echo",
|
||||||
|
}
|
||||||
|
got, ok := MCPCallFrom(WithMCPCall(context.Background(), want))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("MCPCallFrom ok = false, want true")
|
||||||
|
}
|
||||||
|
if got.Subject.Guard != want.Subject.Guard || got.SessionID != want.SessionID || got.Tool != want.Tool {
|
||||||
|
t.Fatalf("MCPCallFrom = %+v, want guard %q session %q tool %q", got, want.Subject.Guard, want.SessionID, want.Tool)
|
||||||
|
}
|
||||||
|
if _, ok := MCPCallFrom(context.Background()); ok {
|
||||||
|
t.Fatal("MCPCallFrom(context.Background()) ok = true, want false")
|
||||||
|
}
|
||||||
|
if _, ok := MCPCallFrom(nil); ok {
|
||||||
|
t.Fatal("MCPCallFrom(nil) ok = true, want false")
|
||||||
|
}
|
||||||
|
if WithMCPCall(nil, want) != nil {
|
||||||
|
t.Fatal("WithMCPCall(nil) invented a context")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ const tokenInvalid = "pier: the caller's token is no longer valid"
|
|||||||
|
|
||||||
// callInfo is what a dispatched call copies from the outer MCP request: the
|
// callInfo is what a dispatched call copies from the outer MCP request: the
|
||||||
// client address, the Host, the forwarding, real-ip and language headers,
|
// client address, the Host, the forwarding, real-ip and language headers,
|
||||||
// plus the session the call belongs to, for the audit line.
|
// plus the session the call belongs to, for the audit line. stateless is
|
||||||
|
// set on the stateless path so the MCP call's session id stays empty
|
||||||
|
// instead of a synthesized SDK session id.
|
||||||
type callInfo struct {
|
type callInfo struct {
|
||||||
remoteAddr string
|
remoteAddr string
|
||||||
host string
|
host string
|
||||||
@@ -45,6 +47,7 @@ type callInfo struct {
|
|||||||
realIP string
|
realIP string
|
||||||
acceptLanguage string
|
acceptLanguage string
|
||||||
session string
|
session string
|
||||||
|
stateless bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// callInfoFrom reads callInfo off an HTTP request.
|
// callInfoFrom reads callInfo off an HTTP request.
|
||||||
@@ -205,6 +208,11 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
|
|||||||
if credential == nil && t.guardName == BackendGuard {
|
if credential == nil && t.guardName == BackendGuard {
|
||||||
credential = id.credential
|
credential = id.credential
|
||||||
}
|
}
|
||||||
|
sessionID := info.session
|
||||||
|
if sessionID == ephemeralSession {
|
||||||
|
sessionID = ""
|
||||||
|
}
|
||||||
|
callCtx = pact.WithMCPCall(callCtx, pact.MCPCall{Subject: *subject, SessionID: sessionID, Tool: t.name})
|
||||||
callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential})
|
callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential})
|
||||||
|
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
|
|||||||
155
modules/pier/mcp_seam_test.go
Normal file
155
modules/pier/mcp_seam_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package pier_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pier"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/wristband"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixedMCPScopes is the MCP flavour's resolved list when ScopesSupported is empty.
|
||||||
|
var fixedMCPScopes = []string{"read", "write", "ai", "offline_access", "backend"}
|
||||||
|
|
||||||
|
// advertisedVerifier is the fake OAuth verifier plus a declared scope list.
|
||||||
|
type advertisedVerifier struct {
|
||||||
|
acmeVerifier
|
||||||
|
scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v advertisedVerifier) MCPScopesSupported() []string {
|
||||||
|
return append([]string(nil), v.scopes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMCPSeam is the tracer for one advertised scope list and one MCP call
|
||||||
|
// on the dispatch context: empty MCP metadata includes backend, a non-empty
|
||||||
|
// list is used as given, pier copies a verifier's advertised scopes (and
|
||||||
|
// keeps PRMScopes otherwise), and one frontend tool call observes MCPCall
|
||||||
|
// beside the bouncer dispatch marker.
|
||||||
|
func TestMCPSeam(t *testing.T) {
|
||||||
|
opts := wristband.DefaultOptions()
|
||||||
|
opts.Issuer = "https://issuer.example.test"
|
||||||
|
opts.Resource = "https://resource.example.test/mcp"
|
||||||
|
opts.ScopesSupported = nil
|
||||||
|
empty := wristband.NewServer(opts)
|
||||||
|
if got := metadataScopes(t, empty); !slices.Equal(got, fixedMCPScopes) {
|
||||||
|
t.Fatalf("empty MCP scopes_supported = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
declared := append(append([]string(nil), fixedMCPScopes...), "acme:read")
|
||||||
|
opts.ScopesSupported = declared
|
||||||
|
listed := wristband.NewServer(opts)
|
||||||
|
if got := metadataScopes(t, listed); !slices.Equal(got, declared) {
|
||||||
|
t.Fatalf("declared MCP scopes_supported = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
advertised := append(append([]string(nil), fixedMCPScopes...), "acme:read")
|
||||||
|
app := newApp(t, appSpec{
|
||||||
|
verifier: advertisedVerifier{scopes: advertised},
|
||||||
|
guards: []pact.MCPGuard{&acmeGuard{}},
|
||||||
|
})
|
||||||
|
plugin := routePlugin{
|
||||||
|
id: "test.acme",
|
||||||
|
routes: []testRoute{{
|
||||||
|
method: http.MethodPost,
|
||||||
|
path: "/acme/seam",
|
||||||
|
mw: []string{"acme.auth"},
|
||||||
|
h: seamHandler,
|
||||||
|
}},
|
||||||
|
tools: []pact.MCPTool{{
|
||||||
|
Name: "seam",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/acme/seam",
|
||||||
|
Input: struct{}{},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
in := startInstance(t, app, plugin)
|
||||||
|
if got := prmScopes(t, in.srv.URL); !slices.Equal(got, advertised) {
|
||||||
|
t.Fatalf("advertised PRM scopes = %v, want %v", got, advertised)
|
||||||
|
}
|
||||||
|
|
||||||
|
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})
|
||||||
|
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_seam"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tools/call: %v", err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("tools/call isError: %+v", res.Content)
|
||||||
|
}
|
||||||
|
body := structured(t, res)
|
||||||
|
if body["ok"] != true || body["tool"] != "acme_seam" || body["dispatched"] != true || body["guard"] != "acme" {
|
||||||
|
t.Fatalf("handler saw %+v, want MCPCall tool acme_seam and a dispatch", body)
|
||||||
|
}
|
||||||
|
if sid, _ := body["session"].(string); sid == "" || sid == "ephemeral" {
|
||||||
|
t.Fatalf("session = %q, want the stateful session id", sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := startInstance(t, newApp(t, appSpec{
|
||||||
|
verifier: acmeVerifier{},
|
||||||
|
guards: []pact.MCPGuard{&acmeGuard{}},
|
||||||
|
}), plugin)
|
||||||
|
if got := prmScopes(t, plain.srv.URL); !slices.Equal(got, pier.PRMScopes) {
|
||||||
|
t.Fatalf("PRM without MCPAdvertisedScopes = %v, want %v", got, pier.PRMScopes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataScopes(t *testing.T, srv *wristband.Server) []string {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("metadata status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Scopes []string `json:"scopes_supported"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return doc.Scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
func prmScopes(t *testing.T, base string) []string {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.Get(base + "/.well-known/oauth-protected-resource")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("PRM status = %d body %s", resp.StatusCode, raw)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Scopes []string `json:"scopes_supported"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return doc.Scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
func seamHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
call, ok := pact.MCPCallFrom(r.Context())
|
||||||
|
_, dispatched := bouncer.Dispatched(r.Context(), "acme")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"ok": ok,
|
||||||
|
"tool": call.Tool,
|
||||||
|
"session": call.SessionID,
|
||||||
|
"guard": call.Subject.Guard,
|
||||||
|
"dispatched": dispatched,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// PRMScopes is the scopes_supported list of the protected resource
|
// PRMScopes is the scopes_supported list of the protected resource
|
||||||
// metadata: the OAuth server's MCP scopes plus backend (D-21).
|
// metadata when the published verifier does not advertise a non-empty
|
||||||
|
// list: the fixed MCP scopes, including backend.
|
||||||
var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"}
|
var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"}
|
||||||
|
|
||||||
// mountPRM serves the RFC 9728 protected resource metadata at
|
// mountPRM serves the RFC 9728 protected resource metadata at
|
||||||
@@ -22,6 +23,7 @@ var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"}
|
|||||||
// resource is logged as a warning at boot (Pitfall 8).
|
// resource is logged as a warning at boot (Pitfall 8).
|
||||||
func (p *Pier) mountPRM() error {
|
func (p *Pier) mountPRM() error {
|
||||||
issuer := p.cfg.AppURL
|
issuer := p.cfg.AppURL
|
||||||
|
scopes := append([]string(nil), PRMScopes...)
|
||||||
if verifier, ok := p.app.Lookup[pact.MCPTokenVerifier](); ok && verifier != nil {
|
if verifier, ok := p.app.Lookup[pact.MCPTokenVerifier](); ok && verifier != nil {
|
||||||
if res := verifier.MCPResource(); res != "" {
|
if res := verifier.MCPResource(); res != "" {
|
||||||
if u, err := url.Parse(res); err == nil && u.Scheme != "" && u.Host != "" {
|
if u, err := url.Parse(res); err == nil && u.Scheme != "" && u.Host != "" {
|
||||||
@@ -32,10 +34,15 @@ func (p *Pier) mountPRM() error {
|
|||||||
"mcp_resource", p.cfg.Resource, "verifier_resource", res)
|
"mcp_resource", p.cfg.Resource, "verifier_resource", res)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if advertised, ok := verifier.(pact.MCPAdvertisedScopes); ok {
|
||||||
|
if list := advertised.MCPScopesSupported(); len(list) > 0 {
|
||||||
|
scopes = append([]string(nil), list...)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
meta := &oauthex.ProtectedResourceMetadata{
|
meta := &oauthex.ProtectedResourceMetadata{
|
||||||
Resource: p.cfg.Resource,
|
Resource: p.cfg.Resource,
|
||||||
ScopesSupported: append([]string(nil), PRMScopes...),
|
ScopesSupported: scopes,
|
||||||
BearerMethodsSupported: []string{"header"},
|
BearerMethodsSupported: []string{"header"},
|
||||||
}
|
}
|
||||||
if issuer != "" {
|
if issuer != "" {
|
||||||
|
|||||||
@@ -704,7 +704,7 @@ func containsPrompt(prompts []*prompt, name string) bool {
|
|||||||
func (p *Pier) toolHandler(t *tool, session Identity) mcp.ToolHandler {
|
func (p *Pier) toolHandler(t *tool, session Identity) mcp.ToolHandler {
|
||||||
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
id, info := identityFromExtra(req.Extra, session)
|
id, info := identityFromExtra(req.Extra, session)
|
||||||
if req.Session != nil && req.Session.ID() != "" {
|
if !info.stateless && req.Session != nil && req.Session.ID() != "" {
|
||||||
info.session = req.Session.ID()
|
info.session = req.Session.ID()
|
||||||
}
|
}
|
||||||
return p.dispatch.call(ctx, id, info, t, req.Params.Arguments), nil
|
return p.dispatch.call(ctx, id, info, t, req.Params.Arguments), nil
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/towel"
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,6 +103,13 @@ func (sp *statelessPath) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
use := &entryUse{}
|
use := &entryUse{}
|
||||||
r = r.WithContext(context.WithValue(r.Context(), entryUseKey{}, use))
|
r = r.WithContext(context.WithValue(r.Context(), entryUseKey{}, use))
|
||||||
defer sp.release(use)
|
defer sp.release(use)
|
||||||
|
// The stateless path has no MCP session. Mark the call before the
|
||||||
|
// handler runs so a synthesized SDK session id is not copied into it.
|
||||||
|
if ti := auth.TokenInfoFromContext(r.Context()); ti != nil && ti.Extra != nil {
|
||||||
|
info, _ := ti.Extra[extraCall].(callInfo)
|
||||||
|
info.stateless = true
|
||||||
|
ti.Extra[extraCall] = info
|
||||||
|
}
|
||||||
if r.Method != http.MethodPost || r.Body == nil {
|
if r.Method != http.MethodPost || r.Body == nil {
|
||||||
sp.handler.ServeHTTP(w, r)
|
sp.handler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,22 +18,17 @@ import (
|
|||||||
"strings"
|
"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
|
// allowedAuthorizeScopes is the set parseAuthorizeScopes accepts on this
|
||||||
// server: exactly Options.ScopesSupported, the values the metadata document
|
// server: the same resolvedScopes slice Metadata advertises. A non-empty
|
||||||
// advertises, so a host that lists an extra scope (such as backend for the
|
// Options.ScopesSupported is used as given, so a host that lists an extra
|
||||||
// MCP module) accepts it at authorize; an empty list keeps the PHP default.
|
// 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 {
|
func (s *Server) allowedAuthorizeScopes() map[string]bool {
|
||||||
if s == nil || len(s.opts.ScopesSupported) == 0 {
|
scopes := s.resolvedScopes()
|
||||||
return authorizeAllowedScopes
|
allowed := make(map[string]bool, len(scopes))
|
||||||
}
|
for _, sc := range scopes {
|
||||||
allowed := make(map[string]bool, len(s.opts.ScopesSupported))
|
|
||||||
for _, sc := range s.opts.ScopesSupported {
|
|
||||||
if sc = strings.TrimSpace(sc); sc != "" {
|
if sc = strings.TrimSpace(sc); sc != "" {
|
||||||
allowed[sc] = true
|
allowed[sc] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMCPScopesSmoke covers the configurable authorize scope set of phase
|
// TestMCPScopesSmoke covers the configurable authorize scope set: a non-empty
|
||||||
// 19.7 (RESEARCH Pitfall 5, D-21 framework side): the allow-list is exactly
|
// Options.ScopesSupported is the allow-list and the metadata list, so a host
|
||||||
// Options.ScopesSupported, so a host that lists backend accepts it, while
|
// that lists backend accepts it, while DefaultOptions (the four PHP names,
|
||||||
// the default server keeps refusing it.
|
// 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) {
|
func TestMCPScopesSmoke(t *testing.T) {
|
||||||
backend := newMemoryBackend()
|
backend := newMemoryBackend()
|
||||||
opts := DefaultOptions()
|
opts := DefaultOptions()
|
||||||
@@ -69,10 +70,25 @@ func TestMCPScopesSmoke(t *testing.T) {
|
|||||||
t.Fatalf("default server accepted backend: %v", q)
|
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 := DefaultOptions()
|
||||||
empty.Issuer, empty.Resource, empty.ScopesSupported = opts.Issuer, opts.Resource, nil
|
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)
|
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
|
ServiceDocumentationPath string
|
||||||
|
|
||||||
// ScopesSupported is the RFC 8414 scopes_supported list. PHP default:
|
// 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
|
ScopesSupported []string
|
||||||
|
|
||||||
// TokenEndpointAuthMethodsSupported is the RFC 8414
|
// 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"},
|
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||||
CodeChallengeMethodsSupported: []string{"S256"},
|
CodeChallengeMethodsSupported: []string{"S256"},
|
||||||
TokenEndpointAuthMethodsSupported: s.opts.TokenEndpointAuthMethodsSupported,
|
TokenEndpointAuthMethodsSupported: s.opts.TokenEndpointAuthMethodsSupported,
|
||||||
ScopesSupported: s.opts.ScopesSupported,
|
ScopesSupported: s.resolvedScopes(),
|
||||||
ServiceDocumentation: s.opts.Issuer + s.opts.ServiceDocumentationPath,
|
ServiceDocumentation: s.opts.Issuer + s.opts.ServiceDocumentationPath,
|
||||||
AuthorizationResponseIssParameterSupported: s.opts.AuthorizationResponseIssParameterSupported,
|
AuthorizationResponseIssParameterSupported: s.opts.AuthorizationResponseIssParameterSupported,
|
||||||
}
|
}
|
||||||
writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": "no-cache, private"})
|
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
|
// writeExactJSON writes v as an unwrapped, no-trailing-newline JSON document
|
||||||
// (matching the wire/response.go WriteJSON technique) but never falls back to
|
// (matching the wire/response.go WriteJSON technique) but never falls back to
|
||||||
// the house opaque-500 envelope: raw RFC responses must never acquire a
|
// the house opaque-500 envelope: raw RFC responses must never acquire a
|
||||||
|
|||||||
Reference in New Issue
Block a user