Skip to content

Commit 73d6da3

Browse files
authored
Merge pull request #584 from koic/describe_the_pending_authorization_checks_as_the_code_establishes_them
[Doc] Describe the pending authorization checks as the code establishes them
2 parents 14dbb20 + 095bd81 commit 73d6da3

3 files changed

Lines changed: 26 additions & 16 deletions

File tree

‎docs/_client/authorization.md‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ Optional keyword arguments:
9797
(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
9898
the authorization server's issuer, and a missing one is rejected when the server advertises `authorization_response_iss_parameter_supported`.
9999
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.
100+
- `pending_authorization_max_age`: Integer seconds a pending authorization stays redeemable, counted from the moment `run!` saves it, when `callback_handler`
101+
is omitted. Defaults to 600.
101102
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
102103
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
103104
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
@@ -185,7 +186,8 @@ by a different process, and relaying the code to a request held open for that lo
185186
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`
186187
keyed by the `state` it generated, hands the authorization URL to `redirect_handler`, and raises `MCP::Client::OAuth::Flow::AuthorizationPendingError`
187188
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+
2. The request that receives the redirect calls `MCP::Client::OAuth::Flow#finish!` with the redirect's whole query. Pass the query as it arrived: an `iss`
190+
the caller drops reads as absent, and the flow cannot tell the difference. Requests made afterwards use the stored tokens.
189191

190192
```ruby
191193
def mcp_oauth_provider(user)
@@ -232,16 +234,18 @@ so the storage should expire entries older than `pending_authorization_max_age`,
232234
`finish!` redeems the code the way the authorization began, and refuses anything else with `Flow::AuthorizationError`:
233235

234236
- The pending authorization is looked up by `state` before any request is made. An unknown, already used, or malformed one is refused,
235-
and one older than `pending_authorization_max_age` is discarded and refused.
237+
and one older than `pending_authorization_max_age`, counted from when `run!` saved it, is discarded and refused.
236238
- `server_url` must name the MCP server the authorization began with.
237-
- 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`
238-
cannot discard the verifier the legitimate callback needs. Because `finish!` sees the whole query, a missing `iss` is refused whenever the authorization server
239-
advertises `authorization_response_iss_parameter_supported`.
239+
- The RFC 9207 `iss` parameter is validated against the recorded issuer before the pending authorization is consumed, so a callback from another authorization
240+
server, as in a mix-up attack, is refused without consuming the entry the legitimate callback needs. The check establishes only that a present `iss`
241+
matches the recorded issuer, not who sent the callback; a callback without `iss` passes it unless the authorization server advertises
242+
`authorization_response_iss_parameter_supported`, in which case a missing `iss` is refused. Whoever holds the `state`, passes that check, and reaches
243+
the consume first takes the entry, with an `error` or an unusable code as well as with the code itself, and the legitimate callback then finds nothing.
240244
- The pending authorization is then consumed through `delete_pending_authorization`, and only a callback that gets the entry back proceeds, so of two callbacks
241245
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`,
242-
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.
246+
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 another server's.
243247
- The code is redeemed at the recorded token endpoint, with the client registration, `resource`, and `redirect_uri` used when the authorization began,
244-
without running discovery again (SEP-2352). A registration replaced in the meantime is refused.
248+
without running discovery again (SEP-2352). A registration whose `client_id` or issuer changed in the meantime is refused; its other members may change.
245249

246250
{: .important }
247251
> `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:

‎lib/mcp/client/oauth/flow.rb‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -493,15 +493,20 @@ def refresh!(server_url:, resource_metadata_url: nil)
493493

494494
# Finishes an authorization that `run!` left pending, in the request that receives the redirect to `redirect_uri`,
495495
# which may run in another process. `callback_params` is that redirect's whole query as a Hash (`code`, `state`, and,
496-
# when present, `iss`, `error`, and `error_description`); passing all of it, rather than picking values out, is what
497-
# lets the flow tell an absent `iss` from one the caller did not look for.
496+
# when present, `iss`, `error`, and `error_description`); passing all of it, rather than picking values out, is
497+
# the caller's part of the `iss` check: an `iss` the caller drops reads as absent, and the flow cannot tell the difference.
498498
#
499499
# The pending authorization is looked up by `state` before any request is made, and it binds the rest of the exchange:
500500
# the code is redeemed at the token endpoint recorded when the authorization began, with the client registration,
501501
# `resource`, and `redirect_uri` used then, and without discovery running again, so the code reaches the authorization
502502
# server the user was sent to (SEP-2352). The RFC 9207 `iss` is validated against the recorded issuer before the pending
503-
# authorization is consumed, so a forged callback carrying a valid `state` cannot discard the verifier the legitimate
504-
# callback needs, and before the callback's `error` is read, since in a mix-up those parameters are the attacker's.
503+
# authorization is consumed, so a callback from another authorization server, as in a mix-up, is refused without
504+
# consuming the entry the legitimate callback needs, and before the callback's `error` is read, since in a mix-up
505+
# those parameters are the other server's. The check establishes only that a present `iss` matches the recorded issuer,
506+
# not who sent the callback, and a callback without `iss` passes it unless the metadata advertises
507+
# `authorization_response_iss_parameter_supported`: whoever holds the `state`, passes that check, and reaches
508+
# the consume first takes the entry, and the legitimate callback then finds nothing.
509+
#
505510
# Past that check the pending authorization is consumed with `delete_pending_authorization`, which returns the entry
506511
# it removed; only a callback that gets the entry back redeems the code, so of callbacks racing on the same `state`
507512
# (a retried redirect, say), at most one does.
@@ -1285,8 +1290,9 @@ def non_empty_string?(value)
12851290
end
12861291

12871292
# An RFC 6749 Section 4.1.2.1 error response, reported with the bounds a token endpoint error gets.
1288-
# Reached only after the `iss` check, so the values are the authorization server's own; they are still text it chose,
1289-
# so they are cut to a bounded length and confined to the printable ASCII the RFC permits.
1293+
# Reached only after the `iss` check, so any `iss` beside the values matched the recorded issuer; that does not
1294+
# prove who sent them, and they are text the sender chose, so they are cut to a bounded length and confined to
1295+
# the printable ASCII the RFC permits.
12901296
def authorization_response_error(error, description)
12911297
error = bounded_diagnostic(error, limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
12921298
description = bounded_diagnostic(description, limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)

‎lib/mcp/client/oauth/provider.rb‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ module OAuth
3333
# Omit it when the redirect arrives in a later request, as it does in a web application:
3434
# the flow then stops after `redirect_handler` with a pending authorization saved in `storage`,
3535
# and the request that receives the redirect finishes it with `Flow#finish!`.
36-
# - `pending_authorization_max_age` - Seconds a pending authorization stays redeemable after the redirect,
37-
# when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
36+
# - `pending_authorization_max_age` - Seconds a pending authorization stays redeemable, counted from the moment
37+
# `run!` saves it, when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
3838
# - `scope` - String of space-separated scopes to request when the server's
3939
# `WWW-Authenticate` does not specify one.
4040
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,

0 commit comments

Comments
 (0)