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/

Instance Method Summary collapse

Instance Method Details

#approveObject

The token is verified here, not only at exchange, so a typo fails on the page the operator is looking at.



116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 116

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_serverObject

S256 because clients send a code_challenge regardless; none because the clients here are public and unverified.



82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 82

def authorization_server
  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: ["code"],
    grant_types_supported: ["authorization_code"],
    code_challenge_methods_supported: ["S256"],
    token_endpoint_auth_methods_supported: ["none"]
  }
end

#authorizeObject

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.



110
111
112
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 110

def authorize
  render :authorize, layout: false, formats: [:html]
end

#protected_resourceObject

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.



71
72
73
74
75
76
77
78
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 71

def protected_resource
  mcp_oauth_forbid_caching
  render json: {
    resource: mcp_oauth_resource_url,
    authorization_servers: [mcp_oauth_issuer],
    bearer_methods_supported: ["header"]
  }
end

#registerObject

Stateless: persisting a client_id nothing reads would only grow a table of strings the bridge never consults.



98
99
100
101
102
103
104
105
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 98

def register
  render json: {
    client_id: SecureRandom.uuid,
    token_endpoint_auth_method: "none",
    grant_types: ["authorization_code"],
    response_types: ["code"]
  }, status: :created
end

#tokenObject



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/mcp_toolkit/oauth/controller_methods.rb', line 129

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