mcp 0.23.0 → 0.25.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.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # OAuth client configuration for the MCP Enterprise Managed Authorization extension (SEP-990, "Cross-App Access"):
7
+ # the client obtains an Identity Assertion Authorization Grant (ID-JAG) from an enterprise identity provider and
8
+ # presents it to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with
9
+ # `client_secret_basic`. Handed to `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`.
10
+ #
11
+ # Mirrors `CrossAppAccessProvider` in the TypeScript SDK: the assertion is supplied by a callable so it can come
12
+ # from `IDJAGTokenExchange` (the common case), an enterprise secret store, or a test double.
13
+ #
14
+ # Required keyword arguments:
15
+ #
16
+ # - `client_id` - String identifying the pre-registered confidential client at the MCP authorization server.
17
+ # - `client_secret` - String shared secret for `client_secret_basic`.
18
+ # - `assertion_provider` - Callable invoked as `call(audience:, resource:)`, returning the ID-JAG assertion to
19
+ # present. `audience` is the authorization server's issuer identifier and `resource` is the canonical MCP server URL;
20
+ # pass both through to `IDJAGTokenExchange.request` when exchanging an IdP ID token.
21
+ #
22
+ # Optional keyword arguments:
23
+ #
24
+ # - `scope` - String of space-separated scopes to request when the server's `WWW-Authenticate` and
25
+ # the Protected Resource Metadata do not specify one.
26
+ # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
27
+ # Defaults to an `InMemoryStorage`.
28
+ #
29
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
30
+ class CrossAppAccessProvider
31
+ include StorageBackedProvider
32
+
33
+ # Raised when the provider is constructed without the pieces the `jwt-bearer` grant needs.
34
+ class InvalidConfigurationError < ArgumentError; end
35
+
36
+ attr_reader :scope, :storage
37
+
38
+ def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil)
39
+ if blank?(client_id)
40
+ raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
41
+ end
42
+
43
+ if blank?(client_secret)
44
+ raise InvalidConfigurationError, "client_secret is required: SEP-990 authenticates the jwt-bearer grant with client_secret_basic."
45
+ end
46
+
47
+ unless assertion_provider.respond_to?(:call)
48
+ raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
49
+ end
50
+
51
+ @assertion_provider = assertion_provider
52
+ @scope = scope
53
+ @storage = storage || InMemoryStorage.new
54
+ @storage.save_client_information(
55
+ "client_id" => client_id,
56
+ "client_secret" => client_secret,
57
+ "token_endpoint_auth_method" => "client_secret_basic",
58
+ )
59
+ end
60
+
61
+ # See `Provider#authorization_flow`.
62
+ def authorization_flow
63
+ :jwt_bearer
64
+ end
65
+
66
+ # Returns the ID-JAG assertion to present at the MCP authorization server. Called by `Flow#run_jwt_bearer!` with the audience
67
+ # and resource resolved during discovery.
68
+ def jwt_bearer_assertion(audience:, resource:)
69
+ @assertion_provider.call(audience: audience, resource: resource)
70
+ end
71
+
72
+ private
73
+
74
+ def blank?(value)
75
+ value.nil? || (value.is_a?(String) && value.strip.empty?)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -37,7 +37,7 @@ module MCP
37
37
  # Matches a single `key=value` pair inside an HTTP auth-scheme challenge.
38
38
  # `value` is either a quoted string (which can contain commas and spaces)
39
39
  # or a bare token, per RFC 7235.
40
- WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/
40
+ WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
41
41
 
42
42
  class << self
43
43
  # Parses a `WWW-Authenticate` header and returns the parameters of
@@ -51,8 +51,11 @@ module MCP
51
51
 
52
52
  as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
53
53
 
54
- if provider_authorization_flow == :client_credentials
54
+ case provider_authorization_flow
55
+ when :client_credentials
55
56
  return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
57
+ when :jwt_bearer
58
+ return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
56
59
  end
57
60
 
58
61
  ensure_pkce_supported!(as_metadata)
@@ -74,13 +77,20 @@ module MCP
74
77
  )
75
78
 
76
79
  @provider.redirect_handler.call(authorization_url)
77
- code, returned_state = Array(@provider.callback_handler.call)
80
+ callback_result = Array(@provider.callback_handler.call)
81
+ code, returned_state, returned_iss = callback_result
78
82
  raise AuthorizationError, "Authorization callback did not return an authorization code." unless code
79
83
 
80
84
  unless states_match?(returned_state, state)
81
85
  raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
82
86
  end
83
87
 
88
+ validate_authorization_response_issuer!(
89
+ as_metadata: as_metadata,
90
+ iss: returned_iss,
91
+ iss_provided: callback_result.length >= 3,
92
+ )
93
+
84
94
  tokens = exchange_authorization_code(
85
95
  as_metadata: as_metadata,
86
96
  client_info: client_info,
@@ -101,6 +111,7 @@ module MCP
101
111
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
102
112
  def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
103
113
  client_info = client_credentials_client_info
114
+ ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
104
115
 
105
116
  form = { "grant_type" => "client_credentials" }
106
117
  effective_scope = resolve_scope(scope: scope, prm: prm)
@@ -127,6 +138,55 @@ module MCP
127
138
  info
128
139
  end
129
140
 
141
+ # Per SEP-2352, static machine-to-machine credentials are bound to their authorization
142
+ # server the same way registered ones are: when the stored `client_information` records
143
+ # an `issuer` that differs from the current authorization server, surface an error
144
+ # instead of silently sending another server's credentials (the spec's "SHOULD surface
145
+ # an error"; the TypeScript SDK raises `AuthorizationServerMismatchError` here).
146
+ # Re-registration is not an option for the `client_credentials` grant - the credentials
147
+ # are pre-registered, not DCR results - so the operator must update the stored credentials.
148
+ # Credentials without a recorded issuer keep working unchanged.
149
+ def ensure_client_credentials_issuer!(client_info, as_metadata:)
150
+ stored_issuer = client_info_required_value(client_info, "issuer")
151
+ return if stored_issuer.nil?
152
+ return if stored_issuer == as_metadata["issuer"]
153
+
154
+ raise AuthorizationError,
155
+ "Stored client credentials are bound to a different authorization server " \
156
+ "(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
157
+ "refusing to send them to the current authorization server (SEP-2352)."
158
+ end
159
+
160
+ # Runs the RFC 7523 `jwt-bearer` grant for the SEP-990 Enterprise Managed Authorization extension:
161
+ # the provider supplies an ID-JAG assertion (typically obtained from an enterprise IdP via `IDJAGTokenExchange`),
162
+ # which is presented at the token endpoint with `client_secret_basic` authentication. Shares the same discovery
163
+ # and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request.
164
+ # The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated.
165
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
166
+ def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:)
167
+ client_info = @provider.client_information
168
+ unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
169
+ raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
170
+ end
171
+
172
+ assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource)
173
+ if assertion.nil? || assertion.to_s.empty?
174
+ raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
175
+ end
176
+
177
+ form = {
178
+ "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
179
+ "assertion" => assertion,
180
+ }
181
+ effective_scope = resolve_scope(scope: scope, prm: prm)
182
+ form["scope"] = effective_scope if effective_scope
183
+ form["resource"] = resource if resource
184
+
185
+ tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
186
+ @provider.save_tokens(tokens)
187
+ :authorized
188
+ end
189
+
130
190
  # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
131
191
  # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
132
192
  # checks before talking to it.
@@ -166,6 +226,7 @@ module MCP
166
226
  client_info = if have_stored_client_info
167
227
  # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
168
228
  # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
229
+ ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
169
230
  stored_client_info
170
231
  elsif as_metadata["client_id_metadata_document_supported"] == true
171
232
  { "client_id" => @provider.client_id_metadata_document_url }
@@ -412,8 +473,8 @@ module MCP
412
473
  end
413
474
 
414
475
  def ensure_client_registered(as_metadata:)
415
- existing = @provider.client_information
416
- return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
476
+ existing = stored_client_information_for(issuer: as_metadata["issuer"])
477
+ return existing if existing
417
478
 
418
479
  # Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
419
480
  # if the authorization server advertises Client ID Metadata Document support and the provider has
@@ -462,7 +523,12 @@ module MCP
462
523
  "Dynamic client registration response is missing `client_id`."
463
524
  end
464
525
 
465
- @provider.save_client_information(info)
526
+ # Per SEP-2352, persisted client credentials are keyed by the issuer identifier of
527
+ # the authorization server that minted them, so a later flow can detect an AS change and
528
+ # re-register instead of replaying another server's credentials. `issuer` is not an RFC 7591 response field;
529
+ # the SDK adds it to the opaque persisted hash.
530
+ @provider.save_client_information(info.merge("issuer" => as_metadata["issuer"]))
531
+
466
532
  info
467
533
  end
468
534
 
@@ -480,6 +546,60 @@ module MCP
480
546
  metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
481
547
  end
482
548
 
549
+ # Returns the stored `client_information` when it may be used against the authorization server
550
+ # identified by `issuer`, applying SEP-2352's authorization server binding rules:
551
+ #
552
+ # - Credentials persisted with an `"issuer"` binding MUST NOT be reused against
553
+ # a different authorization server. When the AS changed, the stale registration and its tokens
554
+ # are discarded (tokens minted by the old AS are dead at the new one) and nil is returned so
555
+ # the flow re-registers.
556
+ # - Stored credentials without an `"issuer"` binding (data persisted by an older SDK version,
557
+ # or user-supplied pre-registered credentials) are bound to the current issuer on first use.
558
+ # - A CIMD `client_id` (an HTTPS URL) is portable across authorization servers, so it is reused
559
+ # and re-bound instead of discarded.
560
+ #
561
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352
562
+ def stored_client_information_for(issuer:)
563
+ existing = @provider.client_information
564
+ return unless existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
565
+
566
+ stored_issuer = client_info_required_value(existing, "issuer")
567
+ return existing if stored_issuer == issuer
568
+ return rebind_client_information(existing, issuer: issuer) if stored_issuer.nil?
569
+
570
+ client_id = client_info_required_value(existing, "client_id")
571
+ return rebind_client_information(existing, issuer: issuer) if Discovery.client_id_metadata_document_url?(client_id)
572
+
573
+ @provider.save_client_information(nil)
574
+ @provider.clear_tokens!
575
+ nil
576
+ end
577
+
578
+ def rebind_client_information(info, issuer:)
579
+ rebound = info.merge("issuer" => issuer)
580
+ @provider.save_client_information(rebound)
581
+ rebound
582
+ end
583
+
584
+ # Per SEP-2352, stored client credentials are bound to the authorization server that issued them;
585
+ # refuse to replay another AS's credentials at this token endpoint. `HTTP#attempt_refresh` rescues
586
+ # the error and falls back to the full flow, which discards the stale registration and re-registers.
587
+ # Credentials without an `"issuer"` binding predate this check and are allowed through;
588
+ # CIMD `client_id`s are portable.
589
+ def ensure_refreshable_client_information!(client_info, as_metadata:)
590
+ stored_issuer = client_info_required_value(client_info, "issuer")
591
+ return if stored_issuer.nil?
592
+ return if stored_issuer == as_metadata["issuer"]
593
+
594
+ client_id = client_info_required_value(client_info, "client_id")
595
+ return if Discovery.client_id_metadata_document_url?(client_id)
596
+
597
+ raise AuthorizationError,
598
+ "Cannot refresh: stored client credentials were issued by a different authorization server " \
599
+ "(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
600
+ "re-registration is required (SEP-2352)."
601
+ end
602
+
483
603
  # Reads `key` from a `client_information` hash that may use either string or
484
604
  # symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
485
605
  # `{ client_id:, client_secret: }` and have both work.
@@ -534,6 +654,43 @@ module MCP
534
654
  raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}."
535
655
  end
536
656
 
657
+ # Validates the RFC 9207 `iss` authorization response parameter against the issuer of the authorization server
658
+ # the flow is talking to, per SEP-2468 (mix-up attack mitigation in multi-IdP setups):
659
+ #
660
+ # - When the callback supplied a non-empty `iss`, it MUST equal the AS metadata `issuer` exactly (simple string comparison,
661
+ # no normalization, per RFC 9207 Section 2.4); on mismatch the flow aborts before the authorization code is ever sent to
662
+ # a token endpoint.
663
+ # - When the callback returned a 3-element `[code, state, iss]` whose `iss` is nil (asserting it inspected the authorization response
664
+ # and found no `iss`) and the AS metadata advertises `authorization_response_iss_parameter_supported: true`, the missing parameter
665
+ # is treated as an attack per RFC 9207 Section 2.4 and the flow aborts.
666
+ # - A legacy 2-element `[code, state]` callback skips the check: the caller never looked for `iss`, so its absence carries no signal.
667
+ #
668
+ # `as_metadata["issuer"]` has already been byte-compared against the discovery URL by `ensure_issuer_matches!`,
669
+ # so it is the RFC 9207 anchor value. `iss` is not a secret; a plain `==` suffices.
670
+ #
671
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468
672
+ # - https://www.rfc-editor.org/rfc/rfc9207
673
+ def validate_authorization_response_issuer!(as_metadata:, iss:, iss_provided:)
674
+ expected = as_metadata["issuer"]
675
+
676
+ if iss && !iss.to_s.empty?
677
+ return if iss.to_s == expected.to_s
678
+
679
+ raise AuthorizationError,
680
+ "Authorization response `iss` does not match the authorization server issuer " \
681
+ "(expected #{expected.inspect}, got #{iss.inspect}) (RFC 9207); " \
682
+ "refusing to exchange the authorization code."
683
+ end
684
+
685
+ return unless iss_provided
686
+ return unless as_metadata["authorization_response_iss_parameter_supported"] == true
687
+
688
+ raise AuthorizationError,
689
+ "Authorization server advertises `authorization_response_iss_parameter_supported` but " \
690
+ "the authorization response carried no `iss` parameter (RFC 9207 Section 2.4); " \
691
+ "refusing to exchange the authorization code."
692
+ end
693
+
537
694
  # Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery
538
695
  # of the expected value.
539
696
  # `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7
@@ -681,7 +838,26 @@ module MCP
681
838
  client_secret = client_info_required_value(client_info, "client_secret")
682
839
  token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
683
840
 
684
- form = form.merge("client_id" => client_id)
841
+ form = if token_endpoint_auth_method == "private_key_jwt"
842
+ # RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of
843
+ # the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046).
844
+ # The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is
845
+ # omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity).
846
+ # The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated.
847
+ unless @provider.respond_to?(:client_assertion)
848
+ raise AuthorizationError,
849
+ "token_endpoint_auth_method is private_key_jwt but the provider does not " \
850
+ "implement `client_assertion(audience:)`."
851
+ end
852
+
853
+ form.merge(
854
+ "client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
855
+ "client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]),
856
+ )
857
+ else
858
+ form.merge("client_id" => client_id)
859
+ end
860
+
685
861
  headers = {}
686
862
  if client_secret
687
863
  case token_endpoint_auth_method
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module MCP
7
+ class Client
8
+ module OAuth
9
+ # RFC 8693 token exchange against an enterprise identity provider, turning an IdP-issued ID token into
10
+ # an Identity Assertion Authorization Grant (ID-JAG) per the MCP Enterprise Managed Authorization extension (SEP-990).
11
+ # The returned ID-JAG is an opaque assertion the client then presents to the MCP authorization server with
12
+ # the RFC 7523 `jwt-bearer` grant (see `CrossAppAccessProvider`).
13
+ # Mirrors `requestJwtAuthorizationGrant` in the TypeScript SDK.
14
+ #
15
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
16
+ # - https://www.rfc-editor.org/rfc/rfc8693
17
+ module IDJAGTokenExchange
18
+ # Raised when the identity provider's token exchange fails or returns something other than an ID-JAG.
19
+ class ExchangeError < StandardError; end
20
+
21
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
22
+ ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
23
+ ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"
24
+
25
+ class << self
26
+ # Exchanges `id_token` for an ID-JAG at the IdP's token endpoint and returns the assertion string.
27
+ #
28
+ # @param token_endpoint [String] The identity provider's token endpoint.
29
+ # @param id_token [String] The IdP-issued ID token (the subject token).
30
+ # @param client_id [String] The client's identifier at the IdP.
31
+ # @param audience [String] The MCP authorization server's issuer identifier.
32
+ # @param resource [String] The canonical MCP server URL (RFC 8707).
33
+ # @param http_client [Object, nil] Faraday-compatible client; built lazily by default.
34
+ def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
35
+ http_client ||= default_http_client
36
+
37
+ response = begin
38
+ http_client.post(token_endpoint) do |req|
39
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
40
+ req.headers["Accept"] = "application/json"
41
+ req.body = URI.encode_www_form(
42
+ "grant_type" => GRANT_TYPE,
43
+ "subject_token" => id_token,
44
+ "subject_token_type" => ID_TOKEN_TYPE,
45
+ "requested_token_type" => ID_JAG_TOKEN_TYPE,
46
+ "audience" => audience,
47
+ "resource" => resource,
48
+ "client_id" => client_id,
49
+ )
50
+ end
51
+ rescue Faraday::Error => e
52
+ raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
53
+ end
54
+
55
+ if response.status < 200 || response.status >= 300
56
+ raise ExchangeError, "Identity provider token exchange returned status #{response.status}."
57
+ end
58
+
59
+ parse_id_jag(response)
60
+ end
61
+
62
+ private
63
+
64
+ def parse_id_jag(response)
65
+ body = response.body.is_a?(String) ? response.body : response.body.to_s
66
+ parsed = begin
67
+ JSON.parse(body)
68
+ rescue JSON::ParserError => e
69
+ raise ExchangeError, "Failed to parse token exchange response: #{e.message}."
70
+ end
71
+
72
+ unless parsed.is_a?(Hash)
73
+ raise ExchangeError, "Token exchange response is not a JSON object (got #{parsed.class})."
74
+ end
75
+
76
+ issued_token_type = parsed["issued_token_type"]
77
+ unless issued_token_type == ID_JAG_TOKEN_TYPE
78
+ raise ExchangeError,
79
+ "Token exchange did not issue an ID-JAG " \
80
+ "(expected issued_token_type #{ID_JAG_TOKEN_TYPE.inspect}, got #{issued_token_type.inspect})."
81
+ end
82
+
83
+ assertion = parsed["access_token"]
84
+ if assertion.nil? || assertion.to_s.empty?
85
+ raise ExchangeError, "Token exchange response is missing `access_token`."
86
+ end
87
+
88
+ assertion
89
+ end
90
+
91
+ def default_http_client
92
+ require "faraday"
93
+ Faraday.new do |faraday|
94
+ faraday.headers["Accept"] = "application/json"
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -12,7 +12,9 @@ module MCP
12
12
  # - `client_information`: the hash returned by Dynamic Client Registration
13
13
  # or supplied as pre-registered credentials
14
14
  # (`client_id`, optional `client_secret`, optional
15
- # `token_endpoint_auth_method`).
15
+ # `token_endpoint_auth_method`). The SDK additionally stamps an `"issuer"` member
16
+ # binding the credentials to the authorization server that issued them (SEP-2352);
17
+ # custom storages should treat the hash as opaque and persist it as-is.
16
18
  #
17
19
  # This class keeps everything in process memory, so the credentials live
18
20
  # only for the lifetime of the Ruby process. Applications that need
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+
8
+ module MCP
9
+ class Client
10
+ module OAuth
11
+ # Builds RFC 7523 Section 2.2 JWT client assertions for the `private_key_jwt`
12
+ # client authentication method used by the `client_credentials` grant
13
+ # (MCP extension `io.modelcontextprotocol/oauth-client-credentials`, SEP-1046).
14
+ #
15
+ # The JWS is assembled with openssl so the SDK stays free of a JWT gem dependency
16
+ # (the TypeScript and Python SDKs use jose and PyJWT for the same assertion).
17
+ # Claims follow SEP-1046 and RFC 7523: `iss` and `sub` carry the client_id,
18
+ # `aud` carries the authorization server's issuer identifier, plus `exp`, `iat`,
19
+ # and a unique `jti`.
20
+ #
21
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046
22
+ # - https://www.rfc-editor.org/rfc/rfc7523#section-2.2
23
+ module JWTClientAssertion
24
+ # Raised when `signing_algorithm` is not supported.
25
+ class UnsupportedAlgorithmError < ArgumentError; end
26
+
27
+ # Raised when the private key cannot be parsed or does not match
28
+ # the requested signing algorithm (e.g. an RSA key with ES256).
29
+ class InvalidKeyError < ArgumentError; end
30
+
31
+ # RFC 7523 Section 2.2 `client_assertion_type` value.
32
+ ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
33
+
34
+ # Assertion lifetime in seconds; matches the TypeScript SDK's `jwtLifetimeSeconds`
35
+ # and the Python SDK's `lifetime_seconds` default.
36
+ DEFAULT_LIFETIME = 300
37
+
38
+ # ES256 produces a raw `r || s` JWS signature of two 32-byte integers.
39
+ ES256_COMPONENT_BYTES = 32
40
+ private_constant :ES256_COMPONENT_BYTES
41
+
42
+ SUPPORTED_ALGORITHMS = ["ES256", "RS256"].freeze
43
+
44
+ class << self
45
+ # Returns a signed compact-serialization JWT (`header.payload.signature`).
46
+ #
47
+ # @param client_id [String] The pre-registered OAuth client identifier.
48
+ # @param audience [String] The authorization server's issuer identifier.
49
+ # @param private_key [String, OpenSSL::PKey::PKey] PEM string (PKCS#8 or
50
+ # traditional encoding) or an already-parsed key.
51
+ # @param signing_algorithm [String] `"ES256"` (prime256v1 EC key) or
52
+ # `"RS256"` (RSA key).
53
+ # @param lifetime [Integer] Seconds until the `exp` claim expires.
54
+ def generate(client_id:, audience:, private_key:, signing_algorithm:, lifetime: DEFAULT_LIFETIME)
55
+ unless SUPPORTED_ALGORITHMS.include?(signing_algorithm)
56
+ raise UnsupportedAlgorithmError,
57
+ "signing_algorithm must be one of #{SUPPORTED_ALGORITHMS.inspect} (got #{signing_algorithm.inspect})."
58
+ end
59
+
60
+ key = parse_key(private_key)
61
+ validate_key!(key, signing_algorithm)
62
+
63
+ now = Time.now.to_i
64
+ header = { alg: signing_algorithm, typ: "JWT" }
65
+ claims = {
66
+ iss: client_id,
67
+ sub: client_id,
68
+ aud: audience,
69
+ exp: now + lifetime,
70
+ iat: now,
71
+ jti: SecureRandom.uuid,
72
+ }
73
+
74
+ signing_input = "#{base64url(JSON.generate(header))}.#{base64url(JSON.generate(claims))}"
75
+ "#{signing_input}.#{base64url(sign(key, signing_algorithm, signing_input))}"
76
+ end
77
+
78
+ private
79
+
80
+ def parse_key(private_key)
81
+ return private_key if private_key.is_a?(OpenSSL::PKey::PKey)
82
+
83
+ OpenSSL::PKey.read(private_key.to_s)
84
+ rescue OpenSSL::PKey::PKeyError => e
85
+ raise InvalidKeyError, "private_key could not be parsed as a PEM-encoded key: #{e.message}."
86
+ end
87
+
88
+ def validate_key!(key, signing_algorithm)
89
+ case signing_algorithm
90
+ when "ES256"
91
+ unless key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == "prime256v1"
92
+ raise InvalidKeyError, "ES256 requires an EC private key on the prime256v1 (P-256) curve."
93
+ end
94
+ when "RS256"
95
+ unless key.is_a?(OpenSSL::PKey::RSA)
96
+ raise InvalidKeyError, "RS256 requires an RSA private key."
97
+ end
98
+ end
99
+
100
+ return if key.private?
101
+
102
+ raise InvalidKeyError, "private_key must contain the private component to sign assertions."
103
+ end
104
+
105
+ def sign(key, signing_algorithm, signing_input)
106
+ der = key.sign(OpenSSL::Digest.new("SHA256"), signing_input)
107
+ return der unless signing_algorithm == "ES256"
108
+
109
+ ecdsa_der_to_raw(der)
110
+ end
111
+
112
+ # `OpenSSL::PKey::EC#sign` returns an ASN.1 DER `SEQUENCE { r, s }`,
113
+ # while JWS ES256 (RFC 7518 Section 3.4) requires the raw 64-byte
114
+ # `r || s` concatenation with each integer left-padded to 32 bytes.
115
+ def ecdsa_der_to_raw(der)
116
+ OpenSSL::ASN1.decode(der).value.map do |integer|
117
+ integer.value.to_s(16).rjust(ES256_COMPONENT_BYTES * 2, "0")
118
+ end.join.then { |hex| [hex].pack("H*") }
119
+ end
120
+
121
+ def base64url(data)
122
+ Base64.urlsafe_encode64(data, padding: false)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -21,15 +21,23 @@ module MCP
21
21
  # - `redirect_handler` - Callable invoked with the fully-built authorization
22
22
  # URL (a `URI`). Implementations typically open the user's browser.
23
23
  # - `callback_handler` - Callable invoked after `redirect_handler`. Returns
24
- # `[code, state]` where `code` is the authorization code and `state` is
25
- # the `state` parameter received on the redirect URI.
24
+ # `[code, state]` or `[code, state, iss]`, where `code` is the authorization code,
25
+ # `state` is the `state` parameter received on the redirect URI, and `iss` is
26
+ # the RFC 9207 `iss` parameter (or nil when the authorization response carried none).
27
+ # Returning the 3-element form opts into SEP-2468 issuer validation: a present `iss`
28
+ # must match the authorization server's issuer, and a nil `iss` is
29
+ # rejected when the AS advertises `authorization_response_iss_parameter_supported`.
30
+ # The 2-element form skips the check for backward compatibility.
26
31
  #
27
32
  # Optional keyword arguments:
28
33
  # - `scope` - String of space-separated scopes to request when the server's
29
34
  # `WWW-Authenticate` does not specify one.
30
35
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
31
36
  # `client_information`, and `save_client_information(info)`. Defaults to
32
- # an `InMemoryStorage`.
37
+ # an `InMemoryStorage`. Persisted `client_information` is stamped with
38
+ # an `"issuer"` member binding it to the authorization server that
39
+ # issued it (SEP-2352); when the authorization server changes, the SDK discards
40
+ # the stale registration and tokens and re-registers.
33
41
  # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
34
42
  # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
35
43
  # When the authorization server advertises `client_id_metadata_document_supported: true`,
@@ -5,14 +5,18 @@ require_relative "oauth/flow"
5
5
  require_relative "oauth/in_memory_storage"
6
6
  require_relative "oauth/pkce"
7
7
  require_relative "oauth/storage_backed_provider"
8
+ require_relative "oauth/jwt_client_assertion"
8
9
  require_relative "oauth/provider"
9
10
  require_relative "oauth/client_credentials_provider"
11
+ require_relative "oauth/id_jag_token_exchange"
12
+ require_relative "oauth/cross_app_access_provider"
10
13
 
11
14
  module MCP
12
15
  class Client
13
16
  # OAuth client support for the MCP Authorization spec (PRM discovery,
14
17
  # Authorization Server metadata discovery, Dynamic Client Registration,
15
- # OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant).
18
+ # OAuth 2.1 Authorization Code + PKCE, the client_credentials grant,
19
+ # and the SEP-990 Enterprise Managed Authorization jwt-bearer grant).
16
20
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
17
21
  module OAuth
18
22
  end
@@ -4,10 +4,12 @@ module MCP
4
4
  class Client
5
5
  # Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
6
6
  # Each carries the page items, an optional opaque `next_cursor` string for continuing pagination,
7
- # and an optional `meta` hash mirroring the MCP `_meta` response field.
8
- ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true)
9
- ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true)
10
- ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true)
11
- ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true)
7
+ # an optional `meta` hash mirroring the MCP `_meta` response field, and the optional SEP-2549
8
+ # cache hints `ttl_ms` (freshness lifetime in milliseconds; 0 means do not cache) and
9
+ # `cache_scope` (`"public"` or `"private"`) mirroring the `ttlMs`/`cacheScope` response fields.
10
+ ListToolsResult = Struct.new(:tools, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
11
+ ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
12
+ ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
13
+ ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
12
14
  end
13
15
  end