mirror of https://github.com/docusealco/docuseal
Merge 104684f163 into 744d45d2c5
commit
3b064ce097
@ -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"]
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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…
Reference in new issue