Class: MCP::Client::OAuth::Flow
- Inherits:
-
Object
- Object
- MCP::Client::OAuth::Flow
- Defined in:
- lib/mcp/client/oauth/flow.rb
Overview
Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow. Driven by ‘MCP::Client::HTTP` on a 401 response. The user-facing surface is `Provider`; this class consumes a Provider plus signal data extracted from the failing response (resource_metadata URL, scope challenge).
Defined Under Namespace
Classes: AuthorizationError, InvalidGrantError
Instance Method Summary collapse
-
#client_credentials_client_info ⇒ Object
Reads the pre-registered credentials for the ‘client_credentials` grant directly from the provider’s stored ‘client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
-
#initialize(provider:, http_client_factory: nil) ⇒ Flow
constructor
A new instance of Flow.
-
#refresh!(server_url:, resource_metadata_url: nil) ⇒ Object
Exchanges the saved ‘refresh_token` for a fresh access token (RFC 6749 Section 6).
-
#run!(server_url:, resource_metadata_url: nil, scope: nil) ⇒ Object
Runs the full discovery, registration, authorization, and token exchange flow.
-
#run_client_credentials!(as_metadata:, prm:, resource:, scope:) ⇒ Object
Runs the OAuth 2.1 ‘client_credentials` grant (machine-to-machine, no user interaction) and persists the resulting token.
Constructor Details
#initialize(provider:, http_client_factory: nil) ⇒ Flow
Returns a new instance of Flow.
26 27 28 29 |
# File 'lib/mcp/client/oauth/flow.rb', line 26 def initialize(provider:, http_client_factory: nil) @provider = provider @http_client_factory = http_client_factory || -> { default_http_client } end |
Instance Method Details
#client_credentials_client_info ⇒ Object
Reads the pre-registered credentials for the ‘client_credentials` grant directly from the provider’s stored ‘client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`). The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback to dynamic registration.
124 125 126 127 128 129 130 131 132 |
# File 'lib/mcp/client/oauth/flow.rb', line 124 def client_credentials_client_info info = @provider.client_information unless info.is_a?(Hash) && client_info_required_value(info, "client_id") raise AuthorizationError, "Cannot run the client_credentials grant: the provider has no stored `client_id`." end info end |
#refresh!(server_url:, resource_metadata_url: nil) ⇒ Object
Exchanges the saved ‘refresh_token` for a fresh access token (RFC 6749 Section 6). Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security checks before talking to it.
Returns ‘:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information, or when the token endpoint refuses the refresh request. www.rfc-editor.org/rfc/rfc6749#section-6
141 142 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 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/mcp/client/oauth/flow.rb', line 141 def refresh!(server_url:, resource_metadata_url: nil) refresh_token = read_token("refresh_token") raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token stored_client_info = @provider.client_information have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id") # A CIMD-configured provider stores no `client_information` on purpose # (the CIMD URL is re-resolved against the live AS metadata on every flow). # Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable. have_cimd_url = !@provider..nil? unless have_stored_client_info || have_cimd_url raise AuthorizationError, "Cannot refresh: no client_information in provider storage." end if ensure_secure_url!(, label: "WWW-Authenticate resource_metadata URL") end prm = ( server_url: server_url, resource_metadata_url: , ) = (prm) ensure_secure_url!(, label: "PRM `authorization_servers` entry") resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"]) = (issuer_url: ) ensure_issuer_matches!(expected: , returned: ["issuer"]) ensure_secure_endpoints!() client_info = if have_stored_client_info # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity, # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support. stored_client_info elsif ["client_id_metadata_document_supported"] == true { "client_id" => @provider. } else raise AuthorizationError, "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \ "`client_id_metadata_document_supported: true`." end new_tokens = exchange_refresh_token( as_metadata: , client_info: client_info, refresh_token: refresh_token, resource: resource, ) @provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token)) :refreshed end |
#run!(server_url:, resource_metadata_url: nil, scope: nil) ⇒ Object
Runs the full discovery, registration, authorization, and token exchange flow. On success, persists tokens via the provider and returns ‘:authorized`.
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 97 98 |
# File 'lib/mcp/client/oauth/flow.rb', line 33 def run!(server_url:, resource_metadata_url: nil, scope: nil) # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very # first thing we contact in the OAuth flow, so it has to clear the same # Communication Security bar as the OAuth endpoints downstream. if ensure_secure_url!(, label: "WWW-Authenticate resource_metadata URL") end prm = ( server_url: server_url, resource_metadata_url: , ) = (prm) ensure_secure_url!(, label: "PRM `authorization_servers` entry") # Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on # both the authorization and token requests. When PRM advertises a `resource`, # it MUST identify the same MCP server we are talking to; otherwise we are # being redirected to credentials minted for a different audience. resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"]) = (issuer_url: ) ensure_issuer_matches!(expected: , returned: ["issuer"]) ensure_secure_endpoints!() if == :client_credentials return run_client_credentials!(as_metadata: , prm: prm, resource: resource, scope: scope) end ensure_pkce_supported!() client_info = ensure_client_registered(as_metadata: ) effective_scope = resolve_scope(scope: scope, prm: prm) effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: ) pkce = PKCE.generate state = SecureRandom.urlsafe_base64(32) = ( as_metadata: , client_id: client_info_required_value(client_info, "client_id"), scope: effective_scope, state: state, code_challenge: pkce[:code_challenge], resource: resource, ) @provider.redirect_handler.call() code, returned_state = Array(@provider.callback_handler.call) raise AuthorizationError, "Authorization callback did not return an authorization code." unless code unless states_match?(returned_state, state) raise AuthorizationError, "OAuth state mismatch (CSRF protection)." end tokens = ( as_metadata: , client_info: client_info, code: code, code_verifier: pkce[:code_verifier], resource: resource, ) @provider.save_tokens(tokens) :authorized end |
#run_client_credentials!(as_metadata:, prm:, resource:, scope:) ⇒ Object
Runs the OAuth 2.1 ‘client_credentials` grant (machine-to-machine, no user interaction) and persists the resulting token. Shares the same discovery and security checks as `run!`; the only difference is the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request, and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3). The pre-registered `client_id` / `client_secret` come from the provider’s stored ‘client_information`. modelcontextprotocol.io/specification/2025-11-25/basic/authorization
106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/mcp/client/oauth/flow.rb', line 106 def run_client_credentials!(as_metadata:, prm:, resource:, scope:) client_info = client_credentials_client_info form = { "grant_type" => "client_credentials" } effective_scope = resolve_scope(scope: scope, prm: prm) form["scope"] = effective_scope if effective_scope form["resource"] = resource if resource tokens = post_to_token_endpoint(as_metadata: , client_info: client_info, form: form) @provider.save_tokens(tokens) :authorized end |