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
-
#adopt_challenge_metadata(url) ⇒ ResourceMetadata
Fetch, validate and adopt the resource metadata a challenge named.
-
#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. -
#extract_challenge_param(header, name) ⇒ String?
Extract an auth-param value from a WWW-Authenticate header (quoted or unquoted form, optional whitespace around '=').
-
#extract_resource_metadata_url(header) ⇒ String?
Extract the protected-resource-metadata URL from a WWW-Authenticate header.
-
#handle_unauthorized_response(response) ⇒ ResourceMetadata?
Handle 401 Unauthorized response (for server discovery).
-
#protected_resource_metadata_url?(url) ⇒ Boolean
RFC 9728 names the challenge parameter
resource_metadata; aresourceparameter is a resource identifier (RFC 8707), not a document. -
#reject_unacceptable_challenge!(resource_metadata) ⇒ void
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!).
-
#resolve_pending_challenge ⇒ void
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.
-
#revoke_token_on_authorization_server_change(resource_metadata) ⇒ void
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.
-
#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.
Instance Method Details
#adopt_challenge_metadata(url) ⇒ ResourceMetadata
Fetch, validate and adopt the resource metadata a challenge named.
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 = () 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.
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 '=').
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.
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)
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 (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.
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!).
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.) end advertised = Array(.).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.}") 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.
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 () advertised = Array(&.).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}). 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.
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 |