mirror of
https://github.com/we-promise/sure.git
synced 2026-09-08 16:14:23 +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.
123 lines
3.8 KiB
Ruby
123 lines
3.8 KiB
Ruby
class OauthRegistrationController < ApplicationController
|
|
LOOPBACK_HOSTS = [ "localhost", "127.0.0.1", "::1" ].freeze
|
|
# Schemes that can execute script, read local files, invoke device handlers,
|
|
# or are not OAuth redirects.
|
|
FORBIDDEN_SCHEMES = %w[javascript data file about blob ws wss ftp mailto tel sms intent].freeze
|
|
SCHEME_PATTERN = /\A[a-z][a-z0-9+\-.]*\z/.freeze
|
|
|
|
skip_authentication
|
|
skip_before_action :verify_authenticity_token
|
|
skip_before_action :require_onboarding_and_upgrade, raise: false
|
|
skip_before_action :set_default_chat, raise: false
|
|
skip_before_action :detect_os, raise: false
|
|
|
|
rescue_from ActionDispatch::Http::Parameters::ParseError do
|
|
render json: {
|
|
error: "invalid_client_metadata",
|
|
error_description: "Invalid JSON"
|
|
}, status: :bad_request
|
|
end
|
|
|
|
# Registers a public OAuth client from MCP dynamic client registration.
|
|
def create
|
|
body = JSON.parse(request.raw_post)
|
|
|
|
redirect_uris = body["redirect_uris"]
|
|
if redirect_uris.blank?
|
|
render json: {
|
|
error: "invalid_client_metadata",
|
|
error_description: "redirect_uris is required"
|
|
}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
redirect_uris = Array(redirect_uris).map { |u| u.to_s.strip }.reject(&:blank?)
|
|
if redirect_uris.empty?
|
|
render json: {
|
|
error: "invalid_client_metadata",
|
|
error_description: "redirect_uris is required"
|
|
}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
unless redirect_uris.all? { |uri| valid_redirect_uri?(uri) }
|
|
render json: {
|
|
error: "invalid_client_metadata",
|
|
error_description: t("oauth.registration.invalid_redirect_uris")
|
|
}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
client_name = body["client_name"].presence || "MCP Client"
|
|
|
|
app = Doorkeeper::Application.new(
|
|
name: client_name,
|
|
redirect_uri: redirect_uris.join("\n"),
|
|
confidential: false,
|
|
# MCP requires the read_write scope. Without assigning it to the
|
|
# dynamically registered client, Doorkeeper falls back to the provider's
|
|
# default read scope and the token is rejected by McpController.
|
|
scopes: "read_write"
|
|
)
|
|
|
|
if app.save
|
|
render json: {
|
|
client_id: app.uid,
|
|
client_name: app.name,
|
|
redirect_uris: app.redirect_uri.split("\n"),
|
|
grant_types: [ "authorization_code" ],
|
|
token_endpoint_auth_method: "none"
|
|
}, status: :created
|
|
else
|
|
render json: {
|
|
error: "invalid_client_metadata",
|
|
error_description: app.errors.full_messages.join(", ")
|
|
}, status: :bad_request
|
|
end
|
|
rescue JSON::ParserError
|
|
render json: {
|
|
error: "invalid_client_metadata",
|
|
error_description: "Invalid JSON"
|
|
}, status: :bad_request
|
|
end
|
|
|
|
private
|
|
|
|
# Returns true for https, loopback http, and RFC 8252 private-use schemes
|
|
# (cursor://, vscode://). Rejects fragments, userinfo, handler schemes, and
|
|
# non-loopback http.
|
|
def valid_redirect_uri?(raw_uri)
|
|
uri = URI.parse(raw_uri)
|
|
return false unless uri.fragment.nil?
|
|
return false if uri.userinfo.present?
|
|
|
|
scheme = uri.scheme.to_s.downcase
|
|
return false if scheme.blank? || FORBIDDEN_SCHEMES.include?(scheme)
|
|
return false unless scheme.match?(SCHEME_PATTERN)
|
|
|
|
case scheme
|
|
when "https"
|
|
uri.host.present?
|
|
when "http"
|
|
loopback_http?(uri)
|
|
else
|
|
native_app_redirect?(uri)
|
|
end
|
|
rescue URI::Error
|
|
false
|
|
end
|
|
|
|
# Returns true when +uri+ is http to localhost, 127.0.0.1, or ::1.
|
|
def loopback_http?(uri)
|
|
return false if uri.host.blank?
|
|
|
|
host = uri.host.downcase.delete_prefix("[").delete_suffix("]")
|
|
LOOPBACK_HOSTS.include?(host)
|
|
end
|
|
|
|
# Returns true when a private-use URI has a host or path to redirect to.
|
|
def native_app_redirect?(uri)
|
|
uri.host.present? || uri.path.present?
|
|
end
|
|
end
|