Files
summercms/modules/wristband/mcp_scopes_test.go
Jakub Zych aba678b196 fix(wristband): drop OIDC identity scopes at MCP authorize
Clients that also read the issuer's OpenID Connect discovery document
add openid, profile or email to their MCP authorize request, and the
MCP flavour refused those requests with invalid_scope ("scope contains
an unsupported value"), so they could not connect.

The three identity scopes are now dropped before the allow-list check,
as RFC 6749 section 3.3 lets a server grant fewer scopes than requested.
A request left with no other scope gets the read default. Any other
unknown scope, including a differently cased identity name, is still
refused. The MCP flavour still never grants identity scopes or issues
an id_token, and the metadata document is unchanged.
2026-11-06 14:17:43 +01:00

95 lines
3.6 KiB
Go

package wristband
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
// TestMCPScopes (D-21, the framework side of the backend scope): the MCP
// flavour's authorize allow-list is exactly Options.ScopesSupported. The
// default server refuses backend; a server that lists it accepts "read
// backend" and advertises it in its metadata; on either, the identity scopes
// openid, profile and email are dropped before the check and a scope nobody
// lists stays refused.
func TestMCPScopes(t *testing.T) {
_, challenge := s256Pair(t)
authorize := func(t *testing.T, s *Server, scope string) map[string]string {
t.Helper()
rec := httptest.NewRecorder()
s.Authorize(rec, authorizeRequest(map[string]string{
"client_id": "cli-scopes", "redirect_uri": authorizeTestRedirect, "response_type": "code",
"code_challenge": challenge, "code_challenge_method": "S256", "scope": scope, "state": "s",
}))
if rec.Code != http.StatusFound {
t.Fatalf("%q: status = %d, want 302", scope, rec.Code)
}
return queryOf(t, rec.Header().Get("Location"))
}
plain, plainBackend := newAuthorizeTestServer()
insertAuthorizeTestClient(plainBackend, "cli-scopes", []string{authorizeTestRedirect}, nil)
listing := DefaultOptions()
listing.Issuer = "https://issuer.example.test"
listing.Resource = "https://resource.example.test/mcp"
listing.ScopesSupported = append(listing.ScopesSupported, "backend")
withBackend := NewServer(listing)
backend := newMemoryBackend()
withBackend.SetBackend(backend)
insertAuthorizeTestClient(backend, "cli-scopes", []string{authorizeTestRedirect}, nil)
t.Run("default server refuses backend", func(t *testing.T) {
if q := authorize(t, plain, "read backend"); q["error"] != "invalid_scope" {
t.Fatalf("default server = %v", q)
}
if q := authorize(t, plain, "read write"); q["error"] != "" {
t.Fatalf("default server refused read write: %v", q)
}
})
t.Run("listing server accepts and advertises backend", func(t *testing.T) {
if q := authorize(t, withBackend, "read backend"); q["error"] != "" {
t.Fatalf("read backend = %v", q)
}
rec := httptest.NewRecorder()
withBackend.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
var doc struct {
Scopes []string `json:"scopes_supported"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil || !slices.Contains(doc.Scopes, "backend") {
t.Fatalf("scopes_supported = %v (%v)", doc.Scopes, err)
}
rec = httptest.NewRecorder()
plain.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil || slices.Contains(doc.Scopes, "backend") {
t.Fatalf("default scopes_supported = %v (%v)", doc.Scopes, err)
}
})
t.Run("identity scopes are dropped and unlisted scopes stay refused", func(t *testing.T) {
for _, s := range []*Server{plain, withBackend} {
if q := authorize(t, s, "read openid"); q["error"] != "" {
t.Fatalf("read openid = %v", q)
}
if q := authorize(t, s, "read admin"); q["error"] != "invalid_scope" {
t.Fatalf("read admin = %v", q)
}
}
if q := authorize(t, plain, "openid backend"); q["error"] != "invalid_scope" {
t.Fatalf("default server openid backend = %v", q)
}
if q := authorize(t, withBackend, "openid backend"); q["error"] != "" {
t.Fatalf("listing server openid backend = %v", q)
}
backend.mu.Lock()
got := backend.codes[len(backend.codes)-1].Scopes
backend.mu.Unlock()
if !slices.Equal(got, []string{"backend"}) {
t.Fatalf("listing server openid backend scopes = %v, want [backend]", got)
}
})
}