You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 9dc7a70
Browse filesBrowse the repository at this point in the historyBrowse files
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.
Copy file name to clipboardExpand all lines: docs/_client/authorization.md
+72-3Lines changed: 72 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -93,10 +93,14 @@ Required keyword arguments to `Provider.new`:
93
93
Optional keyword arguments:
94
94
95
95
-`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).
96
98
-`storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
97
99
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
98
100
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.
100
104
-`client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
101
105
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
102
106
When the authorization server advertises `client_id_metadata_document_supported: true`,
@@ -182,7 +186,7 @@ Keyword arguments:
182
186
-`private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
183
187
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
184
188
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`.
186
190
187
191
### Cross-App Access (JWT Bearer) Grant
188
192
@@ -219,7 +223,7 @@ Keyword arguments:
219
223
-`assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
220
224
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
221
225
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`.
223
227
224
228
### Communication Security
225
229
@@ -258,3 +262,68 @@ The SDK also bounds what those endpoints may return. A discovery, dynamic client
258
262
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.
259
263
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
260
264
`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:
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:
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.
0 commit comments