Support a two-leg authorization code flow for web-hosted clients
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 40/100
- issue の種類
- 機能追加
- 明瞭さ
- 明確に書かれている
- 活発さ
- 活発
- 技術スタック
- ruby
調査の方向性
Start by reading lib/mcp/client/oauth/flow.rb to understand the current single-leg flow. Examine the Provider and Flow classes, focusing on how callback_handler and storage are used. Review the linked TypeScript and Rust implementations for their two-leg patterns. The work involves modifying Flow to optionally persist pending authorization state in storage, adding a finish! method, and updating MCP::Client::HTTP to handle the new flow. Ensure all security requirements for the callback leg are met.
索引モデルが issue の本文から書いたものです。
説明
Problem
OAuth::Flow#run! runs the whole authorization code flow in one call. It generates the PKCE verifier and state as locals, hands the authorization URL to redirect_handler, and then blocks on callback_handler for the code (lib/mcp/client/oauth/flow.rb at 1.6.0):
Provider.new requires callback_handler:, and MCP::Client::HTTP drives run! synchronously on a 401 or a 403 insufficient_scope. That works for CLI and desktop clients, where one process can open a browser and wait on a loopback listener.
It does not work for a client hosted in a web application. There, authorization starts in one HTTP request and the redirect lands on another, often in a different process, minutes later. Nothing is left to block on, and the verifier and state exist only on the stack of the first request.
Assembling the flow outside the SDK is not a good workaround. Discovery and PKCE are public, but client registration, authorization server metadata validation, issuer binding (SEP-2352), RFC 9207 iss validation, the token exchange, and issuer-stamped token storage are all private to Flow. A web client would have to reimplement the security-critical half of the flow to get around one blocking call.
Use case
Our case is a hosted agent where each end user connects their own remote MCP servers. The user starts authorization from one request and the authorization server redirects to our callback endpoint, which is served by whichever pod receives it. Tokens and client registrations are persisted per user connection, which the existing storage: duck type already supports. What's missing is a way to persist the in-flight authorization and finish it later.
Prior art
Two Tier 1 SDKs already split the flow:
- TypeScript.
auth()returns'AUTHORIZED' | 'REDIRECT'. On the first leg it persists the verifier and discovery state throughsaveCodeVerifier()andsaveDiscoveryState(), callsredirectToAuthorization(url), and returns'REDIRECT'; the transport then throwsUnauthorizedError. The callback leg istransport.finishAuth(callbackParams), orauth(provider, { serverUrl, authorizationCode, iss }). It restores that state, validatesissagainst the recorded issuer, redeems the code at the recorded authorization server, and saves tokens. The docs require discovery state to be persisted "with the same durability ascodeVerifier" so the callback leg is bound to the authorization server the redirect targeted. - Rust.
AuthorizationManager#get_authorization_urlstores aStoredAuthorizationStatein a pluggableStateStore, keyed by the CSRF token. The state holds the verifier, expected issuer, whetherissis required, the requested scopes, andcreated_at.exchange_code_for_token_with_issuer(code, csrf_token, iss)loads the entry bystateand validates the issuer, and only then deletes the entry. A forged callback carrying the rightstatetherefore cannot burn the verifier the real callback needs.StateStoreis documented for Redis or database backends (modelcontextprotocol/rust-sdk#614).
Python (redirect_handler + callback_handler), C# (AuthorizationCallbackHandler), and Go (AuthorizationCodeFetcher) are single-leg, like Ruby today.
Proposal
Follow TypeScript's shape, since Provider was modeled on its OAuthClientProvider, and add Rust's binding of pending state to state. Names below are placeholders.
provider = MCP::Client::OAuth::Provider.new(
client_metadata: client_metadata,
redirect_uri: "https://agent.example.com/oauth/mcp/callback",
redirect_handler: ->(url) { hand_to_user(url) },
# No callback_handler: the flow stops after the redirect.
storage: storage, # also implements the pending-authorization methods below
)
flow = MCP::Client::OAuth::Flow.new(provider: provider)
# Request A: begin
flow.run!(server_url: server_url, resource_metadata_url: resource_metadata_url, scope: scope)
# => :redirect
# Request B, possibly another process, with a provider built over the same storage: finish
flow.finish!(server_url: server_url, callback_params: request.query_parameters)
# => :authorized
callback_handler:becomes optional. Without it,run!persists a pending authorization, callsredirect_handler, and returns:redirect. Providers that passcallback_handler:keep the current behavior. Internally, one leg can become begin, thencallback_handler, then finish, so both shapes share every check.- Pending state lives in
storage, keyed bystate. Add optionalsave_pending_authorization(state, pending),pending_authorization(state), anddelete_pending_authorization(state)methods, withInMemoryStorageimplementing them. The two-leg path requires them. The pending entry records:- the PKCE verifier
- a snapshot of the authorization server metadata (issuer, token endpoint, token auth method,
authorization_response_iss_parameter_supported) - the client registration in use
resource,redirect_uri, and the requested scopescreated_at
Flow#finish!takes the whole callback query, as TypeScript'sfinishAuth(URLSearchParams)does. Presence ofissthen comes from the query itself, instead of from the length of the Array thatcallback_handlerreturns today.MCP::Client::HTTPwithoauth:and nocallback_handlerraises a dedicated error after leg 1 instead of retrying. That error sits outsideAuthorizationError, so the refresh-failure fallback does not catch it, and it exposes the authorization URL. Requests afterfinish!pick up the stored tokens. The403 insufficient_scopestep-up path behaves the same way.
Security requirements for the callback leg
- Look the pending entry up by
statebefore any network I/O, and reject an unknown or expiredstatewithout echoing callback parameters. - Validate
issagainst the recorded issuer, and require it when the recorded metadata advertises support, before consuming the entry. This follows Rust's ordering. - Surface the callback's
error/error_descriptiononly after the issuer check passes, since they are attacker-controlled in a mix-up. This follows TypeScript. - Delete the pending entry after the issuer check and before the token request, so it is single-use.
- Redeem the code at the recorded token endpoint with the recorded client registration,
resource, andredirect_uri. Do not re-run discovery on the callback leg, so the code is redeemed at the authorization server the user was sent to (SEP-2352). - Enforce a maximum age from
created_at, and make it configurable. - Never log the verifier; the pending object's
inspectredacts it.
authorization_request_validator, token_request_params:, and http_client_customizer: keep applying on leg 1 and to the leg-2 token request exactly as they do today.
Binding the callback to the end user who started it stays with the application. For example, scoping storage per user means a callback delivered to another user's session cannot find the pending entry. The docs should say so explicitly.
Open questions
- Should
finish!also exist as a convenience onMCP::Client::HTTP(TypeScript has both), or does theFlowentry point suffice, since a callback endpoint usually has no live transport? - What should the default maximum age of a pending entry be?
- 主要言語
- Ruby
- スター
- 914
- フォーク
- 133
- 平均マージ
- 1日 10分
- マージ済み PR(30日)
- 33
コントリビューションガイド
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
modelcontextprotocol/ruby-sdk のほかの issue
-
enhancement
modelcontextprotocol/ruby-sdk#568 · 担当者 1 名 ·
-
enhancement
modelcontextprotocol/ruby-sdk#391 · リアクション 1 件 · 担当者 1 名 ·
modelcontextprotocol/ruby-sdk の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
palladius/rails8-app-on-gcp#145 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
rubocop/rubocop-rspec#2236 ·
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
riscv/riscv-unified-db#2624 · リアクション 1 件 ·
-
難易度 1/5 1時間未満 初心者へのやさしさ 88/100