mistri 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +469 -4
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +289 -385
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/agent.rb +575 -55
  28. data/lib/mistri/budget.rb +26 -1
  29. data/lib/mistri/child.rb +72 -16
  30. data/lib/mistri/compaction.rb +26 -10
  31. data/lib/mistri/compactor.rb +34 -11
  32. data/lib/mistri/console.rb +28 -7
  33. data/lib/mistri/content.rb +9 -3
  34. data/lib/mistri/dispatchers.rb +14 -12
  35. data/lib/mistri/errors.rb +83 -4
  36. data/lib/mistri/event.rb +24 -8
  37. data/lib/mistri/event_delivery.rb +60 -0
  38. data/lib/mistri/locks.rb +3 -3
  39. data/lib/mistri/mcp/client.rb +74 -19
  40. data/lib/mistri/mcp/egress.rb +216 -0
  41. data/lib/mistri/mcp/oauth.rb +476 -127
  42. data/lib/mistri/mcp/wires.rb +115 -23
  43. data/lib/mistri/mcp.rb +42 -8
  44. data/lib/mistri/message.rb +21 -11
  45. data/lib/mistri/models.rb +160 -22
  46. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  47. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  48. data/lib/mistri/providers/anthropic.rb +29 -6
  49. data/lib/mistri/providers/fake.rb +26 -10
  50. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  51. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  52. data/lib/mistri/providers/gemini.rb +31 -5
  53. data/lib/mistri/providers/openai/assembler.rb +337 -60
  54. data/lib/mistri/providers/openai/serializer.rb +13 -12
  55. data/lib/mistri/providers/openai.rb +29 -5
  56. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  57. data/lib/mistri/result.rb +1 -1
  58. data/lib/mistri/schema.rb +893 -75
  59. data/lib/mistri/session.rb +560 -48
  60. data/lib/mistri/sinks/coalesced.rb +17 -10
  61. data/lib/mistri/spawner.rb +111 -61
  62. data/lib/mistri/sse.rb +57 -14
  63. data/lib/mistri/stores/active_record.rb +1 -1
  64. data/lib/mistri/stores/memory.rb +21 -2
  65. data/lib/mistri/sub_agent/execution.rb +81 -0
  66. data/lib/mistri/sub_agent/runtime.rb +297 -0
  67. data/lib/mistri/sub_agent.rb +124 -87
  68. data/lib/mistri/task_output.rb +24 -6
  69. data/lib/mistri/tool.rb +93 -13
  70. data/lib/mistri/tool_arguments.rb +377 -0
  71. data/lib/mistri/tool_call.rb +43 -9
  72. data/lib/mistri/tool_context.rb +4 -2
  73. data/lib/mistri/tool_executor.rb +117 -26
  74. data/lib/mistri/tool_result.rb +15 -10
  75. data/lib/mistri/tools/edit_file.rb +62 -8
  76. data/lib/mistri/tools.rb +41 -4
  77. data/lib/mistri/transport.rb +149 -44
  78. data/lib/mistri/usage.rb +65 -13
  79. data/lib/mistri/version.rb +1 -1
  80. data/lib/mistri/workspace/active_record.rb +183 -3
  81. data/lib/mistri/workspace/directory.rb +28 -8
  82. data/lib/mistri/workspace/memory.rb +34 -9
  83. data/lib/mistri/workspace/single.rb +62 -5
  84. data/lib/mistri/workspace.rb +39 -0
  85. data/lib/mistri.rb +6 -1
  86. data/mistri.gemspec +34 -0
  87. metadata +31 -3
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "json"
5
5
  require "net/http"
6
+ require "openssl"
6
7
  require "securerandom"
7
8
  require "uri"
8
9
 
@@ -14,126 +15,230 @@ module Mistri
14
15
  # persist on the host's own connection record.
15
16
  #
16
17
  # flow = Mistri::MCP::OAuth.start(url: params[:url],
17
- # client_name: "Sendoso",
18
+ # client_name: "YourApp",
18
19
  # redirect_uri: mcp_callback_url)
19
20
  # # persist flow, redirect the user to flow["authorize_url"]
20
21
  #
21
22
  # tokens = Mistri::MCP::OAuth.complete(code: params[:code], **persisted)
22
23
  # tokens = Mistri::MCP::OAuth.refresh(**persisted)
24
+ # # The host verifies callback state before complete.
23
25
  #
24
26
  # Registration happens as the APPLICATION, never as the harness:
25
27
  # client_name has no default because that identity is the host's call.
26
28
  # Servers without dynamic registration take client_id:/client_secret:
27
29
  # directly and skip it.
28
- module OAuth
30
+ module OAuth # rubocop:disable Metrics/ModuleLength -- one flow shares one validated network chain
31
+ class ConnectionFailure < Error; end
32
+ Response = Data.define(:code, :headers, :body) do
33
+ def [](name) = headers[name.to_s.downcase]
34
+ end
35
+ AUTHORIZE_PARAMETERS = %w[response_type client_id redirect_uri state code_challenge
36
+ code_challenge_method resource scope].freeze
37
+ MAX_RESPONSE_BYTES = 256 * 1024
38
+ WWW_AUTH_PARAM = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/
39
+ TOKEN_ERROR_CODES = %w[
40
+ invalid_request invalid_client invalid_grant unauthorized_client
41
+ unsupported_grant_type invalid_scope invalid_target
42
+ ].freeze
43
+ TOKEN_AUTH_METHODS = %w[none client_secret_basic client_secret_post].freeze
44
+ private_constant :ConnectionFailure, :Response, :AUTHORIZE_PARAMETERS,
45
+ :MAX_RESPONSE_BYTES, :WWW_AUTH_PARAM, :TOKEN_ERROR_CODES,
46
+ :TOKEN_AUTH_METHODS
47
+
29
48
  module_function
30
49
 
31
50
  # Discover the server's authorization setup, register the application,
32
51
  # and build the authorize URL. Returns everything the callback and
33
52
  # refresh need: authorize_url, state, code_verifier, client_id,
34
- # client_secret, token_auth_method, token_endpoint, resource,
35
- # redirect_uri.
53
+ # client_secret, token_auth_method, issuer, resource, and redirect_uri.
54
+ # token_endpoint remains informational for compatibility; later
55
+ # operations rediscover it from issuer.
36
56
  #
37
- # With no scope given, the server's advertised scopes_supported are
38
- # requested, and offline_access rides along when the authorization
39
- # server supports it, which is what earns a refresh token from
40
- # providers that require it.
57
+ # With no scope given, the challenge or protected resource's advertised
58
+ # scopes are requested. Extra privileges are always host policy.
41
59
  def start(url:, client_name:, redirect_uri:, scope: nil,
42
- client_id: nil, client_secret: nil)
60
+ client_id: nil, client_secret: nil, token_auth_method: nil,
61
+ issuer: nil, allow_non_public: nil)
62
+ validate_registration_input(client_id:, client_secret:, token_auth_method:, issuer:)
43
63
  resource = canonical(url)
44
- resource_metadata = resource_metadata_for(url)
45
- metadata = server_metadata(Array(resource_metadata["authorization_servers"]).first)
46
- validate_endpoints(metadata)
47
- registration = register(metadata, client_name, redirect_uri, client_id, client_secret)
64
+ redirect_uri = Egress.redirect_uri(redirect_uri)
65
+ resource_metadata, challenge_scope = resource_metadata_for(
66
+ resource, allow_non_public: allow_non_public
67
+ )
68
+ authority = authorization_server(resource_metadata, issuer: issuer)
69
+ metadata = server_metadata(authority, allow_non_public: allow_non_public)
70
+ validate_endpoints(metadata, allow_non_public: allow_non_public)
71
+ validate_pkce(metadata)
72
+ resolved_scope = resolve_scope(scope, challenge_scope, resource_metadata)
73
+ registration = register(metadata, client_name:, redirect_uri:, client_id:, client_secret:,
74
+ token_auth_method:, allow_non_public:)
48
75
  verifier = SecureRandom.urlsafe_base64(48)
49
76
  state = SecureRandom.urlsafe_base64(32)
50
77
  grant = { client_id: registration["client_id"], redirect_uri: redirect_uri,
51
78
  verifier: verifier, state: state, resource: resource,
52
- scope: resolve_scope(scope, resource_metadata, metadata) }
79
+ scope: resolved_scope }
53
80
  {
54
81
  "authorize_url" => authorize_url(metadata, grant),
55
82
  "state" => state, "code_verifier" => verifier,
56
83
  "client_id" => registration["client_id"],
57
84
  "client_secret" => registration["client_secret"],
58
85
  "token_auth_method" => registration["token_endpoint_auth_method"],
86
+ "issuer" => authority,
59
87
  "token_endpoint" => metadata.fetch("token_endpoint"),
60
88
  "resource" => resource, "redirect_uri" => redirect_uri
61
89
  }
62
90
  end
63
91
 
64
92
  # Exchange the callback's code for tokens.
65
- def complete(code:, code_verifier:, client_id:, token_endpoint:, resource:,
66
- redirect_uri:, client_secret: nil, token_auth_method: nil, **)
93
+ def complete(code:, code_verifier:, client_id:, resource:, redirect_uri:, issuer: nil,
94
+ client_secret: nil, token_auth_method: nil,
95
+ allow_non_public: nil, **)
96
+ endpoint = discovered_token_endpoint(issuer, allow_non_public: allow_non_public)
67
97
  form = { "grant_type" => "authorization_code", "code" => code,
68
98
  "code_verifier" => code_verifier, "client_id" => client_id,
69
- "redirect_uri" => redirect_uri, "resource" => resource }
70
- token_request(token_endpoint, form, client_secret, token_auth_method)
99
+ "redirect_uri" => Egress.redirect_uri(redirect_uri),
100
+ "resource" => canonical(resource) }
101
+ token_request(endpoint, form, client_secret, token_auth_method,
102
+ allow_non_public: allow_non_public)
71
103
  end
72
104
 
73
105
  # Trade a refresh token for a fresh set; OAuth 2.1 rotates refresh
74
106
  # tokens, so persist the returned one.
75
- def refresh(refresh_token:, client_id:, token_endpoint:, resource:,
76
- client_secret: nil, token_auth_method: nil, **)
107
+ def refresh(refresh_token:, client_id:, resource:, issuer: nil, client_secret: nil,
108
+ token_auth_method: nil, allow_non_public: nil, **)
109
+ endpoint = discovered_token_endpoint(issuer, allow_non_public: allow_non_public)
77
110
  form = { "grant_type" => "refresh_token", "refresh_token" => refresh_token,
78
- "client_id" => client_id, "resource" => resource }
79
- token_request(token_endpoint, form, client_secret, token_auth_method)
111
+ "client_id" => client_id, "resource" => canonical(resource) }
112
+ token_request(endpoint, form, client_secret, token_auth_method,
113
+ allow_non_public: allow_non_public)
80
114
  end
81
115
 
82
116
  # -- discovery ---------------------------------------------------------
83
117
 
84
118
  # RFC 9728: a 401's WWW-Authenticate names the resource metadata URL;
85
119
  # servers that skip the header serve the well-known path.
86
- def resource_metadata_for(url)
87
- metadata_url = challenge_metadata_url(url) || well_known_resource_url(url)
88
- document = get_json(metadata_url)
89
- if Array(document["authorization_servers"]).empty?
90
- raise Error, "#{metadata_url} names no authorization servers"
120
+ def resource_metadata_for(url, allow_non_public: nil)
121
+ challenge = challenge_parameters(url, allow_non_public: allow_non_public)
122
+ candidates = if challenge["resource_metadata"]
123
+ [[challenge["resource_metadata"], url]]
124
+ else
125
+ resource_metadata_candidates(url)
126
+ end
127
+ candidates.each do |metadata_url, expected_resource|
128
+ document = get_json(metadata_url, allow_non_public: allow_non_public, optional: true)
129
+ next unless document
130
+
131
+ validate_resource(document["resource"], expected_resource)
132
+ authorization_servers(document)
133
+ return [document, challenge["scope"]]
91
134
  end
135
+ raise Error, "no protected resource metadata for #{Egress.display(URI(url))}"
136
+ end
92
137
 
93
- document
138
+ def challenge_parameters(url, allow_non_public: nil)
139
+ targets = Egress.targets(url, allow_non_public:, label: "MCP URL")
140
+ request = Net::HTTP::Post.new(targets.first.uri)
141
+ request["Accept"] = "application/json, text/event-stream"
142
+ request["Content-Type"] = "application/json"
143
+ request.body = JSON.generate({ jsonrpc: "2.0", id: 0, method: "ping" })
144
+ response = http(targets, request)
145
+ return {} unless response.code.to_i == 401
146
+
147
+ parse_www_authenticate(response["WWW-Authenticate"])
94
148
  end
95
149
 
96
- def challenge_metadata_url(url)
97
- uri = URI(url)
98
- response = http(uri) do |connection|
99
- request = Net::HTTP::Post.new(uri)
100
- request["Accept"] = "application/json, text/event-stream"
101
- request["Content-Type"] = "application/json"
102
- request.body = JSON.generate({ jsonrpc: "2.0", id: 0, method: "ping" })
103
- connection.request(request)
104
- end
105
- challenge = response["WWW-Authenticate"].to_s
106
- challenge[/resource_metadata="([^"]+)"/i, 1]
150
+ def well_known_resource_urls(url)
151
+ resource_metadata_candidates(url).map(&:first)
107
152
  end
108
153
 
109
- def well_known_resource_url(url)
154
+ def resource_metadata_candidates(url)
110
155
  uri = URI(url)
111
- path = uri.path.chomp("/")
112
- origin = "#{uri.scheme}://#{uri.host}:#{uri.port}"
113
- "#{origin}/.well-known/oauth-protected-resource#{path unless path.empty?}"
156
+ path = uri.path == "/" ? "" : uri.path.to_s
157
+ specific = "#{Egress.origin(uri)}/.well-known/oauth-protected-resource#{path}"
158
+ specific = "#{specific}?#{uri.query}" if uri.query
159
+ root = "#{Egress.origin(uri)}/.well-known/oauth-protected-resource"
160
+ [[specific, url], [root, Egress.origin(uri)]].uniq(&:first)
114
161
  end
115
162
 
116
163
  # RFC 8414 metadata, with the OpenID Connect path as a fallback since
117
164
  # large providers often serve only that document.
118
- def server_metadata(authority)
119
- uri = URI(authority)
120
- origin = "#{uri.scheme}://#{uri.host}:#{uri.port}"
121
- path = uri.path.chomp("/")
122
- candidates = ["#{origin}/.well-known/oauth-authorization-server#{path unless path.empty?}",
123
- "#{origin}#{path}/.well-known/openid-configuration"]
165
+ def server_metadata(authority, allow_non_public: nil)
166
+ candidates = authorization_metadata_urls(authority)
124
167
  candidates.each do |candidate|
125
- document = try_json(candidate)
126
- return document if document&.key?("token_endpoint")
168
+ document = get_json(candidate, allow_non_public: allow_non_public, optional: true)
169
+ next unless document
170
+
171
+ unless document["issuer"] == authority
172
+ raise Error, "authorization server metadata issuer does not match #{authority}"
173
+ end
174
+
175
+ return document
127
176
  end
128
177
  raise Error, "no authorization server metadata at #{authority}"
129
178
  end
130
179
 
180
+ def discovered_token_endpoint(issuer, allow_non_public: nil)
181
+ unless issuer.is_a?(String) && !issuer.empty?
182
+ raise ConfigurationError,
183
+ "issuer: is required; persist flow[\"issuer\"] from OAuth.start"
184
+ end
185
+
186
+ metadata = server_metadata(issuer, allow_non_public: allow_non_public)
187
+ endpoint = metadata["token_endpoint"]
188
+ raise Error, "authorization server metadata has no token_endpoint" unless endpoint
189
+
190
+ Egress.resolver(endpoint, allow_non_public:, label: "token_endpoint")
191
+ endpoint
192
+ end
193
+
194
+ def authorization_metadata_urls(authority)
195
+ uri = Egress.normalize(authority, "authorization server")
196
+ raise Error, "authorization server must not contain a query" if uri.query
197
+
198
+ origin = Egress.origin(uri)
199
+ path = uri.path.to_s.chomp("/")
200
+ if path.empty?
201
+ ["#{origin}/.well-known/oauth-authorization-server",
202
+ "#{origin}/.well-known/openid-configuration"]
203
+ else
204
+ ["#{origin}/.well-known/oauth-authorization-server#{path}",
205
+ "#{origin}/.well-known/openid-configuration#{path}",
206
+ "#{origin}#{path}/.well-known/openid-configuration"]
207
+ end
208
+ end
209
+
210
+ def authorization_servers(document)
211
+ servers = document["authorization_servers"]
212
+ unless servers.is_a?(Array) && servers.any? &&
213
+ servers.all? { |server| server.is_a?(String) && !server.empty? }
214
+ raise Error, "protected resource metadata names no authorization servers"
215
+ end
216
+
217
+ servers.uniq
218
+ end
219
+
220
+ # Authorization-server selection is host policy whenever discovery
221
+ # offers a choice. Pre-registered client identities are always bound to
222
+ # the exact issuer supplied when they were provisioned.
223
+ def authorization_server(document, issuer: nil)
224
+ servers = authorization_servers(document)
225
+ if issuer
226
+ return issuer if servers.include?(issuer)
227
+
228
+ raise Error, "protected resource metadata does not name the configured issuer"
229
+ end
230
+ return servers.first if servers.one?
231
+
232
+ raise ConfigurationError, "multiple authorization servers advertised; pass issuer:"
233
+ end
234
+
131
235
  # RFC 7591 dynamic registration, as the application. Servers without a
132
236
  # registration endpoint require a pre-registered client id. The
133
237
  # returned hash keeps the token endpoint auth method the server
134
238
  # granted, so token requests authenticate the way it expects.
135
- def register(metadata, client_name, redirect_uri, client_id, client_secret)
136
- return { "client_id" => client_id, "client_secret" => client_secret } if client_id
239
+ def register(metadata, client_name:, redirect_uri:, client_id:, client_secret:,
240
+ token_auth_method:, allow_non_public:)
241
+ return pre_registered_client(client_id, client_secret, token_auth_method) if client_id
137
242
 
138
243
  endpoint = metadata["registration_endpoint"]
139
244
  unless endpoint
@@ -141,49 +246,157 @@ module Mistri
141
246
  "pass client_id:/client_secret: from a manual registration"
142
247
  end
143
248
 
249
+ requested_method = registration_auth_method(metadata)
144
250
  registration = post_json(endpoint, {
145
251
  "client_name" => client_name,
146
252
  "redirect_uris" => [redirect_uri],
147
253
  "grant_types" => %w[authorization_code refresh_token],
148
254
  "response_types" => ["code"],
149
- "token_endpoint_auth_method" => "client_secret_post"
150
- })
151
- {
255
+ "token_endpoint_auth_method" => requested_method
256
+ }, allow_non_public: allow_non_public)
257
+ result = {
152
258
  "client_id" => presence(registration["client_id"]) ||
153
- raise(Error, "registration returned no client_id"),
259
+ raise(Error, "registration returned no client_id"),
154
260
  "client_secret" => presence(registration["client_secret"]),
155
261
  "token_endpoint_auth_method" => presence(registration["token_endpoint_auth_method"])
156
262
  }
263
+ default_method = result["client_secret"] ? requested_method : "none"
264
+ result["token_endpoint_auth_method"] ||= default_method
265
+ validate_token_auth_method(result["token_endpoint_auth_method"])
266
+ if result["token_endpoint_auth_method"] != "none" && !result["client_secret"]
267
+ raise Error, "registration selected secret authentication without a client_secret"
268
+ end
269
+ if result["token_endpoint_auth_method"] == "none" && result["client_secret"]
270
+ raise Error, "registration returned a client_secret for an unauthenticated client"
271
+ end
272
+
273
+ result
157
274
  end
158
275
 
159
- # No scope given: request what the resource advertises, and add
160
- # offline_access when the authorization server supports it (that is
161
- # what earns a refresh token from providers that require it). An
162
- # unsupported offline_access is stripped rather than sent blind.
163
- def resolve_scope(scope, resource_metadata, metadata)
164
- scopes = scope.to_s.split
165
- scopes = Array(resource_metadata["scopes_supported"]) if scopes.empty?
166
- supported = Array(metadata["scopes_supported"])
167
- if supported.include?("offline_access")
168
- scopes |= ["offline_access"]
169
- else
170
- scopes -= ["offline_access"]
276
+ def pre_registered_client(client_id, client_secret, token_auth_method)
277
+ token_auth_method ||= client_secret ? "client_secret_basic" : "none"
278
+ validate_token_auth_method(token_auth_method)
279
+ if token_auth_method != "none" && !client_secret
280
+ raise Error, "#{token_auth_method} requires a client_secret"
281
+ end
282
+
283
+ { "client_id" => client_id, "client_secret" => client_secret,
284
+ "token_endpoint_auth_method" => token_auth_method }
285
+ end
286
+
287
+ def registration_auth_method(metadata)
288
+ supported = metadata["token_endpoint_auth_methods_supported"]
289
+ supported = ["client_secret_basic"] if supported.nil?
290
+ unless supported.is_a?(Array) && supported.all?(String)
291
+ raise Error, "authorization server token auth methods are malformed"
292
+ end
293
+
294
+ preferred = %w[client_secret_basic client_secret_post none]
295
+ preferred.find { |method| supported.include?(method) } ||
296
+ raise(Error, "authorization server offers no supported token auth method")
297
+ end
298
+
299
+ def validate_registration_input(client_id:, client_secret:, token_auth_method:, issuer:)
300
+ validate_client_id(client_id) if client_id
301
+ validate_client_secret(client_secret) if client_secret
302
+ validate_issuer_argument(issuer) if issuer
303
+ validate_token_auth_argument(token_auth_method) if token_auth_method
304
+ validate_registration_relationships(client_id, client_secret, token_auth_method, issuer)
305
+ end
306
+
307
+ def validate_client_id(client_id)
308
+ return if client_id.is_a?(String) && !client_id.strip.empty?
309
+
310
+ raise ConfigurationError, "client_id: must be a non-empty string"
311
+ end
312
+
313
+ def validate_client_secret(client_secret)
314
+ return if client_secret.is_a?(String) && !client_secret.empty?
315
+
316
+ raise ConfigurationError, "client_secret: must be a non-empty string"
317
+ end
318
+
319
+ def validate_issuer_argument(issuer)
320
+ return if issuer.is_a?(String) && !issuer.empty?
321
+
322
+ raise ConfigurationError, "issuer: must be a non-empty string"
323
+ end
324
+
325
+ def validate_token_auth_argument(token_auth_method)
326
+ return if TOKEN_AUTH_METHODS.include?(token_auth_method)
327
+
328
+ raise ConfigurationError,
329
+ "unsupported token endpoint authentication method #{token_auth_method.inspect}"
330
+ end
331
+
332
+ def validate_registration_relationships(client_id, client_secret, token_auth_method, issuer)
333
+ if client_secret && !client_id
334
+ raise ConfigurationError, "client_secret: requires client_id:"
335
+ end
336
+ if token_auth_method && !client_id
337
+ raise ConfigurationError, "token_auth_method: requires client_id:"
338
+ end
339
+ if client_id && !issuer
340
+ raise ConfigurationError,
341
+ "issuer: is required for a pre-registered client_id"
342
+ end
343
+ secret_methods = %w[client_secret_basic client_secret_post]
344
+ if client_id && secret_methods.include?(token_auth_method) && !client_secret
345
+ raise ConfigurationError, "#{token_auth_method} requires a client_secret"
171
346
  end
172
- scopes.empty? ? nil : scopes.join(" ")
347
+ return unless client_secret && token_auth_method == "none"
348
+
349
+ raise ConfigurationError, "token_auth_method: none cannot be paired with client_secret:"
173
350
  end
174
351
 
175
- # The spec requires authorization server endpoints over HTTPS;
176
- # loopback stays allowed for development.
177
- def validate_endpoints(metadata)
352
+ # Explicit host policy wins; otherwise the 401 challenge and then the
353
+ # protected resource metadata define the least-privilege request.
354
+ def resolve_scope(scope, challenge_scope, resource_metadata)
355
+ challenged = challenge_scope.to_s.split.uniq
356
+ if scope
357
+ requested = scope.to_s.split.uniq
358
+ missing = challenged - requested
359
+ unless missing.empty?
360
+ raise ConfigurationError,
361
+ "scope: must include every scope required by the MCP challenge"
362
+ end
363
+
364
+ return requested.empty? ? nil : requested.join(" ")
365
+ end
366
+ return challenged.join(" ") unless challenged.empty?
367
+
368
+ supported = resource_metadata["scopes_supported"]
369
+ return nil if supported.nil?
370
+ unless supported.is_a?(Array) && supported.all?(String)
371
+ raise Error, "protected resource scopes_supported is malformed"
372
+ end
373
+
374
+ supported.empty? ? nil : supported.uniq.join(" ")
375
+ end
376
+
377
+ def validate_endpoints(metadata, allow_non_public: nil)
378
+ %w[authorization_endpoint token_endpoint].each do |required|
379
+ raise Error, "authorization server metadata has no #{required}" unless metadata[required]
380
+ end
178
381
  %w[authorization_endpoint token_endpoint registration_endpoint].each do |key|
179
382
  value = metadata[key] or next
180
- uri = URI(value)
181
- next if uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(uri.host)
182
-
183
- raise Error, "#{key} #{value} is not HTTPS"
383
+ Egress.target(value, allow_non_public:, label: key)
184
384
  end
185
385
  end
186
386
 
387
+ def validate_pkce(metadata)
388
+ methods = metadata["code_challenge_methods_supported"]
389
+ return if methods.is_a?(Array) && methods.include?("S256")
390
+
391
+ raise Error, "authorization server does not advertise S256 PKCE support"
392
+ end
393
+
394
+ def validate_token_auth_method(method)
395
+ return if TOKEN_AUTH_METHODS.include?(method)
396
+
397
+ raise Error, "unsupported token endpoint authentication method"
398
+ end
399
+
187
400
  def presence(value)
188
401
  value.to_s.strip.empty? ? nil : value
189
402
  end
@@ -196,91 +409,227 @@ module Mistri
196
409
  "resource" => grant[:resource] }
197
410
  params["scope"] = grant[:scope] if grant[:scope]
198
411
  endpoint = URI(metadata.fetch("authorization_endpoint"))
199
- endpoint.query = [endpoint.query, URI.encode_www_form(params)].compact.join("&")
412
+ existing = URI.decode_www_form(endpoint.query.to_s)
413
+ existing.reject! { |key, _value| AUTHORIZE_PARAMETERS.include?(key) }
414
+ endpoint.query = URI.encode_www_form(existing + params.to_a)
200
415
  endpoint.to_s
201
416
  end
202
417
 
203
418
  # -- plumbing ----------------------------------------------------------
204
419
 
205
- # RFC 8707 canonical form: lowercase scheme and host, no fragment.
420
+ # RFC 8707 canonical form: lowercase scheme and host, with no default
421
+ # port or semantically empty root path.
206
422
  def canonical(url)
207
- uri = URI(url)
208
- uri.fragment = nil
209
- uri.scheme = uri.scheme.downcase
210
- uri.host = uri.host.downcase if uri.host
211
- uri.to_s
423
+ Egress.normalize(url, "resource").to_s
212
424
  end
213
425
 
214
- def token_request(endpoint, form, client_secret, auth_method = nil)
215
- basic = client_secret && auth_method == "client_secret_basic"
216
- form = form.merge("client_secret" => client_secret) if client_secret && !basic
217
- credentials = basic ? [form["client_id"], client_secret] : nil
218
- payload = post_form(endpoint, form, basic_auth: credentials)
426
+ def token_request(endpoint, form, client_secret, auth_method = nil, allow_non_public: nil)
427
+ # 0.5.0 persisted no method and sent the secret in the form. New flows
428
+ # always persist an explicit method; keep old connected rows usable.
429
+ auth_method ||= client_secret ? "client_secret_post" : "none"
430
+ validate_token_auth_method(auth_method)
431
+ form, credentials = token_authentication(form, client_secret, auth_method)
432
+ payload = post_form(endpoint, form, basic_auth: credentials,
433
+ allow_non_public: allow_non_public)
434
+ validate_token_response(payload)
435
+
219
436
  expires_in = payload["expires_in"]
220
437
  {
221
- "access_token" => payload.fetch("access_token"),
438
+ "access_token" => payload["access_token"],
222
439
  "refresh_token" => payload["refresh_token"],
223
440
  "scope" => payload["scope"],
224
441
  "expires_at" => expires_in ? Time.now.utc + expires_in.to_i : nil
225
442
  }
226
443
  end
227
444
 
228
- def get_json(url)
229
- uri = URI(url)
230
- response = http(uri) { |connection| connection.request(Net::HTTP::Get.new(uri)) }
231
- raise Error, "GET #{url} answered #{response.code}" unless response.code.to_i == 200
445
+ def token_authentication(form, client_secret, auth_method)
446
+ if auth_method == "none" && client_secret
447
+ raise Error, "token_endpoint_auth_method none cannot use a client_secret"
448
+ end
232
449
 
233
- JSON.parse(response.body)
234
- end
450
+ credentials = nil
451
+ case auth_method
452
+ when "client_secret_basic"
453
+ raise Error, "client_secret_basic requires a client_secret" unless client_secret
235
454
 
236
- def try_json(url)
237
- get_json(url)
238
- rescue Error, JSON::ParserError
239
- nil
455
+ credentials = [form["client_id"], client_secret].map do |part|
456
+ URI.encode_www_form_component(part)
457
+ end
458
+ when "client_secret_post"
459
+ raise Error, "client_secret_post requires a client_secret" unless client_secret
460
+
461
+ form = form.merge("client_secret" => client_secret)
462
+ end
463
+
464
+ [form, credentials]
240
465
  end
241
466
 
242
- def post_json(url, body)
243
- uri = URI(url)
244
- response = http(uri) do |connection|
245
- request = Net::HTTP::Post.new(uri)
246
- request["Content-Type"] = "application/json"
247
- request.body = JSON.generate(body)
248
- connection.request(request)
467
+ def validate_token_response(payload)
468
+ token_type = payload["token_type"]
469
+ unless token_type.is_a?(String) && token_type.casecmp?("Bearer")
470
+ raise Error, "token endpoint returned unsupported token_type"
249
471
  end
472
+
473
+ access_token = payload["access_token"]
474
+ return if access_token.is_a?(String) && !access_token.strip.empty?
475
+
476
+ raise Error, "token endpoint returned no access_token"
477
+ end
478
+
479
+ def get_json(url, allow_non_public: nil, optional: false)
480
+ targets = Egress.targets(url, allow_non_public:, label: "metadata URL")
481
+ location = Egress.display(targets.first.uri)
482
+ request = Net::HTTP::Get.new(targets.first.uri)
483
+ request["Accept"] = "application/json"
484
+ response = http(targets, request)
485
+ return nil if optional && response.code.to_i != 200
486
+ raise Error, "GET #{location} answered #{response.code}" unless response.code.to_i == 200
487
+
488
+ object = JSON.parse(response.body)
489
+ raise Error, "GET #{location} did not return a JSON object" unless object.is_a?(Hash)
490
+
491
+ object
492
+ rescue ConnectionFailure
493
+ return nil if optional
494
+
495
+ raise
496
+ rescue JSON::ParserError
497
+ raise Error, "GET #{location} returned invalid JSON"
498
+ end
499
+
500
+ def post_json(url, body, allow_non_public: nil)
501
+ targets = Egress.targets(url, allow_non_public:, label: "registration_endpoint")
502
+ location = Egress.display(targets.first.uri)
503
+ request = Net::HTTP::Post.new(targets.first.uri)
504
+ request["Content-Type"] = "application/json"
505
+ request["Accept"] = "application/json"
506
+ request.body = JSON.generate(body)
507
+ response = http(targets, request)
250
508
  unless %w[200 201].include?(response.code)
251
- raise Error, "POST #{url} answered #{response.code}: #{response.body.to_s[0, 200]}"
509
+ raise Error, "POST #{location} answered #{response.code}"
252
510
  end
253
511
 
254
- JSON.parse(response.body)
512
+ object = JSON.parse(response.body)
513
+ raise Error, "registration endpoint did not return a JSON object" unless object.is_a?(Hash)
514
+
515
+ object
516
+ rescue JSON::ParserError
517
+ raise Error, "registration endpoint returned invalid JSON"
255
518
  end
256
519
 
257
- def post_form(url, form, basic_auth: nil)
258
- uri = URI(url)
259
- response = http(uri) do |connection|
260
- request = Net::HTTP::Post.new(uri)
261
- request["Content-Type"] = "application/x-www-form-urlencoded"
262
- request["Accept"] = "application/json"
263
- request.basic_auth(*basic_auth) if basic_auth
264
- request.body = URI.encode_www_form(form)
265
- connection.request(request)
266
- end
520
+ def post_form(url, form, basic_auth: nil, allow_non_public: nil)
521
+ targets = Egress.targets(url, allow_non_public:, label: "token_endpoint")
522
+ request = Net::HTTP::Post.new(targets.first.uri)
523
+ request["Content-Type"] = "application/x-www-form-urlencoded"
524
+ request["Accept"] = "application/json"
525
+ request.basic_auth(*basic_auth) if basic_auth
526
+ request.body = URI.encode_www_form(form)
527
+ response = http(targets, request)
267
528
  payload = begin
268
529
  JSON.parse(response.body)
269
- rescue StandardError
530
+ rescue JSON::ParserError
531
+ raise Error, "token endpoint returned invalid JSON" if response.code.to_i == 200
532
+
270
533
  {}
271
534
  end
535
+ raise Error, "token endpoint did not return a JSON object" unless payload.is_a?(Hash)
536
+
272
537
  unless response.code.to_i == 200
273
- reason = payload["error_description"] || payload["error"] || response.body.to_s[0, 200]
274
- raise Error, "token request failed (#{response.code}): #{reason}"
538
+ reason = payload["error"]
539
+ reason = nil unless TOKEN_ERROR_CODES.include?(reason)
540
+ detail = reason ? ": #{reason}" : ""
541
+ raise Error, "token request failed (#{response.code})#{detail}"
275
542
  end
276
-
277
543
  payload
278
544
  end
279
545
 
280
- def http(uri, &)
281
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
282
- open_timeout: 15, read_timeout: 30, &)
546
+ def http(targets, request)
547
+ request["Accept-Encoding"] = "identity"
548
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
549
+ targets.each do |target|
550
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
551
+ break unless remaining.positive?
552
+
553
+ connection = oauth_connection(target, open_timeout: remaining)
554
+ begin
555
+ connection.start
556
+ rescue IOError, SocketError, SystemCallError, Timeout::Error,
557
+ Net::HTTPBadResponse, OpenSSL::SSL::SSLError
558
+ next
559
+ end
560
+
561
+ begin
562
+ return bounded_response(connection, request)
563
+ rescue IOError, SocketError, SystemCallError, Timeout::Error,
564
+ Net::HTTPBadResponse, OpenSSL::SSL::SSLError
565
+ raise ConnectionFailure, "OAuth connection failed"
566
+ ensure
567
+ finish_connection(connection)
568
+ end
569
+ end
570
+ raise ConnectionFailure, "OAuth connection failed"
571
+ end
572
+
573
+ def oauth_connection(target, open_timeout:)
574
+ Net::HTTP.new(target.uri.hostname, target.uri.port, nil).tap do |connection|
575
+ connection.ipaddr = target.address
576
+ connection.use_ssl = target.uri.scheme == "https"
577
+ connection.open_timeout = open_timeout
578
+ connection.read_timeout = 30
579
+ connection.max_retries = 0
580
+ end
581
+ end
582
+
583
+ def bounded_response(connection, request)
584
+ result = nil
585
+ connection.request(request) do |response|
586
+ body = +""
587
+ response.read_body do |chunk|
588
+ if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
589
+ raise Error, "OAuth response exceeded #{MAX_RESPONSE_BYTES} bytes"
590
+ end
591
+
592
+ body << chunk
593
+ end
594
+ headers = response.to_hash.transform_values { |values| values.join(", ") }
595
+ result = Response.new(code: response.code, headers: headers, body: body)
596
+ end
597
+ result
598
+ end
599
+
600
+ def finish_connection(connection)
601
+ connection.finish if connection.started?
602
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
603
+ nil
604
+ end
605
+
606
+ def validate_resource(candidate, expected)
607
+ raise Error, "protected resource metadata has no resource" unless candidate.is_a?(String)
608
+
609
+ resource = canonical(candidate)
610
+ return if resource == canonical(expected)
611
+
612
+ raise Error,
613
+ "protected resource metadata resource does not match " \
614
+ "#{Egress.display(URI(expected))}"
615
+ end
616
+
617
+ def parse_www_authenticate(header)
618
+ match = header.to_s.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
619
+ return {} unless match
620
+
621
+ cursor = match.end(0)
622
+ params = {}
623
+ while cursor < header.length
624
+ rest = header[cursor..].sub(/\A\s*,?\s*/, "")
625
+ pair = rest.match(WWW_AUTH_PARAM)
626
+ break unless pair
627
+
628
+ params[pair[1].downcase] = pair[2] ? pair[2].gsub(/\\(.)/, '\\1') : pair[3]
629
+ cursor = header.length - rest.length + pair.end(0)
630
+ end
631
+ params
283
632
  end
284
- end
633
+ end # rubocop:enable Metrics/ModuleLength
285
634
  end
286
635
  end