Add OAuth 2.1 + PKCE + DCR authorization to MCP endpoint#633
Open
Ortes wants to merge 3 commits intodocusealco:masterfrom
Open
Add OAuth 2.1 + PKCE + DCR authorization to MCP endpoint#633Ortes wants to merge 3 commits intodocusealco:masterfrom
Ortes wants to merge 3 commits intodocusealco:masterfrom
Conversation
Adds Doorkeeper-backed OAuth 2.1 (PKCE, public clients, RFC 7591 DCR) so Claude connectors can authorize against DocuSeal without a pre-shared token. The existing McpToken bearer stays as a fallback. - Mount Doorkeeper at /oauth/* plus root aliases (/authorize, /token, /register) for clients that strip paths - Serve RFC 8414 + RFC 9728 discovery at /.well-known/oauth-* - /register implements RFC 7591 DCR for public clients with an IP throttle; redirect_uri restricted to https + loopback - McpController now resolves current_user from a Doorkeeper access token first, emits the RFC 9728 WWW-Authenticate header on 401 - Weekly sweeper for abandoned DCR applications (external cron) - Link Connected apps from MCP settings Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cker
Existing Dockerfile builds the production image with
BUNDLE_WITHOUT=development:test, so it can't run RSpec. This adds a
test-only image that ships every native lib the app loads at boot
(libvips, libpdfium, onnxruntime, chromium for cuprite) plus the full
dev/test gem bundle.
Usage:
docker compose -f docker-compose.test.yml up --build
# or run a subset:
SPEC_FILES="spec/requests/well_known_spec.rb" \
docker compose -f docker-compose.test.yml up --build --exit-code-from test
The bundle gems live in a named volume so `docker compose up` re-runs
are fast after the first build. Postgres uses tmpfs — no state
persists between runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rottle test Three issues surfaced running the suite in docker: - hash_token_secrets stores the access token hashed; specs must use access_token.plaintext_token (not .token) when posing as a client - Doorkeeper's Application model rejects non-HTTPS redirect_uri by default; add force_ssl_in_redirect_uri to allow loopback per OAuth 2.1 - test env uses :null_store, so Rails.cache.increment returned nil and the DCR throttle never fired — stub a real MemoryStore in that spec Also slim Dockerfile.test: drop chromium + chromium-chromedriver (unused by OAuth specs, added ~4min to the build). Add a comment pointing at the apk line to re-enable them for system specs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds OAuth 2.1 authorization to the existing
POST /mcpendpoint so MCP clients that only support OAuth (notably the Claude.ai web-UI custom-connector flow) can authenticate without a pre-shared bearer token. The existingMcpTokenbearer flow is preserved as a fallback for CLI/desktop clients.Re: #630 — I understand the issue was closed as not planned; opening this PR as a concrete reference in case it's useful, and to give self-hosters who want the feature a clean branch to pull from.
Implementation
Doorkeeper 5.9 is mounted alongside the existing Devise stack, reusing the
oauth_applications/oauth_access_grants/oauth_access_tokenstables that already ship in this repo (migrations20240720063827_create_doorkeeper_tables.rband20260224120000_add_pkce_to_doorkeeper_access_grants.rb). No new migrations.force_pkce, 1-hour access tokens, hashed token secrets.POST /register— custom controller (Doorkeeper doesn't ship DCR). Public clients only,client_secret_expires_at: 0, IP-based throttle (20/hour), redirect_uri restricted tohttps://or loopback (127.0.0.1,localhost,::1) per OAuth 2.1 §8.4.2./.well-known/oauth-authorization-server./.well-known/oauth-protected-resource, advertised viaWWW-Authenticate: Bearer resource_metadata="…"on MCP 401 responses./authorize,/token,/register) for clients that strip paths during discovery.McpController#current_userresolves a Doorkeeper access token with scopemcpfirst, then falls back to the existingMcpTokenbearer.OauthApplicationSweeperJob) deletes DCR applications older than 90 days with no active tokens (externally scheduled).Connected appslink added to the MCP settings page so users can revoke authorized applications.Tests
Full RSpec coverage (16 examples, all green) under
spec/requests/:well_known_spec.rb— discovery endpoint shapesmcp_oauth_spec.rb— valid / expired / revoked / wrong-scope / missing token; legacyMcpTokenfallback;WWW-Authenticateheader on 401oauth_register_spec.rb— DCR happy path, loopback redirect, non-loopback rejection, empty/malformed body, throttleoauth_flow_spec.rb— end-to-end register → authorize (PKCE) → token →POST /mcpspec/jobs/oauth_application_sweeper_job_spec.rbA
Dockerfile.test+docker-compose.test.ymlare included so reviewers can run the suite without installing libvips/libpdfium/onnxruntime locally:Compatibility notes
McpTokenbearer flow is unchanged — the OAuth resolution runs first and falls through on miss.doorkeeper ~> 5.9.Test plan
docker compose -f docker-compose.test.yml up --build --exit-code-from testpassesMcpTokenPOST /register, run the full auth-code + PKCE flow, callPOST /mcpwith the resulting access tokencurl /.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resourcereturn valid JSONPOST /mcpwithout a token returns 401 withWWW-Authenticate: Bearer resource_metadata="…"🤖 Generated with Claude Code