Module: McpToolkit::Oauth::ControllerMethods
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/mcp_toolkit/oauth/controller_methods.rb
Overview
The AUTHORITY-side OAuth 2.1 authorization bridge (routes: config/routes.rb; setup + rationale: README).
NOT an identity provider, and reading it as a half-built one will mislead. It
mints no credential, stores no client, models no consent, issues no refresh
token. It is a standards-shaped envelope around tokens the host ALREADY issues
by its own means, for clients that will only authenticate by discovering an
authorization server and running a browser flow: the page asks an operator to
paste a token they hold, and the access_token returned IS that token, verified
through the same config.token_authenticator the transport uses. Scopes,
expiry, revocation and tenancy stay with the host; nothing here widens reach.
So the stubs are deliberate, not unfinished: no endpoint reads the client_id
it hands out (a public client's identifier is self-asserted and gates nothing);
pasting a token you already hold IS the grant; and the pasted token's own expiry
is the real lifetime, so a client re-runs the flow rather than refreshing a
shadow of it.
Two things are NOT mocked, because faking them would be a vulnerability rather
than a skipped ceremony: redirect_uri is checked against the host's policy on
BOTH legs (an unvetted REMOTE target is an open redirect handing out
authorization codes — see Configuration#oauth_allowed_redirect_uris for the
attack it stops), and the PKCE code_verifier is verified.
Constant Summary collapse
- CODE_CACHE_PREFIX =
"mcp_toolkit:oauth:code:"- CODE_BYTES =
32- RESPONSE_OWNED_QUERY_KEYS =
Query parameters the callback response owns: whatever a client put in its own redirect_uri, these are set by the redirect and not carried over from it.
%w[code state].freeze
- PKCE_VALUE =
RFC 7636 §4.1: 43–128 unreserved characters. The challenge is §4.2's base64url of a SHA-256, which is always exactly 43 of the same alphabet.
/\A[A-Za-z0-9\-._~]{43,128}\z/- SUPPORTED_GRANT_TYPES =
What the token endpoint actually honours. Named once because the discovery document and the registration response MUST agree — a client reads the first to find the second, so a disagreement is this server contradicting itself.
%w[authorization_code].freeze
- SUPPORTED_RESPONSE_TYPES =
%w[code].freeze
Instance Method Summary collapse
-
#approve ⇒ Object
The token is verified here, not only at exchange, so a typo fails on the page the operator is looking at.
-
#authorization_server ⇒ Object
S256 because clients send a
code_challengeregardless;nonebecause the clients here are public and unverified. -
#authorize ⇒ Object
formats: [:html]because there is only an HTML template andAcceptpicks the format just as a.jsonsuffix would — without it,Accept: application/jsonraises MissingTemplate on an unauthenticated endpoint. -
#protected_resource ⇒ Object
resourceMUST equal the MCP endpoint URL as the operator typed it into the client, hence derived from the live request origin rather than pinned. -
#register ⇒ Object
Stateless: nothing here is persisted (no endpoint reads a
client_id). - #token ⇒ Object
Instance Method Details
#approve ⇒ Object
The token is verified here, not only at exchange, so a typo fails on the page the operator is looking at.
138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 138 def approve access_token = params[:access_token].to_s return mcp_oauth_reject_paste if mcp_oauth_authenticate(access_token).nil? # 303, not Rails' default 302: this POST carried the operator's token in its # body, and only 303 unambiguously tells the browser to fetch the redirect # target with GET and no body. A 302 leaves re-sending it to the client's # discretion, which would hand the token itself to the callback (RFC 9700 # §4.12). redirect_to mcp_oauth_callback_url(mcp_oauth_issue_code(access_token)), allow_other_host: true, status: :see_other end |
#authorization_server ⇒ Object
S256 because clients send a code_challenge regardless; none because the
clients here are public and unverified.
88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 88 def mcp_oauth_forbid_caching render json: { issuer: mcp_oauth_issuer, authorization_endpoint: mcp_oauth_endpoint_url("authorize"), token_endpoint: mcp_oauth_endpoint_url("token"), registration_endpoint: mcp_oauth_endpoint_url("register"), response_types_supported: SUPPORTED_RESPONSE_TYPES, grant_types_supported: SUPPORTED_GRANT_TYPES, code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"] } end |
#authorize ⇒ Object
formats: [:html] because there is only an HTML template and Accept picks
the format just as a .json suffix would — without it, Accept: application/json raises MissingTemplate on an unauthenticated endpoint.
132 133 134 |
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 132 def render :authorize, layout: false, formats: [:html] end |
#protected_resource ⇒ Object
resource MUST equal the MCP endpoint URL as the operator typed it into the
client, hence derived from the live request origin rather than pinned.
77 78 79 80 81 82 83 84 |
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 77 def protected_resource mcp_oauth_forbid_caching render json: { resource: mcp_oauth_resource_url, authorization_servers: [mcp_oauth_issuer], bearer_methods_supported: ["header"] } end |
#register ⇒ Object
Stateless: nothing here is persisted (no endpoint reads a client_id). The
response still names the redirect_uris the client sent, because a strict
client validates that they come back and abandons a registration that drops
them — the failure a hosted client hit against the pre-0.6.1 stub, which
returned only a client_id. That echo AUTHORIZES nothing: authorize and
token check every redirect_uri against the host's allowlist independently.
The auth method and the grant/response types are SUBSTITUTED rather than
echoed — RFC 7591 §3.2.1 states the metadata as REGISTERED, and lets a server
replace what it does not support. Reflecting the client's request instead
would contradict authorization_server, which is where that client got this
endpoint, and would promise a flow token rejects: a refresh_token echoed
back is a refresh answered unsupported_grant_type. No client_secret is
issued, so none is the only auth method a client here could perform.
116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 116 def register body = { client_id: SecureRandom.uuid, client_id_issued_at: Time.now.to_i, redirect_uris: mcp_oauth_param_list(:redirect_uris), token_endpoint_auth_method: "none", grant_types: mcp_oauth_registered_subset(:grant_types, SUPPORTED_GRANT_TYPES), response_types: mcp_oauth_registered_subset(:response_types, SUPPORTED_RESPONSE_TYPES) } body[:client_name] = params[:client_name].to_s if params[:client_name].present? render json: body, status: :created end |
#token ⇒ Object
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 151 def token return mcp_oauth_render_token_error("unsupported_grant_type") unless params[:grant_type] == "authorization_code" # Shape first, and BEFORE the code is consumed: a request that could never # verify shouldn't cost a legitimate client its code. A well-formed but WRONG # verifier still burns it below — that is deliberate, and the only thing # stopping someone who intercepted a code from retrying verifiers against it. return mcp_oauth_render_token_error("invalid_request") unless mcp_oauth_verifier_well_formed? payload = mcp_oauth_consume_code(params[:code].to_s) return mcp_oauth_render_token_error("invalid_grant") if payload.nil? return mcp_oauth_render_token_error("invalid_grant") unless mcp_oauth_exchange_valid?(payload) access_token = payload[:access_token].to_s return mcp_oauth_render_token_error("invalid_grant") if mcp_oauth_authenticate(access_token).nil? mcp_oauth_forbid_caching render json: { access_token:, token_type: "Bearer" } end |