Module: MCPClient::Auth::OAuthProvider::ChallengeHandling

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

Overview

WWW-Authenticate challenge handling for MCPClient::Auth::OAuthProvider: parsing the challenge, fetching and validating the resource metadata it names, retiring tokens of another authorization server, and the checks a peer-advertised URL must pass. Mixed into OAuthProvider; every method relies on its state.

Constant Summary collapse

IPV4_COMPONENT =

One decimal, octal or hexadecimal component of a numeric IPv4 spec.

/\A(?:0x[0-9a-f]+|0[0-7]*|[1-9][0-9]*)\z/i
PERCENT_ESCAPE =

A percent escape in a hostname.

/%([0-9a-f]{2})/i
HOST_DECODE_PASSES =

How many times a host is percent-decoded before it is classified.

4
HOSTNAME_CHARACTERS =

The characters a hostname or IP literal is spelled with: letters, digits, '-' and '.' for names, ':' for an IPv6 literal, '_' because resolvers and real deployments tolerate it in names.

/\A[a-z0-9\-._:]+\z/i

Instance Method Summary collapse

Instance Method Details

#adopt_challenge_metadata(url) ⇒ ResourceMetadata

Fetch, validate and adopt the resource metadata a challenge named.

Parameters:

  • url (String) —

    the advertised metadata URL

Returns:

Raises:



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 110

def (url)
  # This URL was explicitly advertised by the 401 challenge, so a 404 is a
  # misconfiguration to surface (strict), not a speculative miss to skip.
   = (url, strict: true)
  # Metadata discovery would reject is refused now, whole: it neither
  # retires the token nor lingers as an authoritative challenge.
  reject_unacceptable_challenge!()
  # A validated challenge supersedes an earlier refused one.
  @challenge_error = nil
  # Reuse this challenge-advertised metadata during the subsequent OAuth
  # flow instead of re-deriving (and possibly missing) the well-known URL.
  @challenge_resource_metadata = 
  revoke_token_on_authorization_server_change()
  
end

#bearer_challenge_segment(header) ⇒ String?

Extract the Bearer challenge's own parameter segment from a (possibly multi-challenge) WWW-Authenticate header, so params belonging to other schemes (e.g. Basic resource_metadata="...", Bearer realm="x") are never attributed to the Bearer challenge. Mirrors HttpTransportBase#bearer_challenge_segment.

Parameters:

  • header (String, nil) —

    the WWW-Authenticate header value

Returns:

  • (String, nil) —

    the Bearer challenge's parameters (possibly empty), or nil when the header has no Bearer challenge



248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 248

def bearer_challenge_segment(header)
  # A header value is peer bytes, and `gsub`, `match` and `[]` all
  # raise `ArgumentError` on bytes that are not valid UTF-8 — out of
  # the 401 handler, which would then report the client's own
  # ArgumentError instead of the challenge. It is made decodable
  # before it is read; nothing else about it is changed, because the
  # URL and scope this parser returns have to be what the peer wrote.
  header = matchable_peer_text(header)
  return nil unless header

  # Locate the Bearer scheme token only OUTSIDE quoted strings: a
  # quoted value such as realm="prefix Bearer x" must not anchor the
  # segment.
  masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
  match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
  return nil unless match

  header[match.end(0)..][AUTH_PARAMS_RUN]
end

#extract_challenge_param(header, name) ⇒ String?

Extract an auth-param value from a WWW-Authenticate header (quoted or unquoted form, optional whitespace around '=').

Parameters:

  • header (String) —

    the WWW-Authenticate header value

  • name (String) —

    the auth-param name

Returns:

  • (String, nil) —

    the parameter value if present



273
274
275
276
277
278
279
280
281
282
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 273

def extract_challenge_param(header, name)
  header = matchable_peer_text(header)
  return nil unless header

  if (m = header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i))
    return m[1]
  end

  header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*([^,\s]+)/i)&.captures&.first
end

#extract_resource_metadata_url(header) ⇒ String?

Extract the protected-resource-metadata URL from a WWW-Authenticate header. Per RFC 9728 the parameter is resource_metadata; a legacy resource parameter is accepted as a fallback for older servers. Only the Bearer challenge's own segment is consulted, so a parameter belonging to another scheme's challenge can never drive discovery.

Parameters:

  • header (String) —

    the WWW-Authenticate header value

Returns:

  • (String, nil) —

    the metadata URL if present



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 197

def (header)
  # A URL is not free text. RFC 3986 spells a URI in ASCII, so a
  # header this client had to scrub to read did not advertise one:
  # every undecodable byte in it became a '?', and fetching that
  # rewriting would send a request to a URL nobody wrote. The
  # challenge simply names no metadata URL, and discovery falls back
  # to the well-known URIs.
  return nil unless PeerText.decodable?(header)

  params = bearer_challenge_segment(header)
  return nil unless params

  # Auth-params may include optional whitespace around '=' (RFC 7235).
  # Quoted form: resource_metadata = "https://..."
  if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
    return m[1]
  end

  # Unquoted token form: resource_metadata = https://...
  if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
    return m[1]
  end

  # Legacy fallback: resource="https://.../.well-known/oauth-protected-resource"
  legacy = params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
  legacy if legacy && (legacy)
end

#handle_unauthorized_response(response) ⇒ ResourceMetadata?

Handle 401 Unauthorized response (for server discovery)

Parameters:

  • response (Faraday::Response) —

    HTTP response

Returns:



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 31

def handle_unauthorized_response(response)
  www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
  return nil unless www_authenticate

  # Challenge parameters are read from the Bearer challenge's own
  # segment only — never from the whole (possibly multi-challenge)
  # header — so parameters belonging to Basic or another scheme cannot
  # drive Bearer scope selection or resource metadata discovery. A
  # header without a Bearer challenge carries no usable Bearer params.
  bearer_params = bearer_challenge_segment(www_authenticate)
  # A header naming only schemes this client cannot answer — Basic,
  # Negotiate — is not a Bearer challenge, so it says nothing about
  # the scopes this resource wants and nothing about where its
  # metadata lives. The reset below belongs to a Bearer challenge
  # that carried no scope; letting a Basic 401 make it would drop the
  # scope an insufficient_scope challenge required, and the step-up
  # that follows would ask for less than the server demanded.
  return nil unless bearer_params

  # MCP 2025-11-25: "Clients MUST treat the scopes provided in the
  # challenge as authoritative for satisfying the current request" —
  # including resetting a previously challenged scope when the current
  # challenge carries none.
  url = (www_authenticate)
  scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
  # A step-up challenge (403 insufficient_scope) says this operation
  # needs more scopes, not that the authorization server moved: the
  # token in hand stays valid (MCP 2026-07-28 step-up authorization).
  # So whatever its resource metadata is worth — unfetchable, fetched
  # and refused, or named by an unacceptable URL — the known server
  # stays in place rather than becoming unknown (which would withhold
  # that token from every other operation), and the challenged scope
  # is recorded as the authoritative one. A challenge reporting an
  # invalid token is judged in full, and a refused document stands.
  step_up = step_up_challenge?(bearer_params)
  previous = [@challenge_metadata_url, @challenge_resource_metadata, @challenge_error]

  begin
    # The challenge header is peer-controlled input: validate the
    # advertised URL BEFORE storing, fetching, or recording any challenge
    # state, so a malicious challenge cannot pivot this host into requests
    # against internal services (SSRF) and cannot leave the provider
    # holding half of a rejected challenge.
    validate_peer_advertised_url!(url, 'resource metadata URL (from WWW-Authenticate challenge)') if url

    @challenge_scope = scope
    return nil unless url

    # Remember the advertised URL even if the fetch below fails, so a
    # later discovery retries it instead of probing well-known URIs the
    # challenge already superseded. The current header is the one to
    # honour: an earlier document, and an earlier refusal, are forgotten
    # before the fetch so a failed fetch leaves the flow waiting on this
    # URL rather than completing against stale state.
    @challenge_metadata_url = url
    @challenge_resource_metadata = nil
    @challenge_error = nil
    (url)
  rescue MCPClient::Errors::ConnectionError
    raise unless step_up

    @challenge_metadata_url, @challenge_resource_metadata, @challenge_error = previous
    @challenge_scope = scope
    raise
  end
end

#protected_resource_metadata_url?(url) ⇒ Boolean

RFC 9728 names the challenge parameter resource_metadata; a resource parameter is a resource identifier (RFC 8707), not a document. It is read as a metadata URL only when it points at a protected resource metadata well-known location — read as one otherwise, the MCP endpoint itself would be fetched as metadata, fail, and stand in the way of the well-known fallback MCP 2026-07-28 requires when the challenge names no document.

Parameters:

  • url (String) —

    the parameter value

Returns:

  • (Boolean)


234
235
236
237
238
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 234

def (url)
  URI.parse(url).path.to_s.include?('/.well-known/oauth-protected-resource')
rescue URI::InvalidURIError
  false
end

#reject_unacceptable_challenge!(resource_metadata) ⇒ void

This method returns an undefined value.

Apply the checks discovery applies to challenge-advertised resource metadata (resource identity, an acceptable authorization server URL) before anything acts on it; a failing document refuses the whole challenge (see #reject_challenge!).

Parameters:

Raises:



176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 176

def reject_unacceptable_challenge!()
  begin
    validate_resource_matches!()
  rescue MCPClient::Errors::ConnectionError => e
    reject_challenge!(e.message)
  end
  advertised = Array(.authorization_servers).first
  unless advertised
    reject_challenge!('Protected resource metadata does not advertise any authorization_servers')
  end

  validate_peer_advertised_url!(advertised, 'authorization server URL (from resource metadata)')
end

#resolve_pending_challenge ⇒ void

This method returns an undefined value.

A challenge whose metadata could not be fetched yet is retried before any token is judged: until it resolves, the current authorization server is unknown and nothing is presented.



130
131
132
133
134
135
136
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 130

def resolve_pending_challenge
  return unless @challenge_metadata_url && @challenge_resource_metadata.nil?

  (@challenge_metadata_url)
rescue MCPClient::Errors::ConnectionError => e
  logger.debug("Challenge metadata still unresolved: #{e.message}")
end

#revoke_token_on_authorization_server_change(resource_metadata) ⇒ void

This method returns an undefined value.

A challenge naming another authorization server than the one the stored token came from retires that token at once: it is never presented again, whatever the storage backend can do.

Parameters:



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 143

def revoke_token_on_authorization_server_change()
  advertised = Array(&.authorization_servers).first
  known = &.issuer
  return unless advertised && known && advertised != known

  @authorization_server_switched = true
  # Ending the pending requests and retiring the token are one step
  # against the responses being accepted meanwhile: a code exchange or
  # refresh that has passed its checks holds this lock until its token
  # is written, so it is never this transition that lands in between
  # (see {MCPClient::Auth::OAuthProvider#with_authorization_state_lock}).
  with_authorization_state_lock do
    # The requests still pending with the server this resource left can
    # never complete as this resource's: ended now, before anything is
    # fetched from the advertised server, so a late answer to them is
    # refused by every provider sharing the storage.
    end_pending_requests_of(known)
    # A token another provider sharing the storage already bound to the
    # advertised server is exactly the token to keep.
    next if record_bound_to?(stored_token_or_nil, advertised)

    logger.debug('The challenge names another authorization server; retiring the stored token')
    delete_token(bind_to: Token::RETIRED_ISSUER)
  end
end

#step_up_challenge?(bearer_params) ⇒ Boolean

Whether a Bearer challenge asks for more scopes (SEP-835 / MCP 2026-07-28 step-up), as opposed to reporting an invalid token.

Parameters:

  • bearer_params (String, nil) —

    the Bearer challenge's parameters

Returns:

  • (Boolean)


102
103
104
# File 'lib/mcp_client/auth/oauth_provider/challenge_handling.rb', line 102

def step_up_challenge?(bearer_params)
  bearer_params && extract_challenge_param(bearer_params, 'error') == 'insufficient_scope'
end