mistri 0.4.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +596 -3
- data/CONTRIBUTING.md +52 -0
- data/README.md +291 -306
- data/SECURITY.md +40 -0
- data/UPGRADING.md +640 -0
- data/assets/logo-animated.svg +30 -0
- data/assets/logo-dark.svg +14 -0
- data/assets/logo-light.svg +14 -0
- data/assets/logo.svg +14 -0
- data/assets/social-preview.png +0 -0
- data/docs/README.md +87 -0
- data/docs/context-and-workspaces.md +378 -0
- data/docs/mcp.md +366 -0
- data/docs/reliability.md +450 -0
- data/docs/sessions.md +295 -0
- data/docs/sub-agents.md +401 -0
- data/docs/tool-contracts.md +324 -0
- data/examples/approval.rb +36 -0
- data/examples/browser.rb +27 -0
- data/examples/page_editor.rb +31 -0
- data/examples/quickstart.rb +21 -0
- data/lib/generators/mistri/install/install_generator.rb +7 -3
- data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
- data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +635 -108
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +186 -0
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +35 -12
- data/lib/mistri/console.rb +209 -0
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +49 -0
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +30 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- data/lib/mistri/mcp/client.rb +74 -19
- data/lib/mistri/mcp/egress.rb +216 -0
- data/lib/mistri/mcp/oauth.rb +476 -127
- data/lib/mistri/mcp/wires.rb +115 -23
- data/lib/mistri/mcp.rb +43 -9
- data/lib/mistri/message.rb +21 -11
- data/lib/mistri/models.rb +160 -22
- data/lib/mistri/providers/anthropic/assembler.rb +282 -44
- data/lib/mistri/providers/anthropic/serializer.rb +14 -9
- data/lib/mistri/providers/anthropic.rb +29 -6
- data/lib/mistri/providers/fake.rb +36 -6
- data/lib/mistri/providers/gemini/assembler.rb +148 -21
- data/lib/mistri/providers/gemini/serializer.rb +78 -9
- data/lib/mistri/providers/gemini.rb +31 -5
- data/lib/mistri/providers/openai/assembler.rb +337 -60
- data/lib/mistri/providers/openai/serializer.rb +13 -12
- data/lib/mistri/providers/openai.rb +29 -5
- data/lib/mistri/providers/schema_capabilities.rb +214 -0
- data/lib/mistri/result.rb +8 -3
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +649 -47
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +316 -0
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +22 -7
- data/lib/mistri/stores/jsonl.rb +3 -1
- data/lib/mistri/stores/memory.rb +21 -2
- data/lib/mistri/sub_agent/execution.rb +81 -0
- data/lib/mistri/sub_agent/runtime.rb +297 -0
- data/lib/mistri/sub_agent.rb +238 -103
- data/lib/mistri/task_output.rb +58 -0
- data/lib/mistri/tool.rb +102 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +7 -5
- data/lib/mistri/tool_executor.rb +117 -26
- data/lib/mistri/tool_result.rb +15 -10
- data/lib/mistri/tools/edit_file.rb +62 -8
- data/lib/mistri/tools.rb +41 -4
- data/lib/mistri/transport.rb +149 -44
- data/lib/mistri/usage.rb +65 -13
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +183 -3
- data/lib/mistri/workspace/directory.rb +28 -8
- data/lib/mistri/workspace/memory.rb +34 -9
- data/lib/mistri/workspace/single.rb +62 -5
- data/lib/mistri/workspace.rb +39 -0
- data/lib/mistri.rb +17 -1
- data/mistri.gemspec +34 -0
- metadata +38 -3
data/lib/mistri/mcp/oauth.rb
CHANGED
|
@@ -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: "
|
|
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,
|
|
35
|
-
#
|
|
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
|
|
38
|
-
# requested
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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:
|
|
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:,
|
|
66
|
-
|
|
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,
|
|
70
|
-
|
|
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:,
|
|
76
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
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
|
|
154
|
+
def resource_metadata_candidates(url)
|
|
110
155
|
uri = URI(url)
|
|
111
|
-
path = uri.path
|
|
112
|
-
|
|
113
|
-
"#{
|
|
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
|
-
|
|
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 =
|
|
126
|
-
|
|
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
|
|
136
|
-
|
|
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" =>
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
176
|
-
#
|
|
177
|
-
def
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 #{
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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["
|
|
274
|
-
|
|
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(
|
|
281
|
-
|
|
282
|
-
|
|
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
|