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.
95 lines
3.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|