mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* 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.
340 lines
11 KiB
Ruby
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
|