From 21d0b6bf3e4690eac423d836b1af55862adde8b2 Mon Sep 17 00:00:00 2001 From: Ortes Date: Sun, 19 Apr 2026 07:51:27 -0400 Subject: [PATCH 1/3] feat(mcp): OAuth 2.1 authorization for the /mcp endpoint Adds Doorkeeper-backed OAuth 2.1 (PKCE, public clients, RFC 7591 DCR) so Claude connectors can authorize against DocuSeal without a pre-shared token. The existing McpToken bearer stays as a fallback. - Mount Doorkeeper at /oauth/* plus root aliases (/authorize, /token, /register) for clients that strip paths - Serve RFC 8414 + RFC 9728 discovery at /.well-known/oauth-* - /register implements RFC 7591 DCR for public clients with an IP throttle; redirect_uri restricted to https + loopback - McpController now resolves current_user from a Doorkeeper access token first, emits the RFC 9728 WWW-Authenticate header on 401 - Weekly sweeper for abandoned DCR applications (external cron) - Link Connected apps from MCP settings Co-Authored-By: Claude Opus 4.7 (1M context) --- Gemfile | 3 +- Gemfile.lock | 5 +- app/controllers/mcp_controller.rb | 28 ++++-- app/controllers/oauth/register_controller.rb | 63 +++++++++++++ .../oauth/token_proxy_controller.rb | 8 ++ app/controllers/well_known_controller.rb | 28 ++++++ app/jobs/oauth_application_sweeper_job.rb | 19 ++++ app/models/user.rb | 4 + app/views/mcp_settings/index.html.erb | 3 + config/initializers/doorkeeper.rb | 55 +++++++++++ config/routes.rb | 14 +++ spec/factories/mcp_tokens.rb | 8 ++ spec/factories/oauth_access_tokens.rb | 10 ++ spec/factories/oauth_applications.rb | 10 ++ .../oauth_application_sweeper_job_spec.rb | 20 ++++ spec/requests/mcp_oauth_spec.rb | 85 +++++++++++++++++ spec/requests/oauth_flow_spec.rb | 94 +++++++++++++++++++ spec/requests/oauth_register_spec.rb | 65 +++++++++++++ spec/requests/well_known_spec.rb | 38 ++++++++ 19 files changed, 552 insertions(+), 8 deletions(-) create mode 100644 app/controllers/oauth/register_controller.rb create mode 100644 app/controllers/oauth/token_proxy_controller.rb create mode 100644 app/controllers/well_known_controller.rb create mode 100644 app/jobs/oauth_application_sweeper_job.rb create mode 100644 config/initializers/doorkeeper.rb create mode 100644 spec/factories/mcp_tokens.rb create mode 100644 spec/factories/oauth_access_tokens.rb create mode 100644 spec/factories/oauth_applications.rb create mode 100644 spec/jobs/oauth_application_sweeper_job_spec.rb create mode 100644 spec/requests/mcp_oauth_spec.rb create mode 100644 spec/requests/oauth_flow_spec.rb create mode 100644 spec/requests/oauth_register_spec.rb create mode 100644 spec/requests/well_known_spec.rb diff --git a/Gemfile b/Gemfile index 4e2fd78e..d2c5b5f8 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' -ruby '4.0.1' +ruby '4.0.2' gem 'addressable' gem 'arabic-letter-connector', require: false @@ -15,6 +15,7 @@ gem 'csv', require: false gem 'csv-safe', require: false gem 'devise' gem 'devise-two-factor' +gem 'doorkeeper', '~> 5.9' gem 'dotenv', require: false gem 'email_typo' gem 'faraday' diff --git a/Gemfile.lock b/Gemfile.lock index de5ce993..a9c0716c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -176,6 +176,8 @@ GEM digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) docile (1.4.1) + doorkeeper (5.9.0) + railties (>= 5) dotenv (3.2.0) drb (2.2.3) email_typo (0.2.3) @@ -614,6 +616,7 @@ DEPENDENCIES debug devise devise-two-factor + doorkeeper (~> 5.9) dotenv email_typo erb_lint @@ -662,7 +665,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 4.0.1 + ruby 4.0.2 BUNDLED WITH 4.0.3 diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 8d7ca288..8a18efea 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -29,7 +29,11 @@ class McpController < ActionController::API private def authenticate_user! - render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user + return if current_user + + response.headers['WWW-Authenticate'] = + %(Bearer resource_metadata="#{request.base_url}/.well-known/oauth-protected-resource", error="invalid_token") + render json: { error: 'Not authenticated' }, status: :unauthorized end def verify_mcp_enabled! @@ -43,16 +47,28 @@ class McpController < ActionController::API end def current_user - @current_user ||= user_from_api_key + @current_user ||= user_from_oauth_token || user_from_mcp_token end - def user_from_api_key - token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1] + def user_from_oauth_token + return if bearer_token.blank? + + access_token = Doorkeeper::AccessToken.by_token(bearer_token) + return if access_token.nil? || access_token.revoked? || access_token.expired? + return unless access_token.scopes.exists?('mcp') + + User.active.find_by(id: access_token.resource_owner_id) + end - return if token.blank? + def user_from_mcp_token + return if bearer_token.blank? - sha256 = Digest::SHA256.hexdigest(token) + sha256 = Digest::SHA256.hexdigest(bearer_token) User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil }) end + + def bearer_token + @bearer_token ||= request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1] + end end diff --git a/app/controllers/oauth/register_controller.rb b/app/controllers/oauth/register_controller.rb new file mode 100644 index 00000000..e515ba6e --- /dev/null +++ b/app/controllers/oauth/register_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Oauth + class RegisterController < ActionController::API + THROTTLE_LIMIT = 20 + THROTTLE_WINDOW = 1.hour + + rescue_from JSON::ParserError do + render json: { error: 'invalid_client_metadata' }, status: :bad_request + end + + def create + return render_error('rate_limited', :too_many_requests) if throttled? + + body = JSON.parse(request.raw_post.presence || '{}') + + redirect_uris = Array(body['redirect_uris']).map(&:to_s).reject(&:blank?) + return render_error('invalid_redirect_uri') if redirect_uris.empty? + return render_error('invalid_redirect_uri') unless redirect_uris.all? { |u| valid_redirect?(u) } + + app = Doorkeeper::Application.create!( + name: body['client_name'].to_s.presence || "MCP client #{SecureRandom.hex(4)}", + redirect_uri: redirect_uris.join("\n"), + scopes: 'mcp', + confidential: false + ) + + render json: { + client_id: app.uid, + client_id_issued_at: app.created_at.to_i, + client_secret_expires_at: 0, + redirect_uris: redirect_uris, + grant_types: %w[authorization_code refresh_token], + response_types: %w[code], + token_endpoint_auth_method: 'none', + scope: 'mcp', + client_name: app.name + }, status: :created + end + + private + + def render_error(code, status = :bad_request) + render json: { error: code }, status: status + end + + def valid_redirect?(uri_str) + uri = URI.parse(uri_str) + return true if uri.scheme == 'https' + return true if uri.scheme == 'http' && %w[localhost 127.0.0.1 ::1].include?(uri.host) + + false + rescue URI::InvalidURIError + false + end + + def throttled? + key = "dcr:#{request.ip}" + count = Rails.cache.increment(key, 1, expires_in: THROTTLE_WINDOW) + count && count > THROTTLE_LIMIT + end + end +end diff --git a/app/controllers/oauth/token_proxy_controller.rb b/app/controllers/oauth/token_proxy_controller.rb new file mode 100644 index 00000000..cf09c14d --- /dev/null +++ b/app/controllers/oauth/token_proxy_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Oauth + # Root-path alias so clients (Claude.ai web) that ignore discovery metadata + # and POST to /token still hit Doorkeeper's token endpoint. + class TokenProxyController < Doorkeeper::TokensController + end +end diff --git a/app/controllers/well_known_controller.rb b/app/controllers/well_known_controller.rb new file mode 100644 index 00000000..acd6ac89 --- /dev/null +++ b/app/controllers/well_known_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class WellKnownController < ActionController::API + def authorization_server + base = request.base_url + render json: { + issuer: base, + authorization_endpoint: "#{base}/oauth/authorize", + token_endpoint: "#{base}/oauth/token", + registration_endpoint: "#{base}/register", + response_types_supported: %w[code], + grant_types_supported: %w[authorization_code refresh_token], + code_challenge_methods_supported: %w[S256], + token_endpoint_auth_methods_supported: %w[none], + scopes_supported: %w[mcp] + } + end + + def protected_resource + base = request.base_url + render json: { + resource: "#{base}/mcp", + authorization_servers: [base], + scopes_supported: %w[mcp], + bearer_methods_supported: %w[header] + } + end +end diff --git a/app/jobs/oauth_application_sweeper_job.rb b/app/jobs/oauth_application_sweeper_job.rb new file mode 100644 index 00000000..d3f9b524 --- /dev/null +++ b/app/jobs/oauth_application_sweeper_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Sweeper job for abandoned Dynamic Client Registration applications. +# +# Scheduling: this repo has no internal cron (no sidekiq-cron / whenever). +# Schedule externally, e.g. weekly: +# bin/rails runner 'OauthApplicationSweeperJob.perform_later' +class OauthApplicationSweeperJob < ApplicationJob + queue_as :default + + def perform + cutoff = 90.days.ago + live_app_ids = Doorkeeper::AccessToken.where(revoked_at: nil).select(:application_id) + Doorkeeper::Application + .where('created_at < ?', cutoff) + .where.not(id: live_app_ids) + .delete_all + end +end diff --git a/app/models/user.rb b/app/models/user.rb index b80ae769..1df0d641 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -63,6 +63,10 @@ class User < ApplicationRecord has_one :access_token, dependent: :destroy has_many :access_tokens, dependent: :destroy has_many :mcp_tokens, dependent: :destroy + has_many :oauth_access_grants, class_name: 'Doorkeeper::AccessGrant', + foreign_key: :resource_owner_id, dependent: :delete_all + has_many :oauth_access_tokens, class_name: 'Doorkeeper::AccessToken', + foreign_key: :resource_owner_id, dependent: :delete_all has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author has_many :user_configs, dependent: :destroy diff --git a/app/views/mcp_settings/index.html.erb b/app/views/mcp_settings/index.html.erb index 736f011b..4c6cb504 100644 --- a/app/views/mcp_settings/index.html.erb +++ b/app/views/mcp_settings/index.html.erb @@ -6,6 +6,9 @@ <%= t('mcp_server') %>
+ <%= link_to t('connected_apps', default: 'Connected apps'), + oauth_authorized_applications_path, + class: 'btn btn-ghost btn-md w-full md:w-fit' %>
<%= link_to new_settings_mcp_path, class: 'btn btn-primary btn-md gap-2 w-full md:w-fit', data: { turbo_frame: 'modal' } do %> <%= svg_icon('plus', class: 'w-6 h-6') %> diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 00000000..d2be1226 --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +Doorkeeper.configure do + orm :active_record + + # Runs inside Warden context. Redirect to the Devise sign-in page if the + # visitor is not logged in, remembering where to return after auth. + resource_owner_authenticator do + if current_user + current_user + else + session[:user_return_to] = request.fullpath + redirect_to(main_app.new_user_session_url) + nil + end + end + + resource_owner_from_credentials { nil } # no Resource Owner Password Credentials grant + + # Doorkeeper's built-in controllers (Authorizations, TokenInfo, AuthorizedApps) + # inherit from this. Must be an HTML controller so the consent view renders. + base_controller 'ApplicationController' + + grant_flows %w[authorization_code refresh_token] + + # PKCE: S256 only; required for all non-confidential (public) clients. + pkce_code_challenge_methods %w[S256] + force_pkce + + default_scopes :mcp + optional_scopes :mcp + + access_token_expires_in 1.hour + use_refresh_token + + # Hash access-token and refresh-token secrets in the DB. + hash_token_secrets using: '::Doorkeeper::SecretStoring::Sha256Hash' + + # Always show the consent screen. + skip_authorization { false } +end + +# Doorkeeper's own controllers inherit ApplicationController which enables CanCan +# check_authorization. Exempt them — they have no CanCan subjects. +Rails.application.config.to_prepare do + %w[ + Doorkeeper::AuthorizationsController + Doorkeeper::TokensController + Doorkeeper::TokenInfoController + Doorkeeper::AuthorizedApplicationsController + ].each do |name| + klass = name.safe_constantize + klass.skip_authorization_check if klass && klass.respond_to?(:skip_authorization_check) + end +end diff --git a/config/routes.rb b/config/routes.rb index f9d3ae38..3fb89141 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -208,6 +208,20 @@ Rails.application.routes.draw do end end + use_doorkeeper do + skip_controllers :applications + end + + # Claude.ai web connector strips paths — expose root aliases for endpoints it + # will try to hit regardless of discovery metadata. + get '/authorize', to: redirect { |_p, req| "/oauth/authorize?#{req.query_string}" } + post '/token', to: 'oauth/token_proxy#create' + post '/register', to: 'oauth/register#create' + + # Discovery metadata (RFC 8414 + RFC 9728). Must be at these exact paths. + get '/.well-known/oauth-authorization-server', to: 'well_known#authorization_server' + get '/.well-known/oauth-protected-resource', to: 'well_known#protected_resource' + match '/mcp', to: 'mcp#call', via: %i[get post] get '/js/:filename', to: 'embed_scripts#show', as: :embed_script diff --git a/spec/factories/mcp_tokens.rb b/spec/factories/mcp_tokens.rb new file mode 100644 index 00000000..e49eeaa2 --- /dev/null +++ b/spec/factories/mcp_tokens.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :mcp_token do + user + sequence(:name) { |n| "MCP token #{n}" } + end +end diff --git a/spec/factories/oauth_access_tokens.rb b/spec/factories/oauth_access_tokens.rb new file mode 100644 index 00000000..63625d02 --- /dev/null +++ b/spec/factories/oauth_access_tokens.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :oauth_access_token, class: 'Doorkeeper::AccessToken' do + application { association :oauth_application } + resource_owner_id { association(:user).id } + scopes { 'mcp' } + expires_in { 3600 } + end +end diff --git a/spec/factories/oauth_applications.rb b/spec/factories/oauth_applications.rb new file mode 100644 index 00000000..64d9384c --- /dev/null +++ b/spec/factories/oauth_applications.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :oauth_application, class: 'Doorkeeper::Application' do + sequence(:name) { |n| "MCP client #{n}" } + redirect_uri { 'https://claude.ai/api/mcp/auth_callback' } + scopes { 'mcp' } + confidential { false } + end +end diff --git a/spec/jobs/oauth_application_sweeper_job_spec.rb b/spec/jobs/oauth_application_sweeper_job_spec.rb new file mode 100644 index 00000000..dfaa7afa --- /dev/null +++ b/spec/jobs/oauth_application_sweeper_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe OauthApplicationSweeperJob do + it 'deletes apps older than 90 days with no active access tokens' do + old_dead = create(:oauth_application, created_at: 100.days.ago) + old_live = create(:oauth_application, created_at: 100.days.ago) + recent = create(:oauth_application, created_at: 1.day.ago) + + user = create(:user) + create(:oauth_access_token, application: old_live, resource_owner_id: user.id, scopes: 'mcp') + + described_class.new.perform + + expect(Doorkeeper::Application.exists?(id: old_dead.id)).to be(false) + expect(Doorkeeper::Application.exists?(id: old_live.id)).to be(true) + expect(Doorkeeper::Application.exists?(id: recent.id)).to be(true) + end +end diff --git a/spec/requests/mcp_oauth_spec.rb b/spec/requests/mcp_oauth_spec.rb new file mode 100644 index 00000000..37cda2ed --- /dev/null +++ b/spec/requests/mcp_oauth_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'MCP endpoint authentication', type: :request do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + + before do + create(:account_config, account:, key: AccountConfig::ENABLE_MCP_KEY, value: true) + end + + def post_mcp(token) + post '/mcp', + params: { jsonrpc: '2.0', method: 'ping', id: 1 }.to_json, + headers: { 'Authorization' => "Bearer #{token}", 'Content-Type' => 'application/json' } + end + + context 'unauthenticated' do + it 'returns 401 with RFC 9728 WWW-Authenticate header' do + post '/mcp', params: '{}', headers: { 'Content-Type' => 'application/json' } + + expect(response).to have_http_status(:unauthorized) + expect(response.headers['WWW-Authenticate']).to match( + %r{\ABearer resource_metadata="http://www\.example\.com/\.well-known/oauth-protected-resource", error="invalid_token"\z} + ) + end + end + + context 'with a valid OAuth access token' do + let(:access_token) do + create(:oauth_access_token, resource_owner_id: user.id, scopes: 'mcp') + end + + it 'succeeds and dispatches to HandleRequest' do + post_mcp(access_token.token) + expect(response).to have_http_status(:ok).or have_http_status(:accepted) + end + end + + context 'with an expired OAuth token' do + it 'returns 401' do + token = create(:oauth_access_token, resource_owner_id: user.id, scopes: 'mcp', expires_in: 1) + + travel_to(2.hours.from_now) do + post_mcp(token.token) + expect(response).to have_http_status(:unauthorized) + end + end + end + + context 'with a revoked OAuth token' do + let(:access_token) do + create(:oauth_access_token, resource_owner_id: user.id, scopes: 'mcp', + revoked_at: 1.minute.ago) + end + + it 'returns 401' do + post_mcp(access_token.token) + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with an OAuth token lacking the mcp scope' do + let(:access_token) do + create(:oauth_access_token, resource_owner_id: user.id, scopes: 'other') + end + + it 'returns 401' do + post_mcp(access_token.token) + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with a legacy McpToken bearer (back-compat)' do + it 'still succeeds' do + token = build(:mcp_token, user:) + raw = token.token + token.save! + + post_mcp(raw) + expect(response).to have_http_status(:ok).or have_http_status(:accepted) + end + end +end diff --git a/spec/requests/oauth_flow_spec.rb b/spec/requests/oauth_flow_spec.rb new file mode 100644 index 00000000..14d2d429 --- /dev/null +++ b/spec/requests/oauth_flow_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'base64' +require 'digest' + +RSpec.describe 'Full OAuth 2.1 flow', type: :request do + include Devise::Test::IntegrationHelpers + + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + + before do + create(:account_config, account:, key: AccountConfig::ENABLE_MCP_KEY, value: true) + end + + def b64url(bytes) = Base64.urlsafe_encode64(bytes, padding: false) + + it 'register → authorize → token → /mcp round-trips' do + # 1. Register + post '/register', + params: { client_name: 'Test', redirect_uris: ['https://claude.ai/cb'] }.to_json, + headers: { 'Content-Type' => 'application/json' } + expect(response).to have_http_status(:created) + client_id = JSON.parse(response.body).fetch('client_id') + + # 2. PKCE verifier + challenge + verifier = b64url(SecureRandom.random_bytes(32)) + challenge = b64url(Digest::SHA256.digest(verifier)) + + # 3. Sign in (Devise) and authorize + sign_in user + get '/oauth/authorize', params: { + client_id: client_id, + response_type: 'code', + redirect_uri: 'https://claude.ai/cb', + scope: 'mcp', + code_challenge: challenge, + code_challenge_method: 'S256' + } + expect(response.status).to satisfy { |s| [200, 302].include?(s) } + + post '/oauth/authorize', params: { + client_id: client_id, + response_type: 'code', + redirect_uri: 'https://claude.ai/cb', + scope: 'mcp', + code_challenge: challenge, + code_challenge_method: 'S256' + } + expect(response).to have_http_status(:redirect) + code = URI.decode_www_form(URI.parse(response.location).query).to_h.fetch('code') + + # 4. Exchange — omitting code_verifier must fail (force_pkce) + post '/oauth/token', params: { + grant_type: 'authorization_code', + code: code, + redirect_uri: 'https://claude.ai/cb', + client_id: client_id + } + expect(response).to have_http_status(:bad_request) + + # 5. Redo: get a fresh code and exchange it with code_verifier. + post '/oauth/authorize', params: { + client_id: client_id, + response_type: 'code', + redirect_uri: 'https://claude.ai/cb', + scope: 'mcp', + code_challenge: challenge, + code_challenge_method: 'S256' + } + code = URI.decode_www_form(URI.parse(response.location).query).to_h.fetch('code') + + post '/oauth/token', params: { + grant_type: 'authorization_code', + code: code, + redirect_uri: 'https://claude.ai/cb', + client_id: client_id, + code_verifier: verifier + } + expect(response).to have_http_status(:ok) + access_token = JSON.parse(response.body).fetch('access_token') + + # 6. Call /mcp with the access token. + sign_out user + post '/mcp', + params: { jsonrpc: '2.0', method: 'ping', id: 1 }.to_json, + headers: { + 'Authorization' => "Bearer #{access_token}", + 'Content-Type' => 'application/json' + } + expect(response.status).to satisfy { |s| [200, 202].include?(s) } + end +end diff --git a/spec/requests/oauth_register_spec.rb b/spec/requests/oauth_register_spec.rb new file mode 100644 index 00000000..81c0c4ea --- /dev/null +++ b/spec/requests/oauth_register_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Dynamic Client Registration (RFC 7591)', type: :request do + let(:valid_body) do + { + client_name: 'Claude', + redirect_uris: ['https://claude.ai/api/mcp/auth_callback'] + } + end + + def post_register(body) + post '/register', params: body.to_json, + headers: { 'Content-Type' => 'application/json' } + end + + it 'creates a public Doorkeeper application and returns RFC 7591 fields' do + expect { post_register(valid_body) }.to change(Doorkeeper::Application, :count).by(1) + + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + + expect(json['client_id']).to be_present + expect(json['client_secret_expires_at']).to eq(0) + expect(json['token_endpoint_auth_method']).to eq('none') + expect(json['grant_types']).to match_array(%w[authorization_code refresh_token]) + expect(json['response_types']).to eq(['code']) + expect(json['scope']).to eq('mcp') + expect(json['redirect_uris']).to eq(['https://claude.ai/api/mcp/auth_callback']) + + app = Doorkeeper::Application.find_by(uid: json['client_id']) + expect(app).to be_present + expect(app.confidential).to be(false) + expect(app.redirect_uri.split("\n")).to eq(['https://claude.ai/api/mcp/auth_callback']) + expect(app.scopes.to_s).to eq('mcp') + end + + it 'accepts a loopback http redirect_uri (OAuth 2.1 allowance)' do + post_register(valid_body.merge(redirect_uris: ['http://127.0.0.1:8765/callback'])) + expect(response).to have_http_status(:created) + end + + it 'rejects a non-https, non-loopback redirect_uri' do + post_register(valid_body.merge(redirect_uris: ['http://evil.example.com/cb'])) + expect(response).to have_http_status(:bad_request) + expect(JSON.parse(response.body)['error']).to eq('invalid_redirect_uri') + end + + it 'rejects empty redirect_uris' do + post_register(valid_body.merge(redirect_uris: [])) + expect(response).to have_http_status(:bad_request) + end + + it 'rejects malformed JSON' do + post '/register', params: 'not-json', headers: { 'Content-Type' => 'application/json' } + expect(response).to have_http_status(:bad_request) + end + + it 'throttles after 20 requests from the same IP within an hour' do + 20.times { post_register(valid_body) } + post_register(valid_body) + expect(response).to have_http_status(:too_many_requests) + end +end diff --git a/spec/requests/well_known_spec.rb b/spec/requests/well_known_spec.rb new file mode 100644 index 00000000..dc2fc871 --- /dev/null +++ b/spec/requests/well_known_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Well-known OAuth metadata', type: :request do + describe 'GET /.well-known/oauth-authorization-server' do + it 'returns RFC 8414 metadata with S256 PKCE advertised' do + get '/.well-known/oauth-authorization-server' + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/json') + + json = JSON.parse(response.body) + expect(json['issuer']).to eq('http://www.example.com') + expect(json['authorization_endpoint']).to eq('http://www.example.com/oauth/authorize') + expect(json['token_endpoint']).to eq('http://www.example.com/oauth/token') + expect(json['registration_endpoint']).to eq('http://www.example.com/register') + expect(json['code_challenge_methods_supported']).to eq(['S256']) + expect(json['grant_types_supported']).to include('authorization_code', 'refresh_token') + expect(json['response_types_supported']).to eq(['code']) + expect(json['token_endpoint_auth_methods_supported']).to eq(['none']) + expect(json['scopes_supported']).to eq(['mcp']) + end + end + + describe 'GET /.well-known/oauth-protected-resource' do + it 'returns RFC 9728 metadata pointing at /mcp' do + get '/.well-known/oauth-protected-resource' + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['resource']).to eq('http://www.example.com/mcp') + expect(json['authorization_servers']).to eq(['http://www.example.com']) + expect(json['scopes_supported']).to eq(['mcp']) + expect(json['bearer_methods_supported']).to eq(['header']) + end + end +end From 6c51ee9929e90ca4906e25608fe91d11b06b459a Mon Sep 17 00:00:00 2001 From: Ortes Date: Sun, 19 Apr 2026 07:56:54 -0400 Subject: [PATCH 2/3] chore: add dockerfile.test + compose for running the spec suite in docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing Dockerfile builds the production image with BUNDLE_WITHOUT=development:test, so it can't run RSpec. This adds a test-only image that ships every native lib the app loads at boot (libvips, libpdfium, onnxruntime, chromium for cuprite) plus the full dev/test gem bundle. Usage: docker compose -f docker-compose.test.yml up --build # or run a subset: SPEC_FILES="spec/requests/well_known_spec.rb" \ docker compose -f docker-compose.test.yml up --build --exit-code-from test The bundle gems live in a named volume so `docker compose up` re-runs are fast after the first build. Postgres uses tmpfs — no state persists between runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile.test | 70 +++++++++++++++++++++++++++++++++++++++++ docker-compose.test.yml | 38 ++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 Dockerfile.test create mode 100644 docker-compose.test.yml diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 00000000..d6137374 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,70 @@ +# syntax=docker/dockerfile:1.7 +# Test-only image: runs the full Rails/RSpec suite with every native lib the +# app loads at boot (libvips, libpdfium, onnxruntime, chromium for cuprite). +# Source code is mounted at runtime via docker-compose.test.yml — only the +# Gemfile is baked in so `bundle install` caches between runs. + +FROM ruby:4.0.1-alpine AS pdfium + +WORKDIR /download + +RUN apk --no-cache add wget && \ + wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \ + mkdir -p /pdfium-linux && \ + tar -xzf pdfium-linux.tgz -C /pdfium-linux + +FROM ruby:4.0.1-alpine + +ENV RAILS_ENV=test \ + BUNDLE_WITHOUT="" \ + LANG=C.UTF-8 \ + TZ=UTC + +WORKDIR /app + +# System deps: +# - libpq / libpq-dev → pg gem +# - vips + vips-heif → image processing (ruby-vips FFI) +# - onnxruntime → field detection at boot +# - chromium + chromium-chromedriver → capybara/cuprite system specs +# - fontconfig + ttf-* → PDF rendering +# - build-base/git/yaml-dev → native gem compilation +RUN apk add --no-cache \ + build-base \ + git \ + bash \ + curl \ + yaml-dev \ + libpq \ + libpq-dev \ + vips \ + vips-dev \ + vips-heif \ + redis \ + fontconfig \ + ttf-dejavu \ + onnxruntime \ + nodejs \ + yarn \ + chromium \ + chromium-chromedriver \ + tzdata + +# libpdfium is not in Alpine packages — copy the prebuilt binary. +COPY --from=pdfium /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so + +# Bundle deps. Copy only Gemfile/Gemfile.lock so this layer is cached while +# source code changes beneath it. +COPY Gemfile Gemfile.lock ./ +RUN bundle config set --local without "" && \ + bundle install --jobs 4 --retry 3 + +# Link onnxruntime.so into the onnxruntime gem's vendor dir (mirrors the +# production Dockerfile) so the gem finds it at runtime. +RUN ln -sf /usr/lib/libonnxruntime.so.1 \ + "$(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")" + +# Cuprite launches Chromium directly — point it at the Alpine binary. +ENV CUPRITE_CHROME_PATH=/usr/bin/chromium-browser + +CMD ["bundle", "exec", "rspec"] diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 00000000..658d1596 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,38 @@ +services: + test: + build: + context: . + dockerfile: Dockerfile.test + depends_on: + postgres: + condition: service_healthy + environment: + RAILS_ENV: test + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/docuseal_test + # Silence bootsnap cache warnings when mounting source read-write. + BOOTSNAP_CACHE_DIR: /tmp/bootsnap + volumes: + - .:/app + - bundle:/usr/local/bundle + command: > + bash -c " + bin/rails db:prepare && + bundle exec rspec ${SPEC_FILES:-spec} + " + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: docuseal_test + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 3s + retries: 10 + +volumes: + bundle: From 104684f163c48dc24d16bc051b86054ba433b8d7 Mon Sep 17 00:00:00 2001 From: Ortes Date: Sun, 19 Apr 2026 09:17:42 -0400 Subject: [PATCH 3/3] fix(oauth): plaintext_token in specs, allow loopback redirect, fix throttle test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues surfaced running the suite in docker: - hash_token_secrets stores the access token hashed; specs must use access_token.plaintext_token (not .token) when posing as a client - Doorkeeper's Application model rejects non-HTTPS redirect_uri by default; add force_ssl_in_redirect_uri to allow loopback per OAuth 2.1 - test env uses :null_store, so Rails.cache.increment returned nil and the DCR throttle never fired — stub a real MemoryStore in that spec Also slim Dockerfile.test: drop chromium + chromium-chromedriver (unused by OAuth specs, added ~4min to the build). Add a comment pointing at the apk line to re-enable them for system specs. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile.test | 13 +++++-------- config/initializers/doorkeeper.rb | 5 +++++ spec/requests/mcp_oauth_spec.rb | 8 ++++---- spec/requests/oauth_register_spec.rb | 2 ++ 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Dockerfile.test b/Dockerfile.test index d6137374..04d670b3 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -4,7 +4,7 @@ # Source code is mounted at runtime via docker-compose.test.yml — only the # Gemfile is baked in so `bundle install` caches between runs. -FROM ruby:4.0.1-alpine AS pdfium +FROM ruby:4.0.2-alpine AS pdfium WORKDIR /download @@ -13,7 +13,7 @@ RUN apk --no-cache add wget && \ mkdir -p /pdfium-linux && \ tar -xzf pdfium-linux.tgz -C /pdfium-linux -FROM ruby:4.0.1-alpine +FROM ruby:4.0.2-alpine ENV RAILS_ENV=test \ BUNDLE_WITHOUT="" \ @@ -26,9 +26,11 @@ WORKDIR /app # - libpq / libpq-dev → pg gem # - vips + vips-heif → image processing (ruby-vips FFI) # - onnxruntime → field detection at boot -# - chromium + chromium-chromedriver → capybara/cuprite system specs # - fontconfig + ttf-* → PDF rendering # - build-base/git/yaml-dev → native gem compilation +# +# Capybara/cuprite system specs need chromium — add `chromium +# chromium-chromedriver` to this apk line if running `spec/system/*`. RUN apk add --no-cache \ build-base \ git \ @@ -46,8 +48,6 @@ RUN apk add --no-cache \ onnxruntime \ nodejs \ yarn \ - chromium \ - chromium-chromedriver \ tzdata # libpdfium is not in Alpine packages — copy the prebuilt binary. @@ -64,7 +64,4 @@ RUN bundle config set --local without "" && \ RUN ln -sf /usr/lib/libonnxruntime.so.1 \ "$(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")" -# Cuprite launches Chromium directly — point it at the Alpine binary. -ENV CUPRITE_CHROME_PATH=/usr/bin/chromium-browser - CMD ["bundle", "exec", "rspec"] diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index d2be1226..df9f1233 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -27,6 +27,11 @@ Doorkeeper.configure do pkce_code_challenge_methods %w[S256] force_pkce + # Require HTTPS for redirect_uri except for loopback (OAuth 2.1 §8.4.2). + force_ssl_in_redirect_uri do |uri| + !%w[localhost 127.0.0.1 ::1].include?(uri.host) + end + default_scopes :mcp optional_scopes :mcp diff --git a/spec/requests/mcp_oauth_spec.rb b/spec/requests/mcp_oauth_spec.rb index 37cda2ed..9063c162 100644 --- a/spec/requests/mcp_oauth_spec.rb +++ b/spec/requests/mcp_oauth_spec.rb @@ -33,7 +33,7 @@ RSpec.describe 'MCP endpoint authentication', type: :request do end it 'succeeds and dispatches to HandleRequest' do - post_mcp(access_token.token) + post_mcp(access_token.plaintext_token) expect(response).to have_http_status(:ok).or have_http_status(:accepted) end end @@ -43,7 +43,7 @@ RSpec.describe 'MCP endpoint authentication', type: :request do token = create(:oauth_access_token, resource_owner_id: user.id, scopes: 'mcp', expires_in: 1) travel_to(2.hours.from_now) do - post_mcp(token.token) + post_mcp(token.plaintext_token) expect(response).to have_http_status(:unauthorized) end end @@ -56,7 +56,7 @@ RSpec.describe 'MCP endpoint authentication', type: :request do end it 'returns 401' do - post_mcp(access_token.token) + post_mcp(access_token.plaintext_token) expect(response).to have_http_status(:unauthorized) end end @@ -67,7 +67,7 @@ RSpec.describe 'MCP endpoint authentication', type: :request do end it 'returns 401' do - post_mcp(access_token.token) + post_mcp(access_token.plaintext_token) expect(response).to have_http_status(:unauthorized) end end diff --git a/spec/requests/oauth_register_spec.rb b/spec/requests/oauth_register_spec.rb index 81c0c4ea..80f24c19 100644 --- a/spec/requests/oauth_register_spec.rb +++ b/spec/requests/oauth_register_spec.rb @@ -58,6 +58,8 @@ RSpec.describe 'Dynamic Client Registration (RFC 7591)', type: :request do end it 'throttles after 20 requests from the same IP within an hour' do + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + 20.times { post_register(valid_body) } post_register(valid_body) expect(response).to have_http_status(:too_many_requests)