Skip to content

Commit 59dfc68

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 59dfc68

9 files changed

Lines changed: 1042 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Support a two-leg authorization code flow for web-hosted clients (#573)
13+
1014
## [1.6.0] - 2026-09-21
1115

1216
This release lets an application configure, on the OAuth provider, the requests the flow makes to

‎docs/_client/authorization.md‎

Lines changed: 75 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,74 @@ provider = MCP::Client::OAuth::Provider.new(
173176
)
174177
```
175178

179+
### Two-Leg Authorization for Web Applications
180+
181+
`callback_handler` keeps the flow open until the code comes back, so the process that sent the user to the authorization server stays blocked for as long as
182+
the user takes to sign in and consent. That suits CLI and desktop clients. In a web application the redirect arrives as a separate HTTP request, often served
183+
by a different process, and relaying the code to a request held open for that long is impractical. Omit `callback_handler` and the flow runs in two legs,
184+
as the TypeScript SDK's `auth()` and `finishAuth()` and the Rust SDK's `get_authorization_url` and `exchange_code_for_token` do:
185+
186+
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`
187+
keyed by the `state` it generated, hands the authorization URL to `redirect_handler`, and raises `MCP::Client::OAuth::Flow::AuthorizationPendingError`
188+
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.
189+
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.
190+
191+
```ruby
192+
def mcp_oauth_provider(user)
193+
MCP::Client::OAuth::Provider.new(
194+
client_metadata: {
195+
client_name: "My MCP App",
196+
redirect_uris: ["https://app.example.com/oauth/mcp/callback"],
197+
grant_types: ["authorization_code", "refresh_token"],
198+
response_types: ["code"],
199+
token_endpoint_auth_method: "none",
200+
},
201+
redirect_uri: "https://app.example.com/oauth/mcp/callback",
202+
redirect_handler: ->(_authorization_url) {},
203+
storage: McpCredentialStorage.new(user), # per-user storage, including pending authorizations
204+
)
205+
end
206+
207+
# In a request that talks to the MCP server:
208+
transport = MCP::Client::HTTP.new(url: server_url, oauth: mcp_oauth_provider(current_user))
209+
begin
210+
tools = MCP::Client.new(transport: transport).tools
211+
rescue MCP::Client::OAuth::Flow::AuthorizationPendingError => e
212+
redirect_to(e.authorization_url.to_s, allow_other_host: true)
213+
end
214+
215+
# In the action serving `redirect_uri`:
216+
MCP::Client::OAuth::Flow.new(provider: mcp_oauth_provider(current_user)).finish!(
217+
server_url: server_url,
218+
callback_params: request.query_parameters,
219+
)
220+
```
221+
222+
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`,
223+
and the flow's `authorization_url` reader returns the URL it handed to `redirect_handler`.
224+
225+
The storage must also respond to `save_pending_authorization(state, pending)`, `pending_authorization(state)`, and `delete_pending_authorization(state)`;
226+
`Provider.new` raises `Provider::PendingAuthorizationStorageError` when it does not. A pending authorization is a Hash of JSON-compatible values that includes
227+
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.
228+
`InMemoryStorage` implements the methods for a single process.
229+
230+
`finish!` redeems the code the way the authorization began, and refuses anything else with `Flow::AuthorizationError`:
231+
232+
- The pending authorization is looked up by `state` before any request is made. An unknown, already used, or malformed one is refused,
233+
and one older than `pending_authorization_max_age` is discarded and refused.
234+
- `server_url` must name the MCP server the authorization began with.
235+
- 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`
236+
cannot discard the verifier the legitimate callback needs. Because `finish!` sees the whole query, a missing `iss` is refused whenever the authorization server
237+
advertises `authorization_response_iss_parameter_supported`.
238+
- The pending authorization is then consumed, so it is redeemed at most once. An `error` response is raised with its `error` and `error_description`,
239+
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.
240+
- The code is redeemed at the recorded token endpoint, with the client registration, `resource`, and `redirect_uri` used when the authorization began,
241+
without running discovery again (SEP-2352). A registration replaced in the meantime is refused.
242+
243+
{: .important }
244+
> `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:
245+
> scope `storage` to that user, as in the example, so that a callback delivered to another user's session finds no pending authorization.
246+
176247
### Token Endpoint Errors
177248

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