Hacktoberfest 2026:維護者為十月標記出來的 issue,仍然開放、適合新手。 瀏覽 Hacktoberfest issue

Let a web application finish authorization in the request that receives the redirect

已關閉
#572 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

維護者通常 1 天內回覆

還沒有人認領這個 Issue。

評估

難度
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 內容生成。

描述

enhancement

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

https://github.com/modelcontextprotocol/ruby-sdk/blob/6da3009a6ecd47c58312fa81c96c1ff2d693fc18/lib/mcp/client/oauth/flow.rb#L248-L283

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'. When authorization starts, it persists the verifier and discovery state through saveCodeVerifier() and saveDiscoveryState(), calls redirectToAuthorization(url), and returns 'REDIRECT'; the transport then throws UnauthorizedError. The request that receives the redirect calls transport.finishAuth(callbackParams), or auth(provider, { serverUrl, authorizationCode, iss }). It restores that state, validates iss against 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 as codeVerifier" so finishing is bound to the authorization server the redirect targeted.
  • Rust. AuthorizationManager#get_authorization_url stores a StoredAuthorizationState in a pluggable StateStore, keyed by the CSRF token. The state holds the verifier, expected issuer, whether iss is required, the requested scopes, and created_at. exchange_code_for_token_with_issuer(code, csrf_token, iss) loads the entry by state and validates the issuer, and only then deletes the entry. A forged callback carrying the right state therefore cannot burn the verifier the real callback needs. StateStore is documented for Redis or database backends (modelcontextprotocol/rust-sdk#614).

Python (redirect_handler + callback_handler), C# (AuthorizationCallbackHandler), and Go (AuthorizationCodeFetcher) run the whole flow in one blocking call, 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, calls redirect_handler, and returns :redirect. Providers that pass callback_handler: keep the current behavior. Internally, the blocking flow can become begin, then callback_handler, then finish, so both shapes share every check.
  • Pending state lives in storage, keyed by state. Add optional save_pending_authorization(state, pending), pending_authorization(state), and delete_pending_authorization(state) methods, with InMemoryStorage implementing them. A provider without callback_handler 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 scopes
    • created_at
  • Flow#finish! takes the whole callback query, as TypeScript's finishAuth(URLSearchParams) does. Presence of iss then comes from the query itself, instead of from the length of the Array that callback_handler returns today.
  • MCP::Client::HTTP with oauth: and no callback_handler raises a dedicated error after run! redirects instead of retrying. That error sits outside AuthorizationError, so the refresh-failure fallback does not catch it, and it exposes the authorization URL. Requests after finish! pick up the stored tokens. The 403 insufficient_scope step-up path behaves the same way.

Security requirements for the callback request

  1. Look the pending entry up by state before any network I/O, and reject an unknown or expired state without echoing callback parameters.
  2. Validate iss against the recorded issuer, and require it when the recorded metadata advertises support, before consuming the entry. This follows Rust's ordering.
  3. Surface the callback's error / error_description only after the issuer check passes, since they are attacker-controlled in a mix-up. This follows TypeScript.
  4. Delete the pending entry after the issuer check and before the token request, so it is single-use.
  5. Redeem the code at the recorded token endpoint with the recorded client registration, resource, and redirect_uri. Do not re-run discovery in the callback request, so the code is redeemed at the authorization server the user was sent to (SEP-2352).
  6. Enforce a maximum age from created_at, and make it configurable.
  7. Never log the verifier; the pending object's inspect redacts it.

authorization_request_validator, token_request_params:, and http_client_customizer: keep applying when authorization starts and to the token request that finishes it, 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 on MCP::Client::HTTP (TypeScript has both), or does the Flow entry point suffice, since a callback endpoint usually has no live transport?
  • What should the default maximum age of a pending entry be?
主要語言
Ruby
星號
924
分支
136
平均合併
1 天 6 小時
30 天內合併 PR
34

環境準備

  • 沒有 Dockerfile 或 Docker Compose 檔案
  • 沒有 Pull Request 範本
  • 閱讀貢獻指南

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

modelcontextprotocol/ruby-sdk 的其他 Issue

查看 modelcontextprotocol/ruby-sdk 的全部 Issue

相似的 Issue

更多 Ruby Issue

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。