Class: MCPClient::Auth::OAuthProvider
- Inherits:
-
Object
- Object
- MCPClient::Auth::OAuthProvider
- 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
-
#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
-
#access_token ⇒ Token?
Get current access token (refresh if needed).
-
#apply_authorization(request) ⇒ void
Apply OAuth authorization to HTTP request.
-
#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. -
#complete_authorization_flow(code, state) ⇒ Token
Complete OAuth authorization flow with authorization code.
-
#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).
-
#initialize(server_url:, redirect_uri: 'http://localhost:8080/callback', scope: nil, logger: nil, storage: nil, client_metadata: {}, client_id_metadata_url: nil) ⇒ OAuthProvider
constructor
Initialize OAuth provider.
-
#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.
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
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_scope ⇒ String? (readonly)
Scope requested by the most recent WWW-Authenticate challenge.
273 274 275 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 273 def challenge_scope @challenge_scope end |
#client_id_metadata_url ⇒ String?
Returns 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 |
#logger ⇒ Logger
Returns Logger instance.
38 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 38 attr_accessor :redirect_uri, :scope, :logger, :storage |
#redirect_uri ⇒ String
Returns OAuth redirect URI.
38 39 40 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 38 def redirect_uri @redirect_uri end |
#scope ⇒ String, ...
Returns 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_url ⇒ String
Returns The MCP server URL (normalized).
38 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 38 attr_accessor :redirect_uri, :scope, :logger, :storage |
#storage ⇒ Object
Returns 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_token ⇒ Token?
Get current access token (refresh if needed)
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
165 166 167 168 169 170 171 172 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 165 def (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.
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
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 (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) = raise MCPClient::Errors::ConnectionError, 'Missing PKCE or client info' unless pkce && client_info # Exchange authorization code for tokens token = (, 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 '=').
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.
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)
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 (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_flow ⇒ String
Start OAuth authorization flow
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 # 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 (, 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.
105 106 107 |
# File 'lib/mcp_client/auth/oauth_provider.rb', line 105 def supported_scopes @supported_scopes ||= .scopes_supported || [] end |