Files
yhh 902091b0ac fix(mcp): refuse an OAuth server that does not advertise PKCE, unless the user allows it
The client went ahead when an authorization server's metadata omitted
code_challenge_methods_supported. The spec makes refusing a MUST: without it
the client cannot tell whether the server verifies the challenge it is sent.
It now refuses with ErrOAuthPKCEUnadvertised before any browser opens, and
the error names the escape hatch.

Some real authorization servers leave the field out, so a server entry can
set oauth_allow_missing_pkce_metadata = true. It is honoured only from a
config the user keeps in their own home (user config, legacy user config,
~/.claude.json): a project's config, its .mcp.json and an installed package
are written by someone else, and none of them may relax the check for
whoever opens or installs them. MCPConfigSource.UserAuthorized() also covers
project sources, so it could not be the test. PKCE S256 is still sent when
the check is relaxed.

The renderer writes the flag back, or the next config save would drop it.
The per-server overrides moved from render.go to render_plugin.go to keep
render.go within its complexity and size budget.

Security review (internal/ext/plugin is sensitive): the default is stricter
than before; the only relaxation is per server, from user-owned sources,
pinned by a test per source; the authorization URL still carries an S256
challenge, pinned by the test. plugin, pluginspec, config and boot tests
pass on a clean worktree; golangci-lint reports 0 issues; the coverage gate
does not flag internal/ext/plugin.
2026-09-23 23:17:48 -07:00

33 lines
1.1 KiB
Go

package pluginspec
import (
"testing"
"reasonix/internal/contract/config"
)
// Only a config the user keeps in their own home may relax the PKCE check. A
// project's config, its .mcp.json and an installed package are written by
// someone else, and the flag in them is ignored.
func TestPKCEOptOutIsHonouredOnlyFromTheUsersOwnConfig(t *testing.T) {
cases := map[config.MCPConfigSource]bool{
config.MCPSourceUserConfig: true,
config.MCPSourceLegacyUser: true,
config.MCPSourceClaudeUser: true,
config.MCPSourceClaudeLocal: true,
config.MCPSourceProjectConfig: false,
config.MCPSourceProjectMCPJSON: false,
config.MCPSourcePluginPackage: false,
config.MCPSourceUnknown: false,
}
for source, want := range cases {
spec := FromEntry(config.PluginEntry{
Name: "remote", Type: "http", URL: "https://mcp.example.test/mcp",
Source: source, OAuthAllowMissingPKCEMetadata: true,
}, "", Options{})
if spec.OAuthAllowMissingPKCEMetadata != want {
t.Errorf("source %q: opt-out honoured = %v, want %v", source, spec.OAuthAllowMissingPKCEMetadata, want)
}
}
}