pull/633/merge
Ortes 1 day ago committed by GitHub
commit 3b064ce097
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,67 @@
# 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.2-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.2-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
# - 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 \
bash \
curl \
yaml-dev \
libpq \
libpq-dev \
vips \
vips-dev \
vips-heif \
redis \
fontconfig \
ttf-dejavu \
onnxruntime \
nodejs \
yarn \
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")"
CMD ["bundle", "exec", "rspec"]

@ -2,7 +2,7 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '4.0.1' ruby '4.0.2'
gem 'addressable' gem 'addressable'
gem 'arabic-letter-connector', require: false gem 'arabic-letter-connector', require: false
@ -15,6 +15,7 @@ gem 'csv', require: false
gem 'csv-safe', require: false gem 'csv-safe', require: false
gem 'devise' gem 'devise'
gem 'devise-two-factor' gem 'devise-two-factor'
gem 'doorkeeper', '~> 5.9'
gem 'dotenv', require: false gem 'dotenv', require: false
gem 'email_typo' gem 'email_typo'
gem 'faraday' gem 'faraday'

@ -176,6 +176,8 @@ GEM
digest-crc (0.7.0) digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
docile (1.4.1) docile (1.4.1)
doorkeeper (5.9.0)
railties (>= 5)
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
email_typo (0.2.3) email_typo (0.2.3)
@ -614,6 +616,7 @@ DEPENDENCIES
debug debug
devise devise
devise-two-factor devise-two-factor
doorkeeper (~> 5.9)
dotenv dotenv
email_typo email_typo
erb_lint erb_lint
@ -662,7 +665,7 @@ DEPENDENCIES
webmock webmock
RUBY VERSION RUBY VERSION
ruby 4.0.1 ruby 4.0.2
BUNDLED WITH BUNDLED WITH
4.0.3 4.0.3

@ -29,7 +29,11 @@ class McpController < ActionController::API
private private
def authenticate_user! 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 end
def verify_mcp_enabled! def verify_mcp_enabled!
@ -43,16 +47,28 @@ class McpController < ActionController::API
end end
def current_user def current_user
@current_user ||= user_from_api_key @current_user ||= user_from_oauth_token || user_from_mcp_token
end end
def user_from_api_key def user_from_oauth_token
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1] 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 }) User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end end
def bearer_token
@bearer_token ||= request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
end
end end

@ -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

@ -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

@ -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

@ -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

@ -63,6 +63,10 @@ class User < ApplicationRecord
has_one :access_token, dependent: :destroy has_one :access_token, dependent: :destroy
has_many :access_tokens, dependent: :destroy has_many :access_tokens, dependent: :destroy
has_many :mcp_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 :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 :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy has_many :user_configs, dependent: :destroy

@ -6,6 +6,9 @@
<%= t('mcp_server') %> <%= t('mcp_server') %>
</h1> </h1>
<div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center"> <div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center">
<%= link_to t('connected_apps', default: 'Connected apps'),
oauth_authorized_applications_path,
class: 'btn btn-ghost btn-md w-full md:w-fit' %>
<div class="tooltip"> <div class="tooltip">
<%= link_to new_settings_mcp_path, class: 'btn btn-primary btn-md gap-2 w-full md:w-fit', data: { turbo_frame: 'modal' } do %> <%= 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') %> <%= svg_icon('plus', class: 'w-6 h-6') %>

@ -0,0 +1,60 @@
# 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
# 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
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

@ -210,6 +210,20 @@ Rails.application.routes.draw do
end end
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] match '/mcp', to: 'mcp#call', via: %i[get post]
get '/js/:filename', to: 'embed_scripts#show', as: :embed_script get '/js/:filename', to: 'embed_scripts#show', as: :embed_script

@ -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:

@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :mcp_token do
user
sequence(:name) { |n| "MCP token #{n}" }
end
end

@ -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

@ -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

@ -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

@ -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.plaintext_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.plaintext_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.plaintext_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.plaintext_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

@ -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

@ -0,0 +1,67 @@
# 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
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)
end
end

@ -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
Loading…
Cancel
Save