Files
summercms/modules/wristband/oidc_flavour_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

249 lines
11 KiB
Go

package wristband
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestKindIsolation pins T-19.4-05 in both directions: an OIDC-kind client
// does not exist for the MCP flavour, an MCP-kind client does not exist for
// the OIDC flavour, at authorize, resume, decide, token and userinfo.
func TestKindIsolation(t *testing.T) {
// MCP flavour against an OIDC-kind client.
backend := newMemoryBackend()
mcp := newTestServer(backend)
secret := "mcp-secret"
h := sha256Hex(secret)
oidcClient := insertTokenTestClient(backend, "oidc-kind-client", "client_secret_post", &h, nil)
oidcClient.Kind = ClientKindOIDC
_, challenge := s256Pair(t)
rec := httptest.NewRecorder()
mcp.Authorize(rec, authorizeRequest(map[string]string{
"client_id": oidcClient.ClientID, "redirect_uri": tokenTestRedirect, "response_type": "code",
"code_challenge": challenge, "code_challenge_method": "S256",
}))
if rec.Code != http.StatusBadRequest || rec.Body.String() != "Unknown client." || rec.Header().Get("Location") != "" {
t.Fatalf("MCP authorize, OIDC client = %d %q", rec.Code, rec.Body.String())
}
verifier, ch := s256Pair(t)
raw, _ := insertTokenTestCode(t, backend, oidcClient.ClientID, ch, nil)
rec = httptest.NewRecorder()
form := validExchangeForm(raw, verifier, oidcClient.ClientID)
form.Set("client_secret", secret)
mcp.Token(rec, tokenRequest(form, ""))
if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), `"invalid_client"`) {
t.Fatalf("MCP token, OIDC client = %d %s", rec.Code, rec.Body.String())
}
// The same client as MCP kind works, so the kind alone decided.
oidcClient.Kind = ClientKindMCP
rec = httptest.NewRecorder()
mcp.Token(rec, tokenRequest(form, ""))
if rec.Code != http.StatusOK {
t.Fatalf("MCP token, MCP client = %d %s", rec.Code, rec.Body.String())
}
// OIDC flavour against an MCP-kind client.
f := newOIDCFixture(t, testOIDCIssuer, nil)
mcpKind := f.backend.addClient(OIDCClientRecord{
ClientID: "oas_mcpkind", Kind: ClientKindMCP, RedirectURIs: []string{testOIDCRedirect},
Scopes: []string{"openid", "profile"}, IsActive: true,
}, testOIDCSecret)
assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, func(q url.Values) { q.Set("client_id", mcpKind.ClientID) }).Encode()), AuthorizeErrorUnknownClient)
rec = f.tokenCall(url.Values{
"grant_type": {"authorization_code"}, "code": {"oas_ac_x"}, "redirect_uri": {testOIDCRedirect}, "code_verifier": {oidcTestVerifier},
}, mcpKind.ClientID, testOIDCSecret)
if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), `"invalid_client"`) {
t.Fatalf("OIDC token, MCP client = %d %s", rec.Code, rec.Body.String())
}
// A client that turns MCP-kind between the steps of a flow: resume and
// decide render unknown_client, token and userinfo refuse.
f.host.signIn(7)
q := oidcAuthorizeParams(f, nil)
start := f.get("/oauth/authorize?" + q.Encode())
resume := start.Header().Get("Location")
f.backend.clients[0].Kind = ClientKindMCP
assertRendered(t, f.get(resume), AuthorizeErrorUnknownClient)
f.backend.clients[0].Kind = ClientKindOIDC
f.get(resume)
view := f.host.lastView(t)
f.backend.clients[0].Kind = ClientKindMCP
assertRendered(t, f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}), AuthorizeErrorUnknownClient)
f.backend.clients[0].Kind = ClientKindOIDC
code := f.obtainCode(t, f.client, 7, "openid")
f.backend.clients[0].Kind = ClientKindMCP
if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusUnauthorized {
t.Fatalf("OIDC token after the client turned MCP = %d", rec.Code)
}
f.backend.clients[0].Kind = ClientKindOIDC
tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier))
f.backend.clients[0].Kind = ClientKindMCP
assertUserInfo401(t, f.userinfo(tok.AccessToken))
f.backend.clients[0].Kind = ClientKindOIDC
if rec := f.userinfo(tok.AccessToken); rec.Code != http.StatusOK {
t.Fatalf("userinfo once the client is OIDC again = %d", rec.Code)
}
}
// TestFlavourHandlers pins the flavour dispatch (T-19.4-14): each flavour's
// own handlers answer 404 with an empty body on the other flavour, and an
// OIDC server without SetOIDCBackend answers 500 everywhere.
func TestFlavourHandlers(t *testing.T) {
f := newOIDCFixture(t, testOIDCIssuer, nil)
mcp := newTestServer(newMemoryBackend())
for name, h := range map[string]http.HandlerFunc{"Metadata": f.srv.Metadata, "Register": f.srv.Register} {
for _, method := range []string{http.MethodGet, http.MethodPost} {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(method, "/", strings.NewReader("{}")))
if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 {
t.Fatalf("%s %s on OIDC = %d %q", method, name, rec.Code, rec.Body.String())
}
}
}
for name, h := range map[string]http.HandlerFunc{
"Discovery": mcp.Discovery, "JWKS": mcp.JWKS, "UserInfo": mcp.UserInfo,
"Resume": mcp.Resume, "SwitchAccount": mcp.SwitchAccount,
} {
for _, method := range []string{http.MethodGet, http.MethodPost} {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(method, "/", nil))
if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 {
t.Fatalf("%s %s on MCP = %d %q", method, name, rec.Code, rec.Body.String())
}
}
}
// Authorize and Token dispatch by flavour: the same request gets the
// MCP answer on the MCP server and the OIDC answer on the OIDC server.
rec := httptest.NewRecorder()
mcp.Authorize(rec, httptest.NewRequest(http.MethodGet, "/oauth/authorize?client_id=nope", nil))
if rec.Code != http.StatusBadRequest || rec.Body.String() != "Unknown client." {
t.Fatalf("MCP authorize = %d %q", rec.Code, rec.Body.String())
}
assertRendered(t, f.get("/oauth/authorize?client_id=nope"), AuthorizeErrorUnknownClient)
rec = httptest.NewRecorder()
mcp.Token(rec, tokenRequest(url.Values{"grant_type": {"password"}}, ""))
if strings.Contains(rec.Body.String(), "Only the authorization_code grant is supported.") {
t.Fatalf("MCP token answered with the OIDC text: %s", rec.Body.String())
}
if rec := f.tokenCall(url.Values{"grant_type": {"password"}}, "", ""); !strings.Contains(rec.Body.String(), "Only the authorization_code grant is supported.") {
t.Fatalf("OIDC token = %s", rec.Body.String())
}
// An OIDC server without its backend fails closed.
bare := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions())
mux := http.NewServeMux()
mux.HandleFunc("GET /oauth/authorize", bare.Authorize)
mux.HandleFunc("POST /oauth/authorize", bare.Authorize)
mux.HandleFunc("GET /oauth/authorize/resume/{request}", bare.Resume)
mux.HandleFunc("POST /oauth/authorize/switch-account", bare.SwitchAccount)
mux.HandleFunc("POST /oauth/token", bare.Token)
mux.HandleFunc("GET /oauth/userinfo", bare.UserInfo)
for _, tc := range [][2]string{
{http.MethodGet, "/oauth/authorize"}, {http.MethodPost, "/oauth/authorize"},
{http.MethodGet, "/oauth/authorize/resume/x"}, {http.MethodPost, "/oauth/authorize/switch-account"},
{http.MethodPost, "/oauth/token"}, {http.MethodGet, "/oauth/userinfo"},
} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(tc[0], tc[1], nil))
if rec.Code != http.StatusInternalServerError || rec.Body.Len() != 0 {
t.Fatalf("unconfigured %s %s = %d %q", tc[0], tc[1], rec.Code, rec.Body.String())
}
}
for name, h := range map[string]http.HandlerFunc{"Discovery": bare.Discovery, "JWKS": bare.JWKS} {
if rec := serve(h, "/"); rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` {
t.Fatalf("unconfigured %s = %d %s", name, rec.Code, rec.Body.String())
}
}
}
// TestSetOIDCBackendValidation pins the boot-time checks of SetOIDCBackend:
// an MCP server and every nil argument are refused, so a misconfigured
// application fails at boot rather than on the first sign-in.
func TestSetOIDCBackendValidation(t *testing.T) {
mcp := newTestServer(newMemoryBackend())
if err := mcp.SetOIDCBackend(newMemoryOIDCBackend(), newMemoryKeyStore(), &fakeOIDCHost{}); err == nil || !strings.Contains(err.Error(), "MCP") {
t.Fatalf("MCP server = %v", err)
}
if mcp.oidcReady() {
t.Fatal("an MCP server became OIDC-ready")
}
srv := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions())
for name, call := range map[string]func() error{
"nil backend": func() error { return srv.SetOIDCBackend(nil, newMemoryKeyStore(), &fakeOIDCHost{}) },
"nil key store": func() error { return srv.SetOIDCBackend(newMemoryOIDCBackend(), nil, &fakeOIDCHost{}) },
"nil host": func() error { return srv.SetOIDCBackend(newMemoryOIDCBackend(), newMemoryKeyStore(), nil) },
} {
if err := call(); err == nil {
t.Fatalf("%s accepted", name)
}
if srv.oidcReady() {
t.Fatalf("%s left the server ready", name)
}
}
if err := srv.SetOIDCBackend(newMemoryOIDCBackend(), newMemoryKeyStore(), &fakeOIDCHost{}); err != nil {
t.Fatal(err)
}
if !srv.oidcReady() {
t.Fatal("a configured OIDC server is not ready")
}
if rec := serve(srv.Discovery, "/"); rec.Code != http.StatusOK {
t.Fatalf("discovery after configuration = %d", rec.Code)
}
}
// TestMCPRefusesOpenID pins D-07: the MCP flavour never grants openid.
// Authorize drops it (with profile and email) instead of persisting it, and
// the token answer never carries an id_token, even for a code whose stored
// scopes mention openid.
func TestMCPRefusesOpenID(t *testing.T) {
backend := newMemoryBackend()
mcp := newTestServer(backend)
client := insertAuthorizeTestClient(backend, "mcp-client", []string{"https://client.example.test/cb"}, nil)
_, challenge := s256Pair(t)
for _, scope := range []string{"openid", "openid read", "read openid profile"} {
rec := httptest.NewRecorder()
mcp.Authorize(rec, authorizeRequest(map[string]string{
"client_id": client.ClientID, "redirect_uri": "https://client.example.test/cb", "response_type": "code",
"code_challenge": challenge, "code_challenge_method": "S256", "scope": scope, "state": "s",
}))
if rec.Code != http.StatusFound {
t.Fatalf("MCP authorize with %q = %d", scope, rec.Code)
}
if _, has := queryOf(t, rec.Header().Get("Location"))["error"]; has {
t.Fatalf("MCP authorize with %q = %q", scope, rec.Header().Get("Location"))
}
backend.mu.Lock()
got := backend.codes[len(backend.codes)-1].Scopes
backend.mu.Unlock()
if len(got) != 1 || got[0] != "read" {
t.Fatalf("MCP authorize with %q stored scopes %v, want [read]", scope, got)
}
}
srv, backend2, verifier, rawCode, code := newTokenExchangeFixture(t)
_ = backend2
code.Scopes = []string{"openid", "read"}
rec := httptest.NewRecorder()
srv.Token(rec, tokenRequest(validExchangeForm(rawCode, verifier, "cli-tok"), ""))
if rec.Code != http.StatusOK {
t.Fatalf("MCP exchange = %d %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if _, ok := body["id_token"]; ok || strings.Contains(rec.Body.String(), "id_token") {
t.Fatalf("an MCP token answer carries an id_token: %s", rec.Body.String())
}
if body["access_token"] == nil || body["refresh_token"] == nil {
t.Fatalf("MCP token answer = %s", rec.Body.String())
}
}