Files
sure/test/controllers/oauth_registration_controller_test.rb
T
WeekendsuperHero be779fff08 fix(mcp): accept native-app redirect URIs during OAuth DCR (#3431)
* fix(mcp): accept native-app redirect URIs during OAuth DCR

Dynamic client registration only allowed https and loopback http, so
Cursor's cursor:// callback failed POST /register. One custom scheme
in a mixed list (cursor:// + localhost + https) rejected the whole
client. Accept RFC 8252 private-use schemes while still rejecting
javascript/data/file and non-loopback http.

PKCE and the consent screen remain in place. Loopback-only Cursor
registrations already worked; this unblocks the default mixed payload.

* fix(mcp): harden OAuth DCR redirect URI validation

Reject empty trailing fragments (URI.parse yields "") that present?
missed, and forbid tel/sms/intent handler schemes. Localize the
invalid-redirect error_description. Native app schemes (cursor://,
vscode://, reverse-domain) stay allowed.

* test(mcp): cover native DCR token exchange and documented URIs

Exchange the PKCE authorization code at /oauth/token in the native-app
flow. Lock hosting docs to Cursor's desktop, loopback, and web callbacks
and register each documented redirect URI.

* docs(mcp): add method comments for OAuth DCR helpers

CodeRabbit's docstring coverage check only counted comments on methods
touched by the diff. Document create and the redirect URI helpers.
2026-09-08 02:16:39 +02:00

340 lines
11 KiB
Ruby

require "test_helper"
class OauthRegistrationControllerTest < ActionDispatch::IntegrationTest
DOCUMENTED_NATIVE_REDIRECT_URIS = [
"cursor://anysphere.cursor-mcp/oauth/callback",
"http://localhost:8787/callback",
"https://www.cursor.com/agents/mcp/oauth/callback"
].freeze
test "registers a public client and returns client_id" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "https://claude.ai/callback" ],
grant_types: [ "authorization_code" ],
response_types: [ "code" ],
token_endpoint_auth_method: "none"
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created
json = JSON.parse(response.body)
assert json["client_id"].present?
assert_equal "Claude", json["client_name"]
assert_equal [ "https://claude.ai/callback" ], json["redirect_uris"]
assert_equal [ "authorization_code" ], json["grant_types"]
assert_equal "none", json["token_endpoint_auth_method"]
assert_nil json["client_secret"], "Public client must not return a secret"
app = Doorkeeper::Application.find_by(uid: json["client_id"])
assert app.present?, "Application should be persisted"
assert_not app.confidential?, "Application must be non-confidential (public client)"
assert_equal "read_write", app.scopes.to_s
end
test "returns error for invalid JSON body" do
post "/register",
params: "not json",
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
test "returns error when redirect_uris is missing" do
post "/register",
params: { client_name: "Claude" }.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
test "returns error when redirect_uris contains only blank values" do
post "/register",
params: { client_name: "Claude", redirect_uris: [ "" ] }.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
test "uses fallback name when client_name is absent" do
post "/register",
params: {
redirect_uris: [ "https://claude.ai/callback" ],
token_endpoint_auth_method: "none"
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created
json = JSON.parse(response.body)
assert_equal "MCP Client", json["client_name"]
end
test "rejects non-loopback http redirect uri" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "http://evil.example/callback" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
test "allows loopback http redirect uri" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "http://localhost:3456/callback" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created
json = JSON.parse(response.body)
assert_equal [ "http://localhost:3456/callback" ], json["redirect_uris"]
end
test "allows ipv6 loopback http redirect uri" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "http://[::1]:3456/callback" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created
json = JSON.parse(response.body)
assert_equal [ "http://[::1]:3456/callback" ], json["redirect_uris"]
end
test "allows native app custom scheme redirect uri" do
post "/register",
params: {
client_name: "Cursor",
redirect_uris: [ "cursor://anysphere.cursor-mcp/oauth/callback" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created
json = JSON.parse(response.body)
assert_equal [ "cursor://anysphere.cursor-mcp/oauth/callback" ], json["redirect_uris"]
end
test "allows vscode custom scheme redirect uri" do
post "/register",
params: {
client_name: "VS Code",
redirect_uris: [ "vscode://augment.vscode-augment/auth/mcp" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created
json = JSON.parse(response.body)
assert_equal [ "vscode://augment.vscode-augment/auth/mcp" ], json["redirect_uris"]
end
test "allows mixed native and https redirect uris from a desktop client" do
post "/register",
params: {
client_name: "Cursor",
redirect_uris: [
"cursor://anysphere.cursor-mcp/oauth/callback",
"http://localhost:8787/callback",
"https://www.cursor.com/agents/mcp/oauth/callback"
]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created
json = JSON.parse(response.body)
assert_equal [
"cursor://anysphere.cursor-mcp/oauth/callback",
"http://localhost:8787/callback",
"https://www.cursor.com/agents/mcp/oauth/callback"
], json["redirect_uris"]
end
test "allows reverse-domain native redirect uri without a host" do
post "/register",
params: {
client_name: "Native App",
redirect_uris: [ "com.example.app:/oauth2redirect/example-provider" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created
json = JSON.parse(response.body)
assert_equal [ "com.example.app:/oauth2redirect/example-provider" ], json["redirect_uris"]
end
test "rejects javascript redirect uri" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "javascript:alert(1)" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
test "rejects file redirect uri" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "file:///etc/passwd" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
test "rejects data redirect uri" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "data:text/html,hello" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
test "rejects redirect uri with a fragment" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "https://claude.ai/callback#oops" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
assert_equal I18n.t("oauth.registration.invalid_redirect_uris"), json["error_description"]
end
test "rejects redirect uri with an empty trailing fragment" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "https://claude.ai/callback#" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
test "rejects tel sms and intent redirect uris" do
%w[tel:+15551212 sms:+15551212 intent://scan/oauth].each do |redirect_uri|
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ redirect_uri ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request, "expected #{redirect_uri} to be rejected"
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
end
test "rejects scheme-only custom redirect uri" do
post "/register",
params: {
client_name: "Claude",
redirect_uris: [ "cursor://" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_client_metadata", json["error"]
end
test "authorization redirects to a dynamically registered native app uri" do
post "/register",
params: {
client_name: "Cursor",
redirect_uris: [ "cursor://anysphere.cursor-mcp/oauth/callback" ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created
app = Doorkeeper::Application.find_by!(uid: JSON.parse(response.body)["client_id"])
sign_in(users(:family_admin))
verifier = SecureRandom.urlsafe_base64(64)
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
post "/oauth/authorize", params: {
client_id: app.uid,
redirect_uri: "cursor://anysphere.cursor-mcp/oauth/callback",
response_type: "code",
code_challenge: challenge,
code_challenge_method: "S256"
}
assert_response :redirect
assert response.location.start_with?("cursor://anysphere.cursor-mcp/oauth/callback")
code = Rack::Utils.parse_query(URI.parse(response.location).query)["code"]
assert code.present?, "Authorization response should contain a code"
post "/oauth/token", params: {
grant_type: "authorization_code",
client_id: app.uid,
redirect_uri: "cursor://anysphere.cursor-mcp/oauth/callback",
code: code,
code_verifier: verifier
}
assert_response :success
token_response = JSON.parse(response.body)
assert_equal "Bearer", token_response["token_type"]
assert token_response["access_token"].present?
end
test "hosting docs document native MCP client redirect URIs" do
doc = Rails.root.join("docs/hosting/mcp.md").read
DOCUMENTED_NATIVE_REDIRECT_URIS.each do |uri|
assert_includes doc, uri
end
assert_includes doc, "vscode://"
assert_includes doc, "POST /register"
assert_includes doc, "PKCE"
assert_includes doc, "MCP_API_TOKEN"
end
test "registers each redirect uri documented for native MCP clients" do
DOCUMENTED_NATIVE_REDIRECT_URIS.each do |redirect_uri|
post "/register",
params: {
client_name: "Cursor",
redirect_uris: [ redirect_uri ]
}.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :created, "expected #{redirect_uri} to register"
json = JSON.parse(response.body)
assert_equal [ redirect_uri ], json["redirect_uris"]
end
end
end