Skip to content

Commit 9dc7a70

Browse files
committed
Let the embedding application refuse an authorization request
## Motivation and Context An MCP server chooses the authorization server its users sign in to. It names one in `authorization_servers` in its Protected Resource Metadata, and states the scopes it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. Nothing binds either choice to the server's own identity. No origin rule can settle this, because an authorization server is legitimately a different origin from the resource it protects. Validating that a token was issued for the intended audience is a responsibility the specification places on MCP servers rather than on clients, and there is no portable way for a client to perform it either: access tokens are frequently opaque, and an authorization server is not required to honor the `resource` parameter or to report an audience in the token response. Two things follow from that, and they are separate. The first is a judgement, and it belongs to whoever embeds this SDK, because an application often knows which providers its users deal with. `authorization_request_validator` is handed an `AuthorizationRequest` describing the authorization server, the scopes, the MCP server, and the RFC 8707 resource, and can refuse. It is one object rather than keyword arguments so that later revisions of the specification can add to it without changing the shape implementers wrote. It runs on all three grants, and on the authorization-code grant it runs before dynamic client registration, which is why scope resolution moved above `ensure_client_registered`: a refusal must not leave this client registered at an authorization server the host just rejected. The second is not a judgement and is enforced here rather than asked about. `refresh!` rediscovers the authorization server on every call, so the server named for a session can differ from the one the stored tokens came from. SEP-2352 already binds client credentials to their issuer, but that check passes when the issuer was never recorded and is deliberately skipped for a Client ID Metadata Document URL, which is portable across authorization servers. The issuer that minted the tokens is now recorded with them, and `refresh!` refuses to present them to a different one. The transport answers that refusal by running a full authorization, which brings the new authorization server to the validator above. Tokens stored before this carry no issuer and keep refreshing. The hook decides whether to proceed, not what to ask for. The scopes reach the authorization server unchanged either way, because the specification requires a client to treat the scopes in the challenge as authoritative for the operation, and narrowing them silently would break that. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` assert what the validator receives, that a refusal raises before any registration request is sent and before the redirect handler runs, and that the `client_credentials` and `jwt-bearer` grants consult it too. Others cover the issuer recorded on authorization, the refusal to refresh against a different one, and that tokens stored without an issuer still refresh. A test in `test/mcp/client/oauth/http_oauth_test.rb` drives the two halves together: a stale issuer makes the refresh fail, the transport reauthorizes, and the validator sees the newly named authorization server while no refresh token is ever posted to it. Each of them fails with its half of this change removed. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes Refreshing now fails when the authorization server named for a session differs from the one that issued the stored tokens, and the caller reauthorizes instead. Tokens stored before this change carry no recorded issuer and are unaffected until their next authorization. The validator itself is additive: it defaults to `nil`, and a provider that does not offer it authorizes as before.
1 parent da4ce54 commit 9dc7a70

10 files changed

Lines changed: 468 additions & 17 deletions

File tree

‎docs/_client/authorization.md‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,14 @@ Required keyword arguments to `Provider.new`:
9393
Optional keyword arguments:
9494

9595
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
96+
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
97+
Returning a falsy value abandons the flow with `Flow::AuthorizationError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
9698
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
9799
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
98100
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
99-
(portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is.
101+
(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
102+
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
103+
instead drops these bindings with no error.
100104
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
101105
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
102106
When the authorization server advertises `client_id_metadata_document_supported: true`,
@@ -182,7 +186,7 @@ Keyword arguments:
182186
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
183187
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
184188
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
185-
- `scope`, `storage`: Optional, same meaning as on `Provider`.
189+
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
186190

187191
### Cross-App Access (JWT Bearer) Grant
188192

@@ -219,7 +223,7 @@ Keyword arguments:
219223
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
220224
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
221225
Passing both through to `IDJAGTokenExchange.request` covers the common case.
222-
- `scope`, `storage`: Optional, same meaning as on `Provider`.
226+
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
223227

224228
### Communication Security
225229

@@ -258,3 +262,68 @@ The SDK also bounds what those endpoints may return. A discovery, dynamic client
258262
measured as the body arrives rather than after it has been buffered, so a compressed body that expands past the limit is refused partway through the expansion.
259263
Unlike the transport's `max_message_bytes:`, this limit is not configurable: these documents run to kilobytes in normal operation, and a connection supplied through
260264
`http_client_factory:` is bounded as well, so there is no way to opt out of it.
265+
266+
### Reviewing the authorization request
267+
268+
The checks above constrain where the SDK will send a request. What they cannot decide is whether the authorization server an MCP server names is one you want
269+
your users signing in to. That choice belongs to the MCP server: it publishes `authorization_servers` in its Protected Resource Metadata and states the scopes
270+
it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. An authorization server is legitimately a different origin from the resource it protects,
271+
so no origin rule can settle the question, and validating that a token was issued for the intended audience is a responsibility the specification places on
272+
MCP servers rather than on clients.
273+
274+
`authorization_request_validator` is where an application that *does* know which providers its users deal with can say so:
275+
276+
```ruby
277+
ALLOWED_ISSUERS = ["https://login.example.com", "https://accounts.google.com"]
278+
279+
provider = MCP::Client::OAuth::Provider.new(
280+
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
281+
authorization_request_validator: ->(request) {
282+
ALLOWED_ISSUERS.include?(request.authorization_server)
283+
},
284+
)
285+
```
286+
287+
The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array, empty when neither
288+
the challenge nor the metadata named any), `server_url`, and `resource`. It is one object rather than keyword arguments so that later revisions of the specification
289+
can add to it without changing the shape you wrote.
290+
291+
`server_url` is the URL the transport was configured with, verbatim; `resource` is the value actually sent as the RFC 8707 `resource`, which is that URL canonicalized
292+
(fragment and userinfo dropped, scheme and host lowercased, a default port removed) or, when the Protected Resource Metadata advertises one,
293+
the canonicalized value from there. That advertised value may name a parent path, so a server at `https://api.example.com/mcp` can legitimately produce
294+
a `resource` of `https://api.example.com`. Match on `server_url` when you mean the server you configured.
295+
296+
Compare the issuer as a whole string, the way the SDK compares it everywhere else, rather than picking its host out: multi-tenant providers tell tenants apart by path,
297+
and a legacy authorization server whose metadata never named an issuer arrives as `nil`, which an exact comparison refuses instead of raising.
298+
299+
The provider is only half of the decision. The MCP server chose the scopes too, so a request naming a provider you allow can still ask for more than that server has
300+
any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it:
301+
302+
```ruby
303+
ALLOWED_SCOPES = { "https://api.example.com/mcp" => ["mcp:read", "mcp:write"] }
304+
305+
provider = MCP::Client::OAuth::Provider.new(
306+
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
307+
authorization_request_validator: ->(request) {
308+
ALLOWED_ISSUERS.include?(request.authorization_server) && request.scopes.all? { |scope| ALLOWED_SCOPES.fetch(request.server_url, []).include?(scope) }
309+
},
310+
)
311+
```
312+
313+
A host with a user to ask can put the decision to them instead. The request carries what such a prompt has to name: the provider, the scopes, and the server that asked for them.
314+
315+
It runs on all three grants, after that server's metadata has been fetched (which is where the validated issuer comes from) and before any registration, credential,
316+
or user reaches it: on the authorization-code grant before dynamic client registration and before any browser is opened,
317+
and on the JWT bearer grant before `assertion_provider` is invoked, since obtaining an ID-JAG tells your identity provider which authorization server
318+
the assertion is for. Refusing therefore leaves nothing registered at, and no assertion minted for, the authorization server you rejected.
319+
Returning a falsy value raises `Flow::AuthorizationError`.
320+
321+
The hook decides whether to proceed, not what to ask for: the scopes are passed to the authorization server unchanged either way, because the specification
322+
requires a client to treat the scopes in the challenge as authoritative for the operation. Leaving it unset authorizes whatever the server asked for,
323+
which is what every MCP SDK does today.
324+
325+
It is asked when a new grant is requested, not on every token refresh, which happens unattended and against an authorization server you already answered for.
326+
An authorization server that changes between authorization and refresh is caught instead: the SDK records which one issued the tokens, and `refresh!` refuses to
327+
present a refresh token to a different one, even when the client identity is portable across authorization servers as a Client ID Metadata Document URL is.
328+
The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse.
329+
Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization.

‎lib/mcp/client/oauth.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require_relative "oauth/authorization_request"
34
require_relative "oauth/bounded_body"
45
require_relative "oauth/discovery"
56
require_relative "oauth/flow"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class Client
5+
module OAuth
6+
# The decision an `authorization_request_validator` is asked to approve: which authorization server this client is about to send the user to,
7+
# and with which scopes.
8+
#
9+
# Handed over as one object rather than as keyword arguments so that later revisions of the MCP authorization specification can add to it
10+
# without changing the shape every implementer wrote.
11+
AuthorizationRequest = Struct.new(
12+
# `issuer` of the authorization server selected from the MCP server's Protected Resource Metadata.
13+
:authorization_server,
14+
# The scopes about to be requested, as an Array. Chosen by the MCP server, through the `WWW-Authenticate` challenge or `scopes_supported`.
15+
:scopes,
16+
# The MCP server URL this client was configured with.
17+
:server_url,
18+
# The RFC 8707 `resource` the token is being requested for.
19+
:resource,
20+
keyword_init: true,
21+
)
22+
end
23+
end
24+
end

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ def initialize(
6060
private_key: nil,
6161
signing_algorithm: nil,
6262
scope: nil,
63-
storage: nil
63+
storage: nil,
64+
authorization_request_validator: nil
6465
)
6566
if blank?(client_id)
6667
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
@@ -103,6 +104,7 @@ def initialize(
103104
@signing_algorithm = signing_algorithm
104105
@scope = scope
105106
@storage = storage || InMemoryStorage.new
107+
@authorization_request_validator = authorization_request_validator
106108
@storage.save_client_information(client_information)
107109
end
108110

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class InvalidConfigurationError < ArgumentError; end
3535

3636
attr_reader :scope, :storage
3737

38-
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil)
38+
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
3939
if blank?(client_id)
4040
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
4141
end
@@ -51,6 +51,7 @@ def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, stor
5151
@assertion_provider = assertion_provider
5252
@scope = scope
5353
@storage = storage || InMemoryStorage.new
54+
@authorization_request_validator = authorization_request_validator
5455
@storage.save_client_information(
5556
"client_id" => client_id,
5657
"client_secret" => client_secret,

0 commit comments

Comments
 (0)