Module: Mistri::MCP::OAuth
- Defined in:
- lib/mistri/mcp/oauth.rb
Overview
The OAuth 2.1 subset the MCP spec requires of clients, as three storage-agnostic services a host calls from anywhere: a controller, a GraphQL mutation, a job. Each returns a string-keyed hash ready to persist on the host's own connection record.
flow = Mistri::MCP::OAuth.start(url: params[:url],
client_name: "Sendoso",
redirect_uri: mcp_callback_url)
# persist flow, redirect the user to flow["authorize_url"]
tokens = Mistri::MCP::OAuth.complete(code: params[:code], **persisted)
tokens = Mistri::MCP::OAuth.refresh(**persisted)
Registration happens as the APPLICATION, never as the harness: client_name has no default because that identity is the host's call. Servers without dynamic registration take client_id:/client_secret: directly and skip it.
Class Method Summary collapse
- .authorize_url(metadata, grant) ⇒ Object
-
.canonical(url) ⇒ Object
RFC 8707 canonical form: lowercase scheme and host, no fragment.
- .challenge_metadata_url(url) ⇒ Object
-
.complete(code:, code_verifier:, client_id:, token_endpoint:, resource:, redirect_uri:, client_secret: nil, token_auth_method: nil) ⇒ Object
Exchange the callback's code for tokens.
- .get_json(url) ⇒ Object
- .http(uri) ⇒ Object
- .post_form(url, form, basic_auth: nil) ⇒ Object
- .post_json(url, body) ⇒ Object
- .presence(value) ⇒ Object
-
.refresh(refresh_token:, client_id:, token_endpoint:, resource:, client_secret: nil, token_auth_method: nil) ⇒ Object
Trade a refresh token for a fresh set; OAuth 2.1 rotates refresh tokens, so persist the returned one.
-
.register(metadata, client_name, redirect_uri, client_id, client_secret) ⇒ Object
RFC 7591 dynamic registration, as the application.
-
.resolve_scope(scope, resource_metadata, metadata) ⇒ Object
No scope given: request what the resource advertises, and add offline_access when the authorization server supports it (that is what earns a refresh token from providers that require it).
-
.resource_metadata_for(url) ⇒ Object
RFC 9728: a 401's WWW-Authenticate names the resource metadata URL; servers that skip the header serve the well-known path.
-
.server_metadata(authority) ⇒ Object
RFC 8414 metadata, with the OpenID Connect path as a fallback since large providers often serve only that document.
-
.start(url:, client_name:, redirect_uri:, scope: nil, client_id: nil, client_secret: nil) ⇒ Object
Discover the server's authorization setup, register the application, and build the authorize URL.
- .token_request(endpoint, form, client_secret, auth_method = nil) ⇒ Object
- .try_json(url) ⇒ Object
-
.validate_endpoints(metadata) ⇒ Object
The spec requires authorization server endpoints over HTTPS; loopback stays allowed for development.
- .well_known_resource_url(url) ⇒ Object
Class Method Details
.authorize_url(metadata, grant) ⇒ Object
191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/mistri/mcp/oauth.rb', line 191 def (, grant) challenge = Digest::SHA256.base64digest(grant[:verifier]).tr("+/", "-_").delete("=") params = { "response_type" => "code", "client_id" => grant[:client_id], "redirect_uri" => grant[:redirect_uri], "state" => grant[:state], "code_challenge" => challenge, "code_challenge_method" => "S256", "resource" => grant[:resource] } params["scope"] = grant[:scope] if grant[:scope] endpoint = URI(.fetch("authorization_endpoint")) endpoint.query = [endpoint.query, URI.encode_www_form(params)].compact.join("&") endpoint.to_s end |
.canonical(url) ⇒ Object
RFC 8707 canonical form: lowercase scheme and host, no fragment.
206 207 208 209 210 211 212 |
# File 'lib/mistri/mcp/oauth.rb', line 206 def canonical(url) uri = URI(url) uri.fragment = nil uri.scheme = uri.scheme.downcase uri.host = uri.host.downcase if uri.host uri.to_s end |
.challenge_metadata_url(url) ⇒ Object
96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/mistri/mcp/oauth.rb', line 96 def (url) uri = URI(url) response = http(uri) do |connection| request = Net::HTTP::Post.new(uri) request["Accept"] = "application/json, text/event-stream" request["Content-Type"] = "application/json" request.body = JSON.generate({ jsonrpc: "2.0", id: 0, method: "ping" }) connection.request(request) end challenge = response["WWW-Authenticate"].to_s challenge[/resource_metadata="([^"]+)"/i, 1] end |
.complete(code:, code_verifier:, client_id:, token_endpoint:, resource:, redirect_uri:, client_secret: nil, token_auth_method: nil) ⇒ Object
Exchange the callback's code for tokens.
65 66 67 68 69 70 71 |
# File 'lib/mistri/mcp/oauth.rb', line 65 def complete(code:, code_verifier:, client_id:, token_endpoint:, resource:, redirect_uri:, client_secret: nil, token_auth_method: nil, **) form = { "grant_type" => "authorization_code", "code" => code, "code_verifier" => code_verifier, "client_id" => client_id, "redirect_uri" => redirect_uri, "resource" => resource } token_request(token_endpoint, form, client_secret, token_auth_method) end |
.get_json(url) ⇒ Object
228 229 230 231 232 233 234 |
# File 'lib/mistri/mcp/oauth.rb', line 228 def get_json(url) uri = URI(url) response = http(uri) { |connection| connection.request(Net::HTTP::Get.new(uri)) } raise Error, "GET #{url} answered #{response.code}" unless response.code.to_i == 200 JSON.parse(response.body) end |
.http(uri) ⇒ Object
280 281 282 283 |
# File 'lib/mistri/mcp/oauth.rb', line 280 def http(uri, &) Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 15, read_timeout: 30, &) end |
.post_form(url, form, basic_auth: nil) ⇒ Object
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/mistri/mcp/oauth.rb', line 257 def post_form(url, form, basic_auth: nil) uri = URI(url) response = http(uri) do |connection| request = Net::HTTP::Post.new(uri) request["Content-Type"] = "application/x-www-form-urlencoded" request["Accept"] = "application/json" request.basic_auth(*basic_auth) if basic_auth request.body = URI.encode_www_form(form) connection.request(request) end payload = begin JSON.parse(response.body) rescue StandardError {} end unless response.code.to_i == 200 reason = payload["error_description"] || payload["error"] || response.body.to_s[0, 200] raise Error, "token request failed (#{response.code}): #{reason}" end payload end |
.post_json(url, body) ⇒ Object
242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/mistri/mcp/oauth.rb', line 242 def post_json(url, body) uri = URI(url) response = http(uri) do |connection| request = Net::HTTP::Post.new(uri) request["Content-Type"] = "application/json" request.body = JSON.generate(body) connection.request(request) end unless %w[200 201].include?(response.code) raise Error, "POST #{url} answered #{response.code}: #{response.body.to_s[0, 200]}" end JSON.parse(response.body) end |
.presence(value) ⇒ Object
187 188 189 |
# File 'lib/mistri/mcp/oauth.rb', line 187 def presence(value) value.to_s.strip.empty? ? nil : value end |
.refresh(refresh_token:, client_id:, token_endpoint:, resource:, client_secret: nil, token_auth_method: nil) ⇒ Object
Trade a refresh token for a fresh set; OAuth 2.1 rotates refresh tokens, so persist the returned one.
75 76 77 78 79 80 |
# File 'lib/mistri/mcp/oauth.rb', line 75 def refresh(refresh_token:, client_id:, token_endpoint:, resource:, client_secret: nil, token_auth_method: nil, **) form = { "grant_type" => "refresh_token", "refresh_token" => refresh_token, "client_id" => client_id, "resource" => resource } token_request(token_endpoint, form, client_secret, token_auth_method) end |
.register(metadata, client_name, redirect_uri, client_id, client_secret) ⇒ Object
RFC 7591 dynamic registration, as the application. Servers without a registration endpoint require a pre-registered client id. The returned hash keeps the token endpoint auth method the server granted, so token requests authenticate the way it expects.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/mistri/mcp/oauth.rb', line 135 def register(, client_name, redirect_uri, client_id, client_secret) return { "client_id" => client_id, "client_secret" => client_secret } if client_id endpoint = ["registration_endpoint"] unless endpoint raise Error, "the server does not offer dynamic client registration; " \ "pass client_id:/client_secret: from a manual registration" end registration = post_json(endpoint, { "client_name" => client_name, "redirect_uris" => [redirect_uri], "grant_types" => %w[authorization_code refresh_token], "response_types" => ["code"], "token_endpoint_auth_method" => "client_secret_post" }) { "client_id" => presence(registration["client_id"]) || raise(Error, "registration returned no client_id"), "client_secret" => presence(registration["client_secret"]), "token_endpoint_auth_method" => presence(registration["token_endpoint_auth_method"]) } end |
.resolve_scope(scope, resource_metadata, metadata) ⇒ Object
No scope given: request what the resource advertises, and add offline_access when the authorization server supports it (that is what earns a refresh token from providers that require it). An unsupported offline_access is stripped rather than sent blind.
163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/mistri/mcp/oauth.rb', line 163 def resolve_scope(scope, , ) scopes = scope.to_s.split scopes = Array(["scopes_supported"]) if scopes.empty? supported = Array(["scopes_supported"]) if supported.include?("offline_access") scopes |= ["offline_access"] else scopes -= ["offline_access"] end scopes.empty? ? nil : scopes.join(" ") end |
.resource_metadata_for(url) ⇒ Object
RFC 9728: a 401's WWW-Authenticate names the resource metadata URL; servers that skip the header serve the well-known path.
86 87 88 89 90 91 92 93 94 |
# File 'lib/mistri/mcp/oauth.rb', line 86 def (url) = (url) || well_known_resource_url(url) document = get_json() if Array(document["authorization_servers"]).empty? raise Error, "#{} names no authorization servers" end document end |
.server_metadata(authority) ⇒ Object
RFC 8414 metadata, with the OpenID Connect path as a fallback since large providers often serve only that document.
118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/mistri/mcp/oauth.rb', line 118 def () uri = URI() origin = "#{uri.scheme}://#{uri.host}:#{uri.port}" path = uri.path.chomp("/") candidates = ["#{origin}/.well-known/oauth-authorization-server#{path unless path.empty?}", "#{origin}#{path}/.well-known/openid-configuration"] candidates.each do |candidate| document = try_json(candidate) return document if document&.key?("token_endpoint") end raise Error, "no authorization server metadata at #{}" end |
.start(url:, client_name:, redirect_uri:, scope: nil, client_id: nil, client_secret: nil) ⇒ Object
Discover the server's authorization setup, register the application, and build the authorize URL. Returns everything the callback and refresh need: authorize_url, state, code_verifier, client_id, client_secret, token_auth_method, token_endpoint, resource, redirect_uri.
With no scope given, the server's advertised scopes_supported are requested, and offline_access rides along when the authorization server supports it, which is what earns a refresh token from providers that require it.
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
# File 'lib/mistri/mcp/oauth.rb', line 41 def start(url:, client_name:, redirect_uri:, scope: nil, client_id: nil, client_secret: nil) resource = canonical(url) = (url) = (Array(["authorization_servers"]).first) validate_endpoints() registration = register(, client_name, redirect_uri, client_id, client_secret) verifier = SecureRandom.urlsafe_base64(48) state = SecureRandom.urlsafe_base64(32) grant = { client_id: registration["client_id"], redirect_uri: redirect_uri, verifier: verifier, state: state, resource: resource, scope: resolve_scope(scope, , ) } { "authorize_url" => (, grant), "state" => state, "code_verifier" => verifier, "client_id" => registration["client_id"], "client_secret" => registration["client_secret"], "token_auth_method" => registration["token_endpoint_auth_method"], "token_endpoint" => .fetch("token_endpoint"), "resource" => resource, "redirect_uri" => redirect_uri } end |
.token_request(endpoint, form, client_secret, auth_method = nil) ⇒ Object
214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/mistri/mcp/oauth.rb', line 214 def token_request(endpoint, form, client_secret, auth_method = nil) basic = client_secret && auth_method == "client_secret_basic" form = form.merge("client_secret" => client_secret) if client_secret && !basic credentials = basic ? [form["client_id"], client_secret] : nil payload = post_form(endpoint, form, basic_auth: credentials) expires_in = payload["expires_in"] { "access_token" => payload.fetch("access_token"), "refresh_token" => payload["refresh_token"], "scope" => payload["scope"], "expires_at" => expires_in ? Time.now.utc + expires_in.to_i : nil } end |
.try_json(url) ⇒ Object
236 237 238 239 240 |
# File 'lib/mistri/mcp/oauth.rb', line 236 def try_json(url) get_json(url) rescue Error, JSON::ParserError nil end |
.validate_endpoints(metadata) ⇒ Object
The spec requires authorization server endpoints over HTTPS; loopback stays allowed for development.
177 178 179 180 181 182 183 184 185 |
# File 'lib/mistri/mcp/oauth.rb', line 177 def validate_endpoints() %w[authorization_endpoint token_endpoint registration_endpoint].each do |key| value = [key] or next uri = URI(value) next if uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(uri.host) raise Error, "#{key} #{value} is not HTTPS" end end |
.well_known_resource_url(url) ⇒ Object
109 110 111 112 113 114 |
# File 'lib/mistri/mcp/oauth.rb', line 109 def well_known_resource_url(url) uri = URI(url) path = uri.path.chomp("/") origin = "#{uri.scheme}://#{uri.host}:#{uri.port}" "#{origin}/.well-known/oauth-protected-resource#{path unless path.empty?}" end |