datagrout-conduit 0.1.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.
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module DatagroutConduit
6
+ # mTLS client identity for Conduit connections.
7
+ #
8
+ # Holds the client certificate and private key presented during every TLS
9
+ # handshake. The server verifies the caller's identity without any
10
+ # application-layer token.
11
+ #
12
+ # == Auto-discovery order (try_discover)
13
+ #
14
+ # 1. +override_dir+ (if provided)
15
+ # 2. +CONDUIT_MTLS_CERT+ / +CONDUIT_MTLS_KEY+ env vars (PEM strings)
16
+ # 3. +CONDUIT_IDENTITY_DIR+ env var → directory with identity.pem + identity_key.pem
17
+ # 4. +~/.conduit/identity.pem+ + +identity_key.pem+
18
+ # 5. +.conduit/+ relative to cwd
19
+ class Identity
20
+ attr_reader :cert_pem, :key_pem, :ca_pem, :expires_at
21
+
22
+ def initialize(cert_pem:, key_pem:, ca_pem: nil, expires_at: nil)
23
+ validate_cert!(cert_pem)
24
+ validate_key!(key_pem)
25
+ @cert_pem = cert_pem
26
+ @key_pem = key_pem
27
+ @ca_pem = ca_pem
28
+ @expires_at = expires_at
29
+ end
30
+
31
+ # Build from PEM strings already in memory.
32
+ def self.from_pem(cert_pem, key_pem, ca_pem: nil)
33
+ new(cert_pem: cert_pem, key_pem: key_pem, ca_pem: ca_pem)
34
+ end
35
+
36
+ # Build by reading PEM files from disk.
37
+ def self.from_paths(cert_path, key_path, ca_path: nil)
38
+ cert_pem = File.read(cert_path)
39
+ key_pem = File.read(key_path)
40
+ ca_pem = ca_path ? File.read(ca_path) : nil
41
+ new(cert_pem: cert_pem, key_pem: key_pem, ca_pem: ca_pem)
42
+ rescue Errno::ENOENT => e
43
+ raise ConfigError, "Cannot read identity file: #{e.message}"
44
+ end
45
+
46
+ # Build from environment variables.
47
+ #
48
+ # Variables:
49
+ # - CONDUIT_MTLS_CERT — PEM string for the client certificate
50
+ # - CONDUIT_MTLS_KEY — PEM string for the private key
51
+ # - CONDUIT_MTLS_CA — PEM string for the CA (optional)
52
+ #
53
+ # Returns nil if CONDUIT_MTLS_CERT is not set.
54
+ def self.from_env
55
+ cert = ENV["CONDUIT_MTLS_CERT"]
56
+ return nil if cert.nil? || cert.empty?
57
+
58
+ key = ENV["CONDUIT_MTLS_KEY"]
59
+ raise ConfigError, "CONDUIT_MTLS_CERT is set but CONDUIT_MTLS_KEY is missing" if key.nil? || key.empty?
60
+
61
+ ca = ENV["CONDUIT_MTLS_CA"]
62
+ ca = nil if ca && ca.empty?
63
+
64
+ new(cert_pem: cert, key_pem: key, ca_pem: ca)
65
+ end
66
+
67
+ # Walk the auto-discovery chain and return the first identity found,
68
+ # or nil if nothing is available.
69
+ def self.try_discover(override_dir: nil)
70
+ # 1. Override directory
71
+ if override_dir
72
+ id = try_load_from_dir(override_dir)
73
+ return id if id
74
+ end
75
+
76
+ # 2. Environment variables (individual cert/key PEMs)
77
+ id = from_env
78
+ return id if id
79
+
80
+ # 3. CONDUIT_IDENTITY_DIR env var
81
+ identity_dir = ENV["CONDUIT_IDENTITY_DIR"]
82
+ if identity_dir && !identity_dir.empty?
83
+ id = try_load_from_dir(identity_dir)
84
+ return id if id
85
+ end
86
+
87
+ # 4. ~/.conduit/
88
+ home = ENV["HOME"] || ENV["USERPROFILE"]
89
+ if home
90
+ id = try_load_from_dir(File.join(home, ".conduit"))
91
+ return id if id
92
+ end
93
+
94
+ # 5. .conduit/ relative to cwd
95
+ id = try_load_from_dir(File.join(Dir.pwd, ".conduit"))
96
+ return id if id
97
+
98
+ nil
99
+ rescue ConfigError
100
+ nil
101
+ end
102
+
103
+ def with_expiry(expires_at)
104
+ dup.tap { |i| i.instance_variable_set(:@expires_at, expires_at) }
105
+ end
106
+
107
+ # Returns true if the certificate expires within +threshold_days+.
108
+ # Returns false when no expiry is known.
109
+ def needs_rotation?(threshold_days: 30)
110
+ return false if @expires_at.nil?
111
+
112
+ deadline = Time.now + (threshold_days * 86_400)
113
+ deadline > @expires_at
114
+ end
115
+
116
+ # Return an OpenSSL::X509::Certificate for use with Faraday SSL config.
117
+ def openssl_cert
118
+ OpenSSL::X509::Certificate.new(@cert_pem)
119
+ end
120
+
121
+ # Return an OpenSSL::PKey for use with Faraday SSL config.
122
+ def openssl_key
123
+ OpenSSL::PKey.read(@key_pem)
124
+ end
125
+
126
+ # Return an OpenSSL::X509::Certificate for the CA, if present.
127
+ def openssl_ca
128
+ @ca_pem ? OpenSSL::X509::Certificate.new(@ca_pem) : nil
129
+ end
130
+
131
+ # Configure Faraday SSL options with this identity's mTLS credentials.
132
+ def configure_ssl(ssl)
133
+ ssl.client_cert = openssl_cert
134
+ ssl.client_key = openssl_key
135
+ if @ca_pem
136
+ store = OpenSSL::X509::Store.new
137
+ store.add_cert(openssl_ca)
138
+ ssl.cert_store = store
139
+ end
140
+ end
141
+
142
+ class << self
143
+ private
144
+
145
+ def try_load_from_dir(dir)
146
+ cert_path = File.join(dir, "identity.pem")
147
+ key_path = File.join(dir, "identity_key.pem")
148
+ return nil unless File.exist?(cert_path) && File.exist?(key_path)
149
+
150
+ ca_path = File.join(dir, "ca.pem")
151
+ ca = File.exist?(ca_path) ? ca_path : nil
152
+
153
+ from_paths(cert_path, key_path, ca_path: ca)
154
+ rescue ConfigError
155
+ nil
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ def validate_cert!(pem)
162
+ unless pem.include?("-----BEGIN CERTIFICATE-----")
163
+ raise ConfigError, "cert_pem does not appear to contain a PEM certificate"
164
+ end
165
+ end
166
+
167
+ def validate_key!(pem)
168
+ valid = pem.include?("-----BEGIN PRIVATE KEY-----") ||
169
+ pem.include?("-----BEGIN RSA PRIVATE KEY-----") ||
170
+ pem.include?("-----BEGIN EC PRIVATE KEY-----") ||
171
+ pem.include?("-----BEGIN ENCRYPTED PRIVATE KEY-----")
172
+ raise ConfigError, "key_pem does not appear to contain a PEM private key" unless valid
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+
6
+ module DatagroutConduit
7
+ module OAuth
8
+ # Lazily fetches and caches OAuth 2.1 client_credentials tokens.
9
+ # Thread-safe via Mutex.
10
+ class TokenProvider
11
+ REFRESH_BUFFER_SECONDS = 60
12
+
13
+ attr_reader :client_id, :token_endpoint
14
+
15
+ def initialize(client_id:, client_secret:, token_endpoint:, scope: nil)
16
+ @client_id = client_id
17
+ @client_secret = client_secret
18
+ @token_endpoint = token_endpoint
19
+ @scope = scope
20
+ @mutex = Mutex.new
21
+ @cached_token = nil
22
+ @expires_at = nil
23
+ end
24
+
25
+ # Derive the token endpoint from an MCP URL.
26
+ #
27
+ # "https://app.datagrout.ai/servers/abc/mcp"
28
+ # => "https://app.datagrout.ai/servers/abc/oauth/token"
29
+ def self.derive_token_endpoint(mcp_url)
30
+ idx = mcp_url.index("/mcp")
31
+ base = idx ? mcp_url[0...idx] : mcp_url.chomp("/")
32
+ "#{base}/oauth/token"
33
+ end
34
+
35
+ # Return a valid bearer token, fetching or refreshing as needed.
36
+ def get_token
37
+ @mutex.synchronize do
38
+ return @cached_token if token_valid?
39
+
40
+ fetch_token!
41
+ @cached_token
42
+ end
43
+ end
44
+
45
+ # Force-invalidate the cached token (e.g. on receipt of a 401).
46
+ def invalidate!
47
+ @mutex.synchronize do
48
+ @cached_token = nil
49
+ @expires_at = nil
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def token_valid?
56
+ @cached_token && @expires_at && (Time.now < @expires_at - REFRESH_BUFFER_SECONDS)
57
+ end
58
+
59
+ def fetch_token!
60
+ conn = Faraday.new(url: @token_endpoint) do |f|
61
+ f.request :url_encoded
62
+ f.adapter Faraday.default_adapter
63
+ end
64
+
65
+ params = {
66
+ "grant_type" => "client_credentials",
67
+ "client_id" => @client_id,
68
+ "client_secret" => @client_secret
69
+ }
70
+ params["scope"] = @scope if @scope
71
+
72
+ response = conn.post { |req| req.body = params }
73
+
74
+ unless response.success?
75
+ raise AuthError, "OAuth token endpoint returned #{response.status}: #{response.body}"
76
+ end
77
+
78
+ data = JSON.parse(response.body)
79
+ @cached_token = data["access_token"]
80
+ expires_in = (data["expires_in"] || 3600).to_i
81
+ @expires_at = Time.now + expires_in
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "fileutils"
6
+ require "faraday"
7
+ require "json"
8
+
9
+ module DatagroutConduit
10
+ # Substrate identity registration with the DataGrout CA.
11
+ #
12
+ # Handles the issuance flow — turning a freshly-generated keypair into a
13
+ # DG-CA-signed Identity that DataGrout will accept for mTLS.
14
+ #
15
+ # == Flow
16
+ #
17
+ # 1. Generate an ECDSA P-256 keypair with {.generate_keypair}.
18
+ # The private key never leaves the client.
19
+ # 2. Send the *public key* to the DataGrout CA via {.register_identity}
20
+ # (authenticated with a bearer token — user access token or API key).
21
+ # 3. Persist the returned identity to +~/.conduit/+ via {.save_identity}
22
+ # for auto-discovery by future sessions.
23
+ # 4. On renewal (cert near expiry), call {.rotate_identity} which presents
24
+ # the *existing* client certificate over mTLS — no API key needed.
25
+ class Registration
26
+ DG_CA_URL = "https://ca.datagrout.ai/ca.pem"
27
+ DG_SUBSTRATE_ENDPOINT = "https://app.datagrout.ai/api/v1/substrate/identity"
28
+
29
+ # Generate an ECDSA P-256 keypair.
30
+ #
31
+ # @return [Array(String, String)] +[private_key_pem, public_key_pem]+
32
+ def self.generate_keypair
33
+ key = OpenSSL::PKey::EC.generate("prime256v1")
34
+ private_pem = key.to_pem
35
+
36
+ public_pem = if key.respond_to?(:public_to_pem)
37
+ key.public_to_pem
38
+ else
39
+ pub = OpenSSL::PKey::EC.new(key.group)
40
+ pub.public_key = key.public_key
41
+ pub.to_pem
42
+ end
43
+
44
+ [private_pem, public_pem]
45
+ end
46
+
47
+ # Register identity with the DataGrout CA.
48
+ #
49
+ # Sends only the public key. The private key never leaves the client.
50
+ # Authenticated with a bearer token (user access token or API key).
51
+ #
52
+ # @param public_key_pem [String] PEM-encoded public key
53
+ # @param auth_token [String] bearer token for authentication
54
+ # @param name [String] human-readable label for the substrate instance
55
+ # @param substrate_endpoint [String] registration endpoint URL
56
+ # @return [RegistrationResponse]
57
+ def self.register_identity(public_key_pem, auth_token:, name: "conduit-client",
58
+ substrate_endpoint: DG_SUBSTRATE_ENDPOINT)
59
+ conn = Faraday.new(url: substrate_endpoint) do |f|
60
+ f.request :json
61
+ f.response :json, content_type: /\bjson$/
62
+ f.adapter Faraday.default_adapter
63
+ end
64
+
65
+ response = conn.post do |req|
66
+ req.url "register"
67
+ req.headers["Authorization"] = "Bearer #{auth_token}"
68
+ req.headers["Content-Type"] = "application/json"
69
+ req.body = JSON.generate(
70
+ name: name,
71
+ public_key_pem: public_key_pem
72
+ )
73
+ end
74
+
75
+ unless response.success?
76
+ raise AuthError, "Registration failed (HTTP #{response.status}): #{response.body}"
77
+ end
78
+
79
+ body = response.body
80
+ body = JSON.parse(body) if body.is_a?(String)
81
+
82
+ RegistrationResponse.new(
83
+ id: body["id"],
84
+ cert_pem: body["cert_pem"],
85
+ ca_cert_pem: body["ca_cert_pem"],
86
+ fingerprint: body["fingerprint"],
87
+ name: body["name"],
88
+ registered_at: body["registered_at"],
89
+ valid_until: body["valid_until"]
90
+ )
91
+ end
92
+
93
+ # Rotate identity using existing mTLS cert.
94
+ #
95
+ # Generates a new public key, sends it to the +/rotate+ endpoint
96
+ # authenticated by the *current* cert over mTLS (no API key needed),
97
+ # and returns a fresh DG-CA-signed certificate.
98
+ #
99
+ # @param identity [Identity] current mTLS identity for authentication
100
+ # @param new_public_key_pem [String] PEM-encoded new public key
101
+ # @param name [String] human-readable label
102
+ # @param substrate_endpoint [String] registration endpoint URL
103
+ # @return [RegistrationResponse]
104
+ def self.rotate_identity(identity, new_public_key_pem, name: "conduit-client",
105
+ substrate_endpoint: DG_SUBSTRATE_ENDPOINT)
106
+ conn = Faraday.new(url: substrate_endpoint) do |f|
107
+ f.request :json
108
+ f.response :json, content_type: /\bjson$/
109
+ f.adapter Faraday.default_adapter
110
+ identity.configure_ssl(f.ssl)
111
+ end
112
+
113
+ response = conn.post do |req|
114
+ req.url "rotate"
115
+ req.headers["Content-Type"] = "application/json"
116
+ req.body = JSON.generate(
117
+ name: name,
118
+ public_key_pem: new_public_key_pem
119
+ )
120
+ end
121
+
122
+ unless response.success?
123
+ raise ConnectionError, "Rotation failed (HTTP #{response.status}): #{response.body}"
124
+ end
125
+
126
+ body = response.body
127
+ body = JSON.parse(body) if body.is_a?(String)
128
+
129
+ RegistrationResponse.new(
130
+ id: body["id"],
131
+ cert_pem: body["cert_pem"],
132
+ ca_cert_pem: body["ca_cert_pem"],
133
+ fingerprint: body["fingerprint"],
134
+ name: body["name"],
135
+ registered_at: body["registered_at"],
136
+ valid_until: body["valid_until"]
137
+ )
138
+ end
139
+
140
+ # Save identity files to a directory with secure permissions (0600).
141
+ #
142
+ # @param cert_pem [String] DG-signed certificate PEM
143
+ # @param key_pem [String] private key PEM
144
+ # @param dir [String] directory path
145
+ # @param ca_pem [String, nil] CA certificate PEM
146
+ # @return [Hash] paths to written files (+:cert+, +:key+, +:ca+)
147
+ def self.save_identity(cert_pem, key_pem, dir, ca_pem: nil)
148
+ FileUtils.mkdir_p(dir)
149
+
150
+ cert_path = File.join(dir, "identity.pem")
151
+ key_path = File.join(dir, "identity_key.pem")
152
+
153
+ File.write(cert_path, cert_pem)
154
+ File.write(key_path, key_pem)
155
+ File.chmod(0o600, cert_path)
156
+ File.chmod(0o600, key_path)
157
+
158
+ paths = { cert: cert_path, key: key_path }
159
+
160
+ if ca_pem
161
+ ca_path = File.join(dir, "ca.pem")
162
+ File.write(ca_path, ca_pem)
163
+ File.chmod(0o600, ca_path)
164
+ paths[:ca] = ca_path
165
+ end
166
+
167
+ paths
168
+ end
169
+
170
+ # Fetch the DataGrout CA certificate from +ca.datagrout.ai+.
171
+ #
172
+ # Uses the system trust store for TLS (not the DG CA itself), so there
173
+ # is no circularity.
174
+ #
175
+ # @param ca_url [String] URL to fetch the CA cert from
176
+ # @return [String] PEM-encoded CA certificate
177
+ def self.fetch_ca_cert(ca_url: DG_CA_URL)
178
+ response = Faraday.get(ca_url)
179
+
180
+ unless response.success?
181
+ raise ConnectionError, "Failed to fetch CA cert (HTTP #{response.status})"
182
+ end
183
+
184
+ pem = response.body
185
+ unless pem.include?("-----BEGIN CERTIFICATE-----")
186
+ raise ConnectionError, "Response from #{ca_url} does not look like a PEM certificate"
187
+ end
188
+
189
+ pem
190
+ end
191
+
192
+ # Refresh CA cert in the given directory.
193
+ #
194
+ # @param dir [String] directory to write +ca.pem+ into
195
+ # @param ca_url [String] URL to fetch the CA cert from
196
+ # @return [String] path to the written +ca.pem+ file
197
+ def self.refresh_ca_cert(dir, ca_url: DG_CA_URL)
198
+ ca_pem = fetch_ca_cert(ca_url: ca_url)
199
+ FileUtils.mkdir_p(dir)
200
+ ca_path = File.join(dir, "ca.pem")
201
+ File.write(ca_path, ca_pem)
202
+ File.chmod(0o600, ca_path)
203
+ ca_path
204
+ end
205
+
206
+ # Returns +~/.conduit/+ as the canonical identity directory.
207
+ #
208
+ # @return [String, nil]
209
+ def self.default_identity_dir
210
+ home = ENV["HOME"] || ENV["USERPROFILE"]
211
+ home ? File.join(home, ".conduit") : nil
212
+ end
213
+ end
214
+
215
+ RegistrationResponse = Struct.new(
216
+ :id, :cert_pem, :ca_cert_pem, :fingerprint,
217
+ :name, :registered_at, :valid_until,
218
+ keyword_init: true
219
+ )
220
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "base64"
6
+ require "securerandom"
7
+
8
+ module DatagroutConduit
9
+ module Transport
10
+ # Base transport shared by MCP and JSONRPC transports.
11
+ # Manages a Faraday connection with optional mTLS and auth headers.
12
+ class Base
13
+ attr_reader :url
14
+
15
+ def initialize(url:, auth: {}, identity: nil)
16
+ @url = url
17
+ @auth = normalize_auth(auth)
18
+ @identity = identity
19
+ @connected = false
20
+ @connection = build_connection
21
+ end
22
+
23
+ def connect
24
+ URI.parse(@url) # validate URL
25
+ @connected = true
26
+ end
27
+
28
+ def disconnect
29
+ @connected = false
30
+ end
31
+
32
+ def connected?
33
+ @connected
34
+ end
35
+
36
+ # Subclasses must implement this.
37
+ def send_request(_method, _params = nil, id: nil)
38
+ raise NotImplementedError, "#{self.class}#send_request must be implemented"
39
+ end
40
+
41
+ private
42
+
43
+ def ensure_connected!
44
+ raise NotInitializedError unless @connected
45
+ end
46
+
47
+ def next_id
48
+ SecureRandom.uuid
49
+ end
50
+
51
+ def build_connection
52
+ Faraday.new(url: @url) do |f|
53
+ f.request :json
54
+ f.response :json, content_type: /\bjson$/
55
+ f.adapter Faraday.default_adapter
56
+
57
+ configure_ssl(f) if @identity
58
+ end
59
+ end
60
+
61
+ def configure_ssl(faraday)
62
+ faraday.ssl.client_cert = @identity.openssl_cert
63
+ faraday.ssl.client_key = @identity.openssl_key
64
+ if @identity.ca_pem
65
+ store = OpenSSL::X509::Store.new
66
+ store.add_cert(@identity.openssl_ca)
67
+ faraday.ssl.cert_store = store
68
+ end
69
+ end
70
+
71
+ def build_headers
72
+ headers = { "Content-Type" => "application/json" }
73
+
74
+ case @auth[:type]
75
+ when :bearer
76
+ headers["Authorization"] = "Bearer #{@auth[:token]}"
77
+ when :api_key
78
+ headers["X-API-Key"] = @auth[:key]
79
+ when :basic
80
+ encoded = Base64.strict_encode64("#{@auth[:username]}:#{@auth[:password]}")
81
+ headers["Authorization"] = "Basic #{encoded}"
82
+ when :oauth
83
+ token = @auth[:provider].get_token
84
+ headers["Authorization"] = "Bearer #{token}"
85
+ end
86
+
87
+ headers
88
+ end
89
+
90
+ def build_jsonrpc_body(method, params, id)
91
+ body = {
92
+ "jsonrpc" => "2.0",
93
+ "id" => id || next_id,
94
+ "method" => method
95
+ }
96
+ body["params"] = params if params
97
+ body
98
+ end
99
+
100
+ def handle_response(response)
101
+ check_rate_limit!(response)
102
+
103
+ if response.status == 401 && @auth[:type] == :oauth
104
+ @auth[:provider].invalidate!
105
+ return :retry_oauth
106
+ end
107
+
108
+ return { "accepted" => true } if response.status == 202
109
+
110
+ unless response.success?
111
+ raise ConnectionError, "HTTP #{response.status} error"
112
+ end
113
+
114
+ body = response.body
115
+ body = JSON.parse(body) if body.is_a?(String)
116
+
117
+ if body.is_a?(Hash) && body["error"]
118
+ err = body["error"]
119
+ raise McpError.new(
120
+ code: err["code"] || -1,
121
+ message: err["message"] || "Unknown error",
122
+ data: err["data"]
123
+ )
124
+ end
125
+
126
+ body
127
+ end
128
+
129
+ def check_rate_limit!(response)
130
+ return unless response.status == 429
131
+
132
+ used = response.headers["X-RateLimit-Used"]&.to_i || 0
133
+ limit_str = response.headers["X-RateLimit-Limit"] || "50"
134
+ limit = limit_str.casecmp("unlimited").zero? ? "unlimited" : limit_str.to_i
135
+
136
+ raise RateLimitedError.new(used: used, limit: limit)
137
+ end
138
+
139
+ def normalize_auth(auth)
140
+ return { type: :none } if auth.nil? || auth.empty?
141
+
142
+ auth = auth.transform_keys(&:to_sym) if auth.is_a?(Hash)
143
+
144
+ if auth[:bearer]
145
+ { type: :bearer, token: auth[:bearer] }
146
+ elsif auth[:api_key]
147
+ { type: :api_key, key: auth[:api_key] }
148
+ elsif auth[:basic]
149
+ { type: :basic, username: auth[:basic][:username], password: auth[:basic][:password] }
150
+ elsif auth[:oauth] || auth[:provider]
151
+ { type: :oauth, provider: auth[:oauth] || auth[:provider] }
152
+ else
153
+ { type: :none }
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatagroutConduit
4
+ module Transport
5
+ # JSON-RPC over HTTP POST transport.
6
+ # Sends standard JSON-RPC 2.0 requests via HTTP POST.
7
+ class JsonRpc < Base
8
+ def send_request(method, params = nil, id: nil)
9
+ ensure_connected!
10
+
11
+ request_id = id || next_id
12
+ body = build_jsonrpc_body(method, params, request_id)
13
+ headers = build_headers
14
+
15
+ response = @connection.post do |req|
16
+ req.headers = headers
17
+ req.body = JSON.generate(body)
18
+ end
19
+
20
+ result = handle_response(response)
21
+
22
+ if result == :retry_oauth
23
+ headers = build_headers
24
+ response = @connection.post do |req|
25
+ req.headers = headers
26
+ req.body = JSON.generate(body)
27
+ end
28
+ result = handle_response(response)
29
+ raise AuthError, "OAuth token rejected after refresh" if result == :retry_oauth
30
+ end
31
+
32
+ result
33
+ end
34
+ end
35
+ end
36
+ end