Skip to content

Commit 61780b0

Browse files
authored
Merge pull request #573 from atesgoral/feat/two-leg-authorization-code-flow
Let a web application finish authorization in the request that receives the redirect
2 parents 48cc95e + f4d48cd commit 61780b0

8 files changed

Lines changed: 1112 additions & 13 deletions

File tree

‎docs/_client/authorization.md‎

Lines changed: 76 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 [Authorization in Web Applications](#authorization-in-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+
[Authorization in Web Applications](#authorization-in-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,75 @@ provider = MCP::Client::OAuth::Provider.new(
173176
)
174177
```
175178

179+
### Authorization in 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 authorization spans the two requests:
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+
`run!` can also start an authorization 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+
`delete_pending_authorization` must remove the entry and return it in one atomic step, such as `GETDEL` in Redis or `DELETE ... RETURNING` in SQL,
228+
and return `nil` when there was none. `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 through `delete_pending_authorization`, and only a callback that gets the entry back proceeds, so of two callbacks
239+
racing on the same `state`, such as a retried redirect, at most one redeems the code. An `error` response is raised with its `error` and `error_description`,
240+
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.
241+
- The code is redeemed at the recorded token endpoint, with the client registration, `resource`, and `redirect_uri` used when the authorization began,
242+
without running discovery again (SEP-2352). A registration replaced in the meantime is refused.
243+
244+
{: .important }
245+
> `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:
246+
> scope `storage` to that user, as in the example, so that a callback delivered to another user's session finds no pending authorization.
247+
176248
### Token Endpoint Errors
177249

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