Skip to content

Commit 9e97f55

Browse files
committed
feat: support a two-leg authorization code flow for web-hosted clients
A provider without a callback_handler now stops after the redirect: Flow#run! saves a pending authorization in storage, keyed by state, hands the authorization URL to redirect_handler, and returns :redirect, and MCP::Client::HTTP raises Flow::AuthorizationPendingError instead of retrying. Flow#finish! completes the authorization in the request that receives the redirect, which may run in another process. finish! looks the pending authorization up by state before any request, validates the RFC 9207 iss against the recorded issuer before consuming it, reads an error response only after that check, and redeems the code at the recorded token endpoint with the recorded client registration, resource, and redirect_uri, without running discovery again.
1 parent 6da3009 commit 9e97f55

8 files changed

Lines changed: 1037 additions & 14 deletions

File tree

‎docs/_client/authorization.md‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,14 @@ Required keyword arguments to `Provider.new`:
9090
an explicit value always wins.
9191
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
9292
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
93-
- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form
94-
(with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match
95-
the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`.
9693

9794
Optional keyword arguments:
9895

96+
- `callback_handler`: Callable that returns `[code, state]` or `[code, state, iss]` after the user is redirected back to `redirect_uri`. Returning the 3-element form
97+
(with `iss` set to the RFC 9207 `iss` parameter from the redirect, or `nil` when absent) opts into SEP-2468 issuer validation: a present `iss` must match
98+
the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`.
99+
Omit it when the redirect arrives in a later request, as it does in a web application; see [Two-Leg Authorization for Web Applications](#two-leg-authorization-for-web-applications).
100+
- `pending_authorization_max_age`: Integer seconds a pending authorization stays redeemable after the redirect when `callback_handler` is omitted. Defaults to 600.
99101
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
100102
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
101103
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
@@ -106,7 +108,8 @@ Optional keyword arguments:
106108
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
107109
(portable CIMD `client_id`s are kept). Saved `tokens` carry an `"issuer"` member of their own, recording the authorization server that minted them, which is what
108110
lets a later refresh refuse a server the MCP server has since renamed. Treat both hashes as opaque and persist them as-is; a storage that writes out selected members
109-
instead drops these bindings with no error.
111+
instead drops these bindings with no error. Without `callback_handler`, it must also hold pending authorizations; see
112+
[Two-Leg Authorization for Web Applications](#two-leg-authorization-for-web-applications).
110113
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
111114
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
112115
When the authorization server advertises `client_id_metadata_document_supported: true`,
@@ -173,6 +176,73 @@ provider = MCP::Client::OAuth::Provider.new(
173176
)
174177
```
175178

179+
### Two-Leg Authorization for Web Applications
180+
181+
`callback_handler` assumes the process that sends the user to the authorization server can also wait for the redirect, which holds for CLI and desktop clients.
182+
In a web application the redirect arrives as a separate HTTP request, often served by a different process. Omit `callback_handler` and the flow runs in two legs,
183+
as the TypeScript SDK's `auth()` and `finishAuth()` and the Rust SDK's `get_authorization_url` and `exchange_code_for_token` do:
184+
185+
1. When the transport meets a `401`, or a `403` step-up challenge, the flow runs discovery and registration as usual, saves a pending authorization in `storage`
186+
keyed by the `state` it generated, hands the authorization URL to `redirect_handler`, and raises `MCP::Client::OAuth::Flow::AuthorizationPendingError`
187+
instead of retrying. The error's `authorization_url` reader returns the same URL, so the application can send the user there from wherever is convenient.
188+
2. The request that receives the redirect calls `MCP::Client::OAuth::Flow#finish!` with the redirect's whole query. Requests made afterwards use the stored tokens.
189+
190+
```ruby
191+
def mcp_oauth_provider(user)
192+
MCP::Client::OAuth::Provider.new(
193+
client_metadata: {
194+
client_name: "My MCP App",
195+
redirect_uris: ["https://app.example.com/oauth/mcp/callback"],
196+
grant_types: ["authorization_code", "refresh_token"],
197+
response_types: ["code"],
198+
token_endpoint_auth_method: "none",
199+
},
200+
redirect_uri: "https://app.example.com/oauth/mcp/callback",
201+
redirect_handler: ->(_authorization_url) {},
202+
storage: McpCredentialStorage.new(user), # per-user storage, including pending authorizations
203+
)
204+
end
205+
206+
# In a request that talks to the MCP server:
207+
transport = MCP::Client::HTTP.new(url: server_url, oauth: mcp_oauth_provider(current_user))
208+
begin
209+
tools = MCP::Client.new(transport: transport).tools
210+
rescue MCP::Client::OAuth::Flow::AuthorizationPendingError => e
211+
redirect_to(e.authorization_url.to_s, allow_other_host: true)
212+
end
213+
214+
# In the action serving `redirect_uri`:
215+
MCP::Client::OAuth::Flow.new(provider: mcp_oauth_provider(current_user)).finish!(
216+
server_url: server_url,
217+
callback_params: request.query_parameters,
218+
)
219+
```
220+
221+
The first leg can also run without a request to the MCP server: `MCP::Client::OAuth::Flow.new(provider: provider).run!(server_url: server_url)` returns `:redirect`,
222+
and the flow's `authorization_url` reader returns the URL it handed to `redirect_handler`.
223+
224+
The storage must also respond to `save_pending_authorization(state, pending)`, `pending_authorization(state)`, and `delete_pending_authorization(state)`;
225+
`Provider.new` raises `Provider::PendingAuthorizationStorageError` when it does not. A pending authorization is a Hash of JSON-compatible values that includes
226+
the PKCE verifier, so keep it where you keep credentials, persist it as-is, and share it between the processes that can receive the redirect.
227+
`InMemoryStorage` implements the methods for a single process.
228+
229+
`finish!` redeems the code the way the authorization began, and refuses anything else with `Flow::AuthorizationError`:
230+
231+
- The pending authorization is looked up by `state` before any request is made. An unknown, already used, or malformed one is refused,
232+
and one older than `pending_authorization_max_age` is discarded and refused.
233+
- `server_url` must name the MCP server the authorization began with.
234+
- The RFC 9207 `iss` parameter is validated against the recorded issuer before the pending authorization is consumed, so a forged callback carrying a valid `state`
235+
cannot discard the verifier the legitimate callback needs. Because `finish!` sees the whole query, a missing `iss` is refused whenever the authorization server
236+
advertises `authorization_response_iss_parameter_supported`.
237+
- The pending authorization is then consumed, so it is redeemed at most once. An `error` response is raised with its `error` and `error_description`,
238+
bounded as [token endpoint errors](#token-endpoint-errors) are; it is read only after the `iss` check, since in a mix-up those parameters are the attacker's.
239+
- The code is redeemed at the recorded token endpoint, with the client registration, `resource`, and `redirect_uri` used when the authorization began,
240+
without running discovery again (SEP-2352). A registration replaced in the meantime is refused.
241+
242+
{: .important }
243+
> `state` proves that this SDK started the authorization, not which user did. Binding the callback to the user who started it is the application's responsibility:
244+
> scope `storage` to that user, as in the example, so that a callback delivered to another user's session finds no pending authorization.
245+
176246
### Token Endpoint Errors
177247

178248
When a token exchange or refresh fails, `MCP::Client::OAuth::Flow::AuthorizationError` includes the HTTP status and

‎lib/mcp/client/http.rb‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -902,15 +902,25 @@ def parse_www_authenticate_from_error(error)
902902
MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
903903
end
904904

905+
# A provider without a `callback_handler` finishes the authorization in the request that receives the redirect,
906+
# not here, so there is nothing to retry with yet: the pending authorization surfaces as
907+
# `Flow::AuthorizationPendingError`, and requests made after `Flow#finish!` pick up the stored tokens.
905908
def run_full_authorization_flow!(flow:, params:)
906909
# Use the URL snapshotted at `initialize` time so a post-construction
907910
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
908911
# URL to an attacker-controlled host.
909-
flow.run!(
912+
result = flow.run!(
910913
server_url: @oauth_server_url,
911914
resource_metadata_url: params["resource_metadata"],
912915
scope: params["scope"],
913916
)
917+
return unless result == :redirect
918+
919+
raise MCP::Client::OAuth::Flow::AuthorizationPendingError.new(
920+
"Authorization is pending: the user was sent to the authorization server, and the request can be retried " \
921+
"once `MCP::Client::OAuth::Flow#finish!` completes the authorization with the redirect's query.",
922+
authorization_url: flow.authorization_url,
923+
)
914924
end
915925

916926
# Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy

0 commit comments

Comments
 (0)