Module: MCPClient::Auth::OAuthProvider::ResponseValidation

Included in:
MCPClient::Auth::OAuthProvider
Defined in:
lib/mcp_client/auth/oauth_provider/response_validation.rb

Overview

Type checks for the four peer-controlled JSON documents a flow reads: the token response of RFC 6749 Section 5.1, the client registration response of RFC 7591 Section 3.2.1 (whose client metadata fields keep the types RFC 7591 Section 2 gives them), the protected resource metadata of RFC 9728 Section 2 and the authorization server metadata of RFC 8414 Section 2.

Every document is read field by field and every field ends up somewhere that assumes its RFC type: token_type is capitalized into an Authorization header, expires_in is added to a Time, redirect_uris is asked for its first element, client_secret_expires_at is compared with a Unix timestamp, scopes_supported is joined into a scope parameter and code_challenge_methods_supported is asked whether it includes "S256". A peer that answers with the right names and the wrong JSON types would therefore crash the client with a NoMethodError or a TypeError deep inside the flow — sometimes only after a still-valid token had been overwritten — or, worse, be believed: a code_challenge_methods_supported of "S256 plain" is a String, and a String answers include?("S256") with true, so a server that supports no PKCE at all would read as one that does. A document whose fields are not of their RFC types is a protocol error and is refused as a whole, exactly as a token response without an access token is: no partial acceptance, no coercion of whatever JSON arrived.

Mixed into OAuthProvider.

Constant Summary collapse

TOKEN_RESPONSE_REFERENCE =

Where the token response's field types are specified.

'RFC 6749 Section 5.1'
REQUIRED_TOKEN_RESPONSE_FIELDS =

RFC 6749 Section 5.1 makes BOTH access_token and token_type REQUIRED in a successful token response, and defines no default for either: "Bearer" is one value token_type may carry (RFC 6750), not what its absence means. A response that names no type says nothing about how the credential it carries may be presented, and RFC 6749 Section 7.1 is explicit that "the client MUST NOT use an access token if it does not understand the token type" — which a client that was told no type does not. So an omitted type is refused exactly as an omitted access_token is, and exactly as the DPoP and MAC types this client cannot present are, rather than guessed at and sent out behind Authorization: Bearer. (access_token has a check of its own, #issued_access_token?, which also asks for usable bytes.)

%w[token_type].freeze
REGISTRATION_RESPONSE_REFERENCE =

Where the registration response's field types are specified.

'RFC 7591 Section 3.2.1'
TOKEN_RESPONSE_FIELDS =

The fields of a successful token response and the types RFC 6749 Section 5.1 gives them. access_token and token_type are REQUIRED and end up in an Authorization header, so they must be bytes a header can carry: an empty string is no more usable than a JSON array, and a CR or an LF would not be part of the value at all but the start of another header line. token_type must moreover name a type this client can present (see SUPPORTED_TOKEN_TYPE), and it is REQUIRED: see REQUIRED_TOKEN_RESPONSE_FIELDS. refresh_token is OPTIONAL but is a credential too: bytes or nothing, because "" would be persisted over the refresh token the client already holds. scope is free text. Fields the RFC does not name are ignored: an authorization server may return anything else it likes.

{
  'access_token' => :header_value,
  'token_type' => :token_type,
  'expires_in' => :integer,
  'refresh_token' => :non_empty_string,
  'scope' => :string
}.freeze
REGISTRATION_RESPONSE_FIELDS =

The fields of a client registration response and their types: the registration-specific fields of RFC 7591 Section 3.2.1 followed by the client metadata of Section 2 that the server echoes back.

{
  'client_id' => :non_empty_string,
  'client_secret' => :string,
  'client_id_issued_at' => :integer,
  'client_secret_expires_at' => :integer,
  'redirect_uris' => :redirect_uri_array,
  'token_endpoint_auth_method' => :string,
  'grant_types' => :string_array,
  'response_types' => :string_array,
  'scope' => :string,
  'client_name' => :string,
  'client_uri' => :string,
  'logo_uri' => :string,
  'tos_uri' => :string,
  'policy_uri' => :string,
  'contacts' => :string_array,
  'application_type' => :string
}.freeze
RESOURCE_METADATA_REFERENCE =

Where the protected resource document's field types are specified.

'RFC 9728 Section 2'
SERVER_METADATA_REFERENCE =

Where the authorization server document's field types are specified.

'RFC 8414 Section 2'
REQUIRED_RESOURCE_METADATA_FIELDS =

RFC 9728 Section 2 makes resource REQUIRED, and this client cannot do without it: it is the identifier the confused-deputy check compares with the server URL, and a document that omits it is not a protected resource's metadata at all. Refusing it here rather than at the comparison keeps the document out of the copy MCPClient::Auth::OAuthProvider#fetch_resource_metadata retains for scope resolution.

%w[resource].freeze
RESOURCE_METADATA_FIELDS =

The protected resource metadata fields this client reads, and their RFC 9728 Section 2 types. resource is compared with the server URL, authorization_servers supplies the issuer discovery is driven from, and scopes_supported is joined into the scope parameter of the authorization request.

{
  'resource' => :string,
  'authorization_servers' => :string_array,
  'scopes_supported' => :string_array
}.freeze
SERVER_METADATA_FIELDS =

The authorization server metadata fields this client reads, and their RFC 8414 Section 2 types. The two boolean advertisements (client_id_metadata_document_supported and authorization_response_iss_parameter_supported) are deliberately absent: a value that is not a boolean says nothing this client can act on, and both are already read fail-closed — "not supported" for the first, "advertised, so a response without iss is refused" for the second (see ServerMetadata) — which is a safer reading than refusing the document outright.

{
  'issuer' => :string,
  'authorization_endpoint' => :string,
  'token_endpoint' => :string,
  'registration_endpoint' => :string,
  'scopes_supported' => :string_array,
  'response_types_supported' => :string_array,
  'grant_types_supported' => :string_array,
  'code_challenge_methods_supported' => :string_array
}.freeze
REQUIRED_SERVER_METADATA_FIELDS =

RFC 8414 Section 2 makes issuer, authorization_endpoint and token_endpoint REQUIRED, and every one of them is a URL this client parses: the issuer identifies the authorization server a token and a client are bound to, the authorization endpoint is what the browser is sent to, and the token endpoint is where the code is redeemed. A type check alone accepts a document that simply omits them — there is no field of the wrong type — so the document is cached as metadata and the flow crashes with a URI::InvalidURIError out of start_authorization_flow or complete_authorization_flow, by which time dynamic client registration has already created a client at the authorization server. What the RFC requires is required here, at discovery.

%w[issuer authorization_endpoint token_endpoint].freeze
SUPPORTED_TOKEN_TYPE =

The one access token type this client can present. RFC 6749 Section 7.1: "the client MUST NOT use an access token if it does not understand the token type". A bearer token is presented as it stands (RFC 6750 Section 2.1) and is what MCP 2026-07-28 requires; every other type is a credential this client cannot form a request with — a DPoP token needs a proof JWT of its own, a MAC token a signature — so putting its bytes behind Authorization: DPoP would present a credential in a way its authorization server never authorized. (It would not even be spelled right: the header is built with String#capitalize, which makes "DPoP" "Dpop".) The comparison is case-insensitive: RFC 6749 Section 5.1 makes the value case-insensitive, and servers do answer "bearer".

'bearer'
TYPE_DESCRIPTIONS =

How each type reads in a failure message.

{
  string: 'a string',
  non_empty_string: 'a non-empty string',
  header_value: 'a non-empty string of bytes an HTTP header can carry',
  token_type: 'a token type this client can present ("Bearer")',
  integer: 'an integer',
  string_array: 'an array of strings',
  redirect_uri_array: 'an array of usable redirect URIs'
}.freeze
HEADER_UNSAFE_BYTE =

Bytes no HTTP header field value may carry: the C0 controls (CR and LF above all, which would end the header line and start one of the peer's choosing) and DEL. RFC 6749 Appendix A is stricter still — an access token is 1*VSCHAR — but obs-text is at least transported, while a control byte is either refused by the HTTP stack or splits the request.

->(byte) { byte < 0x20 || byte == 0x7F }
CALLBACK_SCHEMES =

Schemes a callback can actually arrive on this client.

%w[http https].freeze