Class: MCPClient::Auth::OAuthProvider
- Inherits:
-
Object
- Object
- MCPClient::Auth::OAuthProvider
- 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.
%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
-
#application_type ⇒ String?
The explicit application_type for Dynamic Client Registration.
-
#challenge_scope ⇒ String?
readonly
Scope requested by the most recent WWW-Authenticate challenge.
-
#client_id_metadata_url ⇒ String?
HTTPS URL of this client's Client ID Metadata Document (SEP-991).
-
#logger ⇒ Logger
Logger instance.
-
#redirect_uri ⇒ String
OAuth redirect URI.
-
#scope ⇒ String, ...
OAuth scope (use :all for all server-supported scopes).
-
#server_url ⇒ String
The MCP server URL (normalized).
-
#storage ⇒ Object
Storage backend for tokens and client info.
Instance Method Summary collapse
-
#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.
-
#access_token ⇒ Token?
Get current access token (refresh if needed).
-
#apply_authorization(request) ⇒ void
Apply OAuth authorization to HTTP request.
-
#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").
-
#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.
-
#complete_authorization_flow(code, state, iss: nil) ⇒ Token
Complete OAuth authorization flow with authorization code.
-
#discard_pending_pkce(pkce) ⇒ void
Read, compare, delete: a flow started in between loses its record to the delete.
-
#discard_pending_request(pkce) ⇒ void
Delete the pending-flow records of one authorization request, leaving a newer request's records alone.
- #discard_pending_state(recorded) ⇒ void
-
#ensure_client_for_request!(client_info, pkce) ⇒ void
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.
-
#ensure_state_for_request!(pkce, state) ⇒ void
The state of a callback must be the state of the per-request record the rest of the checks read.
-
#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.
-
#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
constructor
A new instance of OAuthProvider.
- #raise_resource_changed!(recorded) ⇒ Object
-
#refresh_if_possible(token) ⇒ Token?
The refreshed token, or nil when no refresh could be obtained.
-
#removed_record(removed, klass) ⇒ Object?
The record a delete answered with, if it answered with one.
-
#request_resource_current?(pkce) ⇒ Boolean
Whether the resource a request was made for is still the one this provider serves.
-
#same_request?(one, other) ⇒ Boolean
Whether both records describe the same authorization request.
-
#start_authorization_flow ⇒ String
Start OAuth authorization flow.
-
#supported_scopes ⇒ Array<String>
Return the scopes supported by the authorization server Discovers server metadata and returns the scopes_supported list.
-
#validate_authorization_response!(state, iss: nil) ⇒ void
Check a success response before anything is shown or exchanged: the state must be the one of the pending flow and the response's
issmust identify the authorization server the request went to (RFC 9207). -
#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.
Methods included from RegistrationStore
Methods included from PeerText
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.
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.
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).
73 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 73 attr_accessor :scope, :logger, :storage |
#logger ⇒ Logger
Returns 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.
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).
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).
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.
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.
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. do (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. (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)
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
620 621 622 623 624 625 626 627 628 629 630 631 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 620 def (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").
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 (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. (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 (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.
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
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 (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 = (pkce.issuer) unless .issuer == pkce.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 = (, 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.
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.
420 421 422 423 424 425 426 427 428 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 420 def discard_pending_request(pkce) 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.
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.
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.
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.
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
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.
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.}") nil end |
#removed_record(removed, klass) ⇒ Object?
The record a delete answered with, if it answered with one.
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.
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.
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
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 # 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) 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 (, 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.
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(.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.
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 (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 = (pkce, cached) ensure_client_for_request!(stored_client_info, pkce) (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.
448 449 450 451 452 453 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 448 def (&) lock = AUTHORIZATION_STATE_LOCKS_GUARD.synchronize do (AUTHORIZATION_STATE_LOCKS[storage] ||= {})[server_url] ||= Monitor.new end lock.synchronize(&) end |