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

333 lines
14 KiB
Go

package wristband
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// oidcAuthorizeParams is a valid authorization request for the fixture's
// client; mutate adjusts it per case.
func oidcAuthorizeParams(f *oidcFixture, mutate func(url.Values)) url.Values {
q := url.Values{
"response_type": {"code"},
"client_id": {f.client.ClientID},
"redirect_uri": {testOIDCRedirect},
"scope": {"openid profile"},
"state": {"st-1"},
"nonce": {"n-1"},
"code_challenge": {s256Challenge(oidcTestVerifier)},
"code_challenge_method": {"S256"},
}
if mutate != nil {
mutate(q)
}
return q
}
// oidcGet serves one GET through the fixture's routes.
func (f *oidcFixture) get(target string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
f.mux().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
return rec
}
// post serves one urlencoded POST through the fixture's routes.
func (f *oidcFixture) post(target string, form url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
f.mux().ServeHTTP(rec, req)
return rec
}
// startAndResume starts an authorization request as the signed-in user and
// follows the resume redirect.
func (f *oidcFixture) startAndResume(t *testing.T, q url.Values) *httptest.ResponseRecorder {
t.Helper()
start := f.get("/oauth/authorize?" + q.Encode())
loc := start.Header().Get("Location")
if start.Code != http.StatusFound || !strings.HasPrefix(loc, resumePathPrefix) {
t.Fatalf("start = %d %q %s", start.Code, loc, start.Body.String())
}
return f.get(loc)
}
func assertRendered(t *testing.T, rec *httptest.ResponseRecorder, code string) {
t.Helper()
if rec.Code != http.StatusBadRequest || rec.Body.String() != code {
t.Fatalf("want rendered %s, got %d %q", code, rec.Code, rec.Body.String())
}
if rec.Header().Get("Location") != "" {
t.Fatalf("rendered error must not redirect, Location = %q", rec.Header().Get("Location"))
}
for k, v := range map[string]string{
"X-Frame-Options": "DENY",
"Content-Security-Policy": "frame-ancestors 'none'",
"Cache-Control": "no-store, private",
"Referrer-Policy": "no-referrer",
} {
if got := rec.Header().Get(k); got != v {
t.Fatalf("%s = %q, want %q", k, got, v)
}
}
}
func TestOIDCAuthorizeStartSmoke(t *testing.T) {
f := newOIDCFixture(t, testOIDCIssuer, nil)
// Unknown client and redirect mismatch are rendered, never redirected.
assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, func(q url.Values) { q.Set("client_id", "oas_nope") }).Encode()), AuthorizeErrorUnknownClient)
assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, func(q url.Values) { q.Set("redirect_uri", testOIDCRedirect+"/") }).Encode()), AuthorizeErrorRedirectMismatch)
// Missing PKCE redirects with invalid_request and the state.
rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { q.Del("code_challenge") }).Encode())
want := testOIDCRedirect + "?error=invalid_request&error_description=A+PKCE+code+challenge+is+required+and+must+be+a+base64url-encoded+SHA-256+digest.&state=st-1"
if rec.Code != http.StatusFound || rec.Header().Get("Location") != want {
t.Fatalf("missing PKCE = %d %q", rec.Code, rec.Header().Get("Location"))
}
if rec.Header().Get("Cache-Control") != "no-store, private" {
t.Fatalf("Cache-Control = %q", rec.Header().Get("Cache-Control"))
}
// Scope rules in PHP order.
for _, tc := range []struct {
scope, desc string
}{
{"profile", "The+scope+parameter+must+include+openid."},
{"openid phone", "One+or+more+requested+scopes+are+not+supported."},
{"openid offline_access", "One+or+more+requested+scopes+are+not+supported."},
} {
rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { q.Set("scope", tc.scope) }).Encode())
if !strings.Contains(rec.Header().Get("Location"), "error=invalid_scope&error_description="+tc.desc) {
t.Fatalf("scope %q: Location = %q", tc.scope, rec.Header().Get("Location"))
}
}
// Anonymous user goes to the login URL carrying only redirect=.
rec = f.get("/oauth/authorize?" + oidcAuthorizeParams(f, nil).Encode())
loc := rec.Header().Get("Location")
if rec.Code != http.StatusFound || !strings.HasPrefix(loc, "/login?redirect=%2Foauth%2Fauthorize%2Fresume%2F") {
t.Fatalf("anonymous = %d %q", rec.Code, loc)
}
u, _ := url.Parse(loc)
if len(u.Query()) != 1 {
t.Fatalf("login URL must carry only redirect=, got %q", loc)
}
// Last value of a repeated parameter wins (PHP parse_str).
q := oidcAuthorizeParams(f, nil)
q["state"] = []string{"first", "last"}
q["code_challenge"] = []string{"short", s256Challenge(oidcTestVerifier)}
f.host.signIn(7)
if rec := f.startAndResume(t, q); rec.Code != http.StatusOK {
t.Fatalf("repeated params = %d", rec.Code)
}
}
func TestOIDCResumeDecideSwitchSmoke(t *testing.T) {
f := newOIDCFixture(t, testOIDCIssuer, nil)
f.host.signIn(7)
// First run: consent for openid profile.
if rec := f.startAndResume(t, oidcAuthorizeParams(f, nil)); rec.Code != http.StatusOK {
t.Fatalf("first resume = %d", rec.Code)
}
view := f.host.lastView(t)
if strings.Join(view.Scopes, " ") != "profile" || view.Incremental {
t.Fatalf("first view = %+v", view)
}
allow := url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}
rec := f.post("/oauth/authorize", allow)
if rec.Code != http.StatusFound || !strings.Contains(rec.Header().Get("Location"), "?code=oas_ac_") {
t.Fatalf("allow = %d %q", rec.Code, rec.Header().Get("Location"))
}
// The consent token was one-shot.
assertRendered(t, f.post("/oauth/authorize", allow), AuthorizeErrorExpiredRequest)
// Second run with the same scopes is silent: resume redirects with a code.
views := len(f.host.views)
rec = f.startAndResume(t, oidcAuthorizeParams(f, nil))
if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), testOIDCRedirect+"?code=oas_ac_") || len(f.host.views) != views {
t.Fatalf("silent resume = %d %q", rec.Code, rec.Header().Get("Location"))
}
// Incremental consent lists only the added scope.
if rec := f.startAndResume(t, oidcAuthorizeParams(f, func(q url.Values) { q.Set("scope", "openid profile email") })); rec.Code != http.StatusOK {
t.Fatalf("incremental resume = %d", rec.Code)
}
view = f.host.lastView(t)
if strings.Join(view.Scopes, " ") != "email" || !view.Incremental {
t.Fatalf("incremental view = %+v", view)
}
// A token minted for user 7 does not work for user 8.
f.host.signIn(8)
assertRendered(t, f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}), AuthorizeErrorExpiredRequest)
// Switch account: user 8 gets a consent page, switches, and the request
// is still answerable for the next user.
if rec := f.get(resumePathPrefix + view.RequestID); rec.Code != http.StatusOK {
t.Fatalf("resume as user 8 = %d", rec.Code)
}
view = f.host.lastView(t)
rec = f.post("/oauth/authorize/switch-account", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}})
if rec.Code != http.StatusFound || rec.Header().Get("Location") != resumePathPrefix+view.RequestID || f.host.endSessions != 1 {
t.Fatalf("switch = %d %q, endSessions %d", rec.Code, rec.Header().Get("Location"), f.host.endSessions)
}
if rec := f.get(resumePathPrefix + view.RequestID); rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), "/login?redirect=") {
t.Fatalf("resume after switch = %d %q", rec.Code, rec.Header().Get("Location"))
}
f.host.signIn(9)
if rec := f.get(resumePathPrefix + view.RequestID); rec.Code != http.StatusOK {
t.Fatalf("resume as user 9 = %d", rec.Code)
}
// Deny consumes the request and redirects with access_denied only.
view = f.host.lastView(t)
rec = f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"no"}})
if rec.Header().Get("Location") != testOIDCRedirect+"?error=access_denied&state=st-1" {
t.Fatalf("deny = %q", rec.Header().Get("Location"))
}
assertRendered(t, f.get(resumePathPrefix+view.RequestID), AuthorizeErrorExpiredRequest)
}
func TestKindIsolationSmoke(t *testing.T) {
// An oidc-kind client is unknown at MCP authorize and invalid_client at
// MCP token.
backend := newMemoryBackend()
opts := DefaultOptions()
opts.Issuer = testOIDCIssuer
mcp := NewServer(opts)
mcp.SetBackend(backend)
secret := "mcp-secret"
h := sha256Hex(secret)
oidcClient := insertTokenTestClient(backend, "oidc-kind-client", "client_secret_post", &h, nil)
oidcClient.Kind = ClientKindOIDC
mcpClient := insertAuthorizeTestClient(backend, "mcp-kind-client", []string{"https://client.example.test/cb"}, nil)
_, 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." {
t.Fatalf("MCP authorize with an oidc client = %d %q", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
mcp.Token(rec, tokenRequest(url.Values{
"grant_type": {"authorization_code"}, "client_id": {oidcClient.ClientID}, "client_secret": {secret},
"code": {"x"}, "redirect_uri": {tokenTestRedirect}, "code_verifier": {"y"},
}, ""))
if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "invalid_client") {
t.Fatalf("MCP token with an oidc client = %d %s", rec.Code, rec.Body.String())
}
// MCP authorize never grants openid (D-07): it is dropped, not persisted.
rec = httptest.NewRecorder()
mcp.Authorize(rec, authorizeRequest(map[string]string{
"client_id": mcpClient.ClientID, "redirect_uri": "https://client.example.test/cb", "response_type": "code",
"code_challenge": challenge, "code_challenge_method": "S256", "scope": "openid read",
}))
if _, has := queryOf(t, rec.Header().Get("Location"))["error"]; rec.Code != http.StatusFound || has {
t.Fatalf("MCP authorize with openid = %d %q", rec.Code, rec.Header().Get("Location"))
}
backend.mu.Lock()
stored := backend.codes[len(backend.codes)-1].Scopes
backend.mu.Unlock()
if len(stored) != 1 || stored[0] != "read" {
t.Fatalf("MCP authorize with openid stored scopes %v, want [read]", stored)
}
// An mcp-kind client is unknown_client at OIDC authorize and
// invalid_client at the OIDC token endpoint.
f := newOIDCFixture(t, testOIDCIssuer, nil)
mcpInOIDC := f.backend.addClient(OIDCClientRecord{
ClientID: "oas_mcpkind", Kind: ClientKindMCP, RedirectURIs: []string{testOIDCRedirect},
Scopes: []string{"openid"}, IsActive: true,
}, testOIDCSecret)
assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, func(q url.Values) { q.Set("client_id", mcpInOIDC.ClientID) }).Encode()), AuthorizeErrorUnknownClient)
rec = f.post("/oauth/token", url.Values{
"grant_type": {"authorization_code"}, "client_id": {mcpInOIDC.ClientID}, "client_secret": {testOIDCSecret},
"code": {"oas_ac_x"}, "redirect_uri": {testOIDCRedirect}, "code_verifier": {oidcTestVerifier},
})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("OIDC token with an mcp client = %d %s", rec.Code, rec.Body.String())
}
// Each flavour's own handlers answer 404 on the other flavour.
for name, h := range map[string]http.HandlerFunc{"Metadata": f.srv.Metadata, "Register": f.srv.Register} {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("%s on OIDC = %d", name, rec.Code)
}
}
for name, h := range map[string]http.HandlerFunc{"Discovery": mcp.Discovery, "JWKS": mcp.JWKS, "UserInfo": mcp.UserInfo, "Resume": mcp.Resume, "SwitchAccount": mcp.SwitchAccount} {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("%s on MCP = %d", name, rec.Code)
}
}
if err := mcp.SetOIDCBackend(f.backend, f.keys, f.host); err == nil {
t.Fatal("SetOIDCBackend on an MCP server must fail")
}
if err := f.srv.SetOIDCBackend(nil, f.keys, f.host); err == nil {
t.Fatal("SetOIDCBackend with a nil backend must fail")
}
}
func TestPHPRedirectEncodingSmoke(t *testing.T) {
f := newOIDCFixture(t, testOIDCIssuer, nil)
state := "a b~c+d/é"
rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) {
q.Set("state", state)
q.Set("response_type", "token")
}).Encode())
loc := rec.Header().Get("Location")
if !strings.HasSuffix(loc, "&state=a+b%7Ec%2Bd%2F%C3%A9") {
t.Fatalf("Location = %q", loc)
}
if strings.Contains(loc, "iss=") {
t.Fatalf("OIDC redirects carry no iss: %q", loc)
}
if got := phpURLEncode("A-z_0.9 ~*'()!"); got != "A-z_0.9+%7E%2A%27%28%29%21" {
t.Fatalf("phpURLEncode = %q", got)
}
// An empty state is dropped, an empty description is dropped.
if got := errorRedirectURL("https://rp.example.test/cb?x=1", "access_denied", "", ""); got != "https://rp.example.test/cb?x=1&error=access_denied" {
t.Fatalf("errorRedirectURL = %q", got)
}
// is_dev loopback port variants only.
reg := []string{"http://127.0.0.1:8080/cb", "http://localhost/cb?x=1", "https://rp.example.test/cb"}
for _, tc := range []struct {
uri string
isDev bool
want bool
}{
{"http://127.0.0.1:9999/cb", true, true},
{"http://127.0.0.1:9999/cb", false, false},
{"http://localhost:3000/cb?x=1", true, true},
{"http://localhost:3000/cb?x=2", true, false},
{"http://localhost:3000/cb/", true, false},
{"https://127.0.0.1:9999/cb", true, false},
{"//127.0.0.1:9999/cb", true, false},
{"http://127.0.0.2:8080/cb", true, false},
{"https://rp.example.test/cb", false, true},
{"https://rp.example.test.evil/cb", true, false},
} {
if got := matchOIDCRedirectURI(reg, tc.uri, tc.isDev); got != tc.want {
t.Fatalf("match(%q, dev=%v) = %v", tc.uri, tc.isDev, got)
}
}
if matchOIDCRedirectURI(nil, "https://rp.example.test/cb", true) {
t.Fatal("no registered URIs must match nothing")
}
}