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:
parent
da4ce54075
commit
09d4bf41aa
7 changed files with 478 additions and 17 deletions
|
|
@ -1,5 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "oauth/authorization_request"
|
||||
require_relative "oauth/bounded_body"
|
||||
require_relative "oauth/discovery"
|
||||
require_relative "oauth/flow"
|
||||
|
|
|
|||
27
lib/mcp/client/oauth/authorization_request.rb
Normal file
27
lib/mcp/client/oauth/authorization_request.rb
Normal 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
|
||||
|
|
@ -60,7 +60,8 @@ module MCP
|
|||
private_key: nil,
|
||||
signing_algorithm: nil,
|
||||
scope: nil,
|
||||
storage: nil
|
||||
storage: nil,
|
||||
authorization_request_validator: nil
|
||||
)
|
||||
if blank?(client_id)
|
||||
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
|
||||
|
|
@ -103,6 +104,7 @@ module MCP
|
|||
@signing_algorithm = signing_algorithm
|
||||
@scope = scope
|
||||
@storage = storage || InMemoryStorage.new
|
||||
@authorization_request_validator = authorization_request_validator
|
||||
@storage.save_client_information(client_information)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ module MCP
|
|||
|
||||
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)
|
||||
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
|
||||
end
|
||||
|
|
@ -51,6 +51,7 @@ module MCP
|
|||
@assertion_provider = assertion_provider
|
||||
@scope = scope
|
||||
@storage = storage || InMemoryStorage.new
|
||||
@authorization_request_validator = authorization_request_validator
|
||||
@storage.save_client_information(
|
||||
"client_id" => client_id,
|
||||
"client_secret" => client_secret,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ module MCP
|
|||
# should leave the refresh token intact.
|
||||
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)
|
||||
@provider = provider
|
||||
@http_client_factory = http_client_factory || -> { default_http_client }
|
||||
|
|
@ -63,17 +68,22 @@ module MCP
|
|||
|
||||
case provider_authorization_flow
|
||||
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
|
||||
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
|
||||
|
||||
ensure_pkce_supported!(as_metadata)
|
||||
|
||||
client_info = ensure_client_registered(as_metadata: as_metadata)
|
||||
|
||||
effective_scope = resolve_scope(scope: scope, prm: prm || {})
|
||||
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
|
||||
state = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
|
|
@ -109,7 +119,7 @@ module MCP
|
|||
resource: resource,
|
||||
)
|
||||
|
||||
@provider.save_tokens(tokens)
|
||||
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
||||
:authorized
|
||||
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).
|
||||
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
|
||||
# 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
|
||||
ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
|
||||
|
||||
form = { "grant_type" => "client_credentials" }
|
||||
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["resource"] = resource if resource
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
|
|
@ -173,12 +185,18 @@ module MCP
|
|||
# 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.
|
||||
# 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
|
||||
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`."
|
||||
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)
|
||||
if assertion.nil? || assertion.to_s.empty?
|
||||
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",
|
||||
"assertion" => assertion,
|
||||
}
|
||||
effective_scope = resolve_scope(scope: scope, prm: prm)
|
||||
form["scope"] = effective_scope if effective_scope
|
||||
form["resource"] = resource if resource
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
|
|
@ -242,6 +259,8 @@ module MCP
|
|||
server_url: server_url,
|
||||
)
|
||||
|
||||
ensure_token_issuer!(as_metadata: as_metadata)
|
||||
|
||||
client_info = if have_stored_client_info
|
||||
# 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.
|
||||
|
|
@ -262,7 +281,7 @@ module MCP
|
|||
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
|
||||
end
|
||||
|
||||
|
|
@ -499,6 +518,43 @@ module MCP
|
|||
# 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.
|
||||
# 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:)
|
||||
return unless private_network_url?(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.
|
||||
# Credentials without an `"issuer"` binding predate this check and are allowed through;
|
||||
# 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:)
|
||||
stored_issuer = client_info_required_value(client_info, "issuer")
|
||||
return if stored_issuer.nil?
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ module MCP
|
|||
callback_handler:,
|
||||
scope: 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)
|
||||
raise InsecureRedirectURIError,
|
||||
|
|
@ -115,6 +116,7 @@ module MCP
|
|||
@scope = scope
|
||||
@storage = storage || InMemoryStorage.new
|
||||
@client_id_metadata_document_url = client_id_metadata_document_url
|
||||
@authorization_request_validator = authorization_request_validator
|
||||
end
|
||||
|
||||
# Identifies the OAuth flow this provider drives.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ module MCP
|
|||
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
||||
# (see `InMemoryStorage`).
|
||||
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
|
||||
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue