- 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
239 lines
11 KiB
Go
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())
|
|
}
|
|
}
|