forked from modelcontextprotocol/ruby-sdk
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathflow.rb
More file actions
883 lines (750 loc) · 42 KB
/
Copy pathflow.rb
File metadata and controls
883 lines (750 loc) · 42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
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
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
# frozen_string_literal: true
require "base64"
require "json"
require "openssl"
require "securerandom"
require "uri"
module MCP
class Client
module OAuth
# Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow.
# Driven by `MCP::Client::HTTP` on a 401 response. The user-facing surface is
# `Provider`; this class consumes a Provider plus signal data extracted from
# the failing response (resource_metadata URL, scope challenge).
class Flow
class AuthorizationError < StandardError; end
# Raised specifically when the token endpoint rejects a grant with
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
# distinguish "the stored refresh token is dead, discard it" from
# transient failures (network, 5xx, other RFC 6749 error codes) that
# should leave the refresh token intact.
class InvalidGrantError < AuthorizationError; end
def initialize(provider:, http_client_factory: nil)
@provider = provider
@http_client_factory = http_client_factory || -> { default_http_client }
end
# Runs the full discovery, registration, authorization, and token exchange flow.
# On success, persists tokens via the provider and returns `:authorized`.
def run!(server_url:, resource_metadata_url: nil, scope: nil)
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
# first thing we contact in the OAuth flow, so it has to clear the same
# Communication Security bar as the OAuth endpoints downstream.
if resource_metadata_url
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
end
prm, authorization_server = locate_authorization_server(
server_url: server_url,
resource_metadata_url: resource_metadata_url,
)
# Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
# both the authorization and token requests. When PRM advertises a `resource`,
# it MUST identify the same MCP server we are talking to; otherwise we are
# being redirected to credentials minted for a different audience.
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
if provider_authorization_flow == :client_credentials
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
end
ensure_pkce_supported!(as_metadata)
client_info = ensure_client_registered(as_metadata: as_metadata)
effective_scope = resolve_scope(scope: scope, prm: prm || {})
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
pkce = PKCE.generate
state = SecureRandom.urlsafe_base64(32)
authorization_url = build_authorization_url(
as_metadata: as_metadata,
client_id: client_info_required_value(client_info, "client_id"),
scope: effective_scope,
state: state,
code_challenge: pkce[:code_challenge],
resource: resource,
)
@provider.redirect_handler.call(authorization_url)
code, returned_state = Array(@provider.callback_handler.call)
raise AuthorizationError, "Authorization callback did not return an authorization code." unless code
unless states_match?(returned_state, state)
raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
end
tokens = exchange_authorization_code(
as_metadata: as_metadata,
client_info: client_info,
code: code,
code_verifier: pkce[:code_verifier],
resource: resource,
)
@provider.save_tokens(tokens)
:authorized
end
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
client_info = client_credentials_client_info
ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
form = { "grant_type" => "client_credentials" }
effective_scope = resolve_scope(scope: scope, prm: prm)
form["scope"] = effective_scope if effective_scope
form["resource"] = resource if resource
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
@provider.save_tokens(tokens)
:authorized
end
# Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored
# `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code
# flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
# The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback
# to dynamic registration.
def client_credentials_client_info
info = @provider.client_information
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
raise AuthorizationError,
"Cannot run the client_credentials grant: the provider has no stored `client_id`."
end
info
end
# Per SEP-2352, static machine-to-machine credentials are bound to their authorization
# server the same way registered ones are: when the stored `client_information` records
# an `issuer` that differs from the current authorization server, surface an error
# instead of silently sending another server's credentials (the spec's "SHOULD surface
# an error"; the TypeScript SDK raises `AuthorizationServerMismatchError` here).
# Re-registration is not an option for the `client_credentials` grant - the credentials
# are pre-registered, not DCR results - so the operator must update the stored credentials.
# Credentials without a recorded issuer keep working unchanged.
def ensure_client_credentials_issuer!(client_info, as_metadata:)
stored_issuer = client_info_required_value(client_info, "issuer")
return if stored_issuer.nil?
return if stored_issuer == as_metadata["issuer"]
raise AuthorizationError,
"Stored client credentials are bound to a different authorization server " \
"(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
"refusing to send them to the current authorization server (SEP-2352)."
end
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
# checks before talking to it.
#
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
# or when the token endpoint refuses the refresh request.
# https://www.rfc-editor.org/rfc/rfc6749#section-6
def refresh!(server_url:, resource_metadata_url: nil)
refresh_token = read_token("refresh_token")
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
stored_client_info = @provider.client_information
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
# A CIMD-configured provider stores no `client_information` on purpose
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
unless have_stored_client_info || have_cimd_url
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
end
if resource_metadata_url
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
end
prm, authorization_server = locate_authorization_server(
server_url: server_url,
resource_metadata_url: resource_metadata_url,
)
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
client_info = if have_stored_client_info
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
stored_client_info
elsif as_metadata["client_id_metadata_document_supported"] == true
{ "client_id" => @provider.client_id_metadata_document_url }
else
raise AuthorizationError,
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
"`client_id_metadata_document_supported: true`."
end
new_tokens = exchange_refresh_token(
as_metadata: as_metadata,
client_info: client_info,
refresh_token: refresh_token,
resource: resource,
)
@provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
:refreshed
end
private
def read_token(key)
tokens = @provider.tokens
return unless tokens.is_a?(Hash)
value = tokens[key] || tokens[key.to_sym]
value.to_s.empty? ? nil : value
end
# Per RFC 6749 Section 6, the refresh response MAY omit `refresh_token`, in
# which case the previous one stays valid. Preserve it explicitly so
# downstream refresh attempts still work.
def preserve_refresh_token(new_tokens, previous_refresh_token)
return new_tokens if new_tokens["refresh_token"] || new_tokens[:refresh_token]
new_tokens.merge("refresh_token" => previous_refresh_token)
end
def fetch_protected_resource_metadata(server_url:, resource_metadata_url:)
urls = Discovery.protected_resource_metadata_urls(
server_url: server_url,
resource_metadata_url: resource_metadata_url,
)
fetch_metadata_json(urls, label: "protected resource metadata")
end
# Locates the authorization server for `server_url` and returns `[prm, authorization_server]`.
#
# Modern path (2025-06-18+): Protected Resource Metadata names the authorization server in
# `authorization_servers`.
#
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
# Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
def locate_authorization_server(server_url:, resource_metadata_url:)
prm = begin
fetch_protected_resource_metadata(
server_url: server_url,
resource_metadata_url: resource_metadata_url,
)
rescue AuthorizationError
nil
end
if prm
authorization_server = first_authorization_server(prm)
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
[prm, authorization_server]
else
authorization_base = server_origin!(server_url)
ensure_secure_url!(authorization_base, label: "MCP server origin (legacy authorization base URL)")
[nil, authorization_base]
end
end
# Fetches and validates the authorization server's RFC 8414 metadata.
#
# On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3).
# On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement,
# and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
# the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
# When even the metadata document is absent, the legacy spec's default endpoints are used.
def authorization_server_metadata(authorization_server:, legacy:)
metadata = if legacy
begin
fetch_authorization_server_metadata(issuer_url: authorization_server)
rescue AuthorizationError
default_legacy_metadata(authorization_server)
end
else
fetch_authorization_server_metadata(issuer_url: authorization_server).tap do |fetched|
ensure_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
end
end
ensure_secure_endpoints!(metadata)
metadata
end
# The 2025-03-26 spec's "Fallbacks for Servers without Metadata Discovery": clients MUST use these default endpoint paths
# relative to the authorization base URL. PKCE S256 is assumed because the legacy spec mandates PKCE and there is no metadata
# to advertise it (the TypeScript and Python SDKs hardcode S256 on this path too).
def default_legacy_metadata(authorization_base)
{
"issuer" => authorization_base,
"authorization_endpoint" => "#{authorization_base}/authorize",
"token_endpoint" => "#{authorization_base}/token",
"registration_endpoint" => "#{authorization_base}/register",
"code_challenge_methods_supported" => ["S256"],
}
end
# Returns `scheme://host[:port]` of `server_url`, the legacy 2025-03-26 authorization base URL for servers without PRM.
def server_origin!(server_url)
uri = URI.parse(server_url.to_s)
unless uri.is_a?(URI::HTTP) && uri.host
raise AuthorizationError,
"Cannot derive a legacy authorization base URL from MCP server URL #{server_url.inspect}."
end
port_part = uri.port == uri.default_port ? "" : ":#{uri.port}"
"#{uri.scheme}://#{uri.host}#{port_part}"
rescue URI::InvalidURIError => e
raise AuthorizationError, "MCP server URL #{server_url.inspect} is not a valid URI: #{e.message}."
end
def fetch_authorization_server_metadata(issuer_url:)
urls = Discovery.authorization_server_metadata_urls(issuer_url)
fetch_metadata_json(urls, label: "authorization server metadata")
end
# Reads `authorization_servers` from a PRM document and returns
# the first entry, raising `AuthorizationError` for any of the malformed
# shapes a non-compliant server could emit (missing field, non-Array
# value, empty Array, non-String first entry). Centralizing this lets
# both the full flow and the refresh flow share the same defensive
# parse instead of each one duplicating a Hash-and-Array check.
def first_authorization_server(prm)
authorization_servers = prm["authorization_servers"]
unless authorization_servers.is_a?(Array)
raise AuthorizationError,
"Protected resource metadata `authorization_servers` is not an array " \
"(got #{authorization_servers.class})."
end
if authorization_servers.empty?
raise AuthorizationError, "Protected resource metadata has no authorization_servers."
end
first = authorization_servers.first
unless first.is_a?(String) && !first.empty?
raise AuthorizationError,
"Protected resource metadata `authorization_servers[0]` is not a non-empty string."
end
first
end
# Walks candidate metadata URLs and returns the parsed JSON body of
# the first 2xx response. Raises `AuthorizationError` for transport
# failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
# so callers do not have to handle raw Faraday/JSON exceptions.
def fetch_metadata_json(urls, label:)
last_error = nil
urls.each do |url|
response = begin
http_get(url)
rescue Faraday::Error => e
last_error = "GET #{url} raised #{e.class}: #{e.message}"
next
end
if response.status >= 200 && response.status < 300
parsed = begin
JSON.parse(response_body_string(response))
rescue JSON::ParserError => e
raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
end
# Even valid JSON can be the wrong shape (a top-level array,
# a bare `null`, a string, ...). The discovery callers index by
# name (`prm["authorization_servers"]`, etc.), so anything that
# is not a Hash would raise `TypeError` / `NoMethodError`
# downstream. Surface that as `AuthorizationError` instead so
# callers see a single, documented error type.
unless parsed.is_a?(Hash)
raise AuthorizationError,
"#{label} from #{url} is not a JSON object (got #{parsed.class})."
end
return parsed
end
last_error = "GET #{url} returned #{response.status}"
end
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
end
def ensure_pkce_supported!(as_metadata)
methods = as_metadata["code_challenge_methods_supported"]
return if methods.is_a?(Array) && methods.include?("S256")
raise AuthorizationError,
"Authorization server does not advertise S256 PKCE support; refusing to proceed."
end
# Per the MCP authorization spec's Communication Security requirement,
# OAuth endpoints MUST use HTTPS unless the host is a loopback address.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
def ensure_secure_url!(url, label:)
return if Discovery.secure_url?(url)
raise AuthorizationError,
"#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
end
def ensure_secure_endpoints!(as_metadata)
["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
endpoint = as_metadata[key]
ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
end
end
# Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
# identical (literal byte-for-byte equality, no normalization) to
# the issuer URL the client used to discover that document. This guards
# against a CDN/relay returning the metadata of a *different*
# authorization server than the one PRM advertised, and against
# ambiguities like trailing `/`, fragments, or case differences that
# could mask a confused-deputy attempt.
# https://www.rfc-editor.org/rfc/rfc8414#section-3.3
def ensure_issuer_matches!(expected:, returned:)
unless returned
raise AuthorizationError, "Authorization server metadata is missing the `issuer` field."
end
return if expected.to_s == returned.to_s
raise AuthorizationError,
"Authorization server metadata `issuer` does not match the discovery URL " \
"(expected #{expected.inspect}, got #{returned.inspect})."
end
def ensure_client_registered(as_metadata:)
existing = stored_client_information_for(issuer: as_metadata["issuer"])
return existing if existing
# Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
# if the authorization server advertises Client ID Metadata Document support and the provider has
# a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration.
#
# The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in.
# A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support,
# otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against
# a server that has not actually adopted it.
#
# The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
# and the current `provider.client_id_metadata_document_url`.
cimd_url = @provider.client_id_metadata_document_url
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
return { "client_id" => cimd_url }
end
registration_endpoint = as_metadata["registration_endpoint"]
unless registration_endpoint
raise AuthorizationError,
"Authorization server has no registration_endpoint and no pre-registered client information was provided."
end
response = begin
http_post_json(registration_endpoint, registration_client_metadata)
rescue Faraday::Error => e
raise AuthorizationError,
"Dynamic client registration failed: #{e.class}: #{e.message}."
end
if response.status < 200 || response.status >= 300
raise AuthorizationError, "Dynamic client registration failed with status #{response.status}."
end
info = begin
JSON.parse(response_body_string(response))
rescue JSON::ParserError => e
raise AuthorizationError,
"Failed to parse dynamic client registration response: #{e.message}."
end
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
raise AuthorizationError,
"Dynamic client registration response is missing `client_id`."
end
# Per SEP-2352, persisted client credentials are keyed by the issuer identifier of
# the authorization server that minted them, so a later flow can detect an AS change and
# re-register instead of replaying another server's credentials. `issuer` is not an RFC 7591 response field;
# the SDK adds it to the opaque persisted hash.
@provider.save_client_information(info.merge("issuer" => as_metadata["issuer"]))
info
end
# Returns the client metadata to submit on Dynamic Client Registration.
# Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type`
# so the authorization server can apply the matching redirect URI policy.
# When the user did not set one explicitly, infer `"native"` vs `"web"` from
# the registered `redirect_uris`; an explicit value always wins.
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
def registration_client_metadata
metadata = @provider.client_metadata
return metadata if metadata[:application_type] || metadata["application_type"]
redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"]
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
end
# Returns the stored `client_information` when it may be used against the authorization server
# identified by `issuer`, applying SEP-2352's authorization server binding rules:
#
# - Credentials persisted with an `"issuer"` binding MUST NOT be reused against
# a different authorization server. When the AS changed, the stale registration and its tokens
# are discarded (tokens minted by the old AS are dead at the new one) and nil is returned so
# the flow re-registers.
# - Stored credentials without an `"issuer"` binding (data persisted by an older SDK version,
# or user-supplied pre-registered credentials) are bound to the current issuer on first use.
# - A CIMD `client_id` (an HTTPS URL) is portable across authorization servers, so it is reused
# and re-bound instead of discarded.
#
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352
def stored_client_information_for(issuer:)
existing = @provider.client_information
return unless existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
stored_issuer = client_info_required_value(existing, "issuer")
return existing if stored_issuer == issuer
return rebind_client_information(existing, issuer: issuer) if stored_issuer.nil?
client_id = client_info_required_value(existing, "client_id")
return rebind_client_information(existing, issuer: issuer) if Discovery.client_id_metadata_document_url?(client_id)
@provider.save_client_information(nil)
@provider.clear_tokens!
nil
end
def rebind_client_information(info, issuer:)
rebound = info.merge("issuer" => issuer)
@provider.save_client_information(rebound)
rebound
end
# Per SEP-2352, stored client credentials are bound to the authorization server that issued them;
# refuse to replay another AS's credentials at this token endpoint. `HTTP#attempt_refresh` rescues
# the error and falls back to the full flow, which discards the stale registration and re-registers.
# Credentials without an `"issuer"` binding predate this check and are allowed through;
# CIMD `client_id`s are portable.
def ensure_refreshable_client_information!(client_info, as_metadata:)
stored_issuer = client_info_required_value(client_info, "issuer")
return if stored_issuer.nil?
return if stored_issuer == as_metadata["issuer"]
client_id = client_info_required_value(client_info, "client_id")
return if Discovery.client_id_metadata_document_url?(client_id)
raise AuthorizationError,
"Cannot refresh: stored client credentials were issued by a different authorization server " \
"(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
"re-registration is required (SEP-2352)."
end
# Reads `key` from a `client_information` hash that may use either string or
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
# `{ client_id:, client_secret: }` and have both work.
def client_info_value(info, key)
info[key] || info[key.to_sym]
end
# Same as `client_info_value` but treats blank strings (`""` or only
# whitespace) as absent. Used for fields where empty values are never
# meaningful (`client_id`, `client_secret`, `token_endpoint_auth_method`)
# and would otherwise let a misbehaving AS or hand-built
# `client_information` short-circuit the "is the client registered?"
# check, or send a literal `client_secret: " "` to the token endpoint.
def client_info_required_value(info, key)
value = client_info_value(info, key)
return if value.nil?
return if value.is_a?(String) && value.strip.empty?
value
end
# Returns the canonical RFC 8707 `resource` URI to send on authorization and
# token requests. When PRM advertises `resource`, that value is
# the authorization server's idea of the resource identifier and is preferred.
# When PRM omits it, the canonicalized MCP server URL is used.
#
# Either way, we validate that PRM's `resource` covers the MCP server URL
# the client is actually talking to (same origin, with PRM's path as a prefix of
# the server URL's path) to prevent a malicious or misconfigured PRM from
# redirecting credentials to a different audience.
def canonical_resource(server_url:, prm_resource:)
server_canonical = safe_canonicalize_url(server_url, label: "MCP server URL")
return server_canonical unless prm_resource
prm_canonical = safe_canonicalize_url(prm_resource, label: "PRM `resource`")
unless Discovery.resource_covers?(prm: prm_canonical, server: server_canonical)
raise AuthorizationError,
"Protected resource metadata `resource` does not match the MCP server URL " \
"(server=#{server_canonical}, prm=#{prm_canonical})."
end
prm_canonical
end
# Wraps `Discovery.canonicalize_url` so that any URI parsing failure
# caused by malformed input from the server (`PRM.resource`, AS metadata
# endpoints, ...) surfaces as `AuthorizationError` instead of leaking
# a raw `URI::InvalidURIError` / `ArgumentError`.
def safe_canonicalize_url(url, label:)
Discovery.canonicalize_url(url)
rescue URI::InvalidURIError, ArgumentError => e
raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}."
end
# Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery
# of the expected value.
# `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7
# (the project's minimum supported version).
# The hand-rolled XOR-sum walks every byte of the equal-length operands, so the running time
# does not leak the position of the first mismatching byte.
def states_match?(returned, expected)
returned = returned.to_s
return false unless returned.bytesize == expected.bytesize
result = 0
returned.bytes.zip(expected.bytes) { |a, b| result |= a ^ b }
result.zero?
end
# Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
# prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
# from the Protected Resource Metadata, and falls back to a provider-supplied
# scope only if both are absent. The provider-supplied scope must not pre-empt
# a server-advertised one.
def resolve_scope(scope:, prm:)
return scope if scope && !scope.empty?
supported = prm["scopes_supported"]
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
return @provider.scope if @provider.scope && !@provider.scope.empty?
nil
end
# Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
#
# - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
# `refresh_token` in its registered `grant_types`) MAY request `offline_access`
# when the authorization server advertises it in metadata `scopes_supported`.
# When the server advertises it and the client opted in, add it if absent.
#
# - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
# strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
# PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
# request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
# or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
# own request consistent with the AS's advertisement.
#
# Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
def normalize_offline_access_scope(scope, as_metadata:)
scopes = scope.to_s.split
if server_supports_offline_access?(as_metadata)
scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
else
scopes.delete("offline_access")
end
scopes.empty? ? nil : scopes.join(" ")
end
def server_supports_offline_access?(as_metadata)
supported = as_metadata["scopes_supported"]
supported.is_a?(Array) && supported.include?("offline_access")
end
def wants_refresh_token?
metadata = @provider.client_metadata
grant_types = metadata[:grant_types] || metadata["grant_types"]
Array(grant_types).include?("refresh_token")
end
# The OAuth flow the provider drives. Dispatching on the provider's
# declared flow keeps `Flow` from second-guessing intent by parsing
# `client_metadata[:grant_types]` (which is protocol metadata for the
# authorization server, not an SDK control signal). A provider that
# predates this method is treated as the interactive authorization-code
# flow it was the only option for.
def provider_authorization_flow
return :authorization_code unless @provider.respond_to?(:authorization_flow)
@provider.authorization_flow
end
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
authorization_endpoint = as_metadata["authorization_endpoint"]
unless authorization_endpoint
raise AuthorizationError,
"Authorization server metadata is missing `authorization_endpoint`."
end
uri = begin
URI.parse(authorization_endpoint)
rescue URI::InvalidURIError => e
raise AuthorizationError,
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
end
params = URI.decode_www_form(uri.query.to_s)
params << ["response_type", "code"]
params << ["client_id", client_id]
params << ["redirect_uri", @provider.redirect_uri]
params << ["code_challenge", code_challenge]
params << ["code_challenge_method", "S256"]
params << ["state", state]
params << ["scope", scope] if scope
params << ["resource", resource] if resource
uri.query = URI.encode_www_form(params)
uri
end
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
form = {
"grant_type" => "authorization_code",
"code" => code,
"redirect_uri" => @provider.redirect_uri,
"code_verifier" => code_verifier,
}
form["resource"] = resource if resource
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
end
def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:)
form = {
"grant_type" => "refresh_token",
"refresh_token" => refresh_token,
}
form["resource"] = resource if resource
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
end
# Submits a form-encoded request to the token endpoint, applying
# the client authentication method advertised in `client_information` and
# adding `client_id` (and `client_secret` when not using HTTP Basic).
def post_to_token_endpoint(as_metadata:, client_info:, form:)
client_id = client_info_required_value(client_info, "client_id")
unless client_id
raise AuthorizationError,
"Cannot post to token endpoint: client_information is missing `client_id`."
end
client_secret = client_info_required_value(client_info, "client_secret")
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
form = form.merge("client_id" => client_id)
headers = {}
if client_secret
case token_endpoint_auth_method
when "client_secret_post"
form["client_secret"] = client_secret
when "none"
# Public client; no credential.
else
# RFC 6749 §2.3.1 recommends Basic for confidential clients and
# both Python and TypeScript SDKs default here when
# the authentication method is not explicitly stored.
headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
end
end
token_endpoint = as_metadata["token_endpoint"]
unless token_endpoint
raise AuthorizationError,
"Authorization server metadata is missing `token_endpoint`."
end
response = begin
http_post_form(token_endpoint, form, headers: headers)
rescue Faraday::Error => e
raise AuthorizationError,
"Token request to #{token_endpoint} failed: #{e.class}: #{e.message}."
end
if response.status < 200 || response.status >= 300
if token_endpoint_error_code(response) == "invalid_grant"
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
end
raise AuthorizationError, "Token endpoint returned status #{response.status}."
end
parsed = begin
JSON.parse(response_body_string(response))
rescue JSON::ParserError => e
raise AuthorizationError, "Failed to parse token endpoint response: #{e.message}."
end
# Token responses MUST be a JSON object per RFC 6749 §5.1. Anything
# else (`null`, `[]`, a bare string) would otherwise be persisted
# as `provider.tokens` and raise raw `NoMethodError` / `TypeError`
# the next time `provider.access_token` is read.
unless parsed.is_a?(Hash)
raise AuthorizationError,
"Token endpoint response is not a JSON object (got #{parsed.class})."
end
parsed
end
# Extracts the `error` code from an RFC 6749 §5.2 error response body
# when one is parseable. Returns nil on any parse failure or when
# the body is not JSON.
def token_endpoint_error_code(response)
body = response_body_string(response).to_s
return if body.empty?
parsed = JSON.parse(body)
parsed["error"] if parsed.is_a?(Hash)
rescue JSON::ParserError
nil
end
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
# `application/x-www-form-urlencoded` encoded before they are joined with
# `:` and base64-encoded for the `Authorization: Basic` header. This is
# what prevents credentials containing `:` or other special characters
# from being mis-parsed by the authorization server.
# https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
def basic_auth_credentials(client_id, client_secret)
encoded_id = URI.encode_www_form_component(client_id)
encoded_secret = URI.encode_www_form_component(client_secret)
Base64.strict_encode64("#{encoded_id}:#{encoded_secret}")
end
def http_get(url)
http_client.get(url)
end
def http_post_json(url, body)
http_client.post(url) do |req|
req.headers["Content-Type"] = "application/json"
req.headers["Accept"] = "application/json"
req.body = JSON.generate(body)
end
end
def http_post_form(url, form, headers: {})
http_client.post(url) do |req|
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
req.headers["Accept"] = "application/json"
headers.each { |key, value| req.headers[key] = value }
req.body = URI.encode_www_form(form)
end
end
def http_client
@http_client ||= @http_client_factory.call
end
def default_http_client
require "faraday"
Faraday.new do |faraday|
faraday.headers["Accept"] = "application/json"
end
end
def response_body_string(response)
body = response.body
body.is_a?(String) ? body : body.to_s
end
end
end
end
end