Let providers without a CIMD URL reader refresh their tokens
## Motivation and Context `Flow#refresh!` reads `client_id_metadata_document_url` from the provider unconditionally, once to decide whether a provider without stored `client_information` may still refresh and once to build the CIMD `client_id`. Only `Provider` defines that reader: `CrossAppAccessProvider` and `ClientCredentialsProvider` raise `NoMethodError` once a stored `refresh_token` is found. `MCP::Client::HTTP` calls `refresh!` on any `401` while a `refresh_token` is stored, so a `jwt-bearer` client whose authorization server issued one fails with `NoMethodError` on the first expired token instead of refreshing. The error is not an `AuthorizationError`, so the transport does not fall back to the grant either. RFC 7521 Section 4.1 makes a refresh token unusual for an assertion grant, not forbidden, and one in storage must not crash the client. The flow now reads the URL through a duck-typed helper, like `authorization_flow` and `client_assertion`, so a provider without the reader has no CIMD URL and refreshes with its stored `client_information`: the `refresh_token` grant is sent with the stored client authentication, and no new ID-JAG assertion is obtained. The authorization-code registration path reads the URL through the same helper. The docs now say that a refresh token issued anyway is used on the next `401`, and that the assertion provider is not called again on refresh. A refresh token stored without the `issuer` the SDK records is refused for these two providers before any request: unlike `Provider`, they have no tokens that predate the record, and a refresh asks no validator, so such a token would be presented to whatever authorization server discovery names. The refusal is an `AuthorizationError`, which sends the transport back through the grant, and the grant asks. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` refresh through a `CrossAppAccessProvider` and a `ClientCredentialsProvider` holding a `refresh_token`, asserting the `refresh_token` grant, the Basic credentials, and that the assertion provider is not called. A transport test in `test/mcp/client/oauth/http_oauth_test.rb` sends a `401` to a `CrossAppAccessProvider` with a stored `refresh_token` and expects the refreshed bearer token on the retry. All three raise `NoMethodError` against the previous library. Further tests pin the issuer stamp on the refreshed tokens, the refusal of an authorization server that did not issue them for a `CrossAppAccessProvider`, a clean `AuthorizationError` for a provider with neither the reader nor stored `client_information`, and registration through DCR for a provider without the reader. A refresh token without a recorded issuer is refused for both providers before any request is sent. ## Breaking Changes None.
This commit is contained in:
parent
f22ce86e97
commit
f51ccfbe66
1 changed files with 227 additions and 5 deletions
|
|
@ -182,7 +182,7 @@ module MCP
|
|||
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
|
||||
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
|
||||
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
|
||||
# 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 is not expected to 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:, server_url:)
|
||||
|
|
@ -275,19 +275,22 @@ module MCP
|
|||
# checks before talking to it.
|
||||
#
|
||||
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
|
||||
# when a `client_credentials` or `jwt-bearer` provider's tokens record no issuer,
|
||||
# or when the token endpoint refuses the refresh request.
|
||||
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
||||
def refresh!(server_url:, resource_metadata_url: nil)
|
||||
refresh_token = read_token("refresh_token")
|
||||
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
|
||||
|
||||
ensure_refresh_token_issuer_recorded!
|
||||
|
||||
stored_client_info = @provider.client_information
|
||||
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
|
||||
|
||||
# A CIMD-configured provider stores no `client_information` on purpose
|
||||
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
|
||||
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
|
||||
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
|
||||
have_cimd_url = !provider_client_id_metadata_document_url.nil?
|
||||
|
||||
unless have_stored_client_info || have_cimd_url
|
||||
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
|
||||
|
|
@ -323,7 +326,7 @@ module MCP
|
|||
ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
|
||||
stored_client_info
|
||||
elsif as_metadata["client_id_metadata_document_supported"] == true
|
||||
{ "client_id" => @provider.client_id_metadata_document_url }
|
||||
{ "client_id" => provider_client_id_metadata_document_url }
|
||||
else
|
||||
raise AuthorizationError,
|
||||
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
|
||||
|
|
@ -684,7 +687,7 @@ module MCP
|
|||
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
|
||||
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
|
||||
# and the current `provider.client_id_metadata_document_url`.
|
||||
cimd_url = @provider.client_id_metadata_document_url
|
||||
cimd_url = provider_client_id_metadata_document_url
|
||||
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
|
||||
return { "client_id" => cimd_url }
|
||||
end
|
||||
|
|
@ -814,6 +817,18 @@ module MCP
|
|||
MESSAGE
|
||||
end
|
||||
|
||||
# `Provider` tolerates tokens stored before the issuer was recorded (see `ensure_token_issuer!`).
|
||||
# The `client_credentials` and `jwt-bearer` providers have no such tokens, since their refresh is new,
|
||||
# and a refresh asks no validator, so a token without an `issuer` would be presented to whatever
|
||||
# authorization server discovery names now. Refusing it sends the transport back through the grant,
|
||||
# which does ask.
|
||||
def ensure_refresh_token_issuer_recorded!
|
||||
return if provider_authorization_flow == :authorization_code
|
||||
return unless read_token("issuer").nil?
|
||||
|
||||
raise AuthorizationError, "Cannot refresh: the stored tokens record no issuer; re-authorization is required."
|
||||
end
|
||||
|
||||
def ensure_refreshable_client_information!(client_info, as_metadata:)
|
||||
stored_issuer = client_info_required_value(client_info, "issuer")
|
||||
return if stored_issuer.nil?
|
||||
|
|
@ -1020,6 +1035,15 @@ module MCP
|
|||
params
|
||||
end
|
||||
|
||||
# The Client ID Metadata Document URL, when the provider has one. Only `Provider` exposes the reader
|
||||
# (CIMD replaces Dynamic Client Registration on the authorization-code flow), while `refresh!` serves
|
||||
# every provider that holds a `refresh_token`, so the read is duck-typed like `authorization_flow`.
|
||||
def provider_client_id_metadata_document_url
|
||||
return unless @provider.respond_to?(:client_id_metadata_document_url)
|
||||
|
||||
@provider.client_id_metadata_document_url
|
||||
end
|
||||
|
||||
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
||||
authorization_endpoint = as_metadata["authorization_endpoint"]
|
||||
unless authorization_endpoint
|
||||
|
|
|
|||
Loading…
Reference in a new issue