Class: MCPClient::Auth::OAuthProvider

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp_client/auth/oauth_provider.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

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})*/

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

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

Initialize OAuth provider

Parameters:

  • server_url (String)

    The MCP server URL (used as OAuth resource parameter)

  • redirect_uri (String) (defaults to: 'http://localhost:8080/callback')

    OAuth redirect URI (default: http://localhost:8080/callback)

  • scope (String, Symbol, nil) (defaults to: nil)

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

  • logger (Logger, nil) (defaults to: nil)

    Optional logger

  • storage (Object, nil) (defaults to: nil)

    Storage backend for tokens and client info

  • client_metadata (Hash) (defaults to: {})

    Extra OIDC client metadata fields for DCR registration. Supported keys: :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts

  • client_id_metadata_url (String, nil) (defaults to: nil)

    HTTPS URL identifying this client per MCP 2025-11-25 Client ID Metadata Documents (SEP-991). The URL doubles as the OAuth client_id when the authorization server advertises client_id_metadata_document_supported, skipping dynamic registration. Hosting the metadata JSON at that URL is the application's responsibility.

Raises:

  • (ArgumentError)

    if client_id_metadata_url is not an HTTPS URL with a path component



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/mcp_client/auth/oauth_provider.rb', line 55

def initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil,
               client_metadata: {}, client_id_metadata_url: 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. = 
  @extra_client_metadata = 
  @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
end

Instance Attribute Details

#challenge_scopeString? (readonly)

Scope requested by the most recent WWW-Authenticate challenge.

Returns:

  • (String, nil)


273
274
275
# File 'lib/mcp_client/auth/oauth_provider.rb', line 273

def challenge_scope
  @challenge_scope
end

#client_id_metadata_urlString?

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)



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

attr_accessor :redirect_uri, :scope, :logger, :storage

#loggerLogger

Returns Logger instance.

Returns:

  • (Logger)

    Logger instance



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

attr_accessor :redirect_uri, :scope, :logger, :storage

#redirect_uriString

Returns OAuth redirect URI.

Returns:

  • (String)

    OAuth redirect URI



38
39
40
# File 'lib/mcp_client/auth/oauth_provider.rb', line 38

def redirect_uri
  @redirect_uri
end

#scopeString, ...

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

Returns:

  • (String, Symbol, nil)

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



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

attr_accessor :redirect_uri, :scope, :logger, :storage

#server_urlString

Returns The MCP server URL (normalized).

Returns:

  • (String)

    The MCP server URL (normalized)



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

attr_accessor :redirect_uri, :scope, :logger, :storage

#storageObject

Returns Storage backend for tokens and client info.

Returns:

  • (Object)

    Storage backend for tokens and client info



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

attr_accessor :redirect_uri, :scope, :logger, :storage

Instance Method Details

#access_tokenToken?

Get current access token (refresh if needed)

Returns:

  • (Token, nil)

    Current valid access token or nil



89
90
91
92
93
94
95
96
97
98
99
# File 'lib/mcp_client/auth/oauth_provider.rb', line 89

def access_token
  token = storage.get_token(server_url)
  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?

  # Try to refresh if we have a refresh token
  refresh_token(token) if token.refresh_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



165
166
167
168
169
170
171
172
# File 'lib/mcp_client/auth/oauth_provider.rb', line 165

def apply_authorization(request)
  token = access_token
  logger.debug("OAuth apply_authorization: token=#{token ? 'present' : 'nil'}")
  return unless token

  logger.debug("OAuth applying authorization header: #{token.to_header[0..20]}...")
  request.headers['Authorization'] = token.to_header
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



245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/mcp_client/auth/oauth_provider.rb', line 245

def bearer_challenge_segment(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

#complete_authorization_flow(code, state) ⇒ Token

Complete OAuth authorization flow with authorization code

Parameters:

  • code (String)

    Authorization code from callback

  • state (String)

    State parameter from callback

Returns:

  • (Token)

    Access token

Raises:



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/mcp_client/auth/oauth_provider.rb', line 137

def complete_authorization_flow(code, state)
  # 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 = storage.get_pkce(server_url)
  client_info = storage.get_client_info(server_url)
   = discover_authorization_server

  raise MCPClient::Errors::ConnectionError, 'Missing PKCE or client info' unless pkce && client_info

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

  # Store token
  storage.set_token(server_url, token)

  # Clean up temporary data
  storage.delete_pkce(server_url)
  storage.delete_state(server_url)

  token
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



263
264
265
266
267
268
269
# File 'lib/mcp_client/auth/oauth_provider.rb', line 263

def extract_challenge_param(header, name)
  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



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/mcp_client/auth/oauth_provider.rb', line 218

def (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://..."
  params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
end

#handle_unauthorized_response(response) ⇒ ResourceMetadata?

Handle 401 Unauthorized response (for server discovery)

Parameters:

  • response (Faraday::Response)

    HTTP response

Returns:



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/mcp_client/auth/oauth_provider.rb', line 177

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)

  # 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.
  @challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')

  url = (www_authenticate)
  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.
  @challenge_metadata_url = 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)
  # 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

#start_authorization_flowString

Start OAuth authorization flow

Returns:

  • (String)

    Authorization URL to redirect user to

Raises:



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

def start_authorization_flow
  # Discover authorization server
   = discover_authorization_server

  # Register client if needed
  client_info = get_or_register_client()

  # Generate PKCE parameters
  pkce = PKCE.new
  storage.set_pkce(server_url, pkce)

  # Generate state parameter
  state = SecureRandom.urlsafe_base64(32)
  storage.set_state(server_url, state)

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

#supported_scopesArray<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:



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

def supported_scopes
  @supported_scopes ||= discover_authorization_server.scopes_supported || []
end