Merge pull request #539 from koic/add_authorization_request_validator
Let the embedding application refuse an authorization request
This commit is contained in:
commit
eeab784fc8
10 changed files with 478 additions and 17 deletions
|
|
@ -93,10 +93,14 @@ Required keyword arguments to `Provider.new`:
|
|||
Optional keyword arguments:
|
||||
|
||||
- `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`,
|
||||
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
|
||||
(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
|
||||
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
|
||||
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
|
||||
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.
|
||||
- `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
|
||||
|
||||
|
|
@ -219,7 +223,7 @@ Keyword arguments:
|
|||
- `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).
|
||||
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
|
||||
|
||||
|
|
@ -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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -363,6 +363,107 @@ module MCP
|
|||
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
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
|
||||
run_authorization_flow
|
||||
|
||||
|
|
@ -2264,6 +2365,64 @@ module MCP
|
|||
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
||||
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
|
||||
stub_request(:post, "#{@auth_base}/token").with(
|
||||
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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1433,6 +1433,94 @@ module MCP
|
|||
assert_requested(:post, "#{@auth_base}/token", times: 1)
|
||||
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
|
||||
|
||||
# Stubs PRM, AS metadata, /register, and /token so a full authorization
|
||||
|
|
|
|||
Loading…
Reference in a new issue