Files
summercms/modules/wristband/oidc_flavour_test.go
Jakub Zych dd167466d2 test(19.4-04): unit tests for wristband's OIDC flavour
- PHP protocol cases, one subtest per PHP method: TestPKCEVerifier,
  TestRedirectURIMatch, TestTokenIssuer, TestAuthCodeLifecycle,
  TestPKCEAndReplay, TestConsentCSRF (cases 1-5)
- Go-only behaviour: discovery bytes, JWKS shapes, PEM parsing, ES256 and
  mixed rings, validation order, repeated parameters, PHP encoding,
  resume, switch account, JSON consent, client authentication, uniform
  userinfo 401, pairwise subjects, opt-in refresh, kind isolation,
  flavour dispatch, SetOIDCBackend checks, MCP refuses openid
- every name required by the gate's go stage
2026-10-19 21:05:21 +02:00

239 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 keeps refusing openid at
// authorize, and its 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 || queryOf(t, rec.Header().Get("Location"))["error"] != "invalid_scope" {
t.Fatalf("MCP authorize with %q = %d %q", scope, rec.Code, rec.Header().Get("Location"))
}
}
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())
}
}