ruby-mcp-client 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +914 -49
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1367 -311
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -36
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +851 -478
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/elicitation_validator.rb +56 -13
- data/lib/mcp_client/errors.rb +481 -6
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +669 -100
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +967 -11
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -2
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_link.rb +0 -2
- data/lib/mcp_client/resource_template.rb +4 -2
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +893 -175
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +12 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
- data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
- data/lib/mcp_client/server_sse.rb +310 -76
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
- data/lib/mcp_client/server_stdio.rb +778 -186
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
- data/lib/mcp_client/server_streamable_http.rb +547 -157
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -5
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +115 -19
- metadata +71 -7
|
@@ -3,7 +3,18 @@
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'uri'
|
|
6
|
+
require 'ipaddr'
|
|
7
|
+
require 'monitor'
|
|
6
8
|
require_relative '../auth'
|
|
9
|
+
require_relative 'peer_text'
|
|
10
|
+
require_relative 'oauth_provider/challenge_handling'
|
|
11
|
+
require_relative 'oauth_provider/client_authentication'
|
|
12
|
+
require_relative 'oauth_provider/pending_requests'
|
|
13
|
+
require_relative 'oauth_provider/registration_store'
|
|
14
|
+
require_relative 'oauth_provider/response_validation'
|
|
15
|
+
require_relative 'oauth_provider/scope_selection'
|
|
16
|
+
require_relative 'oauth_provider/token_store'
|
|
17
|
+
require_relative '../deprecations'
|
|
7
18
|
|
|
8
19
|
module MCPClient
|
|
9
20
|
module Auth
|
|
@@ -11,6 +22,21 @@ module MCPClient
|
|
|
11
22
|
# Handles the complete OAuth flow including server discovery, client registration,
|
|
12
23
|
# authorization, token exchange, and refresh
|
|
13
24
|
class OAuthProvider
|
|
25
|
+
# One lock per storage backend and resource for this resource's
|
|
26
|
+
# authorization state — the pending-flow records and the token slot they
|
|
27
|
+
# end in (see {#with_authorization_state_lock}); storage backends are
|
|
28
|
+
# weak keys, so a storage the host drops takes its locks with it.
|
|
29
|
+
#
|
|
30
|
+
# WeakKeyMap, not WeakMap: WeakMap holds its VALUES weakly too, and the
|
|
31
|
+
# value here is the table of per-resource monitors, which nothing else
|
|
32
|
+
# references. A collection between two acquisitions dropped that table,
|
|
33
|
+
# the next caller built a fresh monitor, and two providers sharing one
|
|
34
|
+
# storage were inside the critical section at the same time — which is
|
|
35
|
+
# exactly what the section exists to prevent.
|
|
36
|
+
AUTHORIZATION_STATE_LOCKS = ObjectSpace::WeakKeyMap.new
|
|
37
|
+
AUTHORIZATION_STATE_LOCKS_GUARD = Mutex.new
|
|
38
|
+
private_constant :AUTHORIZATION_STATE_LOCKS, :AUTHORIZATION_STATE_LOCKS_GUARD
|
|
39
|
+
|
|
14
40
|
# One auth-param (name = token / quoted-string) as it appears in a
|
|
15
41
|
# WWW-Authenticate challenge (RFC 7235 §2.1, optional whitespace around
|
|
16
42
|
# '='). Mirrors HttpTransportBase::AUTH_PARAM so provider-side challenge
|
|
@@ -23,6 +49,15 @@ module MCPClient
|
|
|
23
49
|
# boundaries. Mirrors HttpTransportBase::AUTH_PARAMS_RUN.
|
|
24
50
|
AUTH_PARAMS_RUN = /\A(?:[\s,]*#{AUTH_PARAM})*/
|
|
25
51
|
|
|
52
|
+
include ChallengeHandling
|
|
53
|
+
include ClientAuthentication
|
|
54
|
+
include PeerText
|
|
55
|
+
include PendingRequests
|
|
56
|
+
include RegistrationStore
|
|
57
|
+
include ResponseValidation
|
|
58
|
+
include ScopeSelection
|
|
59
|
+
include TokenStore
|
|
60
|
+
|
|
26
61
|
# @!attribute [rw] redirect_uri
|
|
27
62
|
# @return [String] OAuth redirect URI
|
|
28
63
|
# @!attribute [rw] scope
|
|
@@ -35,8 +70,8 @@ module MCPClient
|
|
|
35
70
|
# @return [String] The MCP server URL (normalized)
|
|
36
71
|
# @!attribute [r] client_id_metadata_url
|
|
37
72
|
# @return [String, nil] HTTPS URL of this client's Client ID Metadata Document (SEP-991)
|
|
38
|
-
attr_accessor :
|
|
39
|
-
attr_reader :server_url, :client_id_metadata_url
|
|
73
|
+
attr_accessor :scope, :logger, :storage
|
|
74
|
+
attr_reader :server_url, :client_id_metadata_url, :redirect_uri
|
|
40
75
|
|
|
41
76
|
# Initialize OAuth provider
|
|
42
77
|
# @param server_url [String] The MCP server URL (used as OAuth resource parameter)
|
|
@@ -52,15 +87,37 @@ module MCPClient
|
|
|
52
87
|
# skipping dynamic registration. Hosting the metadata JSON at that URL is the
|
|
53
88
|
# application's responsibility.
|
|
54
89
|
# @raise [ArgumentError] if client_id_metadata_url is not an HTTPS URL with a path component
|
|
90
|
+
# OIDC application types accepted for Dynamic Client Registration.
|
|
91
|
+
APPLICATION_TYPES = %w[native web].freeze
|
|
92
|
+
|
|
93
|
+
# The authorization request parameters this client puts in the
|
|
94
|
+
# authorization URL. RFC 6749 Section 3.1: "Request and response
|
|
95
|
+
# parameters MUST NOT be included more than once", so an authorization
|
|
96
|
+
# endpoint whose own query already names one of these loses it to the
|
|
97
|
+
# value of this request (see {#merged_authorization_query}).
|
|
98
|
+
AUTHORIZATION_REQUEST_PARAMS = %w[
|
|
99
|
+
response_type client_id redirect_uri scope state code_challenge code_challenge_method resource
|
|
100
|
+
].freeze
|
|
101
|
+
|
|
102
|
+
# Literal names of the loopback interface (see #loopback_address?).
|
|
103
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
|
|
104
|
+
|
|
105
|
+
# @return [String, nil] the explicit application_type for Dynamic Client Registration
|
|
106
|
+
attr_reader :application_type
|
|
107
|
+
|
|
55
108
|
def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil,
|
|
56
|
-
client_metadata: {}, client_id_metadata_url: nil)
|
|
109
|
+
client_metadata: {}, client_id_metadata_url: nil, application_type: nil)
|
|
57
110
|
self.server_url = server_url
|
|
58
111
|
self.redirect_uri = redirect_uri
|
|
59
112
|
self.scope = scope
|
|
60
113
|
self.logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
61
114
|
self.storage = storage || MemoryStorage.new
|
|
62
115
|
self.client_id_metadata_url = client_id_metadata_url
|
|
63
|
-
|
|
116
|
+
# An application_type given through client_metadata is the host's
|
|
117
|
+
# explicit choice too; it never silently overrides the derived type.
|
|
118
|
+
extra = (client_metadata || {}).transform_keys(&:to_sym)
|
|
119
|
+
self.application_type = application_type || extra[:application_type]
|
|
120
|
+
@extra_client_metadata = extra.except(:application_type)
|
|
64
121
|
@http_client = create_http_client
|
|
65
122
|
# Protected resource metadata learned from a 401 WWW-Authenticate
|
|
66
123
|
# challenge, reused by discovery so a challenge-advertised metadata URL
|
|
@@ -68,11 +125,53 @@ module MCPClient
|
|
|
68
125
|
# separately so a failed fetch is retried authoritatively by discovery.
|
|
69
126
|
@challenge_resource_metadata = nil
|
|
70
127
|
@challenge_metadata_url = nil
|
|
128
|
+
# Why a peer-advertised challenge URL was refused, if one was
|
|
129
|
+
@challenge_error = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# MCP 2026-07-28 "Communication Security" requires implementations to
|
|
133
|
+
# follow OAuth 2.1 Section 1.5, and spells out what that means here:
|
|
134
|
+
# "All redirect URIs MUST be either `localhost` or use HTTPS." A
|
|
135
|
+
# callback on any other plain-HTTP host carries the authorization code
|
|
136
|
+
# — and, on an error response, the authorization server's own error
|
|
137
|
+
# text — across the network in the clear, where anyone on the path can
|
|
138
|
+
# redeem it. RFC 8252 Section 7.1 private-use schemes (a native app's
|
|
139
|
+
# `com.example.app:/oauth2redirect`) never leave the device and stay
|
|
140
|
+
# available.
|
|
141
|
+
# @param uri [String] the redirect URI to use
|
|
142
|
+
# @raise [ArgumentError] when it is not a callback this client may register
|
|
143
|
+
def redirect_uri=(uri)
|
|
144
|
+
unless redirect_uri_bytes?(uri)
|
|
145
|
+
raise ArgumentError,
|
|
146
|
+
'redirect_uri must be a localhost HTTP callback, an HTTPS URL or an RFC 8252 private-use ' \
|
|
147
|
+
"scheme URI (MCP 2026-07-28 requires every redirect URI to be localhost or HTTPS): #{uri.inspect}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
@redirect_uri = uri
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @param type [String, nil] 'native', 'web' or nil (derived from the redirect URI)
|
|
154
|
+
# @raise [ArgumentError] for any other value
|
|
155
|
+
def application_type=(type)
|
|
156
|
+
type = type&.to_s
|
|
157
|
+
unless type.nil? || APPLICATION_TYPES.include?(type)
|
|
158
|
+
raise ArgumentError, "application_type must be one of #{APPLICATION_TYPES.join(', ')}: #{type.inspect}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
@application_type = type
|
|
71
162
|
end
|
|
72
163
|
|
|
73
164
|
# @param url [String] Server URL to normalize
|
|
74
165
|
def server_url=(url)
|
|
75
|
-
|
|
166
|
+
normalized = normalize_server_url(url)
|
|
167
|
+
# Everything discovery leaves on the instance describes the resource
|
|
168
|
+
# it was discovered FOR. Retargeting the provider at another server
|
|
169
|
+
# must not let the previous server's metadata answer for the new one:
|
|
170
|
+
# the fallback that survives a backend which does not persist metadata
|
|
171
|
+
# would otherwise send the new server's authorization, registration
|
|
172
|
+
# and token requests to the previous server's endpoints.
|
|
173
|
+
forget_per_server_state if defined?(@server_url) && @server_url && normalized != @server_url
|
|
174
|
+
@server_url = normalized
|
|
76
175
|
end
|
|
77
176
|
|
|
78
177
|
# Set the Client ID Metadata Document URL (SEP-991), validating it per the
|
|
@@ -87,15 +186,50 @@ module MCPClient
|
|
|
87
186
|
# Get current access token (refresh if needed)
|
|
88
187
|
# @return [Token, nil] Current valid access token or nil
|
|
89
188
|
def access_token
|
|
90
|
-
|
|
189
|
+
# A challenge still to be fetched decides which authorization server
|
|
190
|
+
# is current, and may retire the stored token: it is resolved before
|
|
191
|
+
# the token is read, so a record it deleted is never written back.
|
|
192
|
+
resolve_pending_challenge
|
|
193
|
+
token = token_in_use
|
|
91
194
|
logger.debug("OAuth access_token: retrieved token=#{token ? 'present' : 'nil'} for #{server_url}")
|
|
92
195
|
return nil unless token
|
|
93
196
|
|
|
94
197
|
# Return token if still valid
|
|
95
198
|
return token unless token.expired? || token.expires_soon?
|
|
96
199
|
|
|
97
|
-
#
|
|
98
|
-
|
|
200
|
+
# Refresh early when possible; a still-valid token is presented when
|
|
201
|
+
# the refresh (or the discovery it needs) cannot run right now. What
|
|
202
|
+
# comes back is judged against the authorization server that is
|
|
203
|
+
# current NOW, not the one the refresh was started with.
|
|
204
|
+
resource = server_url
|
|
205
|
+
refreshed = refresh_if_possible(token)
|
|
206
|
+
# A provider retargeted at another resource while the refresh was in
|
|
207
|
+
# flight has nothing of the previous resource to present.
|
|
208
|
+
return nil unless server_url == resource
|
|
209
|
+
return refreshed if token_bytes?(refreshed) && token_for_current_issuer?(refreshed)
|
|
210
|
+
|
|
211
|
+
# The token in use may have changed hands while the refresh was in
|
|
212
|
+
# flight — retired by a challenge another provider sharing the storage
|
|
213
|
+
# handled, replaced by a flow it completed: what is in use NOW is what
|
|
214
|
+
# is presented, and the token this refresh started from is not.
|
|
215
|
+
current = token_in_use
|
|
216
|
+
return presentable_token(current) unless current && same_token?(current, token)
|
|
217
|
+
|
|
218
|
+
# The discovery a refresh ran may have retired this very token.
|
|
219
|
+
return nil if token.expired? || retired_token?(token) || !token_for_current_issuer?(token)
|
|
220
|
+
|
|
221
|
+
token
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @param token [Token]
|
|
225
|
+
# @return [Token, nil] the refreshed token, or nil when no refresh could be obtained
|
|
226
|
+
def refresh_if_possible(token)
|
|
227
|
+
return nil unless token.refresh_token
|
|
228
|
+
|
|
229
|
+
refresh_token(token)
|
|
230
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
231
|
+
logger.warn("Token refresh could not run: #{e.message}")
|
|
232
|
+
nil
|
|
99
233
|
end
|
|
100
234
|
|
|
101
235
|
# Return the scopes supported by the authorization server
|
|
@@ -103,7 +237,9 @@ module MCPClient
|
|
|
103
237
|
# @return [Array<String>] supported scopes, or empty array if not advertised
|
|
104
238
|
# @raise [MCPClient::Errors::ConnectionError] if server discovery fails
|
|
105
239
|
def supported_scopes
|
|
106
|
-
|
|
240
|
+
# A record a hash-persisting backend read back may carry anything
|
|
241
|
+
# here; only an array of scopes is a scope list (RFC 8414 Section 2).
|
|
242
|
+
@supported_scopes ||= advertised_scopes(discover_authorization_server.scopes_supported)
|
|
107
243
|
end
|
|
108
244
|
|
|
109
245
|
# Start OAuth authorization flow
|
|
@@ -116,13 +252,37 @@ module MCPClient
|
|
|
116
252
|
# Register client if needed
|
|
117
253
|
client_info = get_or_register_client(server_metadata)
|
|
118
254
|
|
|
119
|
-
# Generate PKCE parameters
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
#
|
|
255
|
+
# Generate the state and the PKCE parameters as ONE per-request
|
|
256
|
+
# record. MCP 2026-07-28 "Authorization Response Validation": the
|
|
257
|
+
# selected authorization server's issuer is recorded "in the same
|
|
258
|
+
# per-request record used to store the PKCE code verifier (and the
|
|
259
|
+
# `state` value, if used)", so the `iss` of the response can be
|
|
260
|
+
# checked against an authenticated value — and so the state the
|
|
261
|
+
# response carries names THIS request rather than whichever record
|
|
262
|
+
# happens to be in the PKCE slot. Two flows sharing one storage
|
|
263
|
+
# backend interleave their writes: with the state in a slot of its
|
|
264
|
+
# own, A's state could end up alongside B's verifier, issuer and
|
|
265
|
+
# client, and A's code would then be redeemed at B.
|
|
124
266
|
state = SecureRandom.urlsafe_base64(32)
|
|
125
|
-
|
|
267
|
+
# What this request asks for is what the next step-up challenge has
|
|
268
|
+
# to be unioned with — and, recorded with the request, what a token
|
|
269
|
+
# response that omits `scope` granted.
|
|
270
|
+
@requested_scope = resolved_scope
|
|
271
|
+
pkce = PKCE.new(issuer: server_metadata.issuer,
|
|
272
|
+
iss_parameter_supported: server_metadata.iss_parameter_supported?,
|
|
273
|
+
client_id: client_info.client_id,
|
|
274
|
+
redirect_uri: client_info.metadata.redirect_uris.first,
|
|
275
|
+
state: state,
|
|
276
|
+
resource: server_url,
|
|
277
|
+
scope: @requested_scope)
|
|
278
|
+
with_authorization_state_lock do
|
|
279
|
+
storage.set_pkce(server_url, pkce)
|
|
280
|
+
|
|
281
|
+
# The separate slot is still written: it is the documented storage
|
|
282
|
+
# interface, and a callback handler (or an older version of this
|
|
283
|
+
# library) reads the state from it.
|
|
284
|
+
storage.set_state(server_url, state)
|
|
285
|
+
end
|
|
126
286
|
|
|
127
287
|
# Build authorization URL
|
|
128
288
|
build_authorization_url(server_metadata, client_info, pkce, state)
|
|
@@ -131,141 +291,343 @@ module MCPClient
|
|
|
131
291
|
# Complete OAuth authorization flow with authorization code
|
|
132
292
|
# @param code [String] Authorization code from callback
|
|
133
293
|
# @param state [String] State parameter from callback
|
|
294
|
+
# @param iss [String, nil] the `iss` parameter of the authorization response (RFC 9207);
|
|
295
|
+
# validated against the issuer recorded when the flow started, before the code is sent
|
|
296
|
+
# to any token endpoint (MCP 2026-07-28)
|
|
134
297
|
# @return [Token] Access token
|
|
135
|
-
# @raise [MCPClient::Errors::ConnectionError] if token exchange fails
|
|
298
|
+
# @raise [MCPClient::Errors::ConnectionError] if the issuer check or the token exchange fails
|
|
136
299
|
# @raise [ArgumentError] if state parameter doesn't match
|
|
137
|
-
def complete_authorization_flow(code, state)
|
|
300
|
+
def complete_authorization_flow(code, state, iss: nil)
|
|
138
301
|
# Verify state parameter
|
|
139
302
|
stored_state = storage.get_state(server_url)
|
|
140
303
|
raise ArgumentError, 'Invalid state parameter' unless stored_state == state
|
|
141
304
|
|
|
142
305
|
# Get stored PKCE and client info
|
|
143
|
-
pkce =
|
|
144
|
-
client_info =
|
|
145
|
-
server_metadata = discover_authorization_server
|
|
146
|
-
|
|
306
|
+
pkce = stored_pkce
|
|
307
|
+
client_info = stored_client_info
|
|
147
308
|
raise MCPClient::Errors::ConnectionError, 'Missing PKCE or client info' unless pkce && client_info
|
|
148
309
|
|
|
310
|
+
# The per-request record must be the record of THIS request.
|
|
311
|
+
ensure_state_for_request!(pkce, state)
|
|
312
|
+
|
|
313
|
+
# The code is redeemed only at the authorization server the request
|
|
314
|
+
# was sent to: the issuer recorded with the PKCE record (RFC 9207
|
|
315
|
+
# mix-up protection). A different server discovered since — a 401
|
|
316
|
+
# challenge pointing elsewhere — ends this flow instead.
|
|
317
|
+
unless pkce.issuer.is_a?(String)
|
|
318
|
+
raise MCPClient::Errors::ConnectionError,
|
|
319
|
+
'Authorization response rejected: no issuer was recorded for this authorization request, ' \
|
|
320
|
+
'so it cannot be bound to an authorization server; restart the authorization'
|
|
321
|
+
end
|
|
322
|
+
server_metadata = discover_authorization_server
|
|
323
|
+
raise_authorization_server_changed!(pkce.issuer) unless server_metadata.issuer == pkce.issuer
|
|
324
|
+
validate_authorization_response_issuer!(iss, pkce.issuer, iss_parameter_supported_for?(pkce, server_metadata))
|
|
325
|
+
# The credentials that redeem the code are the ones the request was
|
|
326
|
+
# made with: a record swapped in shared storage meanwhile (another
|
|
327
|
+
# client id, or credentials of another authorization server) is
|
|
328
|
+
# never sent to this token endpoint.
|
|
329
|
+
ensure_client_for_request!(client_info, pkce)
|
|
330
|
+
|
|
149
331
|
# Exchange authorization code for tokens
|
|
150
332
|
token = exchange_authorization_code(server_metadata, client_info, code, pkce)
|
|
151
333
|
|
|
152
|
-
|
|
153
|
-
|
|
334
|
+
accept_exchanged_token(token, pkce)
|
|
335
|
+
end
|
|
154
336
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
337
|
+
# The response of a code exchange arrives at a client whose
|
|
338
|
+
# authorization server may have changed since the request went out —
|
|
339
|
+
# updated protected resource metadata, a 401 challenge, another provider
|
|
340
|
+
# sharing the storage — exactly as a refresh response does. So the
|
|
341
|
+
# checks made before the request are made again over the response,
|
|
342
|
+
# before anything is written: bytes issued by a server that is no longer
|
|
343
|
+
# this resource's would otherwise be stored over the token of the server
|
|
344
|
+
# that IS in use, and the cleanup would delete the pending authorization
|
|
345
|
+
# request that server had already started.
|
|
346
|
+
# @param token [Token] the token the exchange response carried
|
|
347
|
+
# @param pkce [PKCE] the per-request record the exchange was made with
|
|
348
|
+
# @return [Token]
|
|
349
|
+
# @raise [MCPClient::Errors::ConnectionError] when the response is no longer this resource's to keep
|
|
350
|
+
def accept_exchanged_token(token, pkce)
|
|
351
|
+
# Under the resource's authorization-state lock, so the answers these
|
|
352
|
+
# checks give are still the answers when the token is written: a
|
|
353
|
+
# switch of authorization server validated between the two would
|
|
354
|
+
# otherwise store its token first and have this one written over it.
|
|
355
|
+
with_authorization_state_lock do
|
|
356
|
+
raise_authorization_server_changed!(pkce.issuer) unless exchange_target_current?(pkce.issuer)
|
|
357
|
+
# Two resources may share an authorization server and still be two
|
|
358
|
+
# audiences: a token bought for the resource the request named is
|
|
359
|
+
# never stored as another resource's, however alike their issuers.
|
|
360
|
+
raise_resource_changed!(pkce.resource) unless request_resource_current?(pkce)
|
|
361
|
+
# A validated change of authorization server ends the requests still
|
|
362
|
+
# pending with the previous one (see {#end_pending_requests_of}),
|
|
363
|
+
# whichever provider sharing the storage made them: a request whose
|
|
364
|
+
# record is gone was answered by a server that is no longer this
|
|
365
|
+
# resource's, however current that server still looks from here.
|
|
366
|
+
raise_authorization_server_changed!(pkce.issuer) unless request_still_pending?(pkce)
|
|
367
|
+
|
|
368
|
+
store_token(token)
|
|
369
|
+
|
|
370
|
+
# Clean up this request's temporary data — and only this request's: a
|
|
371
|
+
# flow started meanwhile keeps the records it is waiting on.
|
|
372
|
+
discard_pending_request(pkce)
|
|
373
|
+
end
|
|
158
374
|
|
|
159
375
|
token
|
|
160
376
|
end
|
|
161
377
|
|
|
162
|
-
#
|
|
163
|
-
#
|
|
378
|
+
# Whether the code exchange that just answered is still this resource's:
|
|
379
|
+
# the authorization server it went to is the one in use now (an
|
|
380
|
+
# unresolved or refused challenge means it is unknown, which is not
|
|
381
|
+
# "still A"), and the token slot the response would be written to does
|
|
382
|
+
# not already hold another server's token. A record this client retired
|
|
383
|
+
# is not another server's token to protect — it is what a backend that
|
|
384
|
+
# could not delete left behind — so it does not stand in the way.
|
|
385
|
+
# @param issuer [String] the authorization server the flow started at
|
|
386
|
+
# @return [Boolean]
|
|
387
|
+
def exchange_target_current?(issuer)
|
|
388
|
+
return false unless current_issuer_for_tokens == issuer
|
|
389
|
+
|
|
390
|
+
stored = stored_token_or_nil
|
|
391
|
+
return true unless stored.respond_to?(:issuer) && stored.issuer
|
|
392
|
+
return true if retired_token?(stored)
|
|
393
|
+
|
|
394
|
+
stored.issuer == issuer
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Whether the resource a request was made for is still the one this
|
|
398
|
+
# provider serves. A record made before the resource was recorded says
|
|
399
|
+
# nothing, and is judged by its issuer alone.
|
|
400
|
+
# @param pkce [PKCE] the per-request record
|
|
401
|
+
# @return [Boolean]
|
|
402
|
+
def request_resource_current?(pkce)
|
|
403
|
+
return true unless pkce.respond_to?(:resource) && pkce.resource.is_a?(String)
|
|
404
|
+
|
|
405
|
+
pkce.resource == server_url
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# @param recorded [String] the resource the request was made for
|
|
409
|
+
# @raise [MCPClient::Errors::ConnectionError]
|
|
410
|
+
def raise_resource_changed!(recorded)
|
|
411
|
+
raise MCPClient::Errors::ConnectionError,
|
|
412
|
+
'Authorization response rejected: the resource changed during the flow ' \
|
|
413
|
+
"(the token was issued for #{safe_error_text(recorded)}); restart the authorization"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Delete the pending-flow records of one authorization request, leaving
|
|
417
|
+
# a newer request's records alone.
|
|
418
|
+
# @param pkce [PKCE] the per-request record whose flow just ended
|
|
164
419
|
# @return [void]
|
|
165
|
-
def
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
420
|
+
def discard_pending_request(pkce)
|
|
421
|
+
with_authorization_state_lock do
|
|
422
|
+
pending = stored_pkce
|
|
423
|
+
discard_pending_pkce(pkce) if pending.nil? || same_request?(pending, pkce)
|
|
424
|
+
recorded = pkce.state if pkce.respond_to?(:state)
|
|
425
|
+
stored_state = storage.get_state(server_url)
|
|
426
|
+
discard_pending_state(recorded) if recorded.nil? || stored_state.nil? || stored_state == recorded
|
|
427
|
+
end
|
|
428
|
+
end
|
|
169
429
|
|
|
170
|
-
|
|
171
|
-
|
|
430
|
+
# The authorization state of one resource in one storage backend — the
|
|
431
|
+
# pending-flow records, and the token slot an accepted response is
|
|
432
|
+
# written to — is read, compared and written under one in-process lock.
|
|
433
|
+
# Two windows depend on it. A flow another provider (or thread) starts
|
|
434
|
+
# while a completed flow discards its records waits for the delete
|
|
435
|
+
# instead of losing its records to it; and the checks that accept a
|
|
436
|
+
# token (issuer, resource, pending request, token in use) stay true
|
|
437
|
+
# until that token is stored, so a change of authorization server
|
|
438
|
+
# validated meanwhile cannot have its token written over by a response
|
|
439
|
+
# that passed its checks just before it.
|
|
440
|
+
#
|
|
441
|
+
# The storage interface has no conditional write or delete, and a
|
|
442
|
+
# backend need not answer a delete with the record it removed, so both
|
|
443
|
+
# windows have to be closed on this side. The lock is re-entrant: a flow
|
|
444
|
+
# the SAME thread starts from inside a storage callback is not
|
|
445
|
+
# deadlocked, and falls back to the put-back a record-answering delete
|
|
446
|
+
# allows.
|
|
447
|
+
# @return [Object] the block's value
|
|
448
|
+
def with_authorization_state_lock(&)
|
|
449
|
+
lock = AUTHORIZATION_STATE_LOCKS_GUARD.synchronize do
|
|
450
|
+
(AUTHORIZATION_STATE_LOCKS[storage] ||= {})[server_url] ||= Monitor.new
|
|
451
|
+
end
|
|
452
|
+
lock.synchronize(&)
|
|
172
453
|
end
|
|
173
454
|
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
# MCP 2025-11-25: "Clients MUST treat the scopes provided in the
|
|
189
|
-
# challenge as authoritative for satisfying the current request" —
|
|
190
|
-
# including resetting a previously challenged scope when the current
|
|
191
|
-
# challenge carries none.
|
|
192
|
-
@challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
|
|
193
|
-
|
|
194
|
-
url = extract_resource_metadata_url(www_authenticate)
|
|
195
|
-
return nil unless url
|
|
196
|
-
|
|
197
|
-
# Remember the advertised URL even if the fetch below fails, so a
|
|
198
|
-
# later discovery retries it instead of probing well-known URIs the
|
|
199
|
-
# challenge already superseded.
|
|
200
|
-
@challenge_metadata_url = url
|
|
201
|
-
|
|
202
|
-
# This URL was explicitly advertised by the 401 challenge, so a 404 is a
|
|
203
|
-
# misconfiguration to surface (strict), not a speculative miss to skip.
|
|
204
|
-
metadata = fetch_resource_metadata(url, strict: true)
|
|
205
|
-
# Reuse this challenge-advertised metadata during the subsequent OAuth
|
|
206
|
-
# flow instead of re-deriving (and possibly missing) the well-known URL.
|
|
207
|
-
@challenge_resource_metadata = metadata
|
|
208
|
-
metadata
|
|
455
|
+
# Read, compare, delete: a flow started in between loses its record to
|
|
456
|
+
# the delete. The storage interface has no conditional delete, but a
|
|
457
|
+
# backend that answers the delete with the record it removed (the
|
|
458
|
+
# in-memory one does, as does anything Hash-backed) says whose record
|
|
459
|
+
# went, and a newer flow's is put back — and, held under
|
|
460
|
+
# {#with_authorization_state_lock}, the newer flow cannot start in between at
|
|
461
|
+
# all within one process.
|
|
462
|
+
# @param pkce [PKCE] the record whose flow just ended
|
|
463
|
+
# @return [void]
|
|
464
|
+
def discard_pending_pkce(pkce)
|
|
465
|
+
removed = removed_record(storage.delete_pkce(server_url), PKCE)
|
|
466
|
+
return unless removed.respond_to?(:code_verifier) && !same_request?(removed, pkce)
|
|
467
|
+
|
|
468
|
+
storage.set_pkce(server_url, removed)
|
|
209
469
|
end
|
|
210
470
|
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
# @param header [String] the WWW-Authenticate header value
|
|
217
|
-
# @return [String, nil] the metadata URL if present
|
|
218
|
-
def extract_resource_metadata_url(header)
|
|
219
|
-
params = bearer_challenge_segment(header)
|
|
220
|
-
return nil unless params
|
|
221
|
-
|
|
222
|
-
# Auth-params may include optional whitespace around '=' (RFC 7235).
|
|
223
|
-
# Quoted form: resource_metadata = "https://..."
|
|
224
|
-
if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
|
|
225
|
-
return m[1]
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
# Unquoted token form: resource_metadata = https://...
|
|
229
|
-
if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
|
|
230
|
-
return m[1]
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# Legacy fallback: resource="https://..."
|
|
234
|
-
params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
# Extract the Bearer challenge's own parameter segment from a (possibly
|
|
238
|
-
# multi-challenge) WWW-Authenticate header, so params belonging to other
|
|
239
|
-
# schemes (e.g. `Basic resource_metadata="...", Bearer realm="x"`) are
|
|
240
|
-
# never attributed to the Bearer challenge. Mirrors
|
|
241
|
-
# HttpTransportBase#bearer_challenge_segment.
|
|
242
|
-
# @param header [String, nil] the WWW-Authenticate header value
|
|
243
|
-
# @return [String, nil] the Bearer challenge's parameters (possibly
|
|
244
|
-
# empty), or nil when the header has no Bearer challenge
|
|
245
|
-
def bearer_challenge_segment(header)
|
|
246
|
-
return nil unless header
|
|
247
|
-
|
|
248
|
-
# Locate the Bearer scheme token only OUTSIDE quoted strings: a
|
|
249
|
-
# quoted value such as realm="prefix Bearer x" must not anchor the
|
|
250
|
-
# segment.
|
|
251
|
-
masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
|
|
252
|
-
match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
|
|
253
|
-
return nil unless match
|
|
471
|
+
# @param recorded [String, nil] the state of the request whose flow just ended
|
|
472
|
+
# @return [void]
|
|
473
|
+
def discard_pending_state(recorded)
|
|
474
|
+
removed = storage.delete_state(server_url)
|
|
475
|
+
return unless recorded && removed.is_a?(String) && removed != recorded
|
|
254
476
|
|
|
255
|
-
|
|
477
|
+
storage.set_state(server_url, removed)
|
|
256
478
|
end
|
|
257
479
|
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
# @param
|
|
261
|
-
# @
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
480
|
+
# The record a delete answered with, if it answered with one.
|
|
481
|
+
# @param removed [Object, nil] what the backend returned
|
|
482
|
+
# @param klass [Class] the record class
|
|
483
|
+
# @return [Object, nil]
|
|
484
|
+
def removed_record(removed, klass)
|
|
485
|
+
normalize_record(removed, klass)
|
|
486
|
+
rescue ArgumentError
|
|
487
|
+
nil
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# @param one [PKCE, Object] the record currently in the pending-flow slot
|
|
491
|
+
# @param other [PKCE] the record the flow that just ended was made with
|
|
492
|
+
# @return [Boolean] whether both records describe the same authorization request
|
|
493
|
+
def same_request?(one, other)
|
|
494
|
+
one.respond_to?(:code_verifier) && one.code_verifier == other.code_verifier
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# The state of a callback must be the state of the per-request record
|
|
498
|
+
# the rest of the checks read. A record written by this client always
|
|
499
|
+
# carries one; a record persisted before the state was recorded there
|
|
500
|
+
# carries none, and the separate slot (already compared by the caller)
|
|
501
|
+
# is all there is to go on.
|
|
502
|
+
# @param pkce [PKCE] the per-request record
|
|
503
|
+
# @param state [String, nil] the callback's state parameter
|
|
504
|
+
# @return [void]
|
|
505
|
+
# @raise [MCPClient::Errors::ConnectionError] when the record is another request's
|
|
506
|
+
def ensure_state_for_request!(pkce, state)
|
|
507
|
+
recorded = pkce.state if pkce.respond_to?(:state)
|
|
508
|
+
return if recorded.nil? || recorded == state
|
|
509
|
+
|
|
510
|
+
raise MCPClient::Errors::ConnectionError,
|
|
511
|
+
'Authorization response rejected: the pending authorization request is not the one this ' \
|
|
512
|
+
'response answers; restart the authorization'
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# The stored credentials must be the ones the authorization request
|
|
516
|
+
# was made with; a request that recorded no client cannot be bound to
|
|
517
|
+
# any and fails closed, like one that recorded no issuer.
|
|
518
|
+
# @param client_info [ClientInfo, nil]
|
|
519
|
+
# @param pkce [PKCE]
|
|
520
|
+
# @return [void]
|
|
521
|
+
# @raise [MCPClient::Errors::ConnectionError]
|
|
522
|
+
def ensure_client_for_request!(client_info, pkce)
|
|
523
|
+
unless pkce.respond_to?(:client_id) && pkce.client_id.is_a?(String)
|
|
524
|
+
raise MCPClient::Errors::ConnectionError,
|
|
525
|
+
'Authorization response rejected: no client was recorded for this authorization request; ' \
|
|
526
|
+
'restart the authorization'
|
|
266
527
|
end
|
|
528
|
+
return if client_info && client_for_request?(client_info, pkce)
|
|
529
|
+
|
|
530
|
+
raise MCPClient::Errors::ConnectionError,
|
|
531
|
+
'Authorization response rejected: the client credentials changed during the flow; ' \
|
|
532
|
+
'restart the authorization'
|
|
533
|
+
end
|
|
267
534
|
|
|
268
|
-
|
|
535
|
+
# Whether stored credentials are the ones an authorization request was
|
|
536
|
+
# made with: the recorded client id and, unless portable, bound to the
|
|
537
|
+
# request's authorization server.
|
|
538
|
+
# @param client_info [ClientInfo]
|
|
539
|
+
# @param pkce [PKCE]
|
|
540
|
+
# @return [Boolean]
|
|
541
|
+
def client_for_request?(client_info, pkce)
|
|
542
|
+
return false if pkce.client_id != client_info.client_id
|
|
543
|
+
return true if portable_client?(client_info)
|
|
544
|
+
|
|
545
|
+
# A started flow binds its non-portable client, so an unbound record
|
|
546
|
+
# here was put in storage by someone else meanwhile.
|
|
547
|
+
!client_info.respond_to?(:issuer) || client_info.issuer == pkce.issuer
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Check a success response before anything is shown or exchanged: the
|
|
551
|
+
# state must be the one of the pending flow and the response's `iss`
|
|
552
|
+
# must identify the authorization server the request went to (RFC
|
|
553
|
+
# 9207). {#complete_authorization_flow} repeats the check before the
|
|
554
|
+
# token exchange; a browser callback uses this to answer correctly.
|
|
555
|
+
# @param state [String, nil] the callback's state parameter
|
|
556
|
+
# @param iss [String, nil] the callback's iss parameter
|
|
557
|
+
# @return [void]
|
|
558
|
+
# @raise [MCPClient::Errors::ConnectionError] when the response is not this flow's or the issuer fails
|
|
559
|
+
def validate_authorization_response!(state, iss: nil)
|
|
560
|
+
stored_state = storage.get_state(server_url)
|
|
561
|
+
unless stored_state && stored_state == state
|
|
562
|
+
raise MCPClient::Errors::ConnectionError, 'Authorization response rejected: state mismatch'
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
pkce = stored_pkce
|
|
566
|
+
unless pkce.respond_to?(:issuer) && pkce.issuer.is_a?(String)
|
|
567
|
+
raise MCPClient::Errors::ConnectionError,
|
|
568
|
+
'Authorization response rejected: no issuer was recorded for this authorization request'
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
ensure_state_for_request!(pkce, state)
|
|
572
|
+
cached = stored_server_metadata
|
|
573
|
+
ensure_authorization_server_unchanged!(pkce, cached)
|
|
574
|
+
ensure_client_for_request!(stored_client_info, pkce)
|
|
575
|
+
validate_authorization_response_issuer!(iss, pkce.issuer, iss_advertised_for_response?(pkce, cached))
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# The message to surface for an authorization *error* response, after
|
|
579
|
+
# the same RFC 9207 issuer check as a success response: "on mismatch
|
|
580
|
+
# the client MUST NOT act on or display error, error_description, or
|
|
581
|
+
# error_uri" (MCP 2026-07-28 "Authorization Response Validation").
|
|
582
|
+
# @param params [Hash] the callback parameters (error, error_description, iss, state, ...)
|
|
583
|
+
# @return [String] the error text to show
|
|
584
|
+
# @raise [MCPClient::Errors::ConnectionError] when the response's issuer does not check out
|
|
585
|
+
def authorization_error_message(params)
|
|
586
|
+
params = params.to_h.transform_keys(&:to_s)
|
|
587
|
+
# Every started flow records a state, so a response that cannot be
|
|
588
|
+
# matched to one is not this client's to act on.
|
|
589
|
+
stored_state = storage.get_state(server_url)
|
|
590
|
+
if stored_state.nil? || params['state'] != stored_state
|
|
591
|
+
raise MCPClient::Errors::ConnectionError, 'Authorization error response rejected: state mismatch'
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
pkce = stored_pkce
|
|
595
|
+
# The per-request record must be the record of THIS response, as the
|
|
596
|
+
# success path requires: the separate state slot and the record can
|
|
597
|
+
# be torn apart by two flows sharing a storage backend, and a state
|
|
598
|
+
# that names another request's record makes every check below — the
|
|
599
|
+
# authorization server, the `iss` — a check about that other request.
|
|
600
|
+
# An error response would then be displayed after passing an issuer
|
|
601
|
+
# comparison it never had to satisfy.
|
|
602
|
+
ensure_state_for_request!(pkce, params['state']) if pkce
|
|
603
|
+
cached = stored_server_metadata
|
|
604
|
+
# The same checks the success path makes: an error response of
|
|
605
|
+
# authorization server A is not displayed once a challenge received
|
|
606
|
+
# during the flow — or shared storage — moved the flow to B. Without a
|
|
607
|
+
# recorded issuer there is nothing to compare, and the issuer check
|
|
608
|
+
# below rejects the response outright.
|
|
609
|
+
ensure_authorization_server_unchanged!(pkce, cached) if pkce.respond_to?(:issuer) && pkce.issuer.is_a?(String)
|
|
610
|
+
# Only the request's own authorization server can say whether iss is
|
|
611
|
+
# expected: a cache that names another server is no guide.
|
|
612
|
+
cached = nil unless pkce && cached && cached.issuer == pkce.issuer
|
|
613
|
+
validate_authorization_response_issuer!(params['iss'], pkce&.issuer, iss_advertised_for_response?(pkce, cached))
|
|
614
|
+
safe_error_text((params['error_description'] || params['error'] || 'unknown error').to_s).strip
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Apply OAuth authorization to HTTP request
|
|
618
|
+
# @param request [Faraday::Request] HTTP request to authorize
|
|
619
|
+
# @return [void]
|
|
620
|
+
def apply_authorization(request)
|
|
621
|
+
token = access_token
|
|
622
|
+
logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
|
|
623
|
+
# A record without access token bytes is never presented: a bare
|
|
624
|
+
# "Bearer " is not a credential (RFC 6749 Section 5.1).
|
|
625
|
+
return unless token_bytes?(token)
|
|
626
|
+
|
|
627
|
+
# The header's value is the credential: not a prefix of it, not a
|
|
628
|
+
# truncation of it. It is never written to a log at any level.
|
|
629
|
+
logger.debug('OAuth applying authorization header')
|
|
630
|
+
request.headers['Authorization'] = token.to_header
|
|
269
631
|
end
|
|
270
632
|
|
|
271
633
|
# Scope requested by the most recent WWW-Authenticate challenge.
|
|
@@ -274,27 +636,123 @@ module MCPClient
|
|
|
274
636
|
|
|
275
637
|
private
|
|
276
638
|
|
|
277
|
-
#
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
|
|
284
|
-
|
|
639
|
+
# A challenge received during the flow — refused, still to be fetched,
|
|
640
|
+
# or naming another authorization server — or cached metadata for
|
|
641
|
+
# another server ends the flow here. Applied to a success response and,
|
|
642
|
+
# equally, to an error response: "the client MUST NOT act on or display
|
|
643
|
+
# error, error_description, or error_uri" is worth nothing if the text
|
|
644
|
+
# of authorization server A is shown after the flow moved to B.
|
|
645
|
+
# @param pkce [PKCE] the record of the authorization request
|
|
646
|
+
# @param cached [ServerMetadata, nil] the metadata currently in storage, if any
|
|
647
|
+
# @return [void]
|
|
648
|
+
# @raise [MCPClient::Errors::ConnectionError]
|
|
649
|
+
def ensure_authorization_server_unchanged!(pkce, cached)
|
|
650
|
+
if @challenge_error || (@challenge_metadata_url && @challenge_resource_metadata.nil?)
|
|
651
|
+
raise MCPClient::Errors::ConnectionError,
|
|
652
|
+
'Authorization response rejected: a challenge received during the flow must be resolved first; ' \
|
|
653
|
+
'restart the authorization'
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
advertised = Array(@challenge_resource_metadata&.authorization_servers).first
|
|
657
|
+
return unless (advertised && advertised != pkce.issuer) || (cached && cached.issuer != pkce.issuer)
|
|
285
658
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
659
|
+
raise MCPClient::Errors::ConnectionError,
|
|
660
|
+
'Authorization response rejected: the authorization server changed during the flow; ' \
|
|
661
|
+
'restart the authorization'
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# RFC 9207 Section 2.4 as applied by MCP 2026-07-28: a present `iss`
|
|
665
|
+
# must equal the recorded issuer byte for byte (no scheme/host case
|
|
666
|
+
# folding, default-port elision, trailing-slash or percent-encoding
|
|
667
|
+
# normalization); an absent `iss` is rejected when the authorization
|
|
668
|
+
# server advertises the parameter. Without a recorded issuer nothing
|
|
669
|
+
# can be validated, so a present `iss` is rejected (fail closed).
|
|
670
|
+
# @param iss [String, nil] the response's iss parameter
|
|
671
|
+
# @param expected [String, nil] the issuer recorded when the flow started
|
|
672
|
+
# @param supported [Boolean] whether the request's authorization server advertises iss
|
|
673
|
+
# @return [void]
|
|
674
|
+
# @raise [MCPClient::Errors::ConnectionError]
|
|
675
|
+
def validate_authorization_response_issuer!(iss, expected, supported)
|
|
676
|
+
unless expected.is_a?(String)
|
|
677
|
+
raise MCPClient::Errors::ConnectionError,
|
|
678
|
+
'Authorization response rejected: no issuer was recorded for this authorization request, ' \
|
|
679
|
+
'so it cannot be bound to an authorization server; restart the authorization'
|
|
291
680
|
end
|
|
681
|
+
if iss.nil?
|
|
682
|
+
return unless supported
|
|
292
683
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
684
|
+
raise MCPClient::Errors::ConnectionError,
|
|
685
|
+
'Authorization response rejected: the authorization server advertises the iss parameter ' \
|
|
686
|
+
'(authorization_response_iss_parameter_supported) but the response carries none'
|
|
687
|
+
end
|
|
688
|
+
return if iss.to_s == expected
|
|
296
689
|
|
|
297
|
-
|
|
690
|
+
raise MCPClient::Errors::ConnectionError,
|
|
691
|
+
"Authorization response rejected: issuer mismatch (expected #{safe_error_text(expected)})"
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Whether the authorization server of a request advertised the iss
|
|
695
|
+
# response parameter: recorded with the PKCE record; for a record
|
|
696
|
+
# persisted before that field existed, the metadata of the same
|
|
697
|
+
# issuer decides.
|
|
698
|
+
# @return [Boolean]
|
|
699
|
+
def iss_parameter_supported_for?(pkce, server_metadata)
|
|
700
|
+
recorded = pkce&.iss_parameter_supported
|
|
701
|
+
return recorded == true unless recorded.nil?
|
|
702
|
+
|
|
703
|
+
server_metadata&.iss_parameter_supported? == true
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# The same question for a check made BEFORE the token exchange (the
|
|
707
|
+
# browser callback precheck and the error-response path), which reads
|
|
708
|
+
# cached metadata rather than freshly discovered metadata. Metadata
|
|
709
|
+
# persisted before this client read RFC 9207 records no answer, and
|
|
710
|
+
# reading that silence as "not supported" would show a success page (or
|
|
711
|
+
# the peer's error text) for a response {#complete_authorization_flow}
|
|
712
|
+
# then rejects. So when neither the PKCE record nor the cache carries an
|
|
713
|
+
# answer, the answer is rediscovered exactly as the completion does; an
|
|
714
|
+
# answer that cannot be obtained is unknown, not "no", and the
|
|
715
|
+
# advertisement is assumed (fail closed) so a missing `iss` is refused.
|
|
716
|
+
# @param pkce [PKCE, nil] the record of the authorization request
|
|
717
|
+
# @param cached [ServerMetadata, nil] cached metadata of the same issuer, if any
|
|
718
|
+
# @return [Boolean] whether the request's authorization server advertises iss
|
|
719
|
+
# @raise [MCPClient::Errors::ConnectionError] when rediscovery names another authorization server
|
|
720
|
+
def iss_advertised_for_response?(pkce, cached)
|
|
721
|
+
recorded = pkce&.iss_parameter_supported
|
|
722
|
+
return recorded == true unless recorded.nil?
|
|
723
|
+
return cached.iss_parameter_supported? if cached&.iss_parameter_recorded?
|
|
724
|
+
# Without a recorded issuer nothing can be validated at all; the
|
|
725
|
+
# caller rejects the response, so no discovery is worth making.
|
|
726
|
+
return false unless pkce.respond_to?(:issuer) && pkce.issuer.is_a?(String)
|
|
727
|
+
|
|
728
|
+
rediscovered = begin
|
|
729
|
+
discover_authorization_server
|
|
730
|
+
rescue MCPClient::Errors::ConnectionError
|
|
731
|
+
nil
|
|
732
|
+
end
|
|
733
|
+
# An answer that could not be obtained is unknown, not "no".
|
|
734
|
+
return true if rediscovered.nil?
|
|
735
|
+
|
|
736
|
+
# A rediscovery naming ANOTHER authorization server says nothing about
|
|
737
|
+
# this request's `iss`: it is the very "the authorization server
|
|
738
|
+
# changed during the flow" that {#complete_authorization_flow} rejects.
|
|
739
|
+
# Reducing it to "iss is advertised" would let a callback carrying the
|
|
740
|
+
# recorded issuer pass this check, and a browser flow would show a
|
|
741
|
+
# success page for a response the completion then refuses.
|
|
742
|
+
raise_authorization_server_changed!(pkce.issuer) unless rediscovered.issuer == pkce.issuer
|
|
743
|
+
|
|
744
|
+
rediscovered.iss_parameter_supported?
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# The authorization server of a pending flow is no longer the one the
|
|
748
|
+
# request went to, so the code can never be redeemed: end the flow.
|
|
749
|
+
# @param recorded [String] the issuer recorded when the flow started
|
|
750
|
+
# @return [void]
|
|
751
|
+
# @raise [MCPClient::Errors::ConnectionError]
|
|
752
|
+
def raise_authorization_server_changed!(recorded)
|
|
753
|
+
raise MCPClient::Errors::ConnectionError,
|
|
754
|
+
'Authorization response rejected: the authorization server changed during the flow ' \
|
|
755
|
+
"(recorded #{safe_error_text(recorded)}); restart the authorization"
|
|
298
756
|
end
|
|
299
757
|
|
|
300
758
|
# Normalize server URL to canonical form
|
|
@@ -414,27 +872,84 @@ module MCPClient
|
|
|
414
872
|
# @return [ServerMetadata] Authorization server metadata
|
|
415
873
|
# @raise [MCPClient::Errors::ConnectionError] if discovery fails
|
|
416
874
|
def discover_authorization_server
|
|
875
|
+
# Another provider sharing the storage may have moved this resource to
|
|
876
|
+
# another authorization server since this one last resolved anything.
|
|
877
|
+
forget_foreign_server_switch
|
|
878
|
+
# A CHALLENGE we refused is still authoritative: it says the cached
|
|
879
|
+
# authorization server is no longer the right one. Falling back to
|
|
880
|
+
# that cache (or to speculative well-known probing) would quietly
|
|
881
|
+
# undo the rejection, so surface it instead. Only a challenge sets
|
|
882
|
+
# this: a refused well-known document leaves nothing latched and is
|
|
883
|
+
# fetched again below.
|
|
884
|
+
raise MCPClient::Errors::ConnectionError, @challenge_error if @challenge_error
|
|
885
|
+
|
|
417
886
|
# A fresh 401 challenge is authoritative and overrides any cached
|
|
418
887
|
# (possibly stale or direct-discovered) authorization server metadata —
|
|
419
888
|
# whether the challenge-advertised PRM was already fetched or only its
|
|
420
889
|
# URL is pending (e.g. the initial fetch failed and must be retried).
|
|
421
890
|
challenge_pending = @challenge_resource_metadata || @challenge_metadata_url
|
|
422
|
-
cached =
|
|
891
|
+
cached = stored_server_metadata unless challenge_pending || @rediscover_after_switch
|
|
423
892
|
if cached
|
|
424
893
|
# Validate the cached entry before use so a persisted/older cache with
|
|
425
894
|
# an HTTP endpoint or without S256 is still rejected.
|
|
426
895
|
validate_server_metadata!(cached)
|
|
427
|
-
|
|
896
|
+
# A record persisted before this client read RFC 9207's
|
|
897
|
+
# authorization_response_iss_parameter_supported says nothing about
|
|
898
|
+
# the parameter. Treating that silence as "not supported" would
|
|
899
|
+
# accept an authorization response without `iss` from a server that
|
|
900
|
+
# advertises it, so the answer is rediscovered rather than assumed.
|
|
901
|
+
return note_state_issuer(cached) if cached.iss_parameter_recorded?
|
|
902
|
+
|
|
903
|
+
logger.debug('Cached authorization server metadata predates the iss parameter record; rediscovering')
|
|
428
904
|
end
|
|
429
905
|
|
|
430
|
-
discover_and_cache_authorization_server
|
|
906
|
+
note_state_issuer(discover_and_cache_authorization_server)
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# Drop the issuer-dependent state this provider resolved against an
|
|
910
|
+
# authorization server that another provider sharing the storage has
|
|
911
|
+
# since replaced. The switch itself was made there — the token retired,
|
|
912
|
+
# the pending requests ended — but the scope state cached HERE
|
|
913
|
+
# (the resource metadata that advertised the previous server's scopes,
|
|
914
|
+
# the scopes it supported, the scope this provider last asked it for)
|
|
915
|
+
# would otherwise be carried into a request to the new server: asking B
|
|
916
|
+
# for A's scopes, which B may refuse outright or answer with permissions
|
|
917
|
+
# that do not cover the operation.
|
|
918
|
+
# @return [void]
|
|
919
|
+
def forget_foreign_server_switch
|
|
920
|
+
return unless @state_issuer
|
|
921
|
+
|
|
922
|
+
current = stored_server_metadata&.issuer
|
|
923
|
+
return if current.nil? || current == @state_issuer
|
|
924
|
+
|
|
925
|
+
logger.debug('The authorization server changed in shared storage; discarding the scopes of the previous one')
|
|
926
|
+
@state_issuer = nil
|
|
927
|
+
@supported_scopes = nil
|
|
928
|
+
@resource_metadata = nil
|
|
929
|
+
@requested_scope = nil
|
|
930
|
+
# What the new server advertises for this resource has never been read
|
|
931
|
+
# here: the cached entry another provider wrote says which server it
|
|
932
|
+
# is, not which scopes it offers. One rediscovery pass fetches the
|
|
933
|
+
# protected resource metadata again, so the next request asks for
|
|
934
|
+
# scopes this server actually advertises rather than for none.
|
|
935
|
+
@rediscover_after_switch = true
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# Note the authorization server the state resolved here belongs to, so a
|
|
939
|
+
# change another provider makes in shared storage is recognised.
|
|
940
|
+
# @param metadata [ServerMetadata, nil] the metadata this resolution settled on
|
|
941
|
+
# @return [ServerMetadata, nil] the metadata, unchanged
|
|
942
|
+
def note_state_issuer(metadata)
|
|
943
|
+
@state_issuer = metadata.issuer if metadata.respond_to?(:issuer) && metadata.issuer
|
|
944
|
+
@rediscover_after_switch = nil
|
|
945
|
+
metadata
|
|
431
946
|
end
|
|
432
947
|
|
|
433
948
|
# Discover authorization server metadata, validate it, and cache it.
|
|
434
949
|
# @return [ServerMetadata]
|
|
435
950
|
# @raise [MCPClient::Errors::ConnectionError] if discovery or validation fails
|
|
436
951
|
def discover_and_cache_authorization_server
|
|
437
|
-
previous =
|
|
952
|
+
previous = stored_server_metadata
|
|
438
953
|
|
|
439
954
|
# RFC 9728: Protected Resource Metadata is authoritative — try it first,
|
|
440
955
|
# then fall back to treating the MCP server origin as its own AS.
|
|
@@ -448,14 +963,52 @@ module MCPClient
|
|
|
448
963
|
# Validate BEFORE caching so invalid metadata is never persisted.
|
|
449
964
|
validate_server_metadata!(server_metadata)
|
|
450
965
|
|
|
451
|
-
|
|
966
|
+
if previous
|
|
967
|
+
invalidate_client_info_on_as_change(previous, server_metadata)
|
|
968
|
+
else
|
|
969
|
+
retire_records_without_issuer
|
|
970
|
+
end
|
|
452
971
|
|
|
453
972
|
storage.set_server_metadata(server_url, server_metadata)
|
|
973
|
+
# Remembered in-process as well: a backend that does not persist
|
|
974
|
+
# metadata would otherwise never know the current issuer.
|
|
975
|
+
@discovered_server_metadata = server_metadata
|
|
454
976
|
@challenge_resource_metadata = nil # consumed
|
|
455
977
|
@challenge_metadata_url = nil # consumed
|
|
978
|
+
@challenge_error = nil
|
|
456
979
|
server_metadata
|
|
457
980
|
end
|
|
458
981
|
|
|
982
|
+
# Records persisted before issuers were recorded, with no cached
|
|
983
|
+
# authorization server to prove where they came from, cannot be bound
|
|
984
|
+
# to whatever discovery finds now: a dynamic registration (with its
|
|
985
|
+
# secret) and a token are retired so the flow re-registers and
|
|
986
|
+
# re-authorizes. A portable Client ID Metadata Document id is valid at
|
|
987
|
+
# every authorization server, so it needs no binding; credentials the
|
|
988
|
+
# host pre-registered are kept — this client cannot re-create them —
|
|
989
|
+
# but stay unbound until the host says which authorization server
|
|
990
|
+
# issued them (see RegistrationStore#unbound_static?).
|
|
991
|
+
# @return [void]
|
|
992
|
+
def retire_records_without_issuer
|
|
993
|
+
token = stored_token
|
|
994
|
+
delete_token(bind_to: Token::RETIRED_ISSUER) if token.respond_to?(:issuer) && token.issuer.nil?
|
|
995
|
+
|
|
996
|
+
client_info = stored_client_info
|
|
997
|
+
return unless client_info.respond_to?(:issuer) && client_info.issuer.nil?
|
|
998
|
+
return if portable_client?(client_info) || resolved_registration_type(client_info) != 'dynamic'
|
|
999
|
+
|
|
1000
|
+
logger.debug('Discarding a dynamic OAuth client registration whose authorization server is unknown')
|
|
1001
|
+
# Stamped as retired first, so a backend that cannot delete still
|
|
1002
|
+
# leaves a record no authorization server matches.
|
|
1003
|
+
begin
|
|
1004
|
+
storage.set_client_info(server_url, client_info.with_issuer(Token::RETIRED_ISSUER,
|
|
1005
|
+
registration_type: 'dynamic'))
|
|
1006
|
+
rescue StandardError => e
|
|
1007
|
+
logger.debug("The stale OAuth client registration could not be re-stored as retired (#{e.class})")
|
|
1008
|
+
end
|
|
1009
|
+
delete_client_info
|
|
1010
|
+
end
|
|
1011
|
+
|
|
459
1012
|
# When a 401 challenge changes the authorization server, per-AS state cached
|
|
460
1013
|
# under this server_url becomes invalid: a client_id registered with the
|
|
461
1014
|
# previous AS would fail as invalid_client, and memoized scopes belong to
|
|
@@ -465,17 +1018,63 @@ module MCPClient
|
|
|
465
1018
|
def invalidate_client_info_on_as_change(previous, current)
|
|
466
1019
|
return unless previous && previous.issuer != current.issuer
|
|
467
1020
|
|
|
468
|
-
logger.debug('Authorization server changed; discarding
|
|
1021
|
+
logger.debug('Authorization server changed; discarding the token and scopes from the previous AS')
|
|
469
1022
|
|
|
470
|
-
#
|
|
471
|
-
#
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1023
|
+
# Registration state is per authorization server: a token from the
|
|
1024
|
+
# previous one is not valid for the new one. Credentials stored
|
|
1025
|
+
# without a binding belonged to the previous server, so they are
|
|
1026
|
+
# bound to it first; then they are kept only when bound
|
|
1027
|
+
# (pre-registered, so the mismatch can be reported) or portable
|
|
1028
|
+
# (Client ID Metadata Document), and a dynamic registration is
|
|
1029
|
+
# discarded so the next flow re-registers.
|
|
1030
|
+
@authorization_server_switched = true
|
|
1031
|
+
@supported_scopes = nil
|
|
1032
|
+
# What was requested of the previous authorization server is not a
|
|
1033
|
+
# permission the new one ever granted: the step-up union starts again
|
|
1034
|
+
# rather than asking B for A's scopes.
|
|
1035
|
+
@requested_scope = nil
|
|
1036
|
+
# Records another provider sharing the storage already bound to the
|
|
1037
|
+
# new server are its: only unbound ones and those of the previous
|
|
1038
|
+
# server are affected.
|
|
1039
|
+
withdraw_token(previous.issuer) unless record_bound_to?(stored_token_or_nil, current.issuer)
|
|
1040
|
+
client_info = stored_client_info
|
|
1041
|
+
return if record_bound_to?(client_info, current.issuer)
|
|
1042
|
+
|
|
1043
|
+
# An unbound record this client made is one it made with the previous
|
|
1044
|
+
# server; credentials the HOST pre-registered are not, and are left
|
|
1045
|
+
# unbound rather than attributed to a server that may never have
|
|
1046
|
+
# issued them — attributing them would file them under that server's
|
|
1047
|
+
# own key, and a later flow there would send their secret to it.
|
|
1048
|
+
if client_info.respond_to?(:issuer) && client_info.issuer.nil? &&
|
|
1049
|
+
!portable_client?(client_info) && !unbound_static?(client_info)
|
|
1050
|
+
client_info = client_info.with_issuer(previous.issuer,
|
|
1051
|
+
registration_type: resolved_registration_type(client_info))
|
|
1052
|
+
store_client_info(client_info)
|
|
476
1053
|
end
|
|
1054
|
+
keep = client_info.respond_to?(:portable?) && (portable_client?(client_info) || client_info.pre_registered?)
|
|
1055
|
+
return if keep
|
|
1056
|
+
|
|
1057
|
+
# The registration itself is still valid at the server that made it,
|
|
1058
|
+
# so it is kept under that server's own key; only the answer to
|
|
1059
|
+
# "which credentials does this resource use now" is discarded.
|
|
1060
|
+
preserve_client_registration(client_info)
|
|
1061
|
+
delete_client_info
|
|
1062
|
+
end
|
|
477
1063
|
|
|
478
|
-
|
|
1064
|
+
# @param record [Token, ClientInfo, Object, nil]
|
|
1065
|
+
# @param issuer [String]
|
|
1066
|
+
# @return [Boolean] whether the record says it belongs to that issuer
|
|
1067
|
+
def record_bound_to?(record, issuer)
|
|
1068
|
+
record.respond_to?(:issuer) && record.issuer == issuer
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# The stored token, or nil when the backend cannot even be asked
|
|
1072
|
+
# (a minimal backend without get_token, tolerated as before).
|
|
1073
|
+
# @return [Token, nil]
|
|
1074
|
+
def stored_token_or_nil
|
|
1075
|
+
stored_token
|
|
1076
|
+
rescue StandardError
|
|
1077
|
+
nil
|
|
479
1078
|
end
|
|
480
1079
|
|
|
481
1080
|
# Apply the PKCE-support and HTTPS-endpoint checks to server metadata.
|
|
@@ -498,15 +1097,38 @@ module MCPClient
|
|
|
498
1097
|
resource_metadata = challenge_or_well_known_resource_metadata
|
|
499
1098
|
return nil unless resource_metadata
|
|
500
1099
|
|
|
501
|
-
|
|
1100
|
+
# A document that is not this resource's — or that names no
|
|
1101
|
+
# authorization server — is rejected whole, and the copy
|
|
1102
|
+
# {#fetch_resource_metadata} kept for scope resolution goes with it:
|
|
1103
|
+
# otherwise a later flow (the path 404s, the origin is its own
|
|
1104
|
+
# authorization server) would request the scopes of a document this
|
|
1105
|
+
# discovery refused, exactly the leftover already closed for a refused
|
|
1106
|
+
# authorization server URL.
|
|
1107
|
+
begin
|
|
1108
|
+
validate_resource_matches!(resource_metadata)
|
|
1109
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
1110
|
+
refuse_resource_metadata!(e.message)
|
|
1111
|
+
end
|
|
502
1112
|
|
|
503
1113
|
auth_server_url = Array(resource_metadata.authorization_servers).first
|
|
504
1114
|
unless auth_server_url
|
|
505
|
-
|
|
506
|
-
'Protected resource metadata does not advertise any authorization_servers'
|
|
1115
|
+
refuse_resource_metadata!('Protected resource metadata does not advertise any authorization_servers')
|
|
507
1116
|
end
|
|
508
1117
|
|
|
509
|
-
|
|
1118
|
+
# authorization_servers is untrusted PRM content: validate the
|
|
1119
|
+
# advertised origin BEFORE constructing and fetching well-known URLs
|
|
1120
|
+
# on it, so a malicious protected resource cannot drive discovery GETs
|
|
1121
|
+
# against internal services (SSRF).
|
|
1122
|
+
# Only a challenge-advertised document is authoritative enough for a
|
|
1123
|
+
# refusal to latch: a speculative well-known document that is refused
|
|
1124
|
+
# now must be fetched again by the next discovery, so a server the
|
|
1125
|
+
# operator fixes is not unreachable for the life of this provider.
|
|
1126
|
+
validate_peer_advertised_url!(auth_server_url,
|
|
1127
|
+
'authorization server (advertised by protected resource metadata)',
|
|
1128
|
+
latch: challenge_advertised_metadata?)
|
|
1129
|
+
|
|
1130
|
+
server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url),
|
|
1131
|
+
auth_server_url)
|
|
510
1132
|
unless server_metadata
|
|
511
1133
|
raise MCPClient::Errors::ConnectionError,
|
|
512
1134
|
"Authorization server advertised by protected resource metadata (#{auth_server_url}) " \
|
|
@@ -540,7 +1162,7 @@ module MCPClient
|
|
|
540
1162
|
# @return [ServerMetadata, nil]
|
|
541
1163
|
def discover_via_direct_authorization_server
|
|
542
1164
|
origin = origin_of(URI.parse(server_url))
|
|
543
|
-
fetch_first_server_metadata(authorization_server_metadata_urls(origin))
|
|
1165
|
+
fetch_first_server_metadata(authorization_server_metadata_urls(origin), origin)
|
|
544
1166
|
end
|
|
545
1167
|
|
|
546
1168
|
# Fetch the first Protected Resource Metadata document that resolves.
|
|
@@ -563,21 +1185,57 @@ module MCPClient
|
|
|
563
1185
|
# Fetch the first Authorization Server Metadata document that resolves.
|
|
564
1186
|
# The oauth-authorization-server and openid-configuration forms are
|
|
565
1187
|
# genuine alternatives, so any failing candidate is skipped to try the next.
|
|
566
|
-
# @param urls [Array<String>]
|
|
1188
|
+
# @param urls [Array<String>] well-known candidates
|
|
1189
|
+
# @param issuer [String] the issuer identifier the candidates were built from
|
|
567
1190
|
# @return [ServerMetadata, nil]
|
|
568
|
-
def fetch_first_server_metadata(urls)
|
|
1191
|
+
def fetch_first_server_metadata(urls, issuer)
|
|
1192
|
+
rejected = nil
|
|
569
1193
|
urls.each do |url|
|
|
570
1194
|
md = try_fetch_server_metadata(url)
|
|
571
|
-
|
|
1195
|
+
next unless md
|
|
1196
|
+
|
|
1197
|
+
begin
|
|
1198
|
+
validate_metadata_issuer!(md, issuer)
|
|
1199
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
1200
|
+
# Not this candidate: the next well-known location may hold the
|
|
1201
|
+
# document for the issuer actually asked for.
|
|
1202
|
+
logger.debug("Authorization server metadata candidate rejected (#{safe_error_text(url.to_s)}): " \
|
|
1203
|
+
"#{e.message}")
|
|
1204
|
+
rejected = e
|
|
1205
|
+
next
|
|
1206
|
+
end
|
|
1207
|
+
return md
|
|
572
1208
|
end
|
|
1209
|
+
# Only mismatching documents were found: say so rather than "nothing".
|
|
1210
|
+
raise rejected if rejected
|
|
1211
|
+
|
|
573
1212
|
nil
|
|
574
1213
|
end
|
|
575
1214
|
|
|
1215
|
+
# RFC 8414 Section 3.3 / OpenID Connect Discovery 4.3 (MCP 2026-07-28
|
|
1216
|
+
# "Authorization Server Metadata Discovery"): "the issuer value in the
|
|
1217
|
+
# document MUST be identical to the issuer identifier used to construct
|
|
1218
|
+
# the well-known URL. If they differ, the client MUST NOT use the
|
|
1219
|
+
# metadata." The issuer is the trust anchor of the RFC 9207 check, so a
|
|
1220
|
+
# document naming another issuer is rejected outright.
|
|
1221
|
+
# @param metadata [ServerMetadata]
|
|
1222
|
+
# @param issuer [String]
|
|
1223
|
+
# @raise [MCPClient::Errors::ConnectionError]
|
|
1224
|
+
def validate_metadata_issuer!(metadata, issuer)
|
|
1225
|
+
# Byte-for-byte (RFC 8414 Section 4): no case folding, no slash or
|
|
1226
|
+
# port normalization.
|
|
1227
|
+
return if metadata.issuer.is_a?(String) && metadata.issuer == issuer.to_s
|
|
1228
|
+
|
|
1229
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1230
|
+
"Authorization server metadata rejected: its issuer #{safe_error_text(metadata.issuer.to_s).inspect} " \
|
|
1231
|
+
"is not the identifier it was fetched for (#{safe_error_text(issuer.to_s)})"
|
|
1232
|
+
end
|
|
1233
|
+
|
|
576
1234
|
# Non-raising server-metadata fetch used while iterating candidates.
|
|
577
1235
|
def try_fetch_server_metadata(url)
|
|
578
1236
|
fetch_server_metadata(url)
|
|
579
1237
|
rescue MCPClient::Errors::ConnectionError => e
|
|
580
|
-
logger.debug("Authorization server metadata candidate failed (#{url}): #{e.message}")
|
|
1238
|
+
logger.debug("Authorization server metadata candidate failed (#{safe_error_text(url.to_s)}): #{e.message}")
|
|
581
1239
|
nil
|
|
582
1240
|
end
|
|
583
1241
|
|
|
@@ -594,6 +1252,14 @@ module MCPClient
|
|
|
594
1252
|
raise MCPClient::Errors::ConnectionError,
|
|
595
1253
|
'Authorization server metadata omits code_challenge_methods_supported; ' \
|
|
596
1254
|
'the server does not support PKCE and the client must refuse to proceed'
|
|
1255
|
+
elsif !methods.is_a?(Array)
|
|
1256
|
+
# A String answers `include?('S256')` for "S256 not supported": read
|
|
1257
|
+
# that way, a server with no PKCE at all would pass this check. The
|
|
1258
|
+
# wire path refuses such a document outright; a record read back
|
|
1259
|
+
# from a hash-persisting storage backend arrives here instead.
|
|
1260
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1261
|
+
'Authorization server metadata code_challenge_methods_supported is not an array of strings ' \
|
|
1262
|
+
'(RFC 8414); PKCE support cannot be established and the client must refuse to proceed'
|
|
597
1263
|
elsif !methods.include?('S256')
|
|
598
1264
|
raise MCPClient::Errors::ConnectionError,
|
|
599
1265
|
'Authorization server does not support PKCE S256 ' \
|
|
@@ -601,8 +1267,8 @@ module MCPClient
|
|
|
601
1267
|
end
|
|
602
1268
|
end
|
|
603
1269
|
|
|
604
|
-
# Require HTTPS for all discovered authorization server endpoints, with
|
|
605
|
-
#
|
|
1270
|
+
# Require HTTPS for all discovered authorization server endpoints, with
|
|
1271
|
+
# the local-development exception for a local stack.
|
|
606
1272
|
# @param server_metadata [ServerMetadata]
|
|
607
1273
|
def enforce_https_endpoints!(server_metadata)
|
|
608
1274
|
enforce_https!(server_metadata.authorization_endpoint, 'authorization endpoint')
|
|
@@ -612,22 +1278,34 @@ module MCPClient
|
|
|
612
1278
|
enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
|
|
613
1279
|
end
|
|
614
1280
|
|
|
1281
|
+
# An endpoint discovered in authorization server metadata is as
|
|
1282
|
+
# peer-controlled as a URL a challenge or a protected resource document
|
|
1283
|
+
# advertises — the code, the verifier and the client id are POSTed to
|
|
1284
|
+
# the token endpoint — so it is classified by exactly the same rules:
|
|
1285
|
+
# HTTPS unless this is a local stack (a loopback target advertised to a
|
|
1286
|
+
# client whose configured server is loopback too), never a loopback,
|
|
1287
|
+
# private or link-local address otherwise, and always a host a resolver
|
|
1288
|
+
# could look up. Without that, metadata from any public authorization
|
|
1289
|
+
# server could name `http://app.localhost:3000/steal` or an internal
|
|
1290
|
+
# address and collect the authorization code there. The refusal is not
|
|
1291
|
+
# latched: only a 401 challenge is authoritative enough for that.
|
|
615
1292
|
# @param url [String, nil] endpoint URL
|
|
616
1293
|
# @param label [String] human-readable endpoint name for errors
|
|
617
|
-
# @raise [MCPClient::Errors::ConnectionError] if the URL is not
|
|
1294
|
+
# @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
|
|
618
1295
|
def enforce_https!(url, label)
|
|
619
1296
|
return if url.nil?
|
|
620
1297
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
# Dev exception is only for plain HTTP on a loopback host — not any other
|
|
624
|
-
# scheme (e.g. ftp://localhost). Use #hostname (not #host) so an IPv6
|
|
625
|
-
# loopback like http://[::1]:9292 matches without the surrounding brackets.
|
|
626
|
-
return if uri.scheme == 'http' && %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
|
|
1298
|
+
validate_peer_advertised_url!(url, label, latch: false)
|
|
1299
|
+
end
|
|
627
1300
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1301
|
+
# Forget the protected resource document and refuse: a document rejected
|
|
1302
|
+
# as not this resource's must not go on supplying scopes.
|
|
1303
|
+
# @param message [String] why the document was refused
|
|
1304
|
+
# @return [void]
|
|
1305
|
+
# @raise [MCPClient::Errors::ConnectionError] always
|
|
1306
|
+
def refuse_resource_metadata!(message)
|
|
1307
|
+
@resource_metadata = nil
|
|
1308
|
+
raise MCPClient::Errors::ConnectionError, message
|
|
631
1309
|
end
|
|
632
1310
|
|
|
633
1311
|
# Validate the PRM `resource` identifies this server (RFC 9728 confused
|
|
@@ -651,7 +1329,7 @@ module MCPClient
|
|
|
651
1329
|
return if advertised == expected
|
|
652
1330
|
|
|
653
1331
|
raise MCPClient::Errors::ConnectionError,
|
|
654
|
-
"Protected resource metadata resource (#{resource_metadata.resource}) " \
|
|
1332
|
+
"Protected resource metadata resource (#{safe_error_text(resource_metadata.resource.to_s)}) " \
|
|
655
1333
|
"does not match the server URL (#{server_url})"
|
|
656
1334
|
end
|
|
657
1335
|
|
|
@@ -667,7 +1345,8 @@ module MCPClient
|
|
|
667
1345
|
def resource_identity(url)
|
|
668
1346
|
uri = URI.parse(url)
|
|
669
1347
|
unless uri.scheme && uri.host
|
|
670
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1348
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1349
|
+
"Invalid resource URL (must be absolute): #{safe_error_text(url.to_s)}"
|
|
671
1350
|
end
|
|
672
1351
|
|
|
673
1352
|
scheme = uri.scheme.downcase
|
|
@@ -681,7 +1360,7 @@ module MCPClient
|
|
|
681
1360
|
|
|
682
1361
|
"#{scheme}://#{host}#{":#{port}" if port}#{path}#{query}"
|
|
683
1362
|
rescue URI::InvalidURIError
|
|
684
|
-
raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{url}"
|
|
1363
|
+
raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{safe_error_text(url.to_s)}"
|
|
685
1364
|
end
|
|
686
1365
|
|
|
687
1366
|
# Fetch resource metadata from URL.
|
|
@@ -697,7 +1376,7 @@ module MCPClient
|
|
|
697
1376
|
# @return [ResourceMetadata, nil] metadata, or nil if a speculative URL returns 404
|
|
698
1377
|
# @raise [MCPClient::Errors::ConnectionError] on any non-404 failure, or on 404 when strict
|
|
699
1378
|
def fetch_resource_metadata(url, strict: false)
|
|
700
|
-
logger.debug("Fetching resource metadata from: #{url}")
|
|
1379
|
+
logger.debug("Fetching resource metadata from: #{safe_error_text(url.to_s)}")
|
|
701
1380
|
|
|
702
1381
|
response = @http_client.get(url) do |req|
|
|
703
1382
|
req.headers['Accept'] = 'application/json'
|
|
@@ -710,15 +1389,29 @@ module MCPClient
|
|
|
710
1389
|
end
|
|
711
1390
|
|
|
712
1391
|
data = JSON.parse(response.body)
|
|
1392
|
+
raise MCPClient::Errors::ConnectionError, 'Invalid resource metadata: not a JSON object' unless data.is_a?(Hash)
|
|
1393
|
+
|
|
1394
|
+
# RFC 9728 Section 2 gives every field a type, and this client acts on
|
|
1395
|
+
# them: a `scopes_supported` that is not an array of strings would be
|
|
1396
|
+
# joined into a scope parameter (or crash trying), an
|
|
1397
|
+
# `authorization_servers` that is not one would drive discovery at
|
|
1398
|
+
# whatever `Array()` made of it. A document that breaks a type is a
|
|
1399
|
+
# protocol error, not metadata.
|
|
1400
|
+
if (error = resource_metadata_error(data))
|
|
1401
|
+
raise MCPClient::Errors::ConnectionError, "Invalid resource metadata: #{error}"
|
|
1402
|
+
end
|
|
1403
|
+
|
|
713
1404
|
metadata = ResourceMetadata.from_h(data)
|
|
714
1405
|
# Retain the most recently fetched PRM so scope resolution can fall
|
|
715
1406
|
# back to its scopes_supported (MCP scope selection priority 2).
|
|
716
1407
|
@resource_metadata = metadata
|
|
717
1408
|
metadata
|
|
718
1409
|
rescue JSON::ParserError => e
|
|
719
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1410
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1411
|
+
"Invalid resource metadata JSON: #{describe_parse_error(e, response&.body)}"
|
|
720
1412
|
rescue Faraday::Error => e
|
|
721
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1413
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1414
|
+
"Network error fetching resource metadata: #{safe_error_text(e.message)}"
|
|
722
1415
|
end
|
|
723
1416
|
|
|
724
1417
|
# Fetch authorization server metadata from URL
|
|
@@ -726,7 +1419,7 @@ module MCPClient
|
|
|
726
1419
|
# @return [ServerMetadata] Server metadata
|
|
727
1420
|
# @raise [MCPClient::Errors::ConnectionError] if fetch fails
|
|
728
1421
|
def fetch_server_metadata(url)
|
|
729
|
-
logger.debug("Fetching server metadata from: #{url}")
|
|
1422
|
+
logger.debug("Fetching server metadata from: #{safe_error_text(url.to_s)}")
|
|
730
1423
|
|
|
731
1424
|
response = @http_client.get(url) do |req|
|
|
732
1425
|
req.headers['Accept'] = 'application/json'
|
|
@@ -737,68 +1430,72 @@ module MCPClient
|
|
|
737
1430
|
end
|
|
738
1431
|
|
|
739
1432
|
data = JSON.parse(response.body)
|
|
740
|
-
|
|
1433
|
+
raise MCPClient::Errors::ConnectionError, 'Invalid server metadata: not a JSON object' unless data.is_a?(Hash)
|
|
1434
|
+
|
|
1435
|
+
# RFC 8414 Section 2 gives every field a type, and a document that
|
|
1436
|
+
# breaks one is never usable metadata: an endpoint that is not a
|
|
1437
|
+
# string is not an endpoint, and a `code_challenge_methods_supported`
|
|
1438
|
+
# that is not an array of strings cannot establish PKCE support — a
|
|
1439
|
+
# String would answer `include?("S256")` for "S256 is not supported
|
|
1440
|
+
# here", which is precisely the downgrade the check exists to stop.
|
|
1441
|
+
if (error = server_metadata_error(data))
|
|
1442
|
+
raise MCPClient::Errors::ConnectionError, "Invalid server metadata: #{error}"
|
|
1443
|
+
end
|
|
1444
|
+
|
|
1445
|
+
ServerMetadata.from_discovery_document(data)
|
|
741
1446
|
rescue JSON::ParserError => e
|
|
742
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1447
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1448
|
+
"Invalid server metadata JSON: #{describe_parse_error(e, response&.body)}"
|
|
743
1449
|
rescue Faraday::Error => e
|
|
744
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1450
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1451
|
+
"Network error fetching server metadata: #{safe_error_text(e.message)}"
|
|
745
1452
|
end
|
|
746
1453
|
|
|
747
|
-
#
|
|
748
|
-
#
|
|
749
|
-
#
|
|
750
|
-
#
|
|
751
|
-
#
|
|
752
|
-
#
|
|
753
|
-
#
|
|
754
|
-
#
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
raise MCPClient::Errors::ConnectionError,
|
|
774
|
-
'Dynamic client registration not supported and no client credentials found'
|
|
775
|
-
end
|
|
1454
|
+
# Drop every piece of in-process state that belongs to one MCP server,
|
|
1455
|
+
# so a retargeted provider discovers the new one from scratch:
|
|
1456
|
+
#
|
|
1457
|
+
# * the discovered-metadata fallback and the memoized scopes, which name
|
|
1458
|
+
# the previous server's authorization server and what it advertises;
|
|
1459
|
+
# * the challenge state — an adopted document, a URL whose fetch is
|
|
1460
|
+
# still pending, a latched refusal and the scope the challenge asked
|
|
1461
|
+
# for — all of it said by the previous server's 401;
|
|
1462
|
+
# * the resource metadata kept for scope resolution;
|
|
1463
|
+
# * the "the authorization server changed" flag, which is a fact about
|
|
1464
|
+
# the previous server's history.
|
|
1465
|
+
#
|
|
1466
|
+
# Retirement markers are deliberately kept: they are keyed by the issuer
|
|
1467
|
+
# the bytes were retired for, not by the resource URL, so they stay true
|
|
1468
|
+
# when two MCP servers sit behind one authorization server.
|
|
1469
|
+
# @return [void]
|
|
1470
|
+
def forget_per_server_state
|
|
1471
|
+
@discovered_server_metadata = nil
|
|
1472
|
+
@supported_scopes = nil
|
|
1473
|
+
@resource_metadata = nil
|
|
1474
|
+
@challenge_resource_metadata = nil
|
|
1475
|
+
@challenge_metadata_url = nil
|
|
1476
|
+
@challenge_error = nil
|
|
1477
|
+
@challenge_scope = nil
|
|
1478
|
+
@requested_scope = nil
|
|
1479
|
+
@authorization_server_switched = nil
|
|
776
1480
|
end
|
|
777
1481
|
|
|
778
|
-
#
|
|
779
|
-
#
|
|
780
|
-
#
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
def client_info_from_metadata_url
|
|
785
|
-
logger.debug("Using Client ID Metadata Document URL as client_id: #{client_id_metadata_url}")
|
|
786
|
-
|
|
787
|
-
metadata = ClientMetadata.new(
|
|
788
|
-
redirect_uris: [redirect_uri],
|
|
789
|
-
token_endpoint_auth_method: 'none', # Public client
|
|
790
|
-
grant_types: %w[authorization_code refresh_token],
|
|
791
|
-
response_types: ['code'],
|
|
792
|
-
scope: resolved_scope,
|
|
793
|
-
**@extra_client_metadata
|
|
794
|
-
)
|
|
795
|
-
|
|
796
|
-
client_info = ClientInfo.new(client_id: client_id_metadata_url, metadata: metadata)
|
|
1482
|
+
# Storage backends may persist plain hashes (the FileTokenStorage
|
|
1483
|
+
# example does); records are normalized before any field is read.
|
|
1484
|
+
# @return [ServerMetadata, nil]
|
|
1485
|
+
def stored_server_metadata
|
|
1486
|
+
normalize_record(storage.get_server_metadata(server_url), ServerMetadata) || @discovered_server_metadata
|
|
1487
|
+
end
|
|
797
1488
|
|
|
798
|
-
|
|
799
|
-
|
|
1489
|
+
# @return [PKCE, nil]
|
|
1490
|
+
def stored_pkce
|
|
1491
|
+
normalize_record(storage.get_pkce(server_url), PKCE)
|
|
1492
|
+
end
|
|
800
1493
|
|
|
801
|
-
|
|
1494
|
+
# @param record [Object, Hash, nil]
|
|
1495
|
+
# @param klass [Class] a record class responding to from_h
|
|
1496
|
+
# @return [Object, nil]
|
|
1497
|
+
def normalize_record(record, klass)
|
|
1498
|
+
record.is_a?(Hash) ? klass.from_h(record) : record
|
|
802
1499
|
end
|
|
803
1500
|
|
|
804
1501
|
# Validate a Client ID Metadata Document URL (SEP-991): "The client_id
|
|
@@ -837,38 +1534,43 @@ module MCPClient
|
|
|
837
1534
|
end
|
|
838
1535
|
|
|
839
1536
|
# Register OAuth client dynamically
|
|
1537
|
+
# @deprecated Dynamic Client Registration is deprecated since MCP 2026-07-28
|
|
1538
|
+
# (PR #2858); earliest removal is the first revision released on or after
|
|
1539
|
+
# 2027-07-28. Prefer a Client ID Metadata Document (client_id_metadata_url)
|
|
1540
|
+
# or pre-registered credentials. It remains the fallback for authorization
|
|
1541
|
+
# servers without Client ID Metadata Document support.
|
|
840
1542
|
# @param server_metadata [ServerMetadata] Authorization server metadata
|
|
841
1543
|
# @return [ClientInfo] Registered client information
|
|
842
1544
|
# @raise [MCPClient::Errors::ConnectionError] if registration fails
|
|
843
1545
|
def register_client(server_metadata)
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1546
|
+
MCPClient::Deprecations.warn(:dynamic_client_registration, logger)
|
|
1547
|
+
logger.debug("Registering OAuth client at: #{safe_error_text(server_metadata.registration_endpoint.to_s)}")
|
|
1548
|
+
|
|
1549
|
+
app_type = resolved_application_type
|
|
1550
|
+
response = post_registration(server_metadata, app_type)
|
|
1551
|
+
|
|
1552
|
+
# "Clients MAY retry registration with an adjusted application_type"
|
|
1553
|
+
# when an OIDC server rejects the redirect URI for the type derived
|
|
1554
|
+
# here (never for one the host chose explicitly).
|
|
1555
|
+
if !response.success? && application_type.nil? &&
|
|
1556
|
+
registration_error(response)[:error] == 'invalid_redirect_uri'
|
|
1557
|
+
alternate = app_type == 'native' ? 'web' : 'native'
|
|
1558
|
+
logger.warn("Client registration rejected the redirect URI for application_type #{app_type}; " \
|
|
1559
|
+
"retrying as #{alternate}")
|
|
1560
|
+
app_type = alternate
|
|
1561
|
+
response = post_registration(server_metadata, app_type)
|
|
859
1562
|
end
|
|
860
1563
|
|
|
861
|
-
unless response.success?
|
|
862
|
-
raise MCPClient::Errors::ConnectionError, "Client registration failed: HTTP #{response.status}"
|
|
863
|
-
end
|
|
1564
|
+
raise_registration_failure!(response) unless response.success?
|
|
864
1565
|
|
|
865
1566
|
data = JSON.parse(response.body)
|
|
866
|
-
|
|
1567
|
+
client_id = registered_client_id!(data)
|
|
1568
|
+
logger.debug("OAuth client registered successfully: #{safe_error_text(client_id)}")
|
|
867
1569
|
|
|
868
1570
|
# Parse registered metadata from server response (may differ from our request)
|
|
869
1571
|
registered_metadata = ClientMetadata.new(
|
|
870
|
-
redirect_uris: data
|
|
871
|
-
token_endpoint_auth_method: data
|
|
1572
|
+
redirect_uris: registered_redirect_uris(data),
|
|
1573
|
+
token_endpoint_auth_method: registered_auth_method(data),
|
|
872
1574
|
grant_types: data['grant_types'] || %w[authorization_code refresh_token],
|
|
873
1575
|
response_types: data['response_types'] || ['code'],
|
|
874
1576
|
scope: data['scope'],
|
|
@@ -877,35 +1579,154 @@ module MCPClient
|
|
|
877
1579
|
logo_uri: data['logo_uri'],
|
|
878
1580
|
tos_uri: data['tos_uri'],
|
|
879
1581
|
policy_uri: data['policy_uri'],
|
|
880
|
-
contacts: data['contacts']
|
|
1582
|
+
contacts: data['contacts'],
|
|
1583
|
+
application_type: data['application_type'] || app_type
|
|
881
1584
|
)
|
|
882
1585
|
|
|
883
|
-
|
|
884
|
-
requested_uri = redirect_uri
|
|
885
|
-
registered_uri = registered_metadata.redirect_uris.first
|
|
886
|
-
if registered_uri != requested_uri
|
|
887
|
-
logger.warn('OAuth server changed redirect_uri:')
|
|
888
|
-
logger.warn(" Requested: #{requested_uri}")
|
|
889
|
-
logger.warn(" Registered: #{registered_uri}")
|
|
890
|
-
logger.warn("Using server's registered redirect_uri for token exchange.")
|
|
891
|
-
end
|
|
1586
|
+
warn_registered_redirect_uri(registered_metadata)
|
|
892
1587
|
|
|
893
1588
|
client_info = ClientInfo.new(
|
|
894
|
-
client_id:
|
|
1589
|
+
client_id: client_id,
|
|
895
1590
|
client_secret: data['client_secret'],
|
|
896
1591
|
client_id_issued_at: data['client_id_issued_at'],
|
|
897
1592
|
client_secret_expires_at: data['client_secret_expires_at'],
|
|
898
|
-
metadata: registered_metadata
|
|
1593
|
+
metadata: registered_metadata,
|
|
1594
|
+
# Bound to the authorization server that issued the credentials
|
|
1595
|
+
issuer: server_metadata.issuer,
|
|
1596
|
+
registration_type: 'dynamic'
|
|
899
1597
|
)
|
|
900
1598
|
|
|
901
1599
|
# Store client info
|
|
902
|
-
|
|
1600
|
+
store_client_info(client_info)
|
|
903
1601
|
|
|
904
1602
|
client_info
|
|
905
1603
|
rescue JSON::ParserError => e
|
|
906
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1604
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1605
|
+
"Invalid client registration response: #{describe_parse_error(e, response&.body)}"
|
|
907
1606
|
rescue Faraday::Error => e
|
|
908
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1607
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1608
|
+
"Network error during client registration: #{safe_error_text(e.message)}"
|
|
1609
|
+
end
|
|
1610
|
+
|
|
1611
|
+
# The authorization server may register a redirect URI other than the
|
|
1612
|
+
# one asked for; the registered value is what the token exchange must
|
|
1613
|
+
# then present (RFC 6749 Section 4.1.3), so say so.
|
|
1614
|
+
# @param registered_metadata [ClientMetadata] the metadata as registered
|
|
1615
|
+
# @return [void]
|
|
1616
|
+
def warn_registered_redirect_uri(registered_metadata)
|
|
1617
|
+
registered_uri = registered_metadata.redirect_uris.first
|
|
1618
|
+
return if registered_uri == redirect_uri
|
|
1619
|
+
|
|
1620
|
+
logger.warn('OAuth server changed redirect_uri:')
|
|
1621
|
+
logger.warn(" Requested: #{redirect_uri}")
|
|
1622
|
+
logger.warn(" Registered: #{safe_error_text(registered_uri.to_s)}")
|
|
1623
|
+
logger.warn("Using server's registered redirect_uri for token exchange.")
|
|
1624
|
+
end
|
|
1625
|
+
|
|
1626
|
+
# The client id of a registration response, once the response has been
|
|
1627
|
+
# checked field by field against the types RFC 7591 gives them: a
|
|
1628
|
+
# response that names no client has registered nothing, and one whose
|
|
1629
|
+
# fields are of other JSON types would be stored and only crash later —
|
|
1630
|
+
# a `redirect_uris` string asked for its `first`, a
|
|
1631
|
+
# `client_secret_expires_at` string compared with a Unix timestamp. A
|
|
1632
|
+
# registration response is refused here exactly as a token response
|
|
1633
|
+
# without an access token is, before the browser is ever opened.
|
|
1634
|
+
# @param data [Object, nil] the parsed JSON registration response
|
|
1635
|
+
# @return [String] the registered client id
|
|
1636
|
+
# @raise [MCPClient::Errors::ConnectionError] when the response registers no usable client
|
|
1637
|
+
def registered_client_id!(data)
|
|
1638
|
+
if (error = registration_response_error(data))
|
|
1639
|
+
raise MCPClient::Errors::ConnectionError, "Client registration failed: #{error}"
|
|
1640
|
+
end
|
|
1641
|
+
|
|
1642
|
+
data['client_id']
|
|
1643
|
+
end
|
|
1644
|
+
|
|
1645
|
+
# The redirect URIs a registration response registered, defaulting to
|
|
1646
|
+
# the one the registration request asked for when the server echoes
|
|
1647
|
+
# none back (RFC 7591 Section 2 makes redirect_uris an array of
|
|
1648
|
+
# strings; its type is checked before this runs).
|
|
1649
|
+
# @param data [Hash] the parsed JSON registration response
|
|
1650
|
+
# @return [Array<String>]
|
|
1651
|
+
def registered_redirect_uris(data)
|
|
1652
|
+
uris = data['redirect_uris']
|
|
1653
|
+
uris.is_a?(Array) && !uris.empty? ? uris : [redirect_uri]
|
|
1654
|
+
end
|
|
1655
|
+
|
|
1656
|
+
# One Dynamic Client Registration request.
|
|
1657
|
+
# @param server_metadata [ServerMetadata]
|
|
1658
|
+
# @param app_type [String] the application_type to declare
|
|
1659
|
+
# @return [Faraday::Response]
|
|
1660
|
+
def post_registration(server_metadata, app_type)
|
|
1661
|
+
metadata = ClientMetadata.new(
|
|
1662
|
+
redirect_uris: [redirect_uri],
|
|
1663
|
+
token_endpoint_auth_method: 'none', # Public client
|
|
1664
|
+
grant_types: %w[authorization_code refresh_token],
|
|
1665
|
+
response_types: ['code'],
|
|
1666
|
+
scope: resolved_scope,
|
|
1667
|
+
application_type: app_type,
|
|
1668
|
+
**@extra_client_metadata
|
|
1669
|
+
)
|
|
1670
|
+
|
|
1671
|
+
@http_client.post(server_metadata.registration_endpoint) do |req|
|
|
1672
|
+
req.headers['Content-Type'] = 'application/json'
|
|
1673
|
+
req.headers['Accept'] = 'application/json'
|
|
1674
|
+
req.body = metadata.to_h.to_json
|
|
1675
|
+
end
|
|
1676
|
+
end
|
|
1677
|
+
|
|
1678
|
+
# "When a registration request is rejected, clients SHOULD surface a
|
|
1679
|
+
# meaningful error": the RFC 7591 error and description, when given.
|
|
1680
|
+
# @param response [Faraday::Response] the failed registration response
|
|
1681
|
+
# @raise [MCPClient::Errors::ConnectionError]
|
|
1682
|
+
def raise_registration_failure!(response)
|
|
1683
|
+
error = registration_error(response)
|
|
1684
|
+
detail = [error[:error], error[:error_description]].compact.join(': ')
|
|
1685
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1686
|
+
"Client registration failed: HTTP #{response.status}#{" (#{detail})" unless detail.empty?}"
|
|
1687
|
+
end
|
|
1688
|
+
|
|
1689
|
+
# The application_type to register: the host's explicit choice, else
|
|
1690
|
+
# 'native' for loopback and custom-scheme redirect URIs (desktop, CLI,
|
|
1691
|
+
# mobile, locally hosted apps) and 'web' for a remote redirect URI
|
|
1692
|
+
# (MCP 2026-07-28 "Application Type and Redirect URI Constraints").
|
|
1693
|
+
# @return [String]
|
|
1694
|
+
def resolved_application_type
|
|
1695
|
+
return application_type if application_type
|
|
1696
|
+
|
|
1697
|
+
uri = URI.parse(redirect_uri.to_s)
|
|
1698
|
+
return 'native' unless %w[http https].include?(uri.scheme.to_s.downcase)
|
|
1699
|
+
|
|
1700
|
+
loopback_host?(uri.host) ? 'native' : 'web'
|
|
1701
|
+
rescue URI::InvalidURIError
|
|
1702
|
+
'native'
|
|
1703
|
+
end
|
|
1704
|
+
|
|
1705
|
+
# Whether a redirect URI host is a loopback interface: 'localhost', an
|
|
1706
|
+
# RFC 6761 '*.localhost' name (puma-dev, Caddy), or any loopback address
|
|
1707
|
+
# (127.0.0.0/8, ::1, in any spelling). The host is read the way a
|
|
1708
|
+
# resolver reads it — decoded, unbracketed, undotted, and through the
|
|
1709
|
+
# shorthand IPv4 parser — so '127.1', '0x7f.0.0.1', '127.0.0.1.' and
|
|
1710
|
+
# 'app.localhost' register as native like the plain spelling, instead of
|
|
1711
|
+
# being sent for registration as a web client whose HTTP redirect URI the
|
|
1712
|
+
# authorization server may then reject.
|
|
1713
|
+
# @param host [String, nil]
|
|
1714
|
+
# @return [Boolean]
|
|
1715
|
+
def loopback_host?(host)
|
|
1716
|
+
loopback_address?(host.to_s)
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
# The RFC 7591 error of a failed registration response, sanitized for
|
|
1720
|
+
# a log line or exception message.
|
|
1721
|
+
# @param response [Faraday::Response]
|
|
1722
|
+
# @return [Hash] :error and :error_description (nil when absent)
|
|
1723
|
+
def registration_error(response)
|
|
1724
|
+
body = JSON.parse(response.body.to_s)
|
|
1725
|
+
return {} unless body.is_a?(Hash)
|
|
1726
|
+
|
|
1727
|
+
{ error: safe_error_text(body['error']), error_description: safe_error_text(body['error_description']) }
|
|
1728
|
+
rescue JSON::ParserError
|
|
1729
|
+
{}
|
|
909
1730
|
end
|
|
910
1731
|
|
|
911
1732
|
# Build authorization URL
|
|
@@ -922,7 +1743,7 @@ module MCPClient
|
|
|
922
1743
|
response_type: 'code',
|
|
923
1744
|
client_id: client_info.client_id,
|
|
924
1745
|
redirect_uri: registered_redirect_uri,
|
|
925
|
-
scope:
|
|
1746
|
+
scope: @requested_scope,
|
|
926
1747
|
state: state,
|
|
927
1748
|
code_challenge: pkce.code_challenge,
|
|
928
1749
|
code_challenge_method: pkce.code_challenge_method,
|
|
@@ -930,10 +1751,50 @@ module MCPClient
|
|
|
930
1751
|
}.compact
|
|
931
1752
|
|
|
932
1753
|
uri = URI.parse(server_metadata.authorization_endpoint)
|
|
933
|
-
|
|
1754
|
+
# "The client directs the resource owner to the constructed URI using
|
|
1755
|
+
# an HTTP redirection response... The endpoint URI MAY include an
|
|
1756
|
+
# application/x-www-form-urlencoded formatted query component, which
|
|
1757
|
+
# MUST be retained when adding additional query parameters" (RFC 6749
|
|
1758
|
+
# Section 3.1). An authorization server that identifies a tenant, a
|
|
1759
|
+
# brand or a locale in its endpoint URL loses that identification if
|
|
1760
|
+
# the query is replaced, and sends the user somewhere else entirely.
|
|
1761
|
+
#
|
|
1762
|
+
# The same section: "Request and response parameters MUST NOT be
|
|
1763
|
+
# included more than once." An endpoint query that already names an
|
|
1764
|
+
# authorization request parameter this client sends — `scope`,
|
|
1765
|
+
# `state`, `client_id`, a PKCE challenge — would otherwise appear
|
|
1766
|
+
# twice, and which of the two the server reads is its own business:
|
|
1767
|
+
# a `scope` of the endpoint URL could widen the consent this request
|
|
1768
|
+
# asks for, and a second `state` or `code_challenge` decides the
|
|
1769
|
+
# checks the callback is held to. This request's own parameters are
|
|
1770
|
+
# therefore the only ones with those names; everything else the
|
|
1771
|
+
# endpoint carries is retained.
|
|
1772
|
+
uri.query = merged_authorization_query(uri.query, params)
|
|
934
1773
|
uri.to_s
|
|
935
1774
|
end
|
|
936
1775
|
|
|
1776
|
+
# The authorization endpoint's own query with this request's parameters
|
|
1777
|
+
# appended, and with any endpoint parameter this request names dropped
|
|
1778
|
+
# (RFC 6749 Section 3.1 forbids a repeated request parameter).
|
|
1779
|
+
# @param endpoint_query [String, nil] the query of the authorization endpoint URL
|
|
1780
|
+
# @param params [Hash{Symbol => String}] this request's parameters
|
|
1781
|
+
# @return [String] the query string to send
|
|
1782
|
+
def merged_authorization_query(endpoint_query, params)
|
|
1783
|
+
appended = URI.encode_www_form(params)
|
|
1784
|
+
return appended if endpoint_query.to_s.empty?
|
|
1785
|
+
|
|
1786
|
+
kept = URI.decode_www_form(endpoint_query).reject do |name, _value|
|
|
1787
|
+
next false unless AUTHORIZATION_REQUEST_PARAMS.include?(name)
|
|
1788
|
+
|
|
1789
|
+
logger.debug("Dropping #{name.inspect} from the authorization endpoint query: this authorization " \
|
|
1790
|
+
'request sets it')
|
|
1791
|
+
true
|
|
1792
|
+
end
|
|
1793
|
+
return appended if kept.empty?
|
|
1794
|
+
|
|
1795
|
+
"#{URI.encode_www_form(kept)}&#{appended}"
|
|
1796
|
+
end
|
|
1797
|
+
|
|
937
1798
|
# Exchange authorization code for access token
|
|
938
1799
|
# @param server_metadata [ServerMetadata] Server metadata
|
|
939
1800
|
# @param client_info [ClientInfo] Client information
|
|
@@ -944,8 +1805,10 @@ module MCPClient
|
|
|
944
1805
|
def exchange_authorization_code(server_metadata, client_info, code, pkce)
|
|
945
1806
|
logger.debug('Exchanging authorization code for access token')
|
|
946
1807
|
|
|
947
|
-
#
|
|
948
|
-
|
|
1808
|
+
# The redirect_uri the authorization request was made with (recorded
|
|
1809
|
+
# with the PKCE record), else the registered one
|
|
1810
|
+
recorded_redirect_uri = pkce.redirect_uri if pkce.respond_to?(:redirect_uri)
|
|
1811
|
+
registered_redirect_uri = recorded_redirect_uri || client_info.metadata.redirect_uris.first
|
|
949
1812
|
|
|
950
1813
|
params = {
|
|
951
1814
|
grant_type: 'authorization_code',
|
|
@@ -956,10 +1819,10 @@ module MCPClient
|
|
|
956
1819
|
resource: server_url
|
|
957
1820
|
}
|
|
958
1821
|
|
|
959
|
-
#
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1822
|
+
# The credentials go out the way the authorization server registered
|
|
1823
|
+
# them: in the body for client_secret_post, in an Authorization header
|
|
1824
|
+
# for client_secret_basic (RFC 7591's default).
|
|
1825
|
+
authorization = apply_client_credentials!(params, client_info)
|
|
963
1826
|
|
|
964
1827
|
request_body = URI.encode_www_form(params)
|
|
965
1828
|
|
|
@@ -967,6 +1830,7 @@ module MCPClient
|
|
|
967
1830
|
@http_client.post(server_metadata.token_endpoint) do |req|
|
|
968
1831
|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
969
1832
|
req.headers['Accept'] = 'application/json'
|
|
1833
|
+
req.headers['Authorization'] = authorization if authorization
|
|
970
1834
|
req.body = body
|
|
971
1835
|
end
|
|
972
1836
|
end
|
|
@@ -974,37 +1838,44 @@ module MCPClient
|
|
|
974
1838
|
response = send_token_request.call(request_body)
|
|
975
1839
|
|
|
976
1840
|
unless response.success?
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
if
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
"Token exchange failed: redirect_uri mismatch. Retrying with server's expected value: #{expected_uri}"
|
|
983
|
-
)
|
|
984
|
-
|
|
985
|
-
params[:redirect_uri] = redirect_hint[:expected]
|
|
986
|
-
retry_body = URI.encode_www_form(params)
|
|
987
|
-
|
|
988
|
-
response = send_token_request.call(retry_body)
|
|
1841
|
+
retry_uri = redirect_uri_retry_target(response.body, sent: registered_redirect_uri,
|
|
1842
|
+
recorded: recorded_redirect_uri)
|
|
1843
|
+
if retry_uri
|
|
1844
|
+
params[:redirect_uri] = retry_uri
|
|
1845
|
+
response = send_token_request.call(URI.encode_www_form(params))
|
|
989
1846
|
end
|
|
990
1847
|
end
|
|
991
1848
|
|
|
992
1849
|
unless response.success?
|
|
993
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1850
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1851
|
+
"Token exchange failed: HTTP #{response.status} - #{safe_body_text(response.body)}"
|
|
994
1852
|
end
|
|
995
1853
|
|
|
996
1854
|
data = JSON.parse(response.body)
|
|
1855
|
+
# RFC 6749 Section 5.1 gives every field of a successful response a
|
|
1856
|
+
# type; a 200 that breaks one is a protocol error, not a credential.
|
|
1857
|
+
# Storing it would report success and then present a bare "Bearer ",
|
|
1858
|
+
# or crash later capitalizing a token_type that is not a string.
|
|
1859
|
+
if (error = token_response_error(data))
|
|
1860
|
+
raise MCPClient::Errors::ConnectionError, "Token exchange failed: #{error}"
|
|
1861
|
+
end
|
|
1862
|
+
|
|
997
1863
|
Token.new(
|
|
998
1864
|
access_token: data['access_token'],
|
|
999
|
-
token_type: data['token_type']
|
|
1865
|
+
token_type: data['token_type'],
|
|
1000
1866
|
expires_in: data['expires_in'],
|
|
1001
|
-
scope:
|
|
1002
|
-
|
|
1867
|
+
# "scope: OPTIONAL, if identical to the scope requested by the
|
|
1868
|
+
# client" (RFC 6749 Section 5.1): omitted means granted as asked.
|
|
1869
|
+
scope: data['scope'].nil? ? requested_scope_of(pkce) : data['scope'],
|
|
1870
|
+
refresh_token: data['refresh_token'],
|
|
1871
|
+
issuer: server_metadata.issuer
|
|
1003
1872
|
)
|
|
1004
1873
|
rescue JSON::ParserError => e
|
|
1005
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1874
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1875
|
+
"Invalid token response: #{describe_parse_error(e, response&.body)}"
|
|
1006
1876
|
rescue Faraday::Error => e
|
|
1007
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1877
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1878
|
+
"Network error during token exchange: #{safe_error_text(e.message)}"
|
|
1008
1879
|
end
|
|
1009
1880
|
|
|
1010
1881
|
# Refresh access token
|
|
@@ -1016,10 +1887,15 @@ module MCPClient
|
|
|
1016
1887
|
logger.debug('Refreshing access token')
|
|
1017
1888
|
|
|
1018
1889
|
server_metadata = discover_authorization_server
|
|
1019
|
-
|
|
1890
|
+
# Registration state is per authorization server (SEP-2352): the
|
|
1891
|
+
# credentials of the server being refreshed at, wherever they are kept.
|
|
1892
|
+
client_info = refresh_client_info(server_metadata&.issuer)
|
|
1020
1893
|
|
|
1021
1894
|
return nil unless server_metadata && client_info
|
|
1895
|
+
return nil unless refresh_permitted?(token, client_info, server_metadata)
|
|
1022
1896
|
|
|
1897
|
+
# The resource the refresh is made for, judged again over the response.
|
|
1898
|
+
resource = server_url
|
|
1023
1899
|
params = {
|
|
1024
1900
|
grant_type: 'refresh_token',
|
|
1025
1901
|
refresh_token: token.refresh_token,
|
|
@@ -1027,14 +1903,12 @@ module MCPClient
|
|
|
1027
1903
|
resource: server_url
|
|
1028
1904
|
}
|
|
1029
1905
|
|
|
1030
|
-
|
|
1031
|
-
if client_info.client_secret && client_info.metadata.token_endpoint_auth_method == 'client_secret_post'
|
|
1032
|
-
params[:client_secret] = client_info.client_secret
|
|
1033
|
-
end
|
|
1906
|
+
authorization = apply_client_credentials!(params, client_info)
|
|
1034
1907
|
|
|
1035
1908
|
response = @http_client.post(server_metadata.token_endpoint) do |req|
|
|
1036
1909
|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
1037
1910
|
req.headers['Accept'] = 'application/json'
|
|
1911
|
+
req.headers['Authorization'] = authorization if authorization
|
|
1038
1912
|
req.body = URI.encode_www_form(params)
|
|
1039
1913
|
end
|
|
1040
1914
|
|
|
@@ -1044,34 +1918,216 @@ module MCPClient
|
|
|
1044
1918
|
end
|
|
1045
1919
|
|
|
1046
1920
|
data = JSON.parse(response.body)
|
|
1921
|
+
# A refresh response that breaks RFC 6749 Section 5.1 — no
|
|
1922
|
+
# access_token bytes, or a field of the wrong JSON type — is a failed
|
|
1923
|
+
# refresh: the still-valid token stays in storage rather than being
|
|
1924
|
+
# overwritten with something that would go out as "Bearer ", and
|
|
1925
|
+
# nothing is raised out of the request path that a still-valid token
|
|
1926
|
+
# could have served.
|
|
1927
|
+
if (error = token_response_error(data))
|
|
1928
|
+
logger.warn("Token refresh failed: #{error}; keeping the stored token")
|
|
1929
|
+
return nil
|
|
1930
|
+
end
|
|
1931
|
+
|
|
1047
1932
|
new_token = Token.new(
|
|
1048
1933
|
access_token: data['access_token'],
|
|
1049
|
-
token_type: data['token_type']
|
|
1934
|
+
token_type: data['token_type'],
|
|
1050
1935
|
expires_in: data['expires_in'],
|
|
1051
|
-
scope
|
|
1052
|
-
|
|
1936
|
+
# A refresh that asks for no scope is granted the original one (RFC
|
|
1937
|
+
# 6749 Section 6), so a response that omits it keeps the known set.
|
|
1938
|
+
scope: data['scope'].nil? ? token.scope : data['scope'],
|
|
1939
|
+
refresh_token: data['refresh_token'] || token.refresh_token,
|
|
1940
|
+
issuer: server_metadata.issuer
|
|
1053
1941
|
)
|
|
1054
1942
|
|
|
1055
|
-
|
|
1056
|
-
new_token
|
|
1943
|
+
accept_refreshed_token(new_token, server_metadata.issuer, resource, token)
|
|
1057
1944
|
rescue JSON::ParserError => e
|
|
1058
|
-
logger.warn("Invalid token refresh response: #{e
|
|
1945
|
+
logger.warn("Invalid token refresh response: #{describe_parse_error(e, response&.body)}")
|
|
1059
1946
|
nil
|
|
1060
1947
|
rescue Faraday::Error => e
|
|
1061
|
-
logger.warn("Network error during token refresh: #{e.message}")
|
|
1948
|
+
logger.warn("Network error during token refresh: #{safe_error_text(e.message)}")
|
|
1062
1949
|
nil
|
|
1063
1950
|
end
|
|
1064
1951
|
|
|
1952
|
+
# A refresh is two events with an unbounded gap between them: the
|
|
1953
|
+
# request goes to the authorization server the token came from, and the
|
|
1954
|
+
# response arrives at a client whose authorization server may have
|
|
1955
|
+
# changed meanwhile — updated protected resource metadata, a 401
|
|
1956
|
+
# challenge, another provider sharing the storage. So the check
|
|
1957
|
+
# {#refresh_permitted?} made before the request is made again over the
|
|
1958
|
+
# response, and it covers the write as much as the presentation: bytes
|
|
1959
|
+
# issued by a server that is no longer this resource's would otherwise
|
|
1960
|
+
# be stored over the token of the server that IS in use (resurrecting,
|
|
1961
|
+
# in the challenge case, a token that was just retired) and be handed
|
|
1962
|
+
# straight back to the caller without ever passing the issuer check the
|
|
1963
|
+
# stored-token path makes.
|
|
1964
|
+
# @param new_token [Token] the token the refresh response carried
|
|
1965
|
+
# @param issuer [String] the authorization server the refresh was made with
|
|
1966
|
+
# @param resource [String] the resource the refresh was made for
|
|
1967
|
+
# @param refreshed [Token, nil] the token the refresh was made with
|
|
1968
|
+
# @return [Token, nil] the token, or nil when it is no longer this resource's to keep
|
|
1969
|
+
def accept_refreshed_token(new_token, issuer, resource, refreshed = nil)
|
|
1970
|
+
# A provider retargeted at another resource meanwhile — one that may
|
|
1971
|
+
# share the authorization server — is not handed the previous
|
|
1972
|
+
# resource's token as its own. Judged before the lock: the lock of the
|
|
1973
|
+
# resource this provider now serves says nothing about the one the
|
|
1974
|
+
# refresh was made for.
|
|
1975
|
+
unless resource == server_url
|
|
1976
|
+
logger.warn('Discarding the refreshed token: the resource changed while the refresh was in flight')
|
|
1977
|
+
return nil
|
|
1978
|
+
end
|
|
1979
|
+
|
|
1980
|
+
# The remaining checks and the write are one step, for the reason
|
|
1981
|
+
# {#with_authorization_state_lock} gives: a switch validated between
|
|
1982
|
+
# them would otherwise have its token written over by this response.
|
|
1983
|
+
with_authorization_state_lock do
|
|
1984
|
+
unless refresh_target_current?(issuer)
|
|
1985
|
+
logger.warn('Discarding the refreshed token: the authorization server changed while the refresh ' \
|
|
1986
|
+
'was in flight')
|
|
1987
|
+
next nil
|
|
1988
|
+
end
|
|
1989
|
+
# What shared storage shows is what another provider did meanwhile: a
|
|
1990
|
+
# challenge it validated retired the token being refreshed, or a flow
|
|
1991
|
+
# it completed replaced it. This provider's own view of the
|
|
1992
|
+
# authorization server says nothing about either, so the response is
|
|
1993
|
+
# kept only while the token it refreshed is still the token in use.
|
|
1994
|
+
unless refreshed_token_in_use?(refreshed)
|
|
1995
|
+
logger.warn('Discarding the refreshed token: the token it refreshed is no longer the token in use')
|
|
1996
|
+
next nil
|
|
1997
|
+
end
|
|
1998
|
+
|
|
1999
|
+
store_token(new_token)
|
|
2000
|
+
new_token
|
|
2001
|
+
end
|
|
2002
|
+
end
|
|
2003
|
+
|
|
2004
|
+
# The scope an authorization request asked for, as recorded with it.
|
|
2005
|
+
# @param pkce [PKCE] the per-request record
|
|
2006
|
+
# @return [String, nil]
|
|
2007
|
+
def requested_scope_of(pkce)
|
|
2008
|
+
pkce.scope if pkce.respond_to?(:scope) && pkce.scope.is_a?(String)
|
|
2009
|
+
end
|
|
2010
|
+
|
|
2011
|
+
# Whether a refresh made with an authorization server is still this
|
|
2012
|
+
# resource's: that server is the one in use now (an unresolved or
|
|
2013
|
+
# refused challenge means it is unknown, which is not "still A"), and
|
|
2014
|
+
# the token slot the response would be written to does not already hold
|
|
2015
|
+
# a token of another server.
|
|
2016
|
+
# @param issuer [String] the authorization server the refresh was made with
|
|
2017
|
+
# @return [Boolean]
|
|
2018
|
+
def refresh_target_current?(issuer)
|
|
2019
|
+
return false unless current_issuer_for_tokens == issuer
|
|
2020
|
+
|
|
2021
|
+
stored = stored_token_or_nil
|
|
2022
|
+
return true unless stored.respond_to?(:issuer) && stored.issuer
|
|
2023
|
+
|
|
2024
|
+
stored.issuer == issuer
|
|
2025
|
+
end
|
|
2026
|
+
|
|
2027
|
+
# The credentials a refresh presents are the ones an authorization
|
|
2028
|
+
# request would be made with: the two paths must not disagree about
|
|
2029
|
+
# which record answers for an authorization server. The resource slot
|
|
2030
|
+
# is the slot a host writes to, so credentials rotated there — a new
|
|
2031
|
+
# secret under the same client id — are never overruled by the older
|
|
2032
|
+
# copy kept under that authorization server's own key; the copy answers
|
|
2033
|
+
# only when the slot holds credentials of some other server.
|
|
2034
|
+
# @param issuer [String, nil] the authorization server the refresh is made at
|
|
2035
|
+
# @return [ClientInfo, nil]
|
|
2036
|
+
def refresh_client_info(issuer)
|
|
2037
|
+
in_use = stored_client_info
|
|
2038
|
+
# Credentials the host pre-registered without saying which
|
|
2039
|
+
# authorization server issued them are not presented to the one the
|
|
2040
|
+
# refresh goes to, exactly as an authorization request does not
|
|
2041
|
+
# present them: nothing says that server issued them. Expiry changes
|
|
2042
|
+
# nothing about whom they belong to, so it is judged on the record
|
|
2043
|
+
# itself, before the secret's lifetime is.
|
|
2044
|
+
return registration_for_issuer(issuer) if unbound_static?(in_use)
|
|
2045
|
+
|
|
2046
|
+
usable = in_use.nil? || in_use.client_secret_expired? ? nil : in_use
|
|
2047
|
+
return usable if usable && answers_for_issuer?(usable, issuer)
|
|
2048
|
+
|
|
2049
|
+
# Never the record whose secret was just filtered out: an
|
|
2050
|
+
# authorization request discards an expired registration and registers
|
|
2051
|
+
# again (#usable_client_info), so presenting that secret here is the
|
|
2052
|
+
# disagreement between the two paths this method exists to prevent.
|
|
2053
|
+
# With nothing left to present the refresh is skipped, and the host
|
|
2054
|
+
# authorizes — which is what re-registers.
|
|
2055
|
+
registration_for_issuer(issuer) || usable
|
|
2056
|
+
end
|
|
2057
|
+
|
|
2058
|
+
# A refresh token (and a client secret) is only ever presented to the
|
|
2059
|
+
# authorization server that issued it (MCP 2026-07-28: registration
|
|
2060
|
+
# state and tokens are per authorization server).
|
|
2061
|
+
# @return [Boolean]
|
|
2062
|
+
def refresh_permitted?(token, client_info, server_metadata)
|
|
2063
|
+
issuer = token.respond_to?(:issuer) ? token.issuer : nil
|
|
2064
|
+
if issuer && issuer != server_metadata.issuer
|
|
2065
|
+
logger.warn('Not refreshing the token: the authorization server changed since it was issued')
|
|
2066
|
+
return false
|
|
2067
|
+
end
|
|
2068
|
+
# A token that does not say where it came from is not refreshed once
|
|
2069
|
+
# the authorization server is known to have changed.
|
|
2070
|
+
if issuer.nil? && @authorization_server_switched
|
|
2071
|
+
logger.warn('Not refreshing the token: it records no issuer and the authorization server changed')
|
|
2072
|
+
return false
|
|
2073
|
+
end
|
|
2074
|
+
if client_info.respond_to?(:issuer) && client_info.issuer && !client_info.portable? &&
|
|
2075
|
+
client_info.issuer != server_metadata.issuer
|
|
2076
|
+
logger.warn('Not refreshing the token: the client credentials belong to another authorization server')
|
|
2077
|
+
return false
|
|
2078
|
+
end
|
|
2079
|
+
true
|
|
2080
|
+
end
|
|
2081
|
+
|
|
2082
|
+
# The redirect_uri to retry a failed code exchange with, if any.
|
|
2083
|
+
#
|
|
2084
|
+
# RFC 6749 Section 4.1.3 redeems the code with the redirect_uri of the
|
|
2085
|
+
# authorization request, which is recorded on the PKCE record. Where it
|
|
2086
|
+
# was recorded, a value parsed out of the peer's error text is never
|
|
2087
|
+
# substituted for it: redeeming with a URI this client never sent is not
|
|
2088
|
+
# a recovery, so the contradiction is surfaced. Only legacy records made
|
|
2089
|
+
# before the URI was recorded keep the older retry-with-the-server's-value
|
|
2090
|
+
# behaviour, where there is nothing authoritative to contradict.
|
|
2091
|
+
# @param body [String] the token endpoint's error body
|
|
2092
|
+
# @param sent [String, nil] the redirect_uri the request was made with
|
|
2093
|
+
# @param recorded [String, nil] the redirect_uri recorded for this authorization request
|
|
2094
|
+
# @return [String, nil] the URI to retry with, or nil to keep the failure
|
|
2095
|
+
# @raise [MCPClient::Errors::ConnectionError] when the error contradicts the recorded URI
|
|
2096
|
+
def redirect_uri_retry_target(body, sent:, recorded:)
|
|
2097
|
+
expected = extract_redirect_mismatch(body)&.fetch(:expected, nil)
|
|
2098
|
+
return nil unless expected && expected != sent
|
|
2099
|
+
|
|
2100
|
+
if recorded
|
|
2101
|
+
raise MCPClient::Errors::ConnectionError,
|
|
2102
|
+
'Token exchange failed: the authorization server expected a redirect_uri other than the one the ' \
|
|
2103
|
+
"authorization request was made with (#{recorded}); restart the authorization"
|
|
2104
|
+
end
|
|
2105
|
+
|
|
2106
|
+
logger.warn("Token exchange failed: redirect_uri mismatch. Retrying with server's expected value: " \
|
|
2107
|
+
"#{safe_error_text(expected)}")
|
|
2108
|
+
expected
|
|
2109
|
+
end
|
|
2110
|
+
|
|
1065
2111
|
# Extract redirect_uri mismatch details from an OAuth error response
|
|
2112
|
+
#
|
|
2113
|
+
# The description is a peer's bytes, and nothing about them guarantees
|
|
2114
|
+
# valid UTF-8: `String#match` raises `ArgumentError` on a lone `\xFF`,
|
|
2115
|
+
# out of the very rescue path that exists to turn a 400 into a
|
|
2116
|
+
# ConnectionError. It is made decodable before it is matched — the
|
|
2117
|
+
# bounded, printable form of {#safe_error_text} would do for a message
|
|
2118
|
+
# but not for a pattern, which has to see the URIs the peer actually
|
|
2119
|
+
# wrote.
|
|
1066
2120
|
# @param body [String] Raw HTTP response body
|
|
1067
2121
|
# @return [Hash, nil] Hash with :sent and :expected URIs if mismatch detected
|
|
1068
2122
|
def extract_redirect_mismatch(body)
|
|
1069
|
-
data = JSON.parse(body)
|
|
2123
|
+
data = JSON.parse(body.to_s)
|
|
2124
|
+
return nil unless data.is_a?(Hash)
|
|
2125
|
+
|
|
1070
2126
|
error = data['error'] || data[:error]
|
|
1071
2127
|
return nil unless error == 'unauthorized_client'
|
|
1072
2128
|
|
|
1073
|
-
description = data['error_description'] || data[:error_description]
|
|
1074
|
-
return nil unless description
|
|
2129
|
+
description = matchable_peer_text(data['error_description'] || data[:error_description])
|
|
2130
|
+
return nil unless description
|
|
1075
2131
|
|
|
1076
2132
|
match = description.match(%r{You sent\s+(https?://\S+)[,.]?\s+and we expected\s+(https?://\S+)}i)
|
|
1077
2133
|
return nil unless match
|