Class: MCPClient::Auth::OAuthProvider

Inherits:
Object
  • Object
show all
Includes:
ChallengeHandling, ClientAuthentication, PendingRequests, RegistrationStore, ResponseValidation, ScopeSelection, TokenStore, PeerText
Defined in:
lib/mcp_client/auth/oauth_provider.rb,
lib/mcp_client/auth/oauth_provider/token_store.rb,
lib/mcp_client/auth/oauth_provider/scope_selection.rb,
lib/mcp_client/auth/oauth_provider/pending_requests.rb,
lib/mcp_client/auth/oauth_provider/challenge_handling.rb,
lib/mcp_client/auth/oauth_provider/registration_store.rb,
lib/mcp_client/auth/oauth_provider/response_validation.rb,
lib/mcp_client/auth/oauth_provider/client_authentication.rb

Overview

OAuth 2.1 provider for MCP client authentication Handles the complete OAuth flow including server discovery, client registration, authorization, token exchange, and refresh

Defined Under Namespace

Modules: ChallengeHandling, ClientAuthentication, PendingRequests, RegistrationStore, ResponseValidation, ScopeSelection, TokenStore Classes: MemoryStorage

Constant Summary collapse

AUTH_PARAM =

One auth-param (name = token / quoted-string) as it appears in a WWW-Authenticate challenge (RFC 7235 §2.1, optional whitespace around '='). Mirrors HttpTransportBase::AUTH_PARAM so provider-side challenge parsing segments headers exactly like the transport does.

/[A-Za-z0-9._~+-]+\s*=\s*(?:"(?:[^"\\]|\\.)*"|[^,\s]*)/
AUTH_PARAMS_RUN =

A run of comma/space separated auth-params anchored at the start of a string. The run ends before a token that is NOT followed by '=' — the auth-scheme introducing the next challenge — while commas inside quoted values are consumed by the quoted-string branch, not treated as boundaries. Mirrors HttpTransportBase::AUTH_PARAMS_RUN.

/\A(?:[\s,]*#{AUTH_PARAM})*/
APPLICATION_TYPES =

Initialize OAuth provider OIDC application types accepted for Dynamic Client Registration.

Raises:

  • (ArgumentError) —

    if client_id_metadata_url is not an HTTPS URL with a path component

%w[native web].freeze
AUTHORIZATION_REQUEST_PARAMS =

The authorization request parameters this client puts in the authorization URL. RFC 6749 Section 3.1: "Request and response parameters MUST NOT be included more than once", so an authorization endpoint whose own query already names one of these loses it to the value of this request (see #merged_authorization_query).

%w[
  response_type client_id redirect_uri scope state code_challenge code_challenge_method resource
].freeze
LOOPBACK_HOSTS =

Literal names of the loopback interface (see #loopback_address?).

%w[localhost 127.0.0.1 ::1 [::1]].freeze

Constants included from ResponseValidation

ResponseValidation::CALLBACK_SCHEMES, ResponseValidation::HEADER_UNSAFE_BYTE, ResponseValidation::REGISTRATION_RESPONSE_FIELDS, ResponseValidation::REGISTRATION_RESPONSE_REFERENCE, ResponseValidation::REQUIRED_RESOURCE_METADATA_FIELDS, ResponseValidation::REQUIRED_SERVER_METADATA_FIELDS, ResponseValidation::REQUIRED_TOKEN_RESPONSE_FIELDS, ResponseValidation::RESOURCE_METADATA_FIELDS, ResponseValidation::RESOURCE_METADATA_REFERENCE, ResponseValidation::SERVER_METADATA_FIELDS, ResponseValidation::SERVER_METADATA_REFERENCE, ResponseValidation::SUPPORTED_TOKEN_TYPE, ResponseValidation::TOKEN_RESPONSE_FIELDS, ResponseValidation::TOKEN_RESPONSE_REFERENCE, ResponseValidation::TYPE_DESCRIPTIONS

Constants included from PeerText

PeerText::PEER_TEXT_LIMIT, PeerText::UNDECODABLE_BYTE, PeerText::UNREADABLE_TEXT

Constants included from ClientAuthentication

ClientAuthentication::DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD, ClientAuthentication::NO_CLIENT_AUTHENTICATION

Constants included from ChallengeHandling

ChallengeHandling::HOSTNAME_CHARACTERS, ChallengeHandling::HOST_DECODE_PASSES, ChallengeHandling::IPV4_COMPONENT, ChallengeHandling::PERCENT_ESCAPE

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from RegistrationStore

#client_registration_key

Methods included from PeerText

decodable, decodable?

Methods included from ChallengeHandling

#adopt_challenge_metadata, #bearer_challenge_segment, #extract_challenge_param, #extract_resource_metadata_url, #handle_unauthorized_response, #protected_resource_metadata_url?, #reject_unacceptable_challenge!, #resolve_pending_challenge, #revoke_token_on_authorization_server_change, #step_up_challenge?

Constructor Details

#initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil, client_metadata: {}, client_id_metadata_url: nil, application_type: nil) ⇒ OAuthProvider

Returns a new instance of OAuthProvider.



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/mcp_client/auth/oauth_provider.rb', line 108

def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil,
               client_metadata: {}, client_id_metadata_url: nil, application_type: nil)
  self.server_url = server_url
  self.redirect_uri = redirect_uri
  self.scope = scope
  self.logger = logger || Logger.new($stdout, level: Logger::WARN)
  self.storage = storage || MemoryStorage.new
  self. = 
  # An application_type given through client_metadata is the host's
  # explicit choice too; it never silently overrides the derived type.
  extra = ( || {}).transform_keys(&:to_sym)
  self.application_type = application_type || extra[:application_type]
  @extra_client_metadata = extra.except(:application_type)
  @http_client = create_http_client
  # Protected resource metadata learned from a 401 WWW-Authenticate
  # challenge, reused by discovery so a challenge-advertised metadata URL
  # is not re-derived (and possibly missed). The URL itself is retained
  # separately so a failed fetch is retried authoritatively by discovery.
  @challenge_resource_metadata = nil
  @challenge_metadata_url = nil
  # Why a peer-advertised challenge URL was refused, if one was
  @challenge_error = nil
end

Instance Attribute Details

#application_type ⇒ String?

Returns the explicit application_type for Dynamic Client Registration.

Returns:

  • (String, nil) —

    the explicit application_type for Dynamic Client Registration



106
107
108
# File 'lib/mcp_client/auth/oauth_provider.rb', line 106

def application_type
  @application_type
end

#challenge_scope ⇒ String? (readonly)

Scope requested by the most recent WWW-Authenticate challenge.

Returns:

  • (String, nil)


635
636
637
# File 'lib/mcp_client/auth/oauth_provider.rb', line 635

def challenge_scope
  @challenge_scope
end

#client_id_metadata_url ⇒ String?

Returns HTTPS URL of this client's Client ID Metadata Document (SEP-991).

Returns:

  • (String, nil) —

    HTTPS URL of this client's Client ID Metadata Document (SEP-991)



73
# File 'lib/mcp_client/auth/oauth_provider.rb', line 73

attr_accessor :scope, :logger, :storage

#logger ⇒ Logger

Returns Logger instance.

Returns:

  • (Logger) —

    Logger instance



73
# File 'lib/mcp_client/auth/oauth_provider.rb', line 73

attr_accessor :scope, :logger, :storage

#redirect_uri ⇒ String

Returns OAuth redirect URI.

Returns:

  • (String) —

    OAuth redirect URI



73
# File 'lib/mcp_client/auth/oauth_provider.rb', line 73

attr_accessor :scope, :logger, :storage

#scope ⇒ String, ...

Returns OAuth scope (use :all for all server-supported scopes).

Returns:

  • (String, Symbol, nil) —

    OAuth scope (use :all for all server-supported scopes)



73
74
75
# File 'lib/mcp_client/auth/oauth_provider.rb', line 73

def scope
  @scope
end

#server_url ⇒ String

Returns The MCP server URL (normalized).

Returns:

  • (String) —

    The MCP server URL (normalized)



73
# File 'lib/mcp_client/auth/oauth_provider.rb', line 73

attr_accessor :scope, :logger, :storage

#storage ⇒ Object

Returns Storage backend for tokens and client info.

Returns:

  • (Object) —

    Storage backend for tokens and client info



73
# File 'lib/mcp_client/auth/oauth_provider.rb', line 73

attr_accessor :scope, :logger, :storage

Instance Method Details

#accept_exchanged_token(token, pkce) ⇒ Token

The response of a code exchange arrives at a client whose authorization server may have changed since the request went out — updated protected resource metadata, a 401 challenge, another provider sharing the storage — exactly as a refresh response does. So the checks made before the request are made again over the response, before anything is written: bytes issued by a server that is no longer this resource's would otherwise be stored over the token of the server that IS in use, and the cleanup would delete the pending authorization request that server had already started.

Parameters:

  • token (Token) —

    the token the exchange response carried

  • pkce (PKCE) —

    the per-request record the exchange was made with

Returns:

Raises:



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
# File 'lib/mcp_client/auth/oauth_provider.rb', line 350

def accept_exchanged_token(token, pkce)
  # Under the resource's authorization-state lock, so the answers these
  # checks give are still the answers when the token is written: a
  # switch of authorization server validated between the two would
  # otherwise store its token first and have this one written over it.
  with_authorization_state_lock do
    raise_authorization_server_changed!(pkce.issuer) unless exchange_target_current?(pkce.issuer)
    # Two resources may share an authorization server and still be two
    # audiences: a token bought for the resource the request named is
    # never stored as another resource's, however alike their issuers.
    raise_resource_changed!(pkce.resource) unless request_resource_current?(pkce)
    # A validated change of authorization server ends the requests still
    # pending with the previous one (see {#end_pending_requests_of}),
    # whichever provider sharing the storage made them: a request whose
    # record is gone was answered by a server that is no longer this
    # resource's, however current that server still looks from here.
    raise_authorization_server_changed!(pkce.issuer) unless request_still_pending?(pkce)

    store_token(token)

    # Clean up this request's temporary data — and only this request's: a
    # flow started meanwhile keeps the records it is waiting on.
    discard_pending_request(pkce)
  end

  token
end

#access_token ⇒ Token?

Get current access token (refresh if needed)

Returns:

  • (Token, nil) —

    Current valid access token or nil



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
# File 'lib/mcp_client/auth/oauth_provider.rb', line 188

def access_token
  # A challenge still to be fetched decides which authorization server
  # is current, and may retire the stored token: it is resolved before
  # the token is read, so a record it deleted is never written back.
  resolve_pending_challenge
  token = token_in_use
  logger.debug("OAuth access_token: retrieved token=#{token ? 'present' : 'nil'} for #{server_url}")
  return nil unless token

  # Return token if still valid
  return token unless token.expired? || token.expires_soon?

  # Refresh early when possible; a still-valid token is presented when
  # the refresh (or the discovery it needs) cannot run right now. What
  # comes back is judged against the authorization server that is
  # current NOW, not the one the refresh was started with.
  resource = server_url
  refreshed = refresh_if_possible(token)
  # A provider retargeted at another resource while the refresh was in
  # flight has nothing of the previous resource to present.
  return nil unless server_url == resource
  return refreshed if token_bytes?(refreshed) && token_for_current_issuer?(refreshed)

  # The token in use may have changed hands while the refresh was in
  # flight — retired by a challenge another provider sharing the storage
  # handled, replaced by a flow it completed: what is in use NOW is what
  # is presented, and the token this refresh started from is not.
  current = token_in_use
  return presentable_token(current) unless current && same_token?(current, token)

  # The discovery a refresh ran may have retired this very token.
  return nil if token.expired? || retired_token?(token) || !token_for_current_issuer?(token)

  token
end

#apply_authorization(request) ⇒ void

This method returns an undefined value.

Apply OAuth authorization to HTTP request

Parameters:

  • request (Faraday::Request) —

    HTTP request to authorize



620
621
622
623
624
625
626
627
628
629
630
631
# File 'lib/mcp_client/auth/oauth_provider.rb', line 620

def apply_authorization(request)
  token = access_token
  logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
  # A record without access token bytes is never presented: a bare
  # "Bearer " is not a credential (RFC 6749 Section 5.1).
  return unless token_bytes?(token)

  # The header's value is the credential: not a prefix of it, not a
  # truncation of it. It is never written to a log at any level.
  logger.debug('OAuth applying authorization header')
  request.headers['Authorization'] = token.to_header
end

#authorization_error_message(params) ⇒ String

The message to surface for an authorization error response, after the same RFC 9207 issuer check as a success response: "on mismatch the client MUST NOT act on or display error, error_description, or error_uri" (MCP 2026-07-28 "Authorization Response Validation").

Parameters:

  • params (Hash) —

    the callback parameters (error, error_description, iss, state, ...)

Returns:

  • (String) —

    the error text to show

Raises:



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
# File 'lib/mcp_client/auth/oauth_provider.rb', line 585

def authorization_error_message(params)
  params = params.to_h.transform_keys(&:to_s)
  # Every started flow records a state, so a response that cannot be
  # matched to one is not this client's to act on.
  stored_state = storage.get_state(server_url)
  if stored_state.nil? || params['state'] != stored_state
    raise MCPClient::Errors::ConnectionError, 'Authorization error response rejected: state mismatch'
  end

  pkce = stored_pkce
  # The per-request record must be the record of THIS response, as the
  # success path requires: the separate state slot and the record can
  # be torn apart by two flows sharing a storage backend, and a state
  # that names another request's record makes every check below — the
  # authorization server, the `iss` — a check about that other request.
  # An error response would then be displayed after passing an issuer
  # comparison it never had to satisfy.
  ensure_state_for_request!(pkce, params['state']) if pkce
  cached = 
  # The same checks the success path makes: an error response of
  # authorization server A is not displayed once a challenge received
  # during the flow — or shared storage — moved the flow to B. Without a
  # recorded issuer there is nothing to compare, and the issuer check
  # below rejects the response outright.
  ensure_authorization_server_unchanged!(pkce, cached) if pkce.respond_to?(:issuer) && pkce.issuer.is_a?(String)
  # Only the request's own authorization server can say whether iss is
  # expected: a cache that names another server is no guide.
  cached = nil unless pkce && cached && cached.issuer == pkce.issuer
  validate_authorization_response_issuer!(params['iss'], pkce&.issuer, iss_advertised_for_response?(pkce, cached))
  safe_error_text((params['error_description'] || params['error'] || 'unknown error').to_s).strip
end

#client_for_request?(client_info, pkce) ⇒ Boolean

Whether stored credentials are the ones an authorization request was made with: the recorded client id and, unless portable, bound to the request's authorization server.

Parameters:

Returns:

  • (Boolean)


541
542
543
544
545
546
547
548
# File 'lib/mcp_client/auth/oauth_provider.rb', line 541

def client_for_request?(client_info, pkce)
  return false if pkce.client_id != client_info.client_id
  return true if portable_client?(client_info)

  # A started flow binds its non-portable client, so an unbound record
  # here was put in storage by someone else meanwhile.
  !client_info.respond_to?(:issuer) || client_info.issuer == pkce.issuer
end

#complete_authorization_flow(code, state, iss: nil) ⇒ Token

Complete OAuth authorization flow with authorization code

Parameters:

  • code (String) —

    Authorization code from callback

  • state (String) —

    State parameter from callback

  • iss (String, nil) (defaults to: nil) —

    the iss parameter of the authorization response (RFC 9207); validated against the issuer recorded when the flow started, before the code is sent to any token endpoint (MCP 2026-07-28)

Returns:

  • (Token) —

    Access token

Raises:



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
# File 'lib/mcp_client/auth/oauth_provider.rb', line 300

def complete_authorization_flow(code, state, iss: nil)
  # Verify state parameter
  stored_state = storage.get_state(server_url)
  raise ArgumentError, 'Invalid state parameter' unless stored_state == state

  # Get stored PKCE and client info
  pkce = stored_pkce
  client_info = stored_client_info
  raise MCPClient::Errors::ConnectionError, 'Missing PKCE or client info' unless pkce && client_info

  # The per-request record must be the record of THIS request.
  ensure_state_for_request!(pkce, state)

  # The code is redeemed only at the authorization server the request
  # was sent to: the issuer recorded with the PKCE record (RFC 9207
  # mix-up protection). A different server discovered since — a 401
  # challenge pointing elsewhere — ends this flow instead.
  unless pkce.issuer.is_a?(String)
    raise MCPClient::Errors::ConnectionError,
          'Authorization response rejected: no issuer was recorded for this authorization request, ' \
          'so it cannot be bound to an authorization server; restart the authorization'
  end
   = discover_authorization_server
  raise_authorization_server_changed!(pkce.issuer) unless .issuer == pkce.issuer
  validate_authorization_response_issuer!(iss, pkce.issuer, iss_parameter_supported_for?(pkce, ))
  # The credentials that redeem the code are the ones the request was
  # made with: a record swapped in shared storage meanwhile (another
  # client id, or credentials of another authorization server) is
  # never sent to this token endpoint.
  ensure_client_for_request!(client_info, pkce)

  # Exchange authorization code for tokens
  token = exchange_authorization_code(, client_info, code, pkce)

  accept_exchanged_token(token, pkce)
end

#discard_pending_pkce(pkce) ⇒ void

This method returns an undefined value.

Read, compare, delete: a flow started in between loses its record to the delete. The storage interface has no conditional delete, but a backend that answers the delete with the record it removed (the in-memory one does, as does anything Hash-backed) says whose record went, and a newer flow's is put back — and, held under #with_authorization_state_lock, the newer flow cannot start in between at all within one process.

Parameters:

  • pkce (PKCE) —

    the record whose flow just ended



464
465
466
467
468
469
# File 'lib/mcp_client/auth/oauth_provider.rb', line 464

def discard_pending_pkce(pkce)
  removed = removed_record(storage.delete_pkce(server_url), PKCE)
  return unless removed.respond_to?(:code_verifier) && !same_request?(removed, pkce)

  storage.set_pkce(server_url, removed)
end

#discard_pending_request(pkce) ⇒ void

This method returns an undefined value.

Delete the pending-flow records of one authorization request, leaving a newer request's records alone.

Parameters:

  • pkce (PKCE) —

    the per-request record whose flow just ended



420
421
422
423
424
425
426
427
428
# File 'lib/mcp_client/auth/oauth_provider.rb', line 420

def discard_pending_request(pkce)
  with_authorization_state_lock do
    pending = stored_pkce
    discard_pending_pkce(pkce) if pending.nil? || same_request?(pending, pkce)
    recorded = pkce.state if pkce.respond_to?(:state)
    stored_state = storage.get_state(server_url)
    discard_pending_state(recorded) if recorded.nil? || stored_state.nil? || stored_state == recorded
  end
end

#discard_pending_state(recorded) ⇒ void

This method returns an undefined value.

Parameters:

  • recorded (String, nil) —

    the state of the request whose flow just ended



473
474
475
476
477
478
# File 'lib/mcp_client/auth/oauth_provider.rb', line 473

def discard_pending_state(recorded)
  removed = storage.delete_state(server_url)
  return unless recorded && removed.is_a?(String) && removed != recorded

  storage.set_state(server_url, removed)
end

#ensure_client_for_request!(client_info, pkce) ⇒ void

This method returns an undefined value.

The stored credentials must be the ones the authorization request was made with; a request that recorded no client cannot be bound to any and fails closed, like one that recorded no issuer.

Parameters:

Raises:



522
523
524
525
526
527
528
529
530
531
532
533
# File 'lib/mcp_client/auth/oauth_provider.rb', line 522

def ensure_client_for_request!(client_info, pkce)
  unless pkce.respond_to?(:client_id) && pkce.client_id.is_a?(String)
    raise MCPClient::Errors::ConnectionError,
          'Authorization response rejected: no client was recorded for this authorization request; ' \
          'restart the authorization'
  end
  return if client_info && client_for_request?(client_info, pkce)

  raise MCPClient::Errors::ConnectionError,
        'Authorization response rejected: the client credentials changed during the flow; ' \
        'restart the authorization'
end

#ensure_state_for_request!(pkce, state) ⇒ void

This method returns an undefined value.

The state of a callback must be the state of the per-request record the rest of the checks read. A record written by this client always carries one; a record persisted before the state was recorded there carries none, and the separate slot (already compared by the caller) is all there is to go on.

Parameters:

  • pkce (PKCE) —

    the per-request record

  • state (String, nil) —

    the callback's state parameter

Raises:



506
507
508
509
510
511
512
513
# File 'lib/mcp_client/auth/oauth_provider.rb', line 506

def ensure_state_for_request!(pkce, state)
  recorded = pkce.state if pkce.respond_to?(:state)
  return if recorded.nil? || recorded == state

  raise MCPClient::Errors::ConnectionError,
        'Authorization response rejected: the pending authorization request is not the one this ' \
        'response answers; restart the authorization'
end

#exchange_target_current?(issuer) ⇒ Boolean

Whether the code exchange that just answered is still this resource's: the authorization server it went to is the one in use now (an unresolved or refused challenge means it is unknown, which is not "still A"), and the token slot the response would be written to does not already hold another server's token. A record this client retired is not another server's token to protect — it is what a backend that could not delete left behind — so it does not stand in the way.

Parameters:

  • issuer (String) —

    the authorization server the flow started at

Returns:

  • (Boolean)


387
388
389
390
391
392
393
394
395
# File 'lib/mcp_client/auth/oauth_provider.rb', line 387

def exchange_target_current?(issuer)
  return false unless current_issuer_for_tokens == issuer

  stored = stored_token_or_nil
  return true unless stored.respond_to?(:issuer) && stored.issuer
  return true if retired_token?(stored)

  stored.issuer == issuer
end

#raise_resource_changed!(recorded) ⇒ Object

Parameters:

  • recorded (String) —

    the resource the request was made for

Raises:



410
411
412
413
414
# File 'lib/mcp_client/auth/oauth_provider.rb', line 410

def raise_resource_changed!(recorded)
  raise MCPClient::Errors::ConnectionError,
        'Authorization response rejected: the resource changed during the flow ' \
        "(the token was issued for #{safe_error_text(recorded)}); restart the authorization"
end

#refresh_if_possible(token) ⇒ Token?

Returns the refreshed token, or nil when no refresh could be obtained.

Parameters:

Returns:

  • (Token, nil) —

    the refreshed token, or nil when no refresh could be obtained



226
227
228
229
230
231
232
233
# File 'lib/mcp_client/auth/oauth_provider.rb', line 226

def refresh_if_possible(token)
  return nil unless token.refresh_token

  refresh_token(token)
rescue MCPClient::Errors::ConnectionError => e
  logger.warn("Token refresh could not run: #{e.message}")
  nil
end

#removed_record(removed, klass) ⇒ Object?

The record a delete answered with, if it answered with one.

Parameters:

  • removed (Object, nil) —

    what the backend returned

  • klass (Class) —

    the record class

Returns:

  • (Object, nil)


484
485
486
487
488
# File 'lib/mcp_client/auth/oauth_provider.rb', line 484

def removed_record(removed, klass)
  normalize_record(removed, klass)
rescue ArgumentError
  nil
end

#request_resource_current?(pkce) ⇒ Boolean

Whether the resource a request was made for is still the one this provider serves. A record made before the resource was recorded says nothing, and is judged by its issuer alone.

Parameters:

  • pkce (PKCE) —

    the per-request record

Returns:

  • (Boolean)


402
403
404
405
406
# File 'lib/mcp_client/auth/oauth_provider.rb', line 402

def request_resource_current?(pkce)
  return true unless pkce.respond_to?(:resource) && pkce.resource.is_a?(String)

  pkce.resource == server_url
end

#same_request?(one, other) ⇒ Boolean

Returns whether both records describe the same authorization request.

Parameters:

  • one (PKCE, Object) —

    the record currently in the pending-flow slot

  • other (PKCE) —

    the record the flow that just ended was made with

Returns:

  • (Boolean) —

    whether both records describe the same authorization request



493
494
495
# File 'lib/mcp_client/auth/oauth_provider.rb', line 493

def same_request?(one, other)
  one.respond_to?(:code_verifier) && one.code_verifier == other.code_verifier
end

#start_authorization_flow ⇒ String

Start OAuth authorization flow

Returns:

  • (String) —

    Authorization URL to redirect user to

Raises:



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
# File 'lib/mcp_client/auth/oauth_provider.rb', line 248

def start_authorization_flow
  # Discover authorization server
   = discover_authorization_server

  # Register client if needed
  client_info = get_or_register_client()

  # Generate the state and the PKCE parameters as ONE per-request
  # record. MCP 2026-07-28 "Authorization Response Validation": the
  # selected authorization server's issuer is recorded "in the same
  # per-request record used to store the PKCE code verifier (and the
  # `state` value, if used)", so the `iss` of the response can be
  # checked against an authenticated value — and so the state the
  # response carries names THIS request rather than whichever record
  # happens to be in the PKCE slot. Two flows sharing one storage
  # backend interleave their writes: with the state in a slot of its
  # own, A's state could end up alongside B's verifier, issuer and
  # client, and A's code would then be redeemed at B.
  state = SecureRandom.urlsafe_base64(32)
  # What this request asks for is what the next step-up challenge has
  # to be unioned with — and, recorded with the request, what a token
  # response that omits `scope` granted.
  @requested_scope = resolved_scope
  pkce = PKCE.new(issuer: .issuer,
                  iss_parameter_supported: .iss_parameter_supported?,
                  client_id: client_info.client_id,
                  redirect_uri: client_info..redirect_uris.first,
                  state: state,
                  resource: server_url,
                  scope: @requested_scope)
  with_authorization_state_lock do
    storage.set_pkce(server_url, pkce)

    # The separate slot is still written: it is the documented storage
    # interface, and a callback handler (or an older version of this
    # library) reads the state from it.
    storage.set_state(server_url, state)
  end

  # Build authorization URL
  build_authorization_url(, client_info, pkce, state)
end

#supported_scopes ⇒ Array<String>

Return the scopes supported by the authorization server Discovers server metadata and returns the scopes_supported list.

Returns:

  • (Array<String>) —

    supported scopes, or empty array if not advertised

Raises:



239
240
241
242
243
# File 'lib/mcp_client/auth/oauth_provider.rb', line 239

def supported_scopes
  # A record a hash-persisting backend read back may carry anything
  # here; only an array of scopes is a scope list (RFC 8414 Section 2).
  @supported_scopes ||= advertised_scopes(discover_authorization_server.scopes_supported)
end

#validate_authorization_response!(state, iss: nil) ⇒ void

This method returns an undefined value.

Check a success response before anything is shown or exchanged: the state must be the one of the pending flow and the response's iss must identify the authorization server the request went to (RFC 9207). #complete_authorization_flow repeats the check before the token exchange; a browser callback uses this to answer correctly.

Parameters:

  • state (String, nil) —

    the callback's state parameter

  • iss (String, nil) (defaults to: nil) —

    the callback's iss parameter

Raises:



559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
# File 'lib/mcp_client/auth/oauth_provider.rb', line 559

def validate_authorization_response!(state, iss: nil)
  stored_state = storage.get_state(server_url)
  unless stored_state && stored_state == state
    raise MCPClient::Errors::ConnectionError, 'Authorization response rejected: state mismatch'
  end

  pkce = stored_pkce
  unless pkce.respond_to?(:issuer) && pkce.issuer.is_a?(String)
    raise MCPClient::Errors::ConnectionError,
          'Authorization response rejected: no issuer was recorded for this authorization request'
  end

  ensure_state_for_request!(pkce, state)
  cached = 
  ensure_authorization_server_unchanged!(pkce, cached)
  ensure_client_for_request!(stored_client_info, pkce)
  validate_authorization_response_issuer!(iss, pkce.issuer, iss_advertised_for_response?(pkce, cached))
end

#with_authorization_state_lock ⇒ Object

The authorization state of one resource in one storage backend — the pending-flow records, and the token slot an accepted response is written to — is read, compared and written under one in-process lock. Two windows depend on it. A flow another provider (or thread) starts while a completed flow discards its records waits for the delete instead of losing its records to it; and the checks that accept a token (issuer, resource, pending request, token in use) stay true until that token is stored, so a change of authorization server validated meanwhile cannot have its token written over by a response that passed its checks just before it.

The storage interface has no conditional write or delete, and a backend need not answer a delete with the record it removed, so both windows have to be closed on this side. The lock is re-entrant: a flow the SAME thread starts from inside a storage callback is not deadlocked, and falls back to the put-back a record-answering delete allows.

Returns:

  • (Object) —

    the block's value



448
449
450
451
452
453
# File 'lib/mcp_client/auth/oauth_provider.rb', line 448

def with_authorization_state_lock(&)
  lock = AUTHORIZATION_STATE_LOCKS_GUARD.synchronize do
    (AUTHORIZATION_STATE_LOCKS[storage] ||= {})[server_url] ||= Monitor.new
  end
  lock.synchronize(&)
end