ruby-mcp-client 2.1.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 +825 -48
- 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 +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- 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 +796 -518
- 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/errors.rb +474 -7
- 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 +666 -120
- 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 +900 -13
- 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 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- 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 +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- 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 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -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
|
|
@@ -72,9 +129,49 @@ module MCPClient
|
|
|
72
129
|
@challenge_error = nil
|
|
73
130
|
end
|
|
74
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
|
|
162
|
+
end
|
|
163
|
+
|
|
75
164
|
# @param url [String] Server URL to normalize
|
|
76
165
|
def server_url=(url)
|
|
77
|
-
|
|
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
|
|
78
175
|
end
|
|
79
176
|
|
|
80
177
|
# Set the Client ID Metadata Document URL (SEP-991), validating it per the
|
|
@@ -89,15 +186,50 @@ module MCPClient
|
|
|
89
186
|
# Get current access token (refresh if needed)
|
|
90
187
|
# @return [Token, nil] Current valid access token or nil
|
|
91
188
|
def access_token
|
|
92
|
-
|
|
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
|
|
93
194
|
logger.debug("OAuth access_token: retrieved token=#{token ? 'present' : 'nil'} for #{server_url}")
|
|
94
195
|
return nil unless token
|
|
95
196
|
|
|
96
197
|
# Return token if still valid
|
|
97
198
|
return token unless token.expired? || token.expires_soon?
|
|
98
199
|
|
|
99
|
-
#
|
|
100
|
-
|
|
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
|
|
101
233
|
end
|
|
102
234
|
|
|
103
235
|
# Return the scopes supported by the authorization server
|
|
@@ -105,7 +237,9 @@ module MCPClient
|
|
|
105
237
|
# @return [Array<String>] supported scopes, or empty array if not advertised
|
|
106
238
|
# @raise [MCPClient::Errors::ConnectionError] if server discovery fails
|
|
107
239
|
def supported_scopes
|
|
108
|
-
|
|
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)
|
|
109
243
|
end
|
|
110
244
|
|
|
111
245
|
# Start OAuth authorization flow
|
|
@@ -118,13 +252,37 @@ module MCPClient
|
|
|
118
252
|
# Register client if needed
|
|
119
253
|
client_info = get_or_register_client(server_metadata)
|
|
120
254
|
|
|
121
|
-
# Generate PKCE parameters
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
#
|
|
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.
|
|
126
266
|
state = SecureRandom.urlsafe_base64(32)
|
|
127
|
-
|
|
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
|
|
128
286
|
|
|
129
287
|
# Build authorization URL
|
|
130
288
|
build_authorization_url(server_metadata, client_info, pkce, state)
|
|
@@ -133,148 +291,343 @@ module MCPClient
|
|
|
133
291
|
# Complete OAuth authorization flow with authorization code
|
|
134
292
|
# @param code [String] Authorization code from callback
|
|
135
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)
|
|
136
297
|
# @return [Token] Access token
|
|
137
|
-
# @raise [MCPClient::Errors::ConnectionError] if token exchange fails
|
|
298
|
+
# @raise [MCPClient::Errors::ConnectionError] if the issuer check or the token exchange fails
|
|
138
299
|
# @raise [ArgumentError] if state parameter doesn't match
|
|
139
|
-
def complete_authorization_flow(code, state)
|
|
300
|
+
def complete_authorization_flow(code, state, iss: nil)
|
|
140
301
|
# Verify state parameter
|
|
141
302
|
stored_state = storage.get_state(server_url)
|
|
142
303
|
raise ArgumentError, 'Invalid state parameter' unless stored_state == state
|
|
143
304
|
|
|
144
305
|
# Get stored PKCE and client info
|
|
145
|
-
pkce =
|
|
146
|
-
client_info =
|
|
147
|
-
server_metadata = discover_authorization_server
|
|
148
|
-
|
|
306
|
+
pkce = stored_pkce
|
|
307
|
+
client_info = stored_client_info
|
|
149
308
|
raise MCPClient::Errors::ConnectionError, 'Missing PKCE or client info' unless pkce && client_info
|
|
150
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
|
+
|
|
151
331
|
# Exchange authorization code for tokens
|
|
152
332
|
token = exchange_authorization_code(server_metadata, client_info, code, pkce)
|
|
153
333
|
|
|
154
|
-
|
|
155
|
-
|
|
334
|
+
accept_exchanged_token(token, pkce)
|
|
335
|
+
end
|
|
156
336
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
160
374
|
|
|
161
375
|
token
|
|
162
376
|
end
|
|
163
377
|
|
|
164
|
-
#
|
|
165
|
-
#
|
|
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
|
|
166
419
|
# @return [void]
|
|
167
|
-
def
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
171
429
|
|
|
172
|
-
|
|
173
|
-
|
|
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(&)
|
|
174
453
|
end
|
|
175
454
|
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
# MCP 2025-11-25: "Clients MUST treat the scopes provided in the
|
|
191
|
-
# challenge as authoritative for satisfying the current request" —
|
|
192
|
-
# including resetting a previously challenged scope when the current
|
|
193
|
-
# challenge carries none.
|
|
194
|
-
url = extract_resource_metadata_url(www_authenticate)
|
|
195
|
-
|
|
196
|
-
# The challenge header is peer-controlled input: validate the
|
|
197
|
-
# advertised URL BEFORE storing, fetching, or recording any challenge
|
|
198
|
-
# state, so a malicious challenge cannot pivot this host into requests
|
|
199
|
-
# against internal services (SSRF) and cannot leave the provider
|
|
200
|
-
# holding half of a rejected challenge.
|
|
201
|
-
validate_peer_advertised_url!(url, 'resource metadata URL (from WWW-Authenticate challenge)') if url
|
|
202
|
-
|
|
203
|
-
@challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
|
|
204
|
-
return nil unless url
|
|
205
|
-
|
|
206
|
-
# Remember the advertised URL even if the fetch below fails, so a
|
|
207
|
-
# later discovery retries it instead of probing well-known URIs the
|
|
208
|
-
# challenge already superseded.
|
|
209
|
-
@challenge_metadata_url = url
|
|
210
|
-
|
|
211
|
-
# This URL was explicitly advertised by the 401 challenge, so a 404 is a
|
|
212
|
-
# misconfiguration to surface (strict), not a speculative miss to skip.
|
|
213
|
-
metadata = fetch_resource_metadata(url, strict: true)
|
|
214
|
-
# Reuse this challenge-advertised metadata during the subsequent OAuth
|
|
215
|
-
# flow instead of re-deriving (and possibly missing) the well-known URL.
|
|
216
|
-
@challenge_resource_metadata = metadata
|
|
217
|
-
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)
|
|
218
469
|
end
|
|
219
470
|
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
|
|
239
|
-
return m[1]
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
# Legacy fallback: resource="https://..."
|
|
243
|
-
params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
# Extract the Bearer challenge's own parameter segment from a (possibly
|
|
247
|
-
# multi-challenge) WWW-Authenticate header, so params belonging to other
|
|
248
|
-
# schemes (e.g. `Basic resource_metadata="...", Bearer realm="x"`) are
|
|
249
|
-
# never attributed to the Bearer challenge. Mirrors
|
|
250
|
-
# HttpTransportBase#bearer_challenge_segment.
|
|
251
|
-
# @param header [String, nil] the WWW-Authenticate header value
|
|
252
|
-
# @return [String, nil] the Bearer challenge's parameters (possibly
|
|
253
|
-
# empty), or nil when the header has no Bearer challenge
|
|
254
|
-
def bearer_challenge_segment(header)
|
|
255
|
-
return nil unless header
|
|
256
|
-
|
|
257
|
-
# Locate the Bearer scheme token only OUTSIDE quoted strings: a
|
|
258
|
-
# quoted value such as realm="prefix Bearer x" must not anchor the
|
|
259
|
-
# segment.
|
|
260
|
-
masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
|
|
261
|
-
match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
|
|
262
|
-
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
|
|
476
|
+
|
|
477
|
+
storage.set_state(server_url, removed)
|
|
478
|
+
end
|
|
479
|
+
|
|
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
|
|
263
489
|
|
|
264
|
-
|
|
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
|
|
265
495
|
end
|
|
266
496
|
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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'
|
|
275
527
|
end
|
|
528
|
+
return if client_info && client_for_request?(client_info, pkce)
|
|
276
529
|
|
|
277
|
-
|
|
530
|
+
raise MCPClient::Errors::ConnectionError,
|
|
531
|
+
'Authorization response rejected: the client credentials changed during the flow; ' \
|
|
532
|
+
'restart the authorization'
|
|
533
|
+
end
|
|
534
|
+
|
|
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
|
|
278
631
|
end
|
|
279
632
|
|
|
280
633
|
# Scope requested by the most recent WWW-Authenticate challenge.
|
|
@@ -283,27 +636,123 @@ module MCPClient
|
|
|
283
636
|
|
|
284
637
|
private
|
|
285
638
|
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
|
|
293
|
-
|
|
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)
|
|
658
|
+
|
|
659
|
+
raise MCPClient::Errors::ConnectionError,
|
|
660
|
+
'Authorization response rejected: the authorization server changed during the flow; ' \
|
|
661
|
+
'restart the authorization'
|
|
662
|
+
end
|
|
294
663
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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'
|
|
300
680
|
end
|
|
681
|
+
if iss.nil?
|
|
682
|
+
return unless supported
|
|
301
683
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
305
689
|
|
|
306
|
-
|
|
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"
|
|
307
756
|
end
|
|
308
757
|
|
|
309
758
|
# Normalize server URL to canonical form
|
|
@@ -423,10 +872,15 @@ module MCPClient
|
|
|
423
872
|
# @return [ServerMetadata] Authorization server metadata
|
|
424
873
|
# @raise [MCPClient::Errors::ConnectionError] if discovery fails
|
|
425
874
|
def discover_authorization_server
|
|
426
|
-
#
|
|
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
|
|
427
879
|
# authorization server is no longer the right one. Falling back to
|
|
428
880
|
# that cache (or to speculative well-known probing) would quietly
|
|
429
|
-
# undo the rejection, so surface it instead.
|
|
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.
|
|
430
884
|
raise MCPClient::Errors::ConnectionError, @challenge_error if @challenge_error
|
|
431
885
|
|
|
432
886
|
# A fresh 401 challenge is authoritative and overrides any cached
|
|
@@ -434,22 +888,68 @@ module MCPClient
|
|
|
434
888
|
# whether the challenge-advertised PRM was already fetched or only its
|
|
435
889
|
# URL is pending (e.g. the initial fetch failed and must be retried).
|
|
436
890
|
challenge_pending = @challenge_resource_metadata || @challenge_metadata_url
|
|
437
|
-
cached =
|
|
891
|
+
cached = stored_server_metadata unless challenge_pending || @rediscover_after_switch
|
|
438
892
|
if cached
|
|
439
893
|
# Validate the cached entry before use so a persisted/older cache with
|
|
440
894
|
# an HTTP endpoint or without S256 is still rejected.
|
|
441
895
|
validate_server_metadata!(cached)
|
|
442
|
-
|
|
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')
|
|
443
904
|
end
|
|
444
905
|
|
|
445
|
-
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
|
|
446
946
|
end
|
|
447
947
|
|
|
448
948
|
# Discover authorization server metadata, validate it, and cache it.
|
|
449
949
|
# @return [ServerMetadata]
|
|
450
950
|
# @raise [MCPClient::Errors::ConnectionError] if discovery or validation fails
|
|
451
951
|
def discover_and_cache_authorization_server
|
|
452
|
-
previous =
|
|
952
|
+
previous = stored_server_metadata
|
|
453
953
|
|
|
454
954
|
# RFC 9728: Protected Resource Metadata is authoritative — try it first,
|
|
455
955
|
# then fall back to treating the MCP server origin as its own AS.
|
|
@@ -463,15 +963,52 @@ module MCPClient
|
|
|
463
963
|
# Validate BEFORE caching so invalid metadata is never persisted.
|
|
464
964
|
validate_server_metadata!(server_metadata)
|
|
465
965
|
|
|
466
|
-
|
|
966
|
+
if previous
|
|
967
|
+
invalidate_client_info_on_as_change(previous, server_metadata)
|
|
968
|
+
else
|
|
969
|
+
retire_records_without_issuer
|
|
970
|
+
end
|
|
467
971
|
|
|
468
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
|
|
469
976
|
@challenge_resource_metadata = nil # consumed
|
|
470
977
|
@challenge_metadata_url = nil # consumed
|
|
471
978
|
@challenge_error = nil
|
|
472
979
|
server_metadata
|
|
473
980
|
end
|
|
474
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
|
+
|
|
475
1012
|
# When a 401 challenge changes the authorization server, per-AS state cached
|
|
476
1013
|
# under this server_url becomes invalid: a client_id registered with the
|
|
477
1014
|
# previous AS would fail as invalid_client, and memoized scopes belong to
|
|
@@ -481,17 +1018,63 @@ module MCPClient
|
|
|
481
1018
|
def invalidate_client_info_on_as_change(previous, current)
|
|
482
1019
|
return unless previous && previous.issuer != current.issuer
|
|
483
1020
|
|
|
484
|
-
logger.debug('Authorization server changed; discarding
|
|
1021
|
+
logger.debug('Authorization server changed; discarding the token and scopes from the previous AS')
|
|
485
1022
|
|
|
486
|
-
#
|
|
487
|
-
#
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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)
|
|
492
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
|
|
493
1063
|
|
|
494
|
-
|
|
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
|
|
495
1078
|
end
|
|
496
1079
|
|
|
497
1080
|
# Apply the PKCE-support and HTTPS-endpoint checks to server metadata.
|
|
@@ -514,22 +1097,38 @@ module MCPClient
|
|
|
514
1097
|
resource_metadata = challenge_or_well_known_resource_metadata
|
|
515
1098
|
return nil unless resource_metadata
|
|
516
1099
|
|
|
517
|
-
|
|
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
|
|
518
1112
|
|
|
519
1113
|
auth_server_url = Array(resource_metadata.authorization_servers).first
|
|
520
1114
|
unless auth_server_url
|
|
521
|
-
|
|
522
|
-
'Protected resource metadata does not advertise any authorization_servers'
|
|
1115
|
+
refuse_resource_metadata!('Protected resource metadata does not advertise any authorization_servers')
|
|
523
1116
|
end
|
|
524
1117
|
|
|
525
1118
|
# authorization_servers is untrusted PRM content: validate the
|
|
526
1119
|
# advertised origin BEFORE constructing and fetching well-known URLs
|
|
527
1120
|
# on it, so a malicious protected resource cannot drive discovery GETs
|
|
528
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.
|
|
529
1126
|
validate_peer_advertised_url!(auth_server_url,
|
|
530
|
-
'authorization server (advertised by protected resource metadata)'
|
|
1127
|
+
'authorization server (advertised by protected resource metadata)',
|
|
1128
|
+
latch: challenge_advertised_metadata?)
|
|
531
1129
|
|
|
532
|
-
server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url)
|
|
1130
|
+
server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url),
|
|
1131
|
+
auth_server_url)
|
|
533
1132
|
unless server_metadata
|
|
534
1133
|
raise MCPClient::Errors::ConnectionError,
|
|
535
1134
|
"Authorization server advertised by protected resource metadata (#{auth_server_url}) " \
|
|
@@ -563,7 +1162,7 @@ module MCPClient
|
|
|
563
1162
|
# @return [ServerMetadata, nil]
|
|
564
1163
|
def discover_via_direct_authorization_server
|
|
565
1164
|
origin = origin_of(URI.parse(server_url))
|
|
566
|
-
fetch_first_server_metadata(authorization_server_metadata_urls(origin))
|
|
1165
|
+
fetch_first_server_metadata(authorization_server_metadata_urls(origin), origin)
|
|
567
1166
|
end
|
|
568
1167
|
|
|
569
1168
|
# Fetch the first Protected Resource Metadata document that resolves.
|
|
@@ -586,21 +1185,57 @@ module MCPClient
|
|
|
586
1185
|
# Fetch the first Authorization Server Metadata document that resolves.
|
|
587
1186
|
# The oauth-authorization-server and openid-configuration forms are
|
|
588
1187
|
# genuine alternatives, so any failing candidate is skipped to try the next.
|
|
589
|
-
# @param urls [Array<String>]
|
|
1188
|
+
# @param urls [Array<String>] well-known candidates
|
|
1189
|
+
# @param issuer [String] the issuer identifier the candidates were built from
|
|
590
1190
|
# @return [ServerMetadata, nil]
|
|
591
|
-
def fetch_first_server_metadata(urls)
|
|
1191
|
+
def fetch_first_server_metadata(urls, issuer)
|
|
1192
|
+
rejected = nil
|
|
592
1193
|
urls.each do |url|
|
|
593
1194
|
md = try_fetch_server_metadata(url)
|
|
594
|
-
|
|
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
|
|
595
1208
|
end
|
|
1209
|
+
# Only mismatching documents were found: say so rather than "nothing".
|
|
1210
|
+
raise rejected if rejected
|
|
1211
|
+
|
|
596
1212
|
nil
|
|
597
1213
|
end
|
|
598
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
|
+
|
|
599
1234
|
# Non-raising server-metadata fetch used while iterating candidates.
|
|
600
1235
|
def try_fetch_server_metadata(url)
|
|
601
1236
|
fetch_server_metadata(url)
|
|
602
1237
|
rescue MCPClient::Errors::ConnectionError => e
|
|
603
|
-
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}")
|
|
604
1239
|
nil
|
|
605
1240
|
end
|
|
606
1241
|
|
|
@@ -617,6 +1252,14 @@ module MCPClient
|
|
|
617
1252
|
raise MCPClient::Errors::ConnectionError,
|
|
618
1253
|
'Authorization server metadata omits code_challenge_methods_supported; ' \
|
|
619
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'
|
|
620
1263
|
elsif !methods.include?('S256')
|
|
621
1264
|
raise MCPClient::Errors::ConnectionError,
|
|
622
1265
|
'Authorization server does not support PKCE S256 ' \
|
|
@@ -624,8 +1267,8 @@ module MCPClient
|
|
|
624
1267
|
end
|
|
625
1268
|
end
|
|
626
1269
|
|
|
627
|
-
# Require HTTPS for all discovered authorization server endpoints, with
|
|
628
|
-
#
|
|
1270
|
+
# Require HTTPS for all discovered authorization server endpoints, with
|
|
1271
|
+
# the local-development exception for a local stack.
|
|
629
1272
|
# @param server_metadata [ServerMetadata]
|
|
630
1273
|
def enforce_https_endpoints!(server_metadata)
|
|
631
1274
|
enforce_https!(server_metadata.authorization_endpoint, 'authorization endpoint')
|
|
@@ -635,87 +1278,34 @@ module MCPClient
|
|
|
635
1278
|
enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
|
|
636
1279
|
end
|
|
637
1280
|
|
|
638
|
-
#
|
|
639
|
-
#
|
|
640
|
-
#
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
#
|
|
644
|
-
#
|
|
645
|
-
#
|
|
646
|
-
#
|
|
647
|
-
#
|
|
648
|
-
#
|
|
649
|
-
# The rejection is recorded so a later discovery fails closed instead of
|
|
650
|
-
# silently reusing cached authorization-server metadata.
|
|
651
|
-
#
|
|
652
|
-
# NOTE: hostnames are checked literally. This does not resolve DNS, so a
|
|
653
|
-
# public name that resolves to a private address is not caught here;
|
|
654
|
-
# that needs resolution-time checking in the HTTP layer.
|
|
655
|
-
# @param url [String] the peer-advertised URL
|
|
656
|
-
# @param label [String] human-readable name for errors
|
|
657
|
-
# @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
|
|
658
|
-
def validate_peer_advertised_url!(url, label)
|
|
659
|
-
uri = URI.parse(url)
|
|
660
|
-
host = uri.hostname.to_s.downcase
|
|
661
|
-
|
|
662
|
-
if uri.scheme != 'https' && !(uri.scheme == 'http' && local_development?)
|
|
663
|
-
reject_challenge!("OAuth #{label} must use HTTPS: #{url}")
|
|
664
|
-
end
|
|
665
|
-
reject_challenge!("OAuth #{label} must not target a loopback or private address: #{url}") if
|
|
666
|
-
local_address?(host) && !local_development?
|
|
667
|
-
rescue URI::InvalidURIError
|
|
668
|
-
reject_challenge!("OAuth #{label} is not a valid URL: #{url}")
|
|
669
|
-
end
|
|
670
|
-
|
|
671
|
-
# @param message [String] why the challenge was refused
|
|
672
|
-
# @raise [MCPClient::Errors::ConnectionError] always
|
|
673
|
-
def reject_challenge!(message)
|
|
674
|
-
# Drop every scrap of the refused challenge so nothing half-applied
|
|
675
|
-
# survives, and remember why for the next discovery attempt.
|
|
676
|
-
@challenge_scope = nil
|
|
677
|
-
@challenge_metadata_url = nil
|
|
678
|
-
@challenge_resource_metadata = nil
|
|
679
|
-
@challenge_error = message
|
|
680
|
-
raise MCPClient::Errors::ConnectionError, message
|
|
681
|
-
end
|
|
682
|
-
|
|
683
|
-
# @return [Boolean] whether the configured MCP server is itself local,
|
|
684
|
-
# in which case local discovery targets are expected
|
|
685
|
-
def local_development?
|
|
686
|
-
local_address?(URI.parse(server_url).hostname.to_s.downcase)
|
|
687
|
-
rescue URI::InvalidURIError
|
|
688
|
-
false
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
# @param host [String] a downcased hostname
|
|
692
|
-
# @return [Boolean] whether it names a loopback, private or link-local address
|
|
693
|
-
def local_address?(host)
|
|
694
|
-
return true if %w[localhost 127.0.0.1 ::1 0.0.0.0].include?(host)
|
|
695
|
-
return true if host.end_with?('.localhost', '.local', '.internal')
|
|
696
|
-
return true if host.start_with?('127.', '10.', '192.168.', '169.254.')
|
|
697
|
-
return true if host.match?(/\A172\.(1[6-9]|2\d|3[01])\./)
|
|
698
|
-
return true if host.match?(/\A\[?(fc|fd|fe80)/)
|
|
699
|
-
|
|
700
|
-
false
|
|
701
|
-
end
|
|
702
|
-
|
|
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.
|
|
703
1292
|
# @param url [String, nil] endpoint URL
|
|
704
1293
|
# @param label [String] human-readable endpoint name for errors
|
|
705
|
-
# @raise [MCPClient::Errors::ConnectionError] if the URL is not
|
|
1294
|
+
# @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
|
|
706
1295
|
def enforce_https!(url, label)
|
|
707
1296
|
return if url.nil?
|
|
708
1297
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
# Dev exception is only for plain HTTP on a loopback host — not any other
|
|
712
|
-
# scheme (e.g. ftp://localhost). Use #hostname (not #host) so an IPv6
|
|
713
|
-
# loopback like http://[::1]:9292 matches without the surrounding brackets.
|
|
714
|
-
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
|
|
715
1300
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
719
1309
|
end
|
|
720
1310
|
|
|
721
1311
|
# Validate the PRM `resource` identifies this server (RFC 9728 confused
|
|
@@ -739,7 +1329,7 @@ module MCPClient
|
|
|
739
1329
|
return if advertised == expected
|
|
740
1330
|
|
|
741
1331
|
raise MCPClient::Errors::ConnectionError,
|
|
742
|
-
"Protected resource metadata resource (#{resource_metadata.resource}) " \
|
|
1332
|
+
"Protected resource metadata resource (#{safe_error_text(resource_metadata.resource.to_s)}) " \
|
|
743
1333
|
"does not match the server URL (#{server_url})"
|
|
744
1334
|
end
|
|
745
1335
|
|
|
@@ -755,7 +1345,8 @@ module MCPClient
|
|
|
755
1345
|
def resource_identity(url)
|
|
756
1346
|
uri = URI.parse(url)
|
|
757
1347
|
unless uri.scheme && uri.host
|
|
758
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1348
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1349
|
+
"Invalid resource URL (must be absolute): #{safe_error_text(url.to_s)}"
|
|
759
1350
|
end
|
|
760
1351
|
|
|
761
1352
|
scheme = uri.scheme.downcase
|
|
@@ -769,7 +1360,7 @@ module MCPClient
|
|
|
769
1360
|
|
|
770
1361
|
"#{scheme}://#{host}#{":#{port}" if port}#{path}#{query}"
|
|
771
1362
|
rescue URI::InvalidURIError
|
|
772
|
-
raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{url}"
|
|
1363
|
+
raise MCPClient::Errors::ConnectionError, "Invalid resource URL: #{safe_error_text(url.to_s)}"
|
|
773
1364
|
end
|
|
774
1365
|
|
|
775
1366
|
# Fetch resource metadata from URL.
|
|
@@ -785,7 +1376,7 @@ module MCPClient
|
|
|
785
1376
|
# @return [ResourceMetadata, nil] metadata, or nil if a speculative URL returns 404
|
|
786
1377
|
# @raise [MCPClient::Errors::ConnectionError] on any non-404 failure, or on 404 when strict
|
|
787
1378
|
def fetch_resource_metadata(url, strict: false)
|
|
788
|
-
logger.debug("Fetching resource metadata from: #{url}")
|
|
1379
|
+
logger.debug("Fetching resource metadata from: #{safe_error_text(url.to_s)}")
|
|
789
1380
|
|
|
790
1381
|
response = @http_client.get(url) do |req|
|
|
791
1382
|
req.headers['Accept'] = 'application/json'
|
|
@@ -798,15 +1389,29 @@ module MCPClient
|
|
|
798
1389
|
end
|
|
799
1390
|
|
|
800
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
|
+
|
|
801
1404
|
metadata = ResourceMetadata.from_h(data)
|
|
802
1405
|
# Retain the most recently fetched PRM so scope resolution can fall
|
|
803
1406
|
# back to its scopes_supported (MCP scope selection priority 2).
|
|
804
1407
|
@resource_metadata = metadata
|
|
805
1408
|
metadata
|
|
806
1409
|
rescue JSON::ParserError => e
|
|
807
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1410
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1411
|
+
"Invalid resource metadata JSON: #{describe_parse_error(e, response&.body)}"
|
|
808
1412
|
rescue Faraday::Error => e
|
|
809
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1413
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1414
|
+
"Network error fetching resource metadata: #{safe_error_text(e.message)}"
|
|
810
1415
|
end
|
|
811
1416
|
|
|
812
1417
|
# Fetch authorization server metadata from URL
|
|
@@ -814,7 +1419,7 @@ module MCPClient
|
|
|
814
1419
|
# @return [ServerMetadata] Server metadata
|
|
815
1420
|
# @raise [MCPClient::Errors::ConnectionError] if fetch fails
|
|
816
1421
|
def fetch_server_metadata(url)
|
|
817
|
-
logger.debug("Fetching server metadata from: #{url}")
|
|
1422
|
+
logger.debug("Fetching server metadata from: #{safe_error_text(url.to_s)}")
|
|
818
1423
|
|
|
819
1424
|
response = @http_client.get(url) do |req|
|
|
820
1425
|
req.headers['Accept'] = 'application/json'
|
|
@@ -825,68 +1430,72 @@ module MCPClient
|
|
|
825
1430
|
end
|
|
826
1431
|
|
|
827
1432
|
data = JSON.parse(response.body)
|
|
828
|
-
|
|
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)
|
|
829
1446
|
rescue JSON::ParserError => e
|
|
830
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1447
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1448
|
+
"Invalid server metadata JSON: #{describe_parse_error(e, response&.body)}"
|
|
831
1449
|
rescue Faraday::Error => e
|
|
832
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1450
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1451
|
+
"Network error fetching server metadata: #{safe_error_text(e.message)}"
|
|
833
1452
|
end
|
|
834
1453
|
|
|
835
|
-
#
|
|
836
|
-
#
|
|
837
|
-
#
|
|
838
|
-
#
|
|
839
|
-
#
|
|
840
|
-
#
|
|
841
|
-
#
|
|
842
|
-
#
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
raise MCPClient::Errors::ConnectionError,
|
|
862
|
-
'Dynamic client registration not supported and no client credentials found'
|
|
863
|
-
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
|
|
864
1480
|
end
|
|
865
1481
|
|
|
866
|
-
#
|
|
867
|
-
#
|
|
868
|
-
#
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
def client_info_from_metadata_url
|
|
873
|
-
logger.debug("Using Client ID Metadata Document URL as client_id: #{client_id_metadata_url}")
|
|
874
|
-
|
|
875
|
-
metadata = ClientMetadata.new(
|
|
876
|
-
redirect_uris: [redirect_uri],
|
|
877
|
-
token_endpoint_auth_method: 'none', # Public client
|
|
878
|
-
grant_types: %w[authorization_code refresh_token],
|
|
879
|
-
response_types: ['code'],
|
|
880
|
-
scope: resolved_scope,
|
|
881
|
-
**@extra_client_metadata
|
|
882
|
-
)
|
|
883
|
-
|
|
884
|
-
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
|
|
885
1488
|
|
|
886
|
-
|
|
887
|
-
|
|
1489
|
+
# @return [PKCE, nil]
|
|
1490
|
+
def stored_pkce
|
|
1491
|
+
normalize_record(storage.get_pkce(server_url), PKCE)
|
|
1492
|
+
end
|
|
888
1493
|
|
|
889
|
-
|
|
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
|
|
890
1499
|
end
|
|
891
1500
|
|
|
892
1501
|
# Validate a Client ID Metadata Document URL (SEP-991): "The client_id
|
|
@@ -925,38 +1534,43 @@ module MCPClient
|
|
|
925
1534
|
end
|
|
926
1535
|
|
|
927
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.
|
|
928
1542
|
# @param server_metadata [ServerMetadata] Authorization server metadata
|
|
929
1543
|
# @return [ClientInfo] Registered client information
|
|
930
1544
|
# @raise [MCPClient::Errors::ConnectionError] if registration fails
|
|
931
1545
|
def register_client(server_metadata)
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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)
|
|
947
1562
|
end
|
|
948
1563
|
|
|
949
|
-
unless response.success?
|
|
950
|
-
raise MCPClient::Errors::ConnectionError, "Client registration failed: HTTP #{response.status}"
|
|
951
|
-
end
|
|
1564
|
+
raise_registration_failure!(response) unless response.success?
|
|
952
1565
|
|
|
953
1566
|
data = JSON.parse(response.body)
|
|
954
|
-
|
|
1567
|
+
client_id = registered_client_id!(data)
|
|
1568
|
+
logger.debug("OAuth client registered successfully: #{safe_error_text(client_id)}")
|
|
955
1569
|
|
|
956
1570
|
# Parse registered metadata from server response (may differ from our request)
|
|
957
1571
|
registered_metadata = ClientMetadata.new(
|
|
958
|
-
redirect_uris: data
|
|
959
|
-
token_endpoint_auth_method: data
|
|
1572
|
+
redirect_uris: registered_redirect_uris(data),
|
|
1573
|
+
token_endpoint_auth_method: registered_auth_method(data),
|
|
960
1574
|
grant_types: data['grant_types'] || %w[authorization_code refresh_token],
|
|
961
1575
|
response_types: data['response_types'] || ['code'],
|
|
962
1576
|
scope: data['scope'],
|
|
@@ -965,35 +1579,154 @@ module MCPClient
|
|
|
965
1579
|
logo_uri: data['logo_uri'],
|
|
966
1580
|
tos_uri: data['tos_uri'],
|
|
967
1581
|
policy_uri: data['policy_uri'],
|
|
968
|
-
contacts: data['contacts']
|
|
1582
|
+
contacts: data['contacts'],
|
|
1583
|
+
application_type: data['application_type'] || app_type
|
|
969
1584
|
)
|
|
970
1585
|
|
|
971
|
-
|
|
972
|
-
requested_uri = redirect_uri
|
|
973
|
-
registered_uri = registered_metadata.redirect_uris.first
|
|
974
|
-
if registered_uri != requested_uri
|
|
975
|
-
logger.warn('OAuth server changed redirect_uri:')
|
|
976
|
-
logger.warn(" Requested: #{requested_uri}")
|
|
977
|
-
logger.warn(" Registered: #{registered_uri}")
|
|
978
|
-
logger.warn("Using server's registered redirect_uri for token exchange.")
|
|
979
|
-
end
|
|
1586
|
+
warn_registered_redirect_uri(registered_metadata)
|
|
980
1587
|
|
|
981
1588
|
client_info = ClientInfo.new(
|
|
982
|
-
client_id:
|
|
1589
|
+
client_id: client_id,
|
|
983
1590
|
client_secret: data['client_secret'],
|
|
984
1591
|
client_id_issued_at: data['client_id_issued_at'],
|
|
985
1592
|
client_secret_expires_at: data['client_secret_expires_at'],
|
|
986
|
-
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'
|
|
987
1597
|
)
|
|
988
1598
|
|
|
989
1599
|
# Store client info
|
|
990
|
-
|
|
1600
|
+
store_client_info(client_info)
|
|
991
1601
|
|
|
992
1602
|
client_info
|
|
993
1603
|
rescue JSON::ParserError => e
|
|
994
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1604
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1605
|
+
"Invalid client registration response: #{describe_parse_error(e, response&.body)}"
|
|
995
1606
|
rescue Faraday::Error => e
|
|
996
|
-
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
|
+
{}
|
|
997
1730
|
end
|
|
998
1731
|
|
|
999
1732
|
# Build authorization URL
|
|
@@ -1010,7 +1743,7 @@ module MCPClient
|
|
|
1010
1743
|
response_type: 'code',
|
|
1011
1744
|
client_id: client_info.client_id,
|
|
1012
1745
|
redirect_uri: registered_redirect_uri,
|
|
1013
|
-
scope:
|
|
1746
|
+
scope: @requested_scope,
|
|
1014
1747
|
state: state,
|
|
1015
1748
|
code_challenge: pkce.code_challenge,
|
|
1016
1749
|
code_challenge_method: pkce.code_challenge_method,
|
|
@@ -1018,10 +1751,50 @@ module MCPClient
|
|
|
1018
1751
|
}.compact
|
|
1019
1752
|
|
|
1020
1753
|
uri = URI.parse(server_metadata.authorization_endpoint)
|
|
1021
|
-
|
|
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)
|
|
1022
1773
|
uri.to_s
|
|
1023
1774
|
end
|
|
1024
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
|
+
|
|
1025
1798
|
# Exchange authorization code for access token
|
|
1026
1799
|
# @param server_metadata [ServerMetadata] Server metadata
|
|
1027
1800
|
# @param client_info [ClientInfo] Client information
|
|
@@ -1032,8 +1805,10 @@ module MCPClient
|
|
|
1032
1805
|
def exchange_authorization_code(server_metadata, client_info, code, pkce)
|
|
1033
1806
|
logger.debug('Exchanging authorization code for access token')
|
|
1034
1807
|
|
|
1035
|
-
#
|
|
1036
|
-
|
|
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
|
|
1037
1812
|
|
|
1038
1813
|
params = {
|
|
1039
1814
|
grant_type: 'authorization_code',
|
|
@@ -1044,10 +1819,10 @@ module MCPClient
|
|
|
1044
1819
|
resource: server_url
|
|
1045
1820
|
}
|
|
1046
1821
|
|
|
1047
|
-
#
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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)
|
|
1051
1826
|
|
|
1052
1827
|
request_body = URI.encode_www_form(params)
|
|
1053
1828
|
|
|
@@ -1055,6 +1830,7 @@ module MCPClient
|
|
|
1055
1830
|
@http_client.post(server_metadata.token_endpoint) do |req|
|
|
1056
1831
|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
1057
1832
|
req.headers['Accept'] = 'application/json'
|
|
1833
|
+
req.headers['Authorization'] = authorization if authorization
|
|
1058
1834
|
req.body = body
|
|
1059
1835
|
end
|
|
1060
1836
|
end
|
|
@@ -1062,37 +1838,44 @@ module MCPClient
|
|
|
1062
1838
|
response = send_token_request.call(request_body)
|
|
1063
1839
|
|
|
1064
1840
|
unless response.success?
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
if
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
"Token exchange failed: redirect_uri mismatch. Retrying with server's expected value: #{expected_uri}"
|
|
1071
|
-
)
|
|
1072
|
-
|
|
1073
|
-
params[:redirect_uri] = redirect_hint[:expected]
|
|
1074
|
-
retry_body = URI.encode_www_form(params)
|
|
1075
|
-
|
|
1076
|
-
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))
|
|
1077
1846
|
end
|
|
1078
1847
|
end
|
|
1079
1848
|
|
|
1080
1849
|
unless response.success?
|
|
1081
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1850
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1851
|
+
"Token exchange failed: HTTP #{response.status} - #{safe_body_text(response.body)}"
|
|
1082
1852
|
end
|
|
1083
1853
|
|
|
1084
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
|
+
|
|
1085
1863
|
Token.new(
|
|
1086
1864
|
access_token: data['access_token'],
|
|
1087
|
-
token_type: data['token_type']
|
|
1865
|
+
token_type: data['token_type'],
|
|
1088
1866
|
expires_in: data['expires_in'],
|
|
1089
|
-
scope:
|
|
1090
|
-
|
|
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
|
|
1091
1872
|
)
|
|
1092
1873
|
rescue JSON::ParserError => e
|
|
1093
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1874
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1875
|
+
"Invalid token response: #{describe_parse_error(e, response&.body)}"
|
|
1094
1876
|
rescue Faraday::Error => e
|
|
1095
|
-
raise MCPClient::Errors::ConnectionError,
|
|
1877
|
+
raise MCPClient::Errors::ConnectionError,
|
|
1878
|
+
"Network error during token exchange: #{safe_error_text(e.message)}"
|
|
1096
1879
|
end
|
|
1097
1880
|
|
|
1098
1881
|
# Refresh access token
|
|
@@ -1104,10 +1887,15 @@ module MCPClient
|
|
|
1104
1887
|
logger.debug('Refreshing access token')
|
|
1105
1888
|
|
|
1106
1889
|
server_metadata = discover_authorization_server
|
|
1107
|
-
|
|
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)
|
|
1108
1893
|
|
|
1109
1894
|
return nil unless server_metadata && client_info
|
|
1895
|
+
return nil unless refresh_permitted?(token, client_info, server_metadata)
|
|
1110
1896
|
|
|
1897
|
+
# The resource the refresh is made for, judged again over the response.
|
|
1898
|
+
resource = server_url
|
|
1111
1899
|
params = {
|
|
1112
1900
|
grant_type: 'refresh_token',
|
|
1113
1901
|
refresh_token: token.refresh_token,
|
|
@@ -1115,14 +1903,12 @@ module MCPClient
|
|
|
1115
1903
|
resource: server_url
|
|
1116
1904
|
}
|
|
1117
1905
|
|
|
1118
|
-
|
|
1119
|
-
if client_info.client_secret && client_info.metadata.token_endpoint_auth_method == 'client_secret_post'
|
|
1120
|
-
params[:client_secret] = client_info.client_secret
|
|
1121
|
-
end
|
|
1906
|
+
authorization = apply_client_credentials!(params, client_info)
|
|
1122
1907
|
|
|
1123
1908
|
response = @http_client.post(server_metadata.token_endpoint) do |req|
|
|
1124
1909
|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
1125
1910
|
req.headers['Accept'] = 'application/json'
|
|
1911
|
+
req.headers['Authorization'] = authorization if authorization
|
|
1126
1912
|
req.body = URI.encode_www_form(params)
|
|
1127
1913
|
end
|
|
1128
1914
|
|
|
@@ -1132,34 +1918,216 @@ module MCPClient
|
|
|
1132
1918
|
end
|
|
1133
1919
|
|
|
1134
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
|
+
|
|
1135
1932
|
new_token = Token.new(
|
|
1136
1933
|
access_token: data['access_token'],
|
|
1137
|
-
token_type: data['token_type']
|
|
1934
|
+
token_type: data['token_type'],
|
|
1138
1935
|
expires_in: data['expires_in'],
|
|
1139
|
-
scope
|
|
1140
|
-
|
|
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
|
|
1141
1941
|
)
|
|
1142
1942
|
|
|
1143
|
-
|
|
1144
|
-
new_token
|
|
1943
|
+
accept_refreshed_token(new_token, server_metadata.issuer, resource, token)
|
|
1145
1944
|
rescue JSON::ParserError => e
|
|
1146
|
-
logger.warn("Invalid token refresh response: #{describe_parse_error(e)}")
|
|
1945
|
+
logger.warn("Invalid token refresh response: #{describe_parse_error(e, response&.body)}")
|
|
1147
1946
|
nil
|
|
1148
1947
|
rescue Faraday::Error => e
|
|
1149
|
-
logger.warn("Network error during token refresh: #{e.message}")
|
|
1948
|
+
logger.warn("Network error during token refresh: #{safe_error_text(e.message)}")
|
|
1150
1949
|
nil
|
|
1151
1950
|
end
|
|
1152
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
|
+
|
|
1153
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.
|
|
1154
2120
|
# @param body [String] Raw HTTP response body
|
|
1155
2121
|
# @return [Hash, nil] Hash with :sent and :expected URIs if mismatch detected
|
|
1156
2122
|
def extract_redirect_mismatch(body)
|
|
1157
|
-
data = JSON.parse(body)
|
|
2123
|
+
data = JSON.parse(body.to_s)
|
|
2124
|
+
return nil unless data.is_a?(Hash)
|
|
2125
|
+
|
|
1158
2126
|
error = data['error'] || data[:error]
|
|
1159
2127
|
return nil unless error == 'unauthorized_client'
|
|
1160
2128
|
|
|
1161
|
-
description = data['error_description'] || data[:error_description]
|
|
1162
|
-
return nil unless description
|
|
2129
|
+
description = matchable_peer_text(data['error_description'] || data[:error_description])
|
|
2130
|
+
return nil unless description
|
|
1163
2131
|
|
|
1164
2132
|
match = description.match(%r{You sent\s+(https?://\S+)[,.]?\s+and we expected\s+(https?://\S+)}i)
|
|
1165
2133
|
return nil unless match
|