mcp 0.16.0 → 0.17.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/README.md +107 -0
- data/lib/mcp/client/http.rb +202 -55
- data/lib/mcp/client/oauth/discovery.rb +423 -0
- data/lib/mcp/client/oauth/flow.rb +587 -0
- data/lib/mcp/client/oauth/in_memory_storage.rb +43 -0
- data/lib/mcp/client/oauth/pkce.rb +38 -0
- data/lib/mcp/client/oauth/provider.rb +103 -0
- data/lib/mcp/client/oauth.rb +18 -0
- data/lib/mcp/client.rb +1 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +127 -13
- data/lib/mcp/server.rb +9 -0
- data/lib/mcp/server_session.rb +13 -0
- data/lib/mcp/version.rb +1 -1
- metadata +8 -2
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module MCP
|
|
10
|
+
class Client
|
|
11
|
+
module OAuth
|
|
12
|
+
# Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow.
|
|
13
|
+
# Driven by `MCP::Client::HTTP` on a 401 response. The user-facing surface is
|
|
14
|
+
# `Provider`; this class consumes a Provider plus signal data extracted from
|
|
15
|
+
# the failing response (resource_metadata URL, scope challenge).
|
|
16
|
+
class Flow
|
|
17
|
+
class AuthorizationError < StandardError; end
|
|
18
|
+
|
|
19
|
+
# Raised specifically when the token endpoint rejects a grant with
|
|
20
|
+
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
|
|
21
|
+
# distinguish "the stored refresh token is dead, discard it" from
|
|
22
|
+
# transient failures (network, 5xx, other RFC 6749 error codes) that
|
|
23
|
+
# should leave the refresh token intact.
|
|
24
|
+
class InvalidGrantError < AuthorizationError; end
|
|
25
|
+
|
|
26
|
+
def initialize(provider:, http_client_factory: nil)
|
|
27
|
+
@provider = provider
|
|
28
|
+
@http_client_factory = http_client_factory || -> { default_http_client }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Runs the full discovery, registration, authorization, and token exchange flow.
|
|
32
|
+
# On success, persists tokens via the provider and returns `:authorized`.
|
|
33
|
+
def run!(server_url:, resource_metadata_url: nil, scope: nil)
|
|
34
|
+
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
|
|
35
|
+
# first thing we contact in the OAuth flow, so it has to clear the same
|
|
36
|
+
# Communication Security bar as the OAuth endpoints downstream.
|
|
37
|
+
if resource_metadata_url
|
|
38
|
+
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
prm = fetch_protected_resource_metadata(
|
|
42
|
+
server_url: server_url,
|
|
43
|
+
resource_metadata_url: resource_metadata_url,
|
|
44
|
+
)
|
|
45
|
+
authorization_server = first_authorization_server(prm)
|
|
46
|
+
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
47
|
+
|
|
48
|
+
# Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
|
|
49
|
+
# both the authorization and token requests. When PRM advertises a `resource`,
|
|
50
|
+
# it MUST identify the same MCP server we are talking to; otherwise we are
|
|
51
|
+
# being redirected to credentials minted for a different audience.
|
|
52
|
+
resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
|
|
53
|
+
|
|
54
|
+
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
55
|
+
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
56
|
+
ensure_secure_endpoints!(as_metadata)
|
|
57
|
+
ensure_pkce_supported!(as_metadata)
|
|
58
|
+
|
|
59
|
+
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
60
|
+
|
|
61
|
+
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
62
|
+
pkce = PKCE.generate
|
|
63
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
64
|
+
|
|
65
|
+
authorization_url = build_authorization_url(
|
|
66
|
+
as_metadata: as_metadata,
|
|
67
|
+
client_id: client_info_required_value(client_info, "client_id"),
|
|
68
|
+
scope: effective_scope,
|
|
69
|
+
state: state,
|
|
70
|
+
code_challenge: pkce[:code_challenge],
|
|
71
|
+
resource: resource,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@provider.redirect_handler.call(authorization_url)
|
|
75
|
+
code, returned_state = Array(@provider.callback_handler.call)
|
|
76
|
+
raise AuthorizationError, "Authorization callback did not return an authorization code." unless code
|
|
77
|
+
|
|
78
|
+
unless states_match?(returned_state, state)
|
|
79
|
+
raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
tokens = exchange_authorization_code(
|
|
83
|
+
as_metadata: as_metadata,
|
|
84
|
+
client_info: client_info,
|
|
85
|
+
code: code,
|
|
86
|
+
code_verifier: pkce[:code_verifier],
|
|
87
|
+
resource: resource,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@provider.save_tokens(tokens)
|
|
91
|
+
:authorized
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749
|
|
95
|
+
# Section 6). Re-discovers PRM and AS metadata so we always pick up a moved
|
|
96
|
+
# token endpoint, and re-runs the audience / issuer / security checks
|
|
97
|
+
# before talking to it.
|
|
98
|
+
#
|
|
99
|
+
# Returns `:refreshed` on success. Raises `AuthorizationError` when
|
|
100
|
+
# the provider has no refresh token, no client information, or when
|
|
101
|
+
# the token endpoint refuses the refresh request.
|
|
102
|
+
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
|
103
|
+
def refresh!(server_url:, resource_metadata_url: nil)
|
|
104
|
+
refresh_token = read_token("refresh_token")
|
|
105
|
+
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
|
|
106
|
+
|
|
107
|
+
client_info = @provider.client_information
|
|
108
|
+
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
|
|
109
|
+
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if resource_metadata_url
|
|
113
|
+
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
prm = fetch_protected_resource_metadata(
|
|
117
|
+
server_url: server_url,
|
|
118
|
+
resource_metadata_url: resource_metadata_url,
|
|
119
|
+
)
|
|
120
|
+
authorization_server = first_authorization_server(prm)
|
|
121
|
+
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
122
|
+
|
|
123
|
+
resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])
|
|
124
|
+
|
|
125
|
+
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
126
|
+
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
127
|
+
ensure_secure_endpoints!(as_metadata)
|
|
128
|
+
|
|
129
|
+
new_tokens = exchange_refresh_token(
|
|
130
|
+
as_metadata: as_metadata,
|
|
131
|
+
client_info: client_info,
|
|
132
|
+
refresh_token: refresh_token,
|
|
133
|
+
resource: resource,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
|
|
137
|
+
:refreshed
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def read_token(key)
|
|
143
|
+
tokens = @provider.tokens
|
|
144
|
+
return unless tokens.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
value = tokens[key] || tokens[key.to_sym]
|
|
147
|
+
value.to_s.empty? ? nil : value
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Per RFC 6749 Section 6, the refresh response MAY omit `refresh_token`, in
|
|
151
|
+
# which case the previous one stays valid. Preserve it explicitly so
|
|
152
|
+
# downstream refresh attempts still work.
|
|
153
|
+
def preserve_refresh_token(new_tokens, previous_refresh_token)
|
|
154
|
+
return new_tokens if new_tokens["refresh_token"] || new_tokens[:refresh_token]
|
|
155
|
+
|
|
156
|
+
new_tokens.merge("refresh_token" => previous_refresh_token)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def fetch_protected_resource_metadata(server_url:, resource_metadata_url:)
|
|
160
|
+
urls = Discovery.protected_resource_metadata_urls(
|
|
161
|
+
server_url: server_url,
|
|
162
|
+
resource_metadata_url: resource_metadata_url,
|
|
163
|
+
)
|
|
164
|
+
fetch_metadata_json(urls, label: "protected resource metadata")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def fetch_authorization_server_metadata(issuer_url:)
|
|
168
|
+
urls = Discovery.authorization_server_metadata_urls(issuer_url)
|
|
169
|
+
fetch_metadata_json(urls, label: "authorization server metadata")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Reads `authorization_servers` from a PRM document and returns
|
|
173
|
+
# the first entry, raising `AuthorizationError` for any of the malformed
|
|
174
|
+
# shapes a non-compliant server could emit (missing field, non-Array
|
|
175
|
+
# value, empty Array, non-String first entry). Centralizing this lets
|
|
176
|
+
# both the full flow and the refresh flow share the same defensive
|
|
177
|
+
# parse instead of each one duplicating a Hash-and-Array check.
|
|
178
|
+
def first_authorization_server(prm)
|
|
179
|
+
authorization_servers = prm["authorization_servers"]
|
|
180
|
+
unless authorization_servers.is_a?(Array)
|
|
181
|
+
raise AuthorizationError,
|
|
182
|
+
"Protected resource metadata `authorization_servers` is not an array " \
|
|
183
|
+
"(got #{authorization_servers.class})."
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if authorization_servers.empty?
|
|
187
|
+
raise AuthorizationError, "Protected resource metadata has no authorization_servers."
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
first = authorization_servers.first
|
|
191
|
+
unless first.is_a?(String) && !first.empty?
|
|
192
|
+
raise AuthorizationError,
|
|
193
|
+
"Protected resource metadata `authorization_servers[0]` is not a non-empty string."
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
first
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Walks candidate metadata URLs and returns the parsed JSON body of
|
|
200
|
+
# the first 2xx response. Raises `AuthorizationError` for transport
|
|
201
|
+
# failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
|
|
202
|
+
# so callers do not have to handle raw Faraday/JSON exceptions.
|
|
203
|
+
def fetch_metadata_json(urls, label:)
|
|
204
|
+
last_error = nil
|
|
205
|
+
urls.each do |url|
|
|
206
|
+
response = begin
|
|
207
|
+
http_get(url)
|
|
208
|
+
rescue Faraday::Error => e
|
|
209
|
+
last_error = "GET #{url} raised #{e.class}: #{e.message}"
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if response.status >= 200 && response.status < 300
|
|
214
|
+
parsed = begin
|
|
215
|
+
JSON.parse(response_body_string(response))
|
|
216
|
+
rescue JSON::ParserError => e
|
|
217
|
+
raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Even valid JSON can be the wrong shape (a top-level array,
|
|
221
|
+
# a bare `null`, a string, ...). The discovery callers index by
|
|
222
|
+
# name (`prm["authorization_servers"]`, etc.), so anything that
|
|
223
|
+
# is not a Hash would raise `TypeError` / `NoMethodError`
|
|
224
|
+
# downstream. Surface that as `AuthorizationError` instead so
|
|
225
|
+
# callers see a single, documented error type.
|
|
226
|
+
unless parsed.is_a?(Hash)
|
|
227
|
+
raise AuthorizationError,
|
|
228
|
+
"#{label} from #{url} is not a JSON object (got #{parsed.class})."
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
return parsed
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
last_error = "GET #{url} returned #{response.status}"
|
|
235
|
+
end
|
|
236
|
+
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def ensure_pkce_supported!(as_metadata)
|
|
240
|
+
methods = as_metadata["code_challenge_methods_supported"]
|
|
241
|
+
return if methods.is_a?(Array) && methods.include?("S256")
|
|
242
|
+
|
|
243
|
+
raise AuthorizationError,
|
|
244
|
+
"Authorization server does not advertise S256 PKCE support; refusing to proceed."
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Per the MCP authorization spec's Communication Security requirement,
|
|
248
|
+
# OAuth endpoints MUST use HTTPS unless the host is a loopback address.
|
|
249
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
|
|
250
|
+
def ensure_secure_url!(url, label:)
|
|
251
|
+
return if Discovery.secure_url?(url)
|
|
252
|
+
|
|
253
|
+
raise AuthorizationError,
|
|
254
|
+
"#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def ensure_secure_endpoints!(as_metadata)
|
|
258
|
+
["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
|
|
259
|
+
endpoint = as_metadata[key]
|
|
260
|
+
ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
|
|
265
|
+
# identical (literal byte-for-byte equality, no normalization) to
|
|
266
|
+
# the issuer URL the client used to discover that document. This guards
|
|
267
|
+
# against a CDN/relay returning the metadata of a *different*
|
|
268
|
+
# authorization server than the one PRM advertised, and against
|
|
269
|
+
# ambiguities like trailing `/`, fragments, or case differences that
|
|
270
|
+
# could mask a confused-deputy attempt.
|
|
271
|
+
# https://www.rfc-editor.org/rfc/rfc8414#section-3.3
|
|
272
|
+
def ensure_issuer_matches!(expected:, returned:)
|
|
273
|
+
unless returned
|
|
274
|
+
raise AuthorizationError, "Authorization server metadata is missing the `issuer` field."
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
return if expected.to_s == returned.to_s
|
|
278
|
+
|
|
279
|
+
raise AuthorizationError,
|
|
280
|
+
"Authorization server metadata `issuer` does not match the discovery URL " \
|
|
281
|
+
"(expected #{expected.inspect}, got #{returned.inspect})."
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def ensure_client_registered(as_metadata:)
|
|
285
|
+
existing = @provider.client_information
|
|
286
|
+
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
|
|
287
|
+
|
|
288
|
+
registration_endpoint = as_metadata["registration_endpoint"]
|
|
289
|
+
unless registration_endpoint
|
|
290
|
+
raise AuthorizationError,
|
|
291
|
+
"Authorization server has no registration_endpoint and no pre-registered client information was provided."
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
response = begin
|
|
295
|
+
http_post_json(registration_endpoint, @provider.client_metadata)
|
|
296
|
+
rescue Faraday::Error => e
|
|
297
|
+
raise AuthorizationError,
|
|
298
|
+
"Dynamic client registration failed: #{e.class}: #{e.message}."
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
if response.status < 200 || response.status >= 300
|
|
302
|
+
raise AuthorizationError, "Dynamic client registration failed with status #{response.status}."
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
info = begin
|
|
306
|
+
JSON.parse(response_body_string(response))
|
|
307
|
+
rescue JSON::ParserError => e
|
|
308
|
+
raise AuthorizationError,
|
|
309
|
+
"Failed to parse dynamic client registration response: #{e.message}."
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
|
|
313
|
+
raise AuthorizationError,
|
|
314
|
+
"Dynamic client registration response is missing `client_id`."
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
@provider.save_client_information(info)
|
|
318
|
+
info
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Reads `key` from a `client_information` hash that may use either string or
|
|
322
|
+
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
|
|
323
|
+
# `{ client_id:, client_secret: }` and have both work.
|
|
324
|
+
def client_info_value(info, key)
|
|
325
|
+
info[key] || info[key.to_sym]
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Same as `client_info_value` but treats blank strings (`""` or only
|
|
329
|
+
# whitespace) as absent. Used for fields where empty values are never
|
|
330
|
+
# meaningful (`client_id`, `client_secret`, `token_endpoint_auth_method`)
|
|
331
|
+
# and would otherwise let a misbehaving AS or hand-built
|
|
332
|
+
# `client_information` short-circuit the "is the client registered?"
|
|
333
|
+
# check, or send a literal `client_secret: " "` to the token endpoint.
|
|
334
|
+
def client_info_required_value(info, key)
|
|
335
|
+
value = client_info_value(info, key)
|
|
336
|
+
return if value.nil?
|
|
337
|
+
return if value.is_a?(String) && value.strip.empty?
|
|
338
|
+
|
|
339
|
+
value
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Returns the canonical RFC 8707 `resource` URI to send on authorization and
|
|
343
|
+
# token requests. When PRM advertises `resource`, that value is
|
|
344
|
+
# the authorization server's idea of the resource identifier and is preferred.
|
|
345
|
+
# When PRM omits it, the canonicalized MCP server URL is used.
|
|
346
|
+
#
|
|
347
|
+
# Either way, we validate that PRM's `resource` covers the MCP server URL
|
|
348
|
+
# the client is actually talking to (same origin, with PRM's path as a prefix of
|
|
349
|
+
# the server URL's path) to prevent a malicious or misconfigured PRM from
|
|
350
|
+
# redirecting credentials to a different audience.
|
|
351
|
+
def canonical_resource(server_url:, prm_resource:)
|
|
352
|
+
server_canonical = safe_canonicalize_url(server_url, label: "MCP server URL")
|
|
353
|
+
return server_canonical unless prm_resource
|
|
354
|
+
|
|
355
|
+
prm_canonical = safe_canonicalize_url(prm_resource, label: "PRM `resource`")
|
|
356
|
+
unless Discovery.resource_covers?(prm: prm_canonical, server: server_canonical)
|
|
357
|
+
raise AuthorizationError,
|
|
358
|
+
"Protected resource metadata `resource` does not match the MCP server URL " \
|
|
359
|
+
"(server=#{server_canonical}, prm=#{prm_canonical})."
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
prm_canonical
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Wraps `Discovery.canonicalize_url` so that any URI parsing failure
|
|
366
|
+
# caused by malformed input from the server (`PRM.resource`, AS metadata
|
|
367
|
+
# endpoints, ...) surfaces as `AuthorizationError` instead of leaking
|
|
368
|
+
# a raw `URI::InvalidURIError` / `ArgumentError`.
|
|
369
|
+
def safe_canonicalize_url(url, label:)
|
|
370
|
+
Discovery.canonicalize_url(url)
|
|
371
|
+
rescue URI::InvalidURIError, ArgumentError => e
|
|
372
|
+
raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}."
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery
|
|
376
|
+
# of the expected value.
|
|
377
|
+
# `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7
|
|
378
|
+
# (the project's minimum supported version).
|
|
379
|
+
# The hand-rolled XOR-sum walks every byte of the equal-length operands, so the running time
|
|
380
|
+
# does not leak the position of the first mismatching byte.
|
|
381
|
+
def states_match?(returned, expected)
|
|
382
|
+
returned = returned.to_s
|
|
383
|
+
return false unless returned.bytesize == expected.bytesize
|
|
384
|
+
|
|
385
|
+
result = 0
|
|
386
|
+
returned.bytes.zip(expected.bytes) { |a, b| result |= a ^ b }
|
|
387
|
+
result.zero?
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
|
|
391
|
+
# prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
|
|
392
|
+
# from the Protected Resource Metadata, and falls back to a provider-supplied
|
|
393
|
+
# scope only if both are absent. The provider-supplied scope must not pre-empt
|
|
394
|
+
# a server-advertised one.
|
|
395
|
+
def resolve_scope(scope:, prm:)
|
|
396
|
+
return scope if scope && !scope.empty?
|
|
397
|
+
|
|
398
|
+
supported = prm["scopes_supported"]
|
|
399
|
+
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
|
|
400
|
+
|
|
401
|
+
return @provider.scope if @provider.scope && !@provider.scope.empty?
|
|
402
|
+
|
|
403
|
+
nil
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
|
407
|
+
authorization_endpoint = as_metadata["authorization_endpoint"]
|
|
408
|
+
unless authorization_endpoint
|
|
409
|
+
raise AuthorizationError,
|
|
410
|
+
"Authorization server metadata is missing `authorization_endpoint`."
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
uri = begin
|
|
414
|
+
URI.parse(authorization_endpoint)
|
|
415
|
+
rescue URI::InvalidURIError => e
|
|
416
|
+
raise AuthorizationError,
|
|
417
|
+
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
params = URI.decode_www_form(uri.query.to_s)
|
|
421
|
+
params << ["response_type", "code"]
|
|
422
|
+
params << ["client_id", client_id]
|
|
423
|
+
params << ["redirect_uri", @provider.redirect_uri]
|
|
424
|
+
params << ["code_challenge", code_challenge]
|
|
425
|
+
params << ["code_challenge_method", "S256"]
|
|
426
|
+
params << ["state", state]
|
|
427
|
+
params << ["scope", scope] if scope
|
|
428
|
+
params << ["resource", resource] if resource
|
|
429
|
+
uri.query = URI.encode_www_form(params)
|
|
430
|
+
uri
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
|
|
434
|
+
form = {
|
|
435
|
+
"grant_type" => "authorization_code",
|
|
436
|
+
"code" => code,
|
|
437
|
+
"redirect_uri" => @provider.redirect_uri,
|
|
438
|
+
"code_verifier" => code_verifier,
|
|
439
|
+
}
|
|
440
|
+
form["resource"] = resource if resource
|
|
441
|
+
|
|
442
|
+
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:)
|
|
446
|
+
form = {
|
|
447
|
+
"grant_type" => "refresh_token",
|
|
448
|
+
"refresh_token" => refresh_token,
|
|
449
|
+
}
|
|
450
|
+
form["resource"] = resource if resource
|
|
451
|
+
|
|
452
|
+
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Submits a form-encoded request to the token endpoint, applying
|
|
456
|
+
# the client authentication method advertised in `client_information` and
|
|
457
|
+
# adding `client_id` (and `client_secret` when not using HTTP Basic).
|
|
458
|
+
def post_to_token_endpoint(as_metadata:, client_info:, form:)
|
|
459
|
+
client_id = client_info_required_value(client_info, "client_id")
|
|
460
|
+
unless client_id
|
|
461
|
+
raise AuthorizationError,
|
|
462
|
+
"Cannot post to token endpoint: client_information is missing `client_id`."
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
client_secret = client_info_required_value(client_info, "client_secret")
|
|
466
|
+
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
|
467
|
+
|
|
468
|
+
form = form.merge("client_id" => client_id)
|
|
469
|
+
headers = {}
|
|
470
|
+
if client_secret
|
|
471
|
+
case token_endpoint_auth_method
|
|
472
|
+
when "client_secret_post"
|
|
473
|
+
form["client_secret"] = client_secret
|
|
474
|
+
when "none"
|
|
475
|
+
# Public client; no credential.
|
|
476
|
+
else
|
|
477
|
+
# RFC 6749 §2.3.1 recommends Basic for confidential clients and
|
|
478
|
+
# both Python and TypeScript SDKs default here when
|
|
479
|
+
# the authentication method is not explicitly stored.
|
|
480
|
+
headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
token_endpoint = as_metadata["token_endpoint"]
|
|
485
|
+
unless token_endpoint
|
|
486
|
+
raise AuthorizationError,
|
|
487
|
+
"Authorization server metadata is missing `token_endpoint`."
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
response = begin
|
|
491
|
+
http_post_form(token_endpoint, form, headers: headers)
|
|
492
|
+
rescue Faraday::Error => e
|
|
493
|
+
raise AuthorizationError,
|
|
494
|
+
"Token request to #{token_endpoint} failed: #{e.class}: #{e.message}."
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
if response.status < 200 || response.status >= 300
|
|
498
|
+
if token_endpoint_error_code(response) == "invalid_grant"
|
|
499
|
+
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
raise AuthorizationError, "Token endpoint returned status #{response.status}."
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
parsed = begin
|
|
506
|
+
JSON.parse(response_body_string(response))
|
|
507
|
+
rescue JSON::ParserError => e
|
|
508
|
+
raise AuthorizationError, "Failed to parse token endpoint response: #{e.message}."
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Token responses MUST be a JSON object per RFC 6749 §5.1. Anything
|
|
512
|
+
# else (`null`, `[]`, a bare string) would otherwise be persisted
|
|
513
|
+
# as `provider.tokens` and raise raw `NoMethodError` / `TypeError`
|
|
514
|
+
# the next time `provider.access_token` is read.
|
|
515
|
+
unless parsed.is_a?(Hash)
|
|
516
|
+
raise AuthorizationError,
|
|
517
|
+
"Token endpoint response is not a JSON object (got #{parsed.class})."
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
parsed
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Extracts the `error` code from an RFC 6749 §5.2 error response body
|
|
524
|
+
# when one is parseable. Returns nil on any parse failure or when
|
|
525
|
+
# the body is not JSON.
|
|
526
|
+
def token_endpoint_error_code(response)
|
|
527
|
+
body = response_body_string(response).to_s
|
|
528
|
+
return if body.empty?
|
|
529
|
+
|
|
530
|
+
parsed = JSON.parse(body)
|
|
531
|
+
parsed["error"] if parsed.is_a?(Hash)
|
|
532
|
+
rescue JSON::ParserError
|
|
533
|
+
nil
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
|
|
537
|
+
# `application/x-www-form-urlencoded` encoded before they are joined with
|
|
538
|
+
# `:` and base64-encoded for the `Authorization: Basic` header. This is
|
|
539
|
+
# what prevents credentials containing `:` or other special characters
|
|
540
|
+
# from being mis-parsed by the authorization server.
|
|
541
|
+
# https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
|
|
542
|
+
def basic_auth_credentials(client_id, client_secret)
|
|
543
|
+
encoded_id = URI.encode_www_form_component(client_id)
|
|
544
|
+
encoded_secret = URI.encode_www_form_component(client_secret)
|
|
545
|
+
Base64.strict_encode64("#{encoded_id}:#{encoded_secret}")
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def http_get(url)
|
|
549
|
+
http_client.get(url)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def http_post_json(url, body)
|
|
553
|
+
http_client.post(url) do |req|
|
|
554
|
+
req.headers["Content-Type"] = "application/json"
|
|
555
|
+
req.headers["Accept"] = "application/json"
|
|
556
|
+
req.body = JSON.generate(body)
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def http_post_form(url, form, headers: {})
|
|
561
|
+
http_client.post(url) do |req|
|
|
562
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
563
|
+
req.headers["Accept"] = "application/json"
|
|
564
|
+
headers.each { |key, value| req.headers[key] = value }
|
|
565
|
+
req.body = URI.encode_www_form(form)
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def http_client
|
|
570
|
+
@http_client ||= @http_client_factory.call
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def default_http_client
|
|
574
|
+
require "faraday"
|
|
575
|
+
Faraday.new do |faraday|
|
|
576
|
+
faraday.headers["Accept"] = "application/json"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def response_body_string(response)
|
|
581
|
+
body = response.body
|
|
582
|
+
body.is_a?(String) ? body : body.to_s
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Client
|
|
5
|
+
module OAuth
|
|
6
|
+
# Default reference implementation of the storage contract that
|
|
7
|
+
# `Provider` uses to persist OAuth state. Holds the two pieces of data
|
|
8
|
+
# the flow saves and reads on every request:
|
|
9
|
+
#
|
|
10
|
+
# - `tokens`: the hash returned by the token endpoint
|
|
11
|
+
# (`access_token`, optional `refresh_token`, `expires_in`, `scope`, etc.).
|
|
12
|
+
# - `client_information`: the hash returned by Dynamic Client Registration
|
|
13
|
+
# or supplied as pre-registered credentials
|
|
14
|
+
# (`client_id`, optional `client_secret`, optional
|
|
15
|
+
# `token_endpoint_auth_method`).
|
|
16
|
+
#
|
|
17
|
+
# This class keeps everything in process memory, so the credentials live
|
|
18
|
+
# only for the lifetime of the Ruby process. Applications that need
|
|
19
|
+
# persistence across restarts should supply a custom object responding to
|
|
20
|
+
# the same four-method contract (`tokens`, `save_tokens(t)`,
|
|
21
|
+
# `client_information`, `save_client_information(info)`) and pass it via
|
|
22
|
+
# `Provider.new(storage: ...)`. The shape mirrors Python SDK's
|
|
23
|
+
# `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls
|
|
24
|
+
# the same responsibilities into a single object.
|
|
25
|
+
class InMemoryStorage
|
|
26
|
+
attr_accessor :tokens, :client_information
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@tokens = nil
|
|
30
|
+
@client_information = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def save_tokens(tokens)
|
|
34
|
+
@tokens = tokens
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def save_client_information(info)
|
|
38
|
+
@client_information = info
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module MCP
|
|
8
|
+
class Client
|
|
9
|
+
module OAuth
|
|
10
|
+
# Generates the Proof Key for Code Exchange (PKCE) pair (RFC 7636) used to
|
|
11
|
+
# bind an OAuth 2.1 authorization request to the same client that later
|
|
12
|
+
# redeems the resulting authorization code. Without PKCE, an attacker
|
|
13
|
+
# who steals an authorization code in transit (e.g. through a logged redirect URI)
|
|
14
|
+
# can exchange it for an access token; with PKCE, the attacker would also need
|
|
15
|
+
# the per-request `code_verifier`, which is never sent to the browser or any intermediary.
|
|
16
|
+
#
|
|
17
|
+
# MCP authorization mandates the `S256` method, so this module always returns
|
|
18
|
+
# that method and refuses to expose `plain` as an option.
|
|
19
|
+
#
|
|
20
|
+
# The module is stateless: callers ask for a fresh pair with `generate` for
|
|
21
|
+
# every authorization request and discard the result once the token endpoint has accepted it.
|
|
22
|
+
#
|
|
23
|
+
# - https://datatracker.ietf.org/doc/html/rfc7636
|
|
24
|
+
# - https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection
|
|
25
|
+
module PKCE
|
|
26
|
+
class << self
|
|
27
|
+
# Generates a PKCE pair (code_verifier and S256 code_challenge).
|
|
28
|
+
def generate
|
|
29
|
+
verifier = SecureRandom.urlsafe_base64(64)
|
|
30
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
31
|
+
|
|
32
|
+
{ code_verifier: verifier, code_challenge: challenge, code_challenge_method: "S256" }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|