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: "YourApp",
                              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)
# The host verifies callback state before complete.

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

Class Method Details

.authorization_metadata_urls(authority) ⇒ Object

Raises:



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/mistri/mcp/oauth.rb', line 194

def (authority)
  uri = Egress.normalize(authority, "authorization server")
  raise Error, "authorization server must not contain a query" if uri.query

  origin = Egress.origin(uri)
  path = uri.path.to_s.chomp("/")
  if path.empty?
    ["#{origin}/.well-known/oauth-authorization-server",
     "#{origin}/.well-known/openid-configuration"]
  else
    ["#{origin}/.well-known/oauth-authorization-server#{path}",
     "#{origin}/.well-known/openid-configuration#{path}",
     "#{origin}#{path}/.well-known/openid-configuration"]
  end
end

.authorization_server(document, issuer: nil) ⇒ Object

Authorization-server selection is host policy whenever discovery offers a choice. Pre-registered client identities are always bound to the exact issuer supplied when they were provisioned.

Raises:



223
224
225
226
227
228
229
230
231
232
233
# File 'lib/mistri/mcp/oauth.rb', line 223

def authorization_server(document, issuer: nil)
  servers = authorization_servers(document)
  if issuer
    return issuer if servers.include?(issuer)

    raise Error, "protected resource metadata does not name the configured issuer"
  end
  return servers.first if servers.one?

  raise ConfigurationError, "multiple authorization servers advertised; pass issuer:"
end

.authorization_servers(document) ⇒ Object



210
211
212
213
214
215
216
217
218
# File 'lib/mistri/mcp/oauth.rb', line 210

def authorization_servers(document)
  servers = document["authorization_servers"]
  unless servers.is_a?(Array) && servers.any? &&
         servers.all? { |server| server.is_a?(String) && !server.empty? }
    raise Error, "protected resource metadata names no authorization servers"
  end

  servers.uniq
end

.authorize_url(metadata, grant) ⇒ Object



404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/mistri/mcp/oauth.rb', line 404

def authorize_url(, 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"))
  existing = URI.decode_www_form(endpoint.query.to_s)
  existing.reject! { |key, _value| AUTHORIZE_PARAMETERS.include?(key) }
  endpoint.query = URI.encode_www_form(existing + params.to_a)
  endpoint.to_s
end

.bounded_response(connection, request) ⇒ Object



583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
# File 'lib/mistri/mcp/oauth.rb', line 583

def bounded_response(connection, request)
  result = nil
  connection.request(request) do |response|
    body = +""
    response.read_body do |chunk|
      if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
        raise Error, "OAuth response exceeded #{MAX_RESPONSE_BYTES} bytes"
      end

      body << chunk
    end
    headers = response.to_hash.transform_values { |values| values.join(", ") }
    result = Response.new(code: response.code, headers: headers, body: body)
  end
  result
end

.canonical(url) ⇒ Object

RFC 8707 canonical form: lowercase scheme and host, with no default port or semantically empty root path.



422
423
424
# File 'lib/mistri/mcp/oauth.rb', line 422

def canonical(url)
  Egress.normalize(url, "resource").to_s
end

.challenge_parameters(url, allow_non_public: nil) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
# File 'lib/mistri/mcp/oauth.rb', line 138

def challenge_parameters(url, allow_non_public: nil)
  targets = Egress.targets(url, allow_non_public:, label: "MCP URL")
  request = Net::HTTP::Post.new(targets.first.uri)
  request["Accept"] = "application/json, text/event-stream"
  request["Content-Type"] = "application/json"
  request.body = JSON.generate({ jsonrpc: "2.0", id: 0, method: "ping" })
  response = http(targets, request)
  return {} unless response.code.to_i == 401

  parse_www_authenticate(response["WWW-Authenticate"])
end

.complete(code:, code_verifier:, client_id:, resource:, redirect_uri:, issuer: nil, client_secret: nil, token_auth_method: nil, allow_non_public: nil) ⇒ Object

Exchange the callback's code for tokens.



93
94
95
96
97
98
99
100
101
102
103
# File 'lib/mistri/mcp/oauth.rb', line 93

def complete(code:, code_verifier:, client_id:, resource:, redirect_uri:, issuer: nil,
             client_secret: nil, token_auth_method: nil,
             allow_non_public: nil, **)
  endpoint = discovered_token_endpoint(issuer, allow_non_public: allow_non_public)
  form = { "grant_type" => "authorization_code", "code" => code,
           "code_verifier" => code_verifier, "client_id" => client_id,
           "redirect_uri" => Egress.redirect_uri(redirect_uri),
           "resource" => canonical(resource) }
  token_request(endpoint, form, client_secret, token_auth_method,
                allow_non_public: allow_non_public)
end

.discovered_token_endpoint(issuer, allow_non_public: nil) ⇒ Object

Raises:



180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/mistri/mcp/oauth.rb', line 180

def discovered_token_endpoint(issuer, allow_non_public: nil)
  unless issuer.is_a?(String) && !issuer.empty?
    raise ConfigurationError,
          "issuer: is required; persist flow[\"issuer\"] from OAuth.start"
  end

   = (issuer, allow_non_public: allow_non_public)
  endpoint = ["token_endpoint"]
  raise Error, "authorization server metadata has no token_endpoint" unless endpoint

  Egress.resolver(endpoint, allow_non_public:, label: "token_endpoint")
  endpoint
end

.finish_connection(connection) ⇒ Object



600
601
602
603
604
# File 'lib/mistri/mcp/oauth.rb', line 600

def finish_connection(connection)
  connection.finish if connection.started?
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
  nil
end

.get_json(url, allow_non_public: nil, optional: false) ⇒ Object



479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
# File 'lib/mistri/mcp/oauth.rb', line 479

def get_json(url, allow_non_public: nil, optional: false)
  targets = Egress.targets(url, allow_non_public:, label: "metadata URL")
  location = Egress.display(targets.first.uri)
  request = Net::HTTP::Get.new(targets.first.uri)
  request["Accept"] = "application/json"
  response = http(targets, request)
  return nil if optional && response.code.to_i != 200
  raise Error, "GET #{location} answered #{response.code}" unless response.code.to_i == 200

  object = JSON.parse(response.body)
  raise Error, "GET #{location} did not return a JSON object" unless object.is_a?(Hash)

  object
rescue ConnectionFailure
  return nil if optional

  raise
rescue JSON::ParserError
  raise Error, "GET #{location} returned invalid JSON"
end

.http(targets, request) ⇒ Object

Raises:

  • (ConnectionFailure)


546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
# File 'lib/mistri/mcp/oauth.rb', line 546

def http(targets, request)
  request["Accept-Encoding"] = "identity"
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
  targets.each do |target|
    remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
    break unless remaining.positive?

    connection = oauth_connection(target, open_timeout: remaining)
    begin
      connection.start
    rescue IOError, SocketError, SystemCallError, Timeout::Error,
           Net::HTTPBadResponse, OpenSSL::SSL::SSLError
      next
    end

    begin
      return bounded_response(connection, request)
    rescue IOError, SocketError, SystemCallError, Timeout::Error,
           Net::HTTPBadResponse, OpenSSL::SSL::SSLError
      raise ConnectionFailure, "OAuth connection failed"
    ensure
      finish_connection(connection)
    end
  end
  raise ConnectionFailure, "OAuth connection failed"
end

.oauth_connection(target, open_timeout:) ⇒ Object



573
574
575
576
577
578
579
580
581
# File 'lib/mistri/mcp/oauth.rb', line 573

def oauth_connection(target, open_timeout:)
  Net::HTTP.new(target.uri.hostname, target.uri.port, nil).tap do |connection|
    connection.ipaddr = target.address
    connection.use_ssl = target.uri.scheme == "https"
    connection.open_timeout = open_timeout
    connection.read_timeout = 30
    connection.max_retries = 0
  end
end

.parse_www_authenticate(header) ⇒ Object



617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
# File 'lib/mistri/mcp/oauth.rb', line 617

def parse_www_authenticate(header)
  match = header.to_s.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
  return {} unless match

  cursor = match.end(0)
  params = {}
  while cursor < header.length
    rest = header[cursor..].sub(/\A\s*,?\s*/, "")
    pair = rest.match(WWW_AUTH_PARAM)
    break unless pair

    params[pair[1].downcase] = pair[2] ? pair[2].gsub(/\\(.)/, '\\1') : pair[3]
    cursor = header.length - rest.length + pair.end(0)
  end
  params
end

.post_form(url, form, basic_auth: nil, allow_non_public: nil) ⇒ Object

Raises:



520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# File 'lib/mistri/mcp/oauth.rb', line 520

def post_form(url, form, basic_auth: nil, allow_non_public: nil)
  targets = Egress.targets(url, allow_non_public:, label: "token_endpoint")
  request = Net::HTTP::Post.new(targets.first.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)
  response = http(targets, request)
  payload = begin
    JSON.parse(response.body)
  rescue JSON::ParserError
    raise Error, "token endpoint returned invalid JSON" if response.code.to_i == 200

    {}
  end
  raise Error, "token endpoint did not return a JSON object" unless payload.is_a?(Hash)

  unless response.code.to_i == 200
    reason = payload["error"]
    reason = nil unless TOKEN_ERROR_CODES.include?(reason)
    detail = reason ? ": #{reason}" : ""
    raise Error, "token request failed (#{response.code})#{detail}"
  end
  payload
end

.post_json(url, body, allow_non_public: nil) ⇒ Object



500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
# File 'lib/mistri/mcp/oauth.rb', line 500

def post_json(url, body, allow_non_public: nil)
  targets = Egress.targets(url, allow_non_public:, label: "registration_endpoint")
  location = Egress.display(targets.first.uri)
  request = Net::HTTP::Post.new(targets.first.uri)
  request["Content-Type"] = "application/json"
  request["Accept"] = "application/json"
  request.body = JSON.generate(body)
  response = http(targets, request)
  unless %w[200 201].include?(response.code)
    raise Error, "POST #{location} answered #{response.code}"
  end

  object = JSON.parse(response.body)
  raise Error, "registration endpoint did not return a JSON object" unless object.is_a?(Hash)

  object
rescue JSON::ParserError
  raise Error, "registration endpoint returned invalid JSON"
end

.pre_registered_client(client_id, client_secret, token_auth_method) ⇒ Object



276
277
278
279
280
281
282
283
284
285
# File 'lib/mistri/mcp/oauth.rb', line 276

def pre_registered_client(client_id, client_secret, token_auth_method)
  token_auth_method ||= client_secret ? "client_secret_basic" : "none"
  validate_token_auth_method(token_auth_method)
  if token_auth_method != "none" && !client_secret
    raise Error, "#{token_auth_method} requires a client_secret"
  end

  { "client_id" => client_id, "client_secret" => client_secret,
    "token_endpoint_auth_method" => token_auth_method }
end

.presence(value) ⇒ Object



400
401
402
# File 'lib/mistri/mcp/oauth.rb', line 400

def presence(value)
  value.to_s.strip.empty? ? nil : value
end

.refresh(refresh_token:, client_id:, resource:, issuer: nil, client_secret: nil, token_auth_method: nil, allow_non_public: nil) ⇒ Object

Trade a refresh token for a fresh set; OAuth 2.1 rotates refresh tokens, so persist the returned one.



107
108
109
110
111
112
113
114
# File 'lib/mistri/mcp/oauth.rb', line 107

def refresh(refresh_token:, client_id:, resource:, issuer: nil, client_secret: nil,
            token_auth_method: nil, allow_non_public: nil, **)
  endpoint = discovered_token_endpoint(issuer, allow_non_public: allow_non_public)
  form = { "grant_type" => "refresh_token", "refresh_token" => refresh_token,
           "client_id" => client_id, "resource" => canonical(resource) }
  token_request(endpoint, form, client_secret, token_auth_method,
                allow_non_public: allow_non_public)
end

.register(metadata, client_name:, redirect_uri:, client_id:, client_secret:, token_auth_method:, allow_non_public:) ⇒ 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.



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/mistri/mcp/oauth.rb', line 239

def register(, client_name:, redirect_uri:, client_id:, client_secret:,
             token_auth_method:, allow_non_public:)
  return pre_registered_client(client_id, client_secret, token_auth_method) 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

  requested_method = registration_auth_method()
  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" => requested_method
                           }, allow_non_public: allow_non_public)
  result = {
    "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"])
  }
  default_method = result["client_secret"] ? requested_method : "none"
  result["token_endpoint_auth_method"] ||= default_method
  validate_token_auth_method(result["token_endpoint_auth_method"])
  if result["token_endpoint_auth_method"] != "none" && !result["client_secret"]
    raise Error, "registration selected secret authentication without a client_secret"
  end
  if result["token_endpoint_auth_method"] == "none" && result["client_secret"]
    raise Error, "registration returned a client_secret for an unauthenticated client"
  end

  result
end

.registration_auth_method(metadata) ⇒ Object



287
288
289
290
291
292
293
294
295
296
297
# File 'lib/mistri/mcp/oauth.rb', line 287

def registration_auth_method()
  supported = ["token_endpoint_auth_methods_supported"]
  supported = ["client_secret_basic"] if supported.nil?
  unless supported.is_a?(Array) && supported.all?(String)
    raise Error, "authorization server token auth methods are malformed"
  end

  preferred = %w[client_secret_basic client_secret_post none]
  preferred.find { |method| supported.include?(method) } ||
    raise(Error, "authorization server offers no supported token auth method")
end

.resolve_scope(scope, challenge_scope, resource_metadata) ⇒ Object

Explicit host policy wins; otherwise the 401 challenge and then the protected resource metadata define the least-privilege request.



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'lib/mistri/mcp/oauth.rb', line 354

def resolve_scope(scope, challenge_scope, )
  challenged = challenge_scope.to_s.split.uniq
  if scope
    requested = scope.to_s.split.uniq
    missing = challenged - requested
    unless missing.empty?
      raise ConfigurationError,
            "scope: must include every scope required by the MCP challenge"
    end

    return requested.empty? ? nil : requested.join(" ")
  end
  return challenged.join(" ") unless challenged.empty?

  supported = ["scopes_supported"]
  return nil if supported.nil?
  unless supported.is_a?(Array) && supported.all?(String)
    raise Error, "protected resource scopes_supported is malformed"
  end

  supported.empty? ? nil : supported.uniq.join(" ")
end

.resource_metadata_candidates(url) ⇒ Object



154
155
156
157
158
159
160
161
# File 'lib/mistri/mcp/oauth.rb', line 154

def (url)
  uri = URI(url)
  path = uri.path == "/" ? "" : uri.path.to_s
  specific = "#{Egress.origin(uri)}/.well-known/oauth-protected-resource#{path}"
  specific = "#{specific}?#{uri.query}" if uri.query
  root = "#{Egress.origin(uri)}/.well-known/oauth-protected-resource"
  [[specific, url], [root, Egress.origin(uri)]].uniq(&:first)
end

.resource_metadata_for(url, allow_non_public: nil) ⇒ Object

RFC 9728: a 401's WWW-Authenticate names the resource metadata URL; servers that skip the header serve the well-known path.

Raises:



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/mistri/mcp/oauth.rb', line 120

def (url, allow_non_public: nil)
  challenge = challenge_parameters(url, allow_non_public: allow_non_public)
  candidates = if challenge["resource_metadata"]
                 [[challenge["resource_metadata"], url]]
               else
                 (url)
               end
  candidates.each do |, expected_resource|
    document = get_json(, allow_non_public: allow_non_public, optional: true)
    next unless document

    validate_resource(document["resource"], expected_resource)
    authorization_servers(document)
    return [document, challenge["scope"]]
  end
  raise Error, "no protected resource metadata for #{Egress.display(URI(url))}"
end

.server_metadata(authority, allow_non_public: nil) ⇒ Object

RFC 8414 metadata, with the OpenID Connect path as a fallback since large providers often serve only that document.

Raises:



165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/mistri/mcp/oauth.rb', line 165

def (authority, allow_non_public: nil)
  candidates = (authority)
  candidates.each do |candidate|
    document = get_json(candidate, allow_non_public: allow_non_public, optional: true)
    next unless document

    unless document["issuer"] == authority
      raise Error, "authorization server metadata issuer does not match #{authority}"
    end

    return document
  end
  raise Error, "no authorization server metadata at #{authority}"
end

.start(url:, client_name:, redirect_uri:, scope: nil, client_id: nil, client_secret: nil, token_auth_method: nil, issuer: nil, allow_non_public: 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, issuer, resource, and redirect_uri. token_endpoint remains informational for compatibility; later operations rediscover it from issuer.

With no scope given, the challenge or protected resource's advertised scopes are requested. Extra privileges are always host policy.



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
# File 'lib/mistri/mcp/oauth.rb', line 59

def start(url:, client_name:, redirect_uri:, scope: nil,
          client_id: nil, client_secret: nil, token_auth_method: nil,
          issuer: nil, allow_non_public: nil)
  validate_registration_input(client_id:, client_secret:, token_auth_method:, issuer:)
  resource = canonical(url)
  redirect_uri = Egress.redirect_uri(redirect_uri)
  , challenge_scope = (
    resource, allow_non_public: allow_non_public
  )
  authority = authorization_server(, issuer: issuer)
   = (authority, allow_non_public: allow_non_public)
  validate_endpoints(, allow_non_public: allow_non_public)
  validate_pkce()
  resolved_scope = resolve_scope(scope, challenge_scope, )
  registration = register(, client_name:, redirect_uri:, client_id:, client_secret:,
                                    token_auth_method:, allow_non_public:)
  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: resolved_scope }
  {
    "authorize_url" => 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"],
    "issuer" => authority,
    "token_endpoint" => .fetch("token_endpoint"),
    "resource" => resource, "redirect_uri" => redirect_uri
  }
end

.token_authentication(form, client_secret, auth_method) ⇒ Object



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# File 'lib/mistri/mcp/oauth.rb', line 445

def token_authentication(form, client_secret, auth_method)
  if auth_method == "none" && client_secret
    raise Error, "token_endpoint_auth_method none cannot use a client_secret"
  end

  credentials = nil
  case auth_method
  when "client_secret_basic"
    raise Error, "client_secret_basic requires a client_secret" unless client_secret

    credentials = [form["client_id"], client_secret].map do |part|
      URI.encode_www_form_component(part)
    end
  when "client_secret_post"
    raise Error, "client_secret_post requires a client_secret" unless client_secret

    form = form.merge("client_secret" => client_secret)
  end

  [form, credentials]
end

.token_request(endpoint, form, client_secret, auth_method = nil, allow_non_public: nil) ⇒ Object



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
# File 'lib/mistri/mcp/oauth.rb', line 426

def token_request(endpoint, form, client_secret, auth_method = nil, allow_non_public: nil)
  # 0.5.0 persisted no method and sent the secret in the form. New flows
  # always persist an explicit method; keep old connected rows usable.
  auth_method ||= client_secret ? "client_secret_post" : "none"
  validate_token_auth_method(auth_method)
  form, credentials = token_authentication(form, client_secret, auth_method)
  payload = post_form(endpoint, form, basic_auth: credentials,
                                      allow_non_public: allow_non_public)
  validate_token_response(payload)

  expires_in = payload["expires_in"]
  {
    "access_token" => payload["access_token"],
    "refresh_token" => payload["refresh_token"],
    "scope" => payload["scope"],
    "expires_at" => expires_in ? Time.now.utc + expires_in.to_i : nil
  }
end

.validate_client_id(client_id) ⇒ Object

Raises:



307
308
309
310
311
# File 'lib/mistri/mcp/oauth.rb', line 307

def validate_client_id(client_id)
  return if client_id.is_a?(String) && !client_id.strip.empty?

  raise ConfigurationError, "client_id: must be a non-empty string"
end

.validate_client_secret(client_secret) ⇒ Object

Raises:



313
314
315
316
317
# File 'lib/mistri/mcp/oauth.rb', line 313

def validate_client_secret(client_secret)
  return if client_secret.is_a?(String) && !client_secret.empty?

  raise ConfigurationError, "client_secret: must be a non-empty string"
end

.validate_endpoints(metadata, allow_non_public: nil) ⇒ Object



377
378
379
380
381
382
383
384
385
# File 'lib/mistri/mcp/oauth.rb', line 377

def validate_endpoints(, allow_non_public: nil)
  %w[authorization_endpoint token_endpoint].each do |required|
    raise Error, "authorization server metadata has no #{required}" unless [required]
  end
  %w[authorization_endpoint token_endpoint registration_endpoint].each do |key|
    value = [key] or next
    Egress.target(value, allow_non_public:, label: key)
  end
end

.validate_issuer_argument(issuer) ⇒ Object

Raises:



319
320
321
322
323
# File 'lib/mistri/mcp/oauth.rb', line 319

def validate_issuer_argument(issuer)
  return if issuer.is_a?(String) && !issuer.empty?

  raise ConfigurationError, "issuer: must be a non-empty string"
end

.validate_pkce(metadata) ⇒ Object

Raises:



387
388
389
390
391
392
# File 'lib/mistri/mcp/oauth.rb', line 387

def validate_pkce()
  methods = ["code_challenge_methods_supported"]
  return if methods.is_a?(Array) && methods.include?("S256")

  raise Error, "authorization server does not advertise S256 PKCE support"
end

.validate_registration_input(client_id:, client_secret:, token_auth_method:, issuer:) ⇒ Object



299
300
301
302
303
304
305
# File 'lib/mistri/mcp/oauth.rb', line 299

def validate_registration_input(client_id:, client_secret:, token_auth_method:, issuer:)
  validate_client_id(client_id) if client_id
  validate_client_secret(client_secret) if client_secret
  validate_issuer_argument(issuer) if issuer
  validate_token_auth_argument(token_auth_method) if token_auth_method
  validate_registration_relationships(client_id, client_secret, token_auth_method, issuer)
end

.validate_registration_relationships(client_id, client_secret, token_auth_method, issuer) ⇒ Object

Raises:



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/mistri/mcp/oauth.rb', line 332

def validate_registration_relationships(client_id, client_secret, token_auth_method, issuer)
  if client_secret && !client_id
    raise ConfigurationError, "client_secret: requires client_id:"
  end
  if token_auth_method && !client_id
    raise ConfigurationError, "token_auth_method: requires client_id:"
  end
  if client_id && !issuer
    raise ConfigurationError,
          "issuer: is required for a pre-registered client_id"
  end
  secret_methods = %w[client_secret_basic client_secret_post]
  if client_id && secret_methods.include?(token_auth_method) && !client_secret
    raise ConfigurationError, "#{token_auth_method} requires a client_secret"
  end
  return unless client_secret && token_auth_method == "none"

  raise ConfigurationError, "token_auth_method: none cannot be paired with client_secret:"
end

.validate_resource(candidate, expected) ⇒ Object

Raises:



606
607
608
609
610
611
612
613
614
615
# File 'lib/mistri/mcp/oauth.rb', line 606

def validate_resource(candidate, expected)
  raise Error, "protected resource metadata has no resource" unless candidate.is_a?(String)

  resource = canonical(candidate)
  return if resource == canonical(expected)

  raise Error,
        "protected resource metadata resource does not match " \
        "#{Egress.display(URI(expected))}"
end

.validate_token_auth_argument(token_auth_method) ⇒ Object

Raises:



325
326
327
328
329
330
# File 'lib/mistri/mcp/oauth.rb', line 325

def validate_token_auth_argument(token_auth_method)
  return if TOKEN_AUTH_METHODS.include?(token_auth_method)

  raise ConfigurationError,
        "unsupported token endpoint authentication method #{token_auth_method.inspect}"
end

.validate_token_auth_method(method) ⇒ Object

Raises:



394
395
396
397
398
# File 'lib/mistri/mcp/oauth.rb', line 394

def validate_token_auth_method(method)
  return if TOKEN_AUTH_METHODS.include?(method)

  raise Error, "unsupported token endpoint authentication method"
end

.validate_token_response(payload) ⇒ Object

Raises:



467
468
469
470
471
472
473
474
475
476
477
# File 'lib/mistri/mcp/oauth.rb', line 467

def validate_token_response(payload)
  token_type = payload["token_type"]
  unless token_type.is_a?(String) && token_type.casecmp?("Bearer")
    raise Error, "token endpoint returned unsupported token_type"
  end

  access_token = payload["access_token"]
  return if access_token.is_a?(String) && !access_token.strip.empty?

  raise Error, "token endpoint returned no access_token"
end

.well_known_resource_urls(url) ⇒ Object



150
151
152
# File 'lib/mistri/mcp/oauth.rb', line 150

def well_known_resource_urls(url)
  (url).map(&:first)
end