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