datagrout-conduit 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 177af76b0cdd7d7e82fd1384db89da6a5dd4b3152ff5f07e255075dec041a91c
4
- data.tar.gz: 34b1c4d17471013349db85beb09f167719e4fc0189204b117042c1394fc175f8
3
+ metadata.gz: c586cb9aae6cf748677d41c1b453bc4ed896e440b68d43451a50c5ed3333282d
4
+ data.tar.gz: bda496e4dc74051928c73115e6c69a23515e3b930ba82ccbe5bfedd12131e00b
5
5
  SHA512:
6
- metadata.gz: 6812db873347c88a86bfd0ecce380e6d264b4f66854d3eeb01e3ee4a28b89cf2b6e8f93423442ea9b0c14fcb40c68cd6bcc2d6210a5f343200115dd92343ff35
7
- data.tar.gz: 23deaeee4cfe9be72cc546b919e5a87cb5ea5fa108831c346a4e02b0faa501578b582632f538a42dd0456095a9b8589511e349318c95f414b086bed7a0d90980
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.5.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.5.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