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