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()) } }