datagrout-conduit 0.6.0 → 0.8.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/README.md +102 -2
- data/lib/datagrout_conduit/authcode/loopback.rb +236 -0
- data/lib/datagrout_conduit/authcode.rb +845 -0
- data/lib/datagrout_conduit/delegation.rb +871 -0
- data/lib/datagrout_conduit/transport/base.rb +29 -2
- data/lib/datagrout_conduit/transport/mcp.rb +4 -1
- data/lib/datagrout_conduit/transport/ws.rb +75 -2
- data/lib/datagrout_conduit/version.rb +1 -1
- data/lib/datagrout_conduit.rb +8 -0
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c586cb9aae6cf748677d41c1b453bc4ed896e440b68d43451a50c5ed3333282d
|
|
4
|
+
data.tar.gz: bda496e4dc74051928c73115e6c69a23515e3b930ba82ccbe5bfedd12131e00b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 856a644371b034af39a9e6caf6ea15ff6c088c0d504c0ef22094f35f70f46eadf7bf6ed85967a14484872f7a8d49f1e2064400d34325c8700032dfbb3d45f134
|
|
7
|
+
data.tar.gz: 4f34ee08b4d4e126bde2a8b053ec2f9443ae31f1b1aa332eac6649d88944a736a3f31f6ac87880a685f246a4a68fab56c1bb86f720fa1e0adb5df6d08b2d48df
|
data/README.md
CHANGED
|
@@ -9,13 +9,13 @@ Connect to remote MCP and JSONRPC servers, invoke tools, discover capabilities w
|
|
|
9
9
|
Add to your Gemfile:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
|
-
gem "datagrout-conduit", "~> 0.
|
|
12
|
+
gem "datagrout-conduit", "~> 0.8.0"
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Or install directly:
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
gem install datagrout-conduit -v 0.
|
|
18
|
+
gem install datagrout-conduit -v 0.8.0
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Quick Start
|
|
@@ -81,6 +81,106 @@ client = DatagroutConduit::Client.new(
|
|
|
81
81
|
|
|
82
82
|
The token endpoint is auto-derived from MCP URLs — `/mcp` becomes `/oauth/token`. Tokens are cached and refreshed 60 seconds before expiry.
|
|
83
83
|
|
|
84
|
+
### OAuth 2.1 (authorization code + PKCE)
|
|
85
|
+
|
|
86
|
+
Client credentials authenticate a *machine*, with a secret issued out of band.
|
|
87
|
+
To authenticate a *person* — and to reach
|
|
88
|
+
`https://gateway.datagrout.ai/connect`, where the server binding is chosen at
|
|
89
|
+
consent time and lives in the token rather than the URL — run the
|
|
90
|
+
browser-consent flow once and persist the grant:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
AC = DatagroutConduit::AuthCode
|
|
94
|
+
|
|
95
|
+
listener = AC::LoopbackListener.bind # 127.0.0.1, OS-chosen port
|
|
96
|
+
flow = AC::Flow.discover("https://gateway.datagrout.ai/connect")
|
|
97
|
+
registered = flow.register("My App", listener.redirect_uri)
|
|
98
|
+
|
|
99
|
+
url, pending = flow.authorize_url
|
|
100
|
+
puts "Open this to sign in:\n#{url}" # the SDK never opens a browser
|
|
101
|
+
|
|
102
|
+
redirect = listener.wait(timeout: 300)
|
|
103
|
+
grant = flow.exchange(pending, redirect.code, redirect.state)
|
|
104
|
+
|
|
105
|
+
# Persist BOTH: a client id without its redirect URI cannot be reused, because
|
|
106
|
+
# the authorization server matches redirect URIs exactly.
|
|
107
|
+
save_somewhere(registered: registered.to_h, grant: grant.to_h)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
On later runs, skip straight to the grant:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
client = DatagroutConduit::Client.new(
|
|
114
|
+
url: "https://gateway.datagrout.ai/connect",
|
|
115
|
+
auth: { authorization_code: grant }
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
DataGrout **rotates refresh tokens**, so a grant that is refreshed and not
|
|
120
|
+
written back leaves a consumed token on disk. Own the provider when you care:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
provider = AC::Provider.new(grant)
|
|
124
|
+
client = DatagroutConduit::Client.new(url: url, auth: { authorization_code: provider })
|
|
125
|
+
|
|
126
|
+
# ...periodically, or once on shutdown:
|
|
127
|
+
rotated = provider.take_if_dirty
|
|
128
|
+
save_somewhere(registered: registered.to_h, grant: rotated.to_h) if rotated
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Where the grant lives is your decision — a keychain, a config file, a vault.
|
|
132
|
+
The SDK owns its shape and its refresh, and deliberately picks no location. The
|
|
133
|
+
shape is identical across every conduit SDK, so a grant written by the Python
|
|
134
|
+
client is readable by this one.
|
|
135
|
+
|
|
136
|
+
See [`examples/browser_signin.rb`](examples/browser_signin.rb) for a complete
|
|
137
|
+
run that registers, signs in, saves, and reuses.
|
|
138
|
+
|
|
139
|
+
### Delegation (RFC 8693 — an agent acting for a user)
|
|
140
|
+
|
|
141
|
+
Client credentials say *which machine* is calling; authorization code says
|
|
142
|
+
*which person* consented. An agent working on a user's behalf needs both, so
|
|
143
|
+
the resource server can audit, rate-limit and revoke the agent separately from
|
|
144
|
+
the user. An RFC 8693 exchange produces that token from two you already have:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
D = DatagroutConduit::Delegation
|
|
148
|
+
|
|
149
|
+
# The agent's own credential — the actor.
|
|
150
|
+
agent = DatagroutConduit::OAuth::TokenProvider.new(
|
|
151
|
+
client_id: "agent_client_id",
|
|
152
|
+
client_secret: "agent_client_secret",
|
|
153
|
+
token_endpoint: "https://gateway.datagrout.ai/oauth/token"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
request = D::Request.new(
|
|
157
|
+
token_endpoint: "https://gateway.datagrout.ai/oauth/token",
|
|
158
|
+
client_id: "agent_client_id",
|
|
159
|
+
client_secret: "agent_client_secret",
|
|
160
|
+
resource: "https://gateway.datagrout.ai/connect"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
provider = D::Provider.new(
|
|
164
|
+
request,
|
|
165
|
+
# The user's token — the subject. A long-lived app would pass
|
|
166
|
+
# D::TokenSource.authorization_code(ac_provider) instead.
|
|
167
|
+
subject: D::TokenSource.static_token(user_token),
|
|
168
|
+
actor: D::TokenSource.client_credentials(agent)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
client = DatagroutConduit::Client.new(
|
|
172
|
+
url: "https://gateway.datagrout.ai/connect",
|
|
173
|
+
auth: { delegation: provider }
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The issued token names the user as `sub` and the agent in an `act` claim. An
|
|
178
|
+
**actor token is required by default**; `impersonation: true` on the request is
|
|
179
|
+
the only way to omit it, and DataGrout does not issue impersonation tokens. The
|
|
180
|
+
provider caches the exchanged token, re-exchanges near expiry or after a 401,
|
|
181
|
+
and consults both token sources on every exchange — so an expiring upstream
|
|
182
|
+
credential is handled by the provider that owns it.
|
|
183
|
+
|
|
84
184
|
### mTLS (Mutual TLS)
|
|
85
185
|
|
|
86
186
|
```ruby
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module DatagroutConduit
|
|
7
|
+
module AuthCode
|
|
8
|
+
# What the authorization server sent back to the redirect URI.
|
|
9
|
+
class Redirect
|
|
10
|
+
attr_reader :code, :state
|
|
11
|
+
|
|
12
|
+
def initialize(code:, state:)
|
|
13
|
+
@code = code
|
|
14
|
+
@state = state
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Capture the OAuth redirect on +127.0.0.1+.
|
|
20
|
+
#
|
|
21
|
+
# A native app has no web server to redirect to, so it runs one for a few
|
|
22
|
+
# seconds: bind a loopback port, send the user to the consent page, and read
|
|
23
|
+
# the +code+ off the single request the browser makes coming back.
|
|
24
|
+
#
|
|
25
|
+
# This lives in its own file rather than in {AuthCode} so a headless caller
|
|
26
|
+
# can take the flow without a listener it will never bind — the same split
|
|
27
|
+
# every conduit SDK makes, so the surface looks the same in every language.
|
|
28
|
+
# It needs nothing beyond the standard library.
|
|
29
|
+
#
|
|
30
|
+
# listener = DatagroutConduit::AuthCode::LoopbackListener.bind
|
|
31
|
+
# flow = DatagroutConduit::AuthCode::Flow.discover(GATEWAY)
|
|
32
|
+
# flow.register("My App", listener.redirect_uri)
|
|
33
|
+
#
|
|
34
|
+
# url, pending = flow.authorize_url
|
|
35
|
+
# puts "Open: #{url}"
|
|
36
|
+
#
|
|
37
|
+
# redirect = listener.wait(timeout: 300)
|
|
38
|
+
# grant = flow.exchange(pending, redirect.code, redirect.state)
|
|
39
|
+
class LoopbackListener
|
|
40
|
+
# Most bytes read from a redirect request. A URL longer than this is not a
|
|
41
|
+
# redirect we can use.
|
|
42
|
+
MAX_REQUEST_BYTES = 8192
|
|
43
|
+
|
|
44
|
+
attr_reader :port
|
|
45
|
+
|
|
46
|
+
def initialize(server, port, path)
|
|
47
|
+
@server = server
|
|
48
|
+
@port = port
|
|
49
|
+
@path = path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Bind an OS-assigned port on +127.0.0.1+.
|
|
53
|
+
#
|
|
54
|
+
# Letting the OS choose avoids fighting whatever else owns a fixed port —
|
|
55
|
+
# and because registration happens after binding, the real port is already
|
|
56
|
+
# known by the time the redirect URI is registered.
|
|
57
|
+
def self.bind
|
|
58
|
+
bind_on(0, "/callback")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Bind a specific port and path.
|
|
62
|
+
#
|
|
63
|
+
# Use when the client was registered out of band against a fixed redirect
|
|
64
|
+
# URI and the authorization server will accept no other.
|
|
65
|
+
def self.bind_on(port, path)
|
|
66
|
+
server = begin
|
|
67
|
+
TCPServer.new("127.0.0.1", port)
|
|
68
|
+
rescue SystemCallError => e
|
|
69
|
+
raise HttpError, "cannot bind loopback port: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
normalized = path.to_s.start_with?("/") ? path.to_s : "/#{path}"
|
|
73
|
+
new(server, server.addr[1], normalized)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Re-bind the exact port and path of a previously registered redirect URI.
|
|
77
|
+
#
|
|
78
|
+
# Needed whenever a saved {RegisteredClient} is reused: the authorization
|
|
79
|
+
# server matches the redirect URI exactly, so the listener has to come
|
|
80
|
+
# back on the same port it registered.
|
|
81
|
+
#
|
|
82
|
+
# Raises if that port is occupied. The right recovery is to {bind} a fresh
|
|
83
|
+
# port and register a new client — not to retry, and not to authorize
|
|
84
|
+
# against a URI the server will reject.
|
|
85
|
+
def self.bind_for(redirect_uri)
|
|
86
|
+
parsed = begin
|
|
87
|
+
URI.parse(redirect_uri.to_s)
|
|
88
|
+
rescue URI::InvalidURIError => e
|
|
89
|
+
raise HttpError, "bad redirect_uri #{redirect_uri}: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raise HttpError, "bad redirect_uri #{redirect_uri}" unless parsed.scheme && parsed.host
|
|
93
|
+
|
|
94
|
+
# URI fills in the scheme's default port, so `parsed.port` cannot tell
|
|
95
|
+
# an explicit one from an absent one. The authority is what decides.
|
|
96
|
+
unless redirect_uri.to_s.include?("#{parsed.host}:#{parsed.port}")
|
|
97
|
+
raise HttpError, "redirect_uri #{redirect_uri} names no port"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
bind_on(parsed.port, parsed.path.empty? ? "/" : parsed.path)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The redirect URI to register and to send in the authorize request.
|
|
104
|
+
#
|
|
105
|
+
# Uses +127.0.0.1+ rather than +localhost+: RFC 8252 recommends the
|
|
106
|
+
# literal address, and it sidesteps hosts where +localhost+ resolves to
|
|
107
|
+
# IPv6 first while the listener is bound to IPv4.
|
|
108
|
+
def redirect_uri
|
|
109
|
+
"http://127.0.0.1:#{@port}#{@path}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Wait for the browser's redirect, up to +timeout+ seconds.
|
|
113
|
+
#
|
|
114
|
+
# Serves a small page either way so the user sees an outcome rather than a
|
|
115
|
+
# browser error, then stops listening. Requests to other paths are
|
|
116
|
+
# answered 404 and ignored — browsers routinely ask for +/favicon.ico+,
|
|
117
|
+
# and treating that as the redirect would abort the flow.
|
|
118
|
+
def wait(timeout: 300)
|
|
119
|
+
# A monotonic clock, because this measures an interval. Grant expiry is
|
|
120
|
+
# the opposite case and uses Unix seconds, so it survives being written
|
|
121
|
+
# down.
|
|
122
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
123
|
+
|
|
124
|
+
loop do
|
|
125
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
126
|
+
if remaining <= 0 || IO.select([@server], nil, nil, remaining).nil?
|
|
127
|
+
raise HttpError,
|
|
128
|
+
"timed out after #{timeout.round}s waiting for the authorization redirect"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
outcome = handle(@server.accept)
|
|
132
|
+
next if outcome.nil?
|
|
133
|
+
|
|
134
|
+
raise outcome if outcome.is_a?(StandardError)
|
|
135
|
+
|
|
136
|
+
return outcome
|
|
137
|
+
end
|
|
138
|
+
ensure
|
|
139
|
+
close
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Stop listening. Safe to call more than once.
|
|
143
|
+
def close
|
|
144
|
+
@server.close unless @server.closed?
|
|
145
|
+
rescue IOError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Serve one request. Returns a {Redirect} on success, an exception to
|
|
152
|
+
# raise, or nil when the request was not the redirect and we should keep
|
|
153
|
+
# waiting.
|
|
154
|
+
def handle(socket)
|
|
155
|
+
target = request_target(socket.readpartial(MAX_REQUEST_BYTES))
|
|
156
|
+
return nil if target.nil?
|
|
157
|
+
|
|
158
|
+
path, _, query = target.partition("?")
|
|
159
|
+
|
|
160
|
+
unless path == @path
|
|
161
|
+
respond(socket, 404, "Not found")
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
params = parse_query(query)
|
|
166
|
+
|
|
167
|
+
if params.key?("error")
|
|
168
|
+
respond(socket, 200, "Authorization was denied. You can close this window.")
|
|
169
|
+
return DeniedError.new(error: params["error"],
|
|
170
|
+
description: params["error_description"])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if params.key?("code") && params.key?("state")
|
|
174
|
+
respond(socket, 200, "Signed in. You can close this window and return to the app.")
|
|
175
|
+
return Redirect.new(code: params["code"], state: params["state"])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
respond(socket, 400, "Missing code or state.")
|
|
179
|
+
DiscoveryError.new("redirect carried neither an error nor a code/state pair")
|
|
180
|
+
rescue EOFError
|
|
181
|
+
nil
|
|
182
|
+
ensure
|
|
183
|
+
begin
|
|
184
|
+
socket.close
|
|
185
|
+
rescue IOError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The request target from a raw HTTP request line.
|
|
191
|
+
def request_target(request)
|
|
192
|
+
first = request.to_s.split(/\r?\n/, 2).first.to_s
|
|
193
|
+
parts = first.split
|
|
194
|
+
parts.length >= 2 ? parts[1] : nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Decode a query string.
|
|
198
|
+
#
|
|
199
|
+
# Authorization codes and state values are opaque and routinely contain
|
|
200
|
+
# characters that must survive a round trip through the query string, so
|
|
201
|
+
# +%XX+ escapes and +++ are decoded.
|
|
202
|
+
def parse_query(query)
|
|
203
|
+
query.to_s.split("&").each_with_object({}) do |pair, out|
|
|
204
|
+
key, sep, value = pair.partition("=")
|
|
205
|
+
next if sep.empty?
|
|
206
|
+
|
|
207
|
+
out[unescape(key)] = unescape(value)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def unescape(value)
|
|
212
|
+
value.tr("+", " ").gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }
|
|
213
|
+
.force_encoding(Encoding::UTF_8)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def respond(socket, status, message)
|
|
217
|
+
body = <<~HTML
|
|
218
|
+
<!DOCTYPE html><html><head><meta charset="utf-8"><title>DataGrout</title>
|
|
219
|
+
<style>body{font:15px/1.5 system-ui,sans-serif;margin:16vh auto;max-width:26rem;text-align:center;color-scheme:light dark}</style></head>
|
|
220
|
+
<body><p>#{message}</p></body></html>
|
|
221
|
+
HTML
|
|
222
|
+
|
|
223
|
+
socket.write(
|
|
224
|
+
"HTTP/1.1 #{status} OK\r\n" \
|
|
225
|
+
"content-type: text/html; charset=utf-8\r\n" \
|
|
226
|
+
"content-length: #{body.bytesize}\r\n" \
|
|
227
|
+
"connection: close\r\n\r\n#{body}"
|
|
228
|
+
)
|
|
229
|
+
socket.flush
|
|
230
|
+
rescue IOError, SystemCallError
|
|
231
|
+
# The browser may have closed already; the outcome is what matters.
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|