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.
This commit is contained in:
Koichi ITO 2026-08-30 06:41:27 +09:00
commit 09d4bf41aa
10 changed files with 478 additions and 17 deletions

View file

@ -93,10 +93,14 @@ Required keyword arguments to `Provider.new`:
Optional keyword arguments: Optional keyword arguments:
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one. - `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
- `authorization_request_validator`: Callable invoked with an `MCP::Client::OAuth::AuthorizationRequest` before any authorization request is built.
Returning a falsy value abandons the flow with `Flow::AuthorizationRefusedError`. See [Reviewing the authorization request](#reviewing-the-authorization-request).
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`, - `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that which keeps credentials in process memory only. Persisted `client_information` is stamped with an `"issuer"` member binding it to the authorization server that
issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically issued it (SEP-2352): when the server's authorization server changes, the SDK discards the stale registration and its tokens and re-registers automatically
(portable CIMD `client_id`s are kept). Treat the hash as opaque and persist it as-is. (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
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
instead drops these bindings with no error.
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document - `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification). (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
When the authorization server advertises `client_id_metadata_document_supported: true`, When the authorization server advertises `client_id_metadata_document_supported: true`,
@ -182,7 +186,7 @@ Keyword arguments:
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string - `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"` or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
or `"RS256"`; `client_secret` must not be set, because the private key is the credential. or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
- `scope`, `storage`: Optional, same meaning as on `Provider`. - `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
### Cross-App Access (JWT Bearer) Grant ### Cross-App Access (JWT Bearer) Grant
@ -219,7 +223,7 @@ Keyword arguments:
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion. - `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707). `audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
Passing both through to `IDJAGTokenExchange.request` covers the common case. Passing both through to `IDJAGTokenExchange.request` covers the common case.
- `scope`, `storage`: Optional, same meaning as on `Provider`. - `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
### Communication Security ### Communication Security
@ -258,3 +262,70 @@ The SDK also bounds what those endpoints may return. A discovery, dynamic client
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. 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.
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 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
`http_client_factory:` is bounded as well, so there is no way to opt out of it. `http_client_factory:` is bounded as well, so there is no way to opt out of it.
### Reviewing the authorization request
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
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
it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. An authorization server is legitimately a different origin from the resource it protects,
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
MCP servers rather than on clients.
`authorization_request_validator` is where an application that *does* know which providers its users deal with can say so:
```ruby
ALLOWED_ISSUERS = ["https://login.example.com", "https://accounts.google.com"]
provider = MCP::Client::OAuth::Provider.new(
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
authorization_request_validator: ->(request) {
ALLOWED_ISSUERS.include?(request.authorization_server)
},
)
```
The argument is an `MCP::Client::OAuth::AuthorizationRequest` carrying `authorization_server` (the selected issuer), `scopes` (an Array, empty when neither
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
can add to it without changing the shape you wrote. Only the named readers are the contract. The positional access a `Struct` also happens to provide
(`request[0]`, `to_a`, `each`) is not, and can break when the representation changes.
`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
(fragment and userinfo dropped, scheme and host lowercased, a default port removed) or, when the Protected Resource Metadata advertises one,
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
a `resource` of `https://api.example.com`. Match on `server_url` when you mean the server you configured.
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,
and a legacy authorization server whose metadata never named an issuer arrives as `nil`, which an exact comparison refuses instead of raising.
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
any business asking for. `scopes` rides on the request so that a host with a policy per server can apply it:
```ruby
ALLOWED_SCOPES = { "https://api.example.com/mcp" => ["mcp:read", "mcp:write"] }
provider = MCP::Client::OAuth::Provider.new(
# client_metadata:, redirect_uri:, redirect_handler:, and callback_handler: as in the first example.
authorization_request_validator: ->(request) {
ALLOWED_ISSUERS.include?(request.authorization_server) && request.scopes.all? { |scope| ALLOWED_SCOPES.fetch(request.server_url, []).include?(scope) }
},
)
```
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.
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,
or user reaches it: on the authorization-code grant before dynamic client registration and before any browser is opened,
and on the JWT bearer grant before `assertion_provider` is invoked, since obtaining an ID-JAG tells your identity provider which authorization server
the assertion is for. Refusing therefore leaves nothing registered at, and no assertion minted for, the authorization server you rejected.
Returning a falsy value raises `Flow::AuthorizationRefusedError`. It subclasses `Flow::AuthorizationError`, so a rescue written for that still catches it,
while rescuing the narrower class tells a refusal by your own policy apart from a network or metadata failure.
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
requires a client to treat the scopes in the challenge as authoritative for the operation. Leaving it unset authorizes whatever the server asked for,
which is what every MCP SDK does today.
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.
An authorization server that changes between authorization and refresh is caught instead: the SDK records which one issued the tokens, and `refresh!` refuses to
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.
The transport answers that refusal by running a full authorization, which brings the new authorization server back here for you to accept or refuse.
Tokens stored before this behavior shipped carry no issuer and keep refreshing; the binding applies from their next authorization.

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "oauth/authorization_request"
require_relative "oauth/bounded_body" require_relative "oauth/bounded_body"
require_relative "oauth/discovery" require_relative "oauth/discovery"
require_relative "oauth/flow" require_relative "oauth/flow"

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
module MCP
class Client
module OAuth
# The decision an `authorization_request_validator` is asked to approve: which authorization server this client is about to send the user to,
# and with which scopes.
#
# Handed over as one object rather than as keyword arguments so that later revisions of the MCP authorization specification can add to it
# without changing the shape every implementer wrote.
#
# Only the named readers are the contract. The positional access `Struct` also provides (`request[0]`, `to_a`, `each`) is not,
# so the representation can change later.
AuthorizationRequest = Struct.new(
# `issuer` of the authorization server selected from the MCP server's Protected Resource Metadata.
:authorization_server,
# The scopes about to be requested, as an Array. Chosen by the MCP server, through the `WWW-Authenticate` challenge or `scopes_supported`.
:scopes,
# The MCP server URL this client was configured with.
:server_url,
# The RFC 8707 `resource` the token is being requested for.
:resource,
keyword_init: true,
)
end
end
end

View file

@ -60,7 +60,8 @@ module MCP
private_key: nil, private_key: nil,
signing_algorithm: nil, signing_algorithm: nil,
scope: nil, scope: nil,
storage: nil storage: nil,
authorization_request_validator: nil
) )
if blank?(client_id) if blank?(client_id)
raise InvalidCredentialsError, "client_id is required for the client_credentials grant." raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
@ -103,6 +104,7 @@ module MCP
@signing_algorithm = signing_algorithm @signing_algorithm = signing_algorithm
@scope = scope @scope = scope
@storage = storage || InMemoryStorage.new @storage = storage || InMemoryStorage.new
@authorization_request_validator = authorization_request_validator
@storage.save_client_information(client_information) @storage.save_client_information(client_information)
end end

View file

@ -35,7 +35,7 @@ module MCP
attr_reader :scope, :storage attr_reader :scope, :storage
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil) def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
if blank?(client_id) if blank?(client_id)
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant." raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
end end
@ -51,6 +51,7 @@ module MCP
@assertion_provider = assertion_provider @assertion_provider = assertion_provider
@scope = scope @scope = scope
@storage = storage || InMemoryStorage.new @storage = storage || InMemoryStorage.new
@authorization_request_validator = authorization_request_validator
@storage.save_client_information( @storage.save_client_information(
"client_id" => client_id, "client_id" => client_id,
"client_secret" => client_secret, "client_secret" => client_secret,

View file

@ -23,6 +23,11 @@ module MCP
# should leave the refresh token intact. # should leave the refresh token intact.
class InvalidGrantError < AuthorizationError; end class InvalidGrantError < AuthorizationError; end
# Raised when `authorization_request_validator` returns a falsy value. Separate from its parent
# so that a caller can tell a refusal by its own policy from a network, discovery,
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
class AuthorizationRefusedError < AuthorizationError; end
def initialize(provider:, http_client_factory: nil) def initialize(provider:, http_client_factory: nil)
@provider = provider @provider = provider
@http_client_factory = http_client_factory || -> { default_http_client } @http_client_factory = http_client_factory || -> { default_http_client }
@ -63,17 +68,22 @@ module MCP
case provider_authorization_flow case provider_authorization_flow
when :client_credentials when :client_credentials
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
when :jwt_bearer when :jwt_bearer
return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope) return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
end end
ensure_pkce_supported!(as_metadata) ensure_pkce_supported!(as_metadata)
client_info = ensure_client_registered(as_metadata: as_metadata)
effective_scope = resolve_scope(scope: scope, prm: prm || {}) effective_scope = resolve_scope(scope: scope, prm: prm || {})
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata) effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
# the embedding application has just rejected.
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
client_info = ensure_client_registered(as_metadata: as_metadata)
pkce = PKCE.generate pkce = PKCE.generate
state = SecureRandom.urlsafe_base64(32) state = SecureRandom.urlsafe_base64(32)
@ -109,7 +119,7 @@ module MCP
resource: resource, resource: resource,
) )
@provider.save_tokens(tokens) save_tokens_issued_by(tokens, as_metadata: as_metadata)
:authorized :authorized
end end
@ -119,17 +129,19 @@ module MCP
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3). # and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`. # The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
def run_client_credentials!(as_metadata:, prm:, resource:, scope:) def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:)
client_info = client_credentials_client_info client_info = client_credentials_client_info
ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata) ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
form = { "grant_type" => "client_credentials" } form = { "grant_type" => "client_credentials" }
effective_scope = resolve_scope(scope: scope, prm: prm) effective_scope = resolve_scope(scope: scope, prm: prm)
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
form["scope"] = effective_scope if effective_scope form["scope"] = effective_scope if effective_scope
form["resource"] = resource if resource form["resource"] = resource if resource
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
@provider.save_tokens(tokens) save_tokens_issued_by(tokens, as_metadata: as_metadata)
:authorized :authorized
end end
@ -173,12 +185,18 @@ module MCP
# and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request. # and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request.
# The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated. # The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated.
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990 # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:) def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:)
client_info = @provider.client_information client_info = @provider.client_information
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id") unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`." raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
end end
# Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant:
# obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once
# the host has refused it.
effective_scope = resolve_scope(scope: scope, prm: prm)
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource) assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource)
if assertion.nil? || assertion.to_s.empty? if assertion.nil? || assertion.to_s.empty?
raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion." raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
@ -188,12 +206,11 @@ module MCP
"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion" => assertion, "assertion" => assertion,
} }
effective_scope = resolve_scope(scope: scope, prm: prm)
form["scope"] = effective_scope if effective_scope form["scope"] = effective_scope if effective_scope
form["resource"] = resource if resource form["resource"] = resource if resource
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form) tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
@provider.save_tokens(tokens) save_tokens_issued_by(tokens, as_metadata: as_metadata)
:authorized :authorized
end end
@ -242,6 +259,8 @@ module MCP
server_url: server_url, server_url: server_url,
) )
ensure_token_issuer!(as_metadata: as_metadata)
client_info = if have_stored_client_info client_info = if have_stored_client_info
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity, # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support. # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
@ -262,7 +281,7 @@ module MCP
resource: resource, resource: resource,
) )
@provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token)) save_tokens_issued_by(preserve_refresh_token(new_tokens, refresh_token), as_metadata: as_metadata)
:refreshed :refreshed
end end
@ -499,6 +518,43 @@ module MCP
# the authorization server on two loopback ports), and deployments that never leave a corporate network. # the authorization server on two loopback ports), and deployments that never leave a corporate network.
# Only a server reachable on the public internet is barred from steering the client inward. # Only a server reachable on the public internet is barred from steering the client inward.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
# Hands the embedding application the authorization server and the scopes that are about to be requested,
# and abandons the flow when it refuses them.
#
# Both values are chosen by the MCP server: it names its own authorization server in Protected
# Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge.
# Neither the specification nor any MCP SDK binds that choice to the server's own identity,
# and validating that a token was issued for the intended audience is a responsibility the specification
# places on MCP servers rather than on clients.
# A host that knows which providers its user deals with can apply that knowledge here.
#
# The scopes are passed on unchanged whatever the host decides, because the specification requires
# a client to treat the challenged scopes as authoritative for the operation; the choice offered is
# to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
#
# Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
# this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
# an authorization server that has changed since the tokens were issued sends the flow back through here,
# where the host sees the new one and decides again.
def authorize_request!(as_metadata:, scope:, server_url:, resource:)
return unless @provider.respond_to?(:authorization_request_validator)
return unless (validator = @provider.authorization_request_validator)
request = AuthorizationRequest.new(
authorization_server: as_metadata["issuer"],
scopes: scope.to_s.split,
server_url: server_url,
resource: resource,
).freeze
return if validator.call(request)
raise AuthorizationRefusedError, <<~MESSAGE
The authorization request was refused by `authorization_request_validator` \
(authorization server #{request.authorization_server.inspect}, scopes #{request.scopes.inspect}).
MESSAGE
end
def ensure_routable_destination!(url, label:, server_url:) def ensure_routable_destination!(url, label:, server_url:)
return unless private_network_url?(url) return unless private_network_url?(url)
return if private_network_url?(server_url) return if private_network_url?(server_url)
@ -669,6 +725,39 @@ module MCP
# the error and falls back to the full flow, which discards the stale registration and re-registers. # the error and falls back to the full flow, which discards the stale registration and re-registers.
# Credentials without an `"issuer"` binding predate this check and are allowed through; # Credentials without an `"issuer"` binding predate this check and are allowed through;
# CIMD `client_id`s are portable. # CIMD `client_id`s are portable.
# Records which authorization server minted these tokens, alongside the tokens themselves.
# SEP-2352 already binds *client credentials* to their issuer; this binds the *tokens*, which that
# SEP leaves unbound.
# Stored in the token hash so it travels through any `storage` a caller supplied, without widening
# the storage interface that custom implementations have to satisfy.
def save_tokens_issued_by(tokens, as_metadata:)
issuer = as_metadata["issuer"]
tokens = tokens.merge("issuer" => issuer) if tokens.is_a?(Hash) && issuer
@provider.save_tokens(tokens)
end
# Refuses to present a refresh token to an authorization server other than the one that issued it.
# `refresh!` rediscovers the authorization server from Protected Resource Metadata every time,
# so the server named for a session can differ from the one the stored tokens came from.
# Unlike `ensure_refreshable_client_information!` this holds whatever the client identity is,
# including a Client ID Metadata Document URL, which is portable across authorization servers precisely
# so that re-registration is unnecessary.
#
# Tokens stored before this shipped carry no issuer and are left alone, matching how SEP-2352 treats
# client information without one; the binding takes effect from their next authorization.
# The caller answers a refusal by running the full authorization flow, which is where the embedding application
# is asked about the new authorization server.
def ensure_token_issuer!(as_metadata:)
stored_issuer = read_token("issuer")
return if stored_issuer.nil? || stored_issuer == as_metadata["issuer"]
raise AuthorizationError, <<~MESSAGE
Cannot refresh: the stored tokens were issued by a different authorization server (stored issuer #{stored_issuer.inspect}, \
current #{as_metadata["issuer"].inspect}); re-authorization is required.
MESSAGE
end
def ensure_refreshable_client_information!(client_info, as_metadata:) def ensure_refreshable_client_information!(client_info, as_metadata:)
stored_issuer = client_info_required_value(client_info, "issuer") stored_issuer = client_info_required_value(client_info, "issuer")
return if stored_issuer.nil? return if stored_issuer.nil?

View file

@ -86,7 +86,8 @@ module MCP
callback_handler:, callback_handler:,
scope: nil, scope: nil,
storage: nil, storage: nil,
client_id_metadata_document_url: nil client_id_metadata_document_url: nil,
authorization_request_validator: nil
) )
unless Discovery.secure_url?(redirect_uri) unless Discovery.secure_url?(redirect_uri)
raise InsecureRedirectURIError, raise InsecureRedirectURIError,
@ -115,6 +116,7 @@ module MCP
@scope = scope @scope = scope
@storage = storage || InMemoryStorage.new @storage = storage || InMemoryStorage.new
@client_id_metadata_document_url = client_id_metadata_document_url @client_id_metadata_document_url = client_id_metadata_document_url
@authorization_request_validator = authorization_request_validator
end end
# Identifies the OAuth flow this provider drives. # Identifies the OAuth flow this provider drives.

View file

@ -14,6 +14,13 @@ module MCP
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)` # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
# (see `InMemoryStorage`). # (see `InMemoryStorage`).
module StorageBackedProvider module StorageBackedProvider
# Optional `->(request) { true | false }` hook, called with an `AuthorizationRequest`.
# An MCP server names its own authorization server and the scopes to ask for, so this is where
# an embedding application sees both before the request is made and can decline to proceed.
# `nil` (the default) authorizes whatever the server asked for, which is the behavior every
# MCP SDK has today.
attr_reader :authorization_request_validator
def access_token def access_token
tokens&.dig("access_token") || tokens&.dig(:access_token) tokens&.dig("access_token") || tokens&.dig(:access_token)
end end

View file

@ -363,6 +363,107 @@ module MCP
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end end
# Runs the authorization-code flow with an `authorization_request_validator` that records what it
# was handed and answers `approve`.
private def run_flow_with_validator(approve:, recorder: [])
state_holder = {}
provider = Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
recorder << :redirected
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
authorization_request_validator: ->(request) {
recorder << request
approve
},
)
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
def test_run_hands_the_authorization_server_and_scopes_to_the_validator
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
resource: @server_url,
authorization_servers: [@auth_base],
scopes_supported: ["Mail.Read", "Files.ReadWrite.All"],
),
)
recorder = []
run_flow_with_validator(approve: true, recorder: recorder)
request = recorder.first
assert_equal(@auth_base, request.authorization_server)
assert_equal(["Mail.Read", "Files.ReadWrite.All"], request.scopes)
assert_equal(@server_url, request.server_url)
assert_equal("https://srv.example.com/mcp", request.resource)
end
def test_run_refuses_the_flow_and_registers_nothing_when_the_validator_declines
recorder = []
error = assert_raises(Flow::AuthorizationRefusedError) do
run_flow_with_validator(approve: false, recorder: recorder)
end
assert_match(/refused by `authorization_request_validator`/, error.message)
# Refused before registration, so nothing of this client is left at the authorization server
# the host just rejected, and no browser was opened.
assert_not_requested(:post, "#{@auth_base}/register")
refute_includes(recorder, :redirected)
end
def test_run_client_credentials_consults_the_validator
provider = ClientCredentialsProvider.new(
client_id: "cc-client",
client_secret: "cc-secret",
authorization_request_validator: ->(_request) { false },
)
error = assert_raises(Flow::AuthorizationRefusedError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/refused by `authorization_request_validator`/, error.message)
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_run_jwt_bearer_consults_the_validator
minted = false
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(audience:, resource:) {
minted = true
"id-jag-assertion"
},
authorization_request_validator: ->(_request) { false },
)
error = assert_raises(Flow::AuthorizationRefusedError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/refused by `authorization_request_validator`/, error.message)
assert_not_requested(:post, "#{@auth_base}/token")
# Refused before the identity provider was asked for an assertion carrying this authorization server as its audience.
refute(minted, "the ID-JAG assertion must not be minted for a refused authorization server")
end
def test_run_registers_native_application_type_for_loopback_redirect_uri def test_run_registers_native_application_type_for_loopback_redirect_uri
run_authorization_flow run_authorization_flow
@ -2264,6 +2365,64 @@ module MCP
assert_equal("saved-rt", provider.tokens["refresh_token"]) assert_equal("saved-rt", provider.tokens["refresh_token"])
end end
def test_run_records_the_issuer_that_minted_the_tokens
state_holder = {}
provider = Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
)
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(@auth_base, provider.tokens["issuer"])
end
def test_refresh_refuses_an_authorization_server_that_did_not_issue_the_tokens
# The stored tokens came from a different authorization server, so this refresh token must not reach the one named now.
stub_request(:post, "#{@auth_base}/token").to_raise(StandardError.new("must not reach the token endpoint."))
provider = refresh_only_provider
provider.save_tokens(
"access_token" => "stale-at",
"refresh_token" => "saved-rt",
"issuer" => "https://issued-by.example.com",
)
error = assert_raises(Flow::AuthorizationError) do
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/issued by a different authorization server/, error.message)
# Left in place: it is still good at the server that issued it, and the caller answers this by running the full authorization flow.
assert_equal("saved-rt", provider.tokens["refresh_token"])
end
def test_refresh_proceeds_for_tokens_stored_before_the_issuer_was_recorded
stub_request(:post, "#{@auth_base}/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"),
)
provider = refresh_only_provider
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:refreshed, result)
# Recorded on the way out, so the binding takes effect from here on.
assert_equal(@auth_base, provider.tokens["issuer"])
end
def test_refresh_succeeds_when_stored_issuer_matches def test_refresh_succeeds_when_stored_issuer_matches
stub_request(:post, "#{@auth_base}/token").with( stub_request(:post, "#{@auth_base}/token").with(
body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt"), body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt"),
@ -2913,6 +3072,20 @@ module MCP
URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp" URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp"
end end
end end
private
def refresh_only_provider
provider = Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { [nil, nil] },
)
provider.save_client_information("client_id" => "test-client")
provider
end
end end
end end
end end

View file

@ -1433,6 +1433,94 @@ module MCP
assert_requested(:post, "#{@auth_base}/token", times: 1) assert_requested(:post, "#{@auth_base}/token", times: 1)
end end
def test_send_request_reauthorizes_when_the_authorization_server_did_not_issue_the_stored_tokens
# The refusal in `refresh!` is only half the answer; this is the other half.
# A renamed authorization server makes the refresh fail, the transport falls through to a full authorization,
# and that is where the embedding application is asked about the new one.
stub_request(:post, @mcp_url).with { |req|
req.headers["Authorization"] != "Bearer fresh-at"
}.to_return(
status: 401,
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
body: "",
)
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer fresh-at" }
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: @mcp_url, authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{@auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "reauthorized-client"),
)
stub_request(:post, "#{@auth_base}/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"),
)
seen = []
state_holder = {}
provider = Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
authorization_request_validator: ->(request) {
seen << request.authorization_server
true
},
)
# Registered and refreshable, so the refresh path is genuinely reachable and the issuer binding is
# what stops it rather than a missing client identity.
provider.save_client_information("client_id" => "test-client")
provider.save_tokens(
"access_token" => "stale-at",
"refresh_token" => "saved-rt",
"issuer" => "https://issued-by.example.com",
)
HTTP.new(url: @mcp_url, oauth: provider).send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
# The refresh token was never presented to the authorization server now being named.
assert_not_requested(:post, "#{@auth_base}/token") do |req|
URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token"
end
assert_equal([@auth_base], seen)
assert_equal(@auth_base, provider.tokens["issuer"])
end
private private
# Stubs PRM, AS metadata, /register, and /token so a full authorization # Stubs PRM, AS metadata, /register, and /token so a full authorization