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:
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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,
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue