tungsten-runtime 0.1.2

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,343 @@
1
+ # frozen_string_literal: true
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ module Tungsten
5
+ module Runtime
6
+ class ClientCore
7
+ DEFAULT_MACRO_BUDGET_MS = 30_000
8
+ MACRO_PAGE_LIMIT = 100
9
+ SAFETY_RANK = { "read_only" => 0, "mutating" => 1, "destructive" => 2, "irreversible" => 3 }.freeze
10
+
11
+ # A macro run's one-time spend of its confirmation token: the first
12
+ # step about to send claims it.
13
+ class MacroClaim
14
+ def initialize(name, token, bind)
15
+ @name = name
16
+ @token = token
17
+ @bind = bind
18
+ @lock = Mutex.new
19
+ @claimed = false
20
+ end
21
+
22
+ def claim(core)
23
+ return nil if @token.nil?
24
+
25
+ @lock.synchronize do
26
+ return nil if @claimed
27
+
28
+ @claimed = true
29
+ core.send(:claim_token, @name, @token, @bind, "the macro's preview(...)")
30
+ end
31
+ end
32
+ end
33
+
34
+ # Run a macro by name (or descriptor) with its input keyed by the SDK's
35
+ # names: Ok with its output, or Err with the failing step's envelope
36
+ # and the completed steps' results as `partial`. A destructive or
37
+ # irreversible macro needs `confirm`.
38
+ def run_macro(macro, input = {}, opts = nil)
39
+ with_macro(macro, opts) { |m, o| run_macro_with(m, input, o) }
40
+ end
41
+
42
+ # Preview a macro without sending anything.
43
+ def preview_macro(macro, input = {}, opts = nil)
44
+ with_macro(macro, opts) { |m, o| preview_macro_with(m, input, o) }
45
+ end
46
+
47
+ private
48
+
49
+ def with_macro(macro, opts)
50
+ m = macro.is_a?(MacroDescriptor) ? macro : @descriptors.macro(macro.to_s)
51
+ unless m
52
+ return Runtime.diag(macro.to_s, "VALIDATION_FAILED").param("macro").expected("a macro name of this SDK")
53
+ .remedy("This SDK has no macro #{macro}; check the name. Nothing was sent.").err
54
+ end
55
+ begin
56
+ o = CallOptions.from(opts)
57
+ rescue ArgumentError => e
58
+ return Runtime.diag(m.name, "VALIDATION_FAILED").param("opts").expected("call options")
59
+ .remedy("Pass the call options as a Hash of idempotency_key, confirm, timeout_ms, verify and headers (#{e.message}).").err
60
+ end
61
+ yield m, o
62
+ end
63
+
64
+ def uses_key?(op) = %w[caller_owned auto].include?(op.agent["idempotency"]["policy"])
65
+
66
+ def macro_invalid(name, remediation)
67
+ Runtime.diag(name, "VALIDATION_FAILED").param("macro").expected("a valid macro descriptor").remedy(remediation).err
68
+ end
69
+
70
+ # The input keyed by the arguments layout keys, with the added
71
+ # fields' defaults, or an Err.
72
+ def macro_input(m, input)
73
+ converted =
74
+ begin
75
+ Json.from_ruby(input.nil? ? {} : input)
76
+ rescue ArgumentError
77
+ UNSET
78
+ end
79
+ if Util.nests_deeper?(converted, Util::MAX_ARG_DEPTH)
80
+ return Runtime.diag(m.name, "VALIDATION_FAILED").param("input")
81
+ .expected("a JSON value nested at most #{Util::MAX_ARG_DEPTH} levels")
82
+ .remedy("Pass the input of #{m.name} without such deep nesting.").err
83
+ end
84
+ unless converted.is_a?(Hash)
85
+ return Runtime.diag(m.name, "VALIDATION_FAILED").param("input").expected("an object")
86
+ .remedy("Pass the input of #{m.name} as one object.").err
87
+ end
88
+
89
+ keys = m.input_fields.to_h { |key, name, _| [name, key] }
90
+ effective = converted.to_h { |k, v| [keys.fetch(k, k), v] }
91
+ m.added.each do |key, schema|
92
+ effective[key] = schema["default"] if !effective.key?(key) && schema.is_a?(Hash) && schema.key?("default")
93
+ end
94
+ effective
95
+ end
96
+
97
+ # [[step, op], ...] and the strictest safety, or an Err.
98
+ def macro_plan(m)
99
+ safety = m.safety
100
+ steps = []
101
+ m.steps.each_with_index do |step, i|
102
+ op = operation(step["operation"].to_s)
103
+ return macro_invalid(m.name, "Step #{i + 1} of #{m.name} names #{step['operation']}, which is not registered with the client.") unless op
104
+
105
+ safety = op.agent["safety"] if SAFETY_RANK.fetch(op.agent["safety"], 0) > SAFETY_RANK.fetch(safety, 0)
106
+ steps << [step, op]
107
+ end
108
+ [steps, safety]
109
+ end
110
+
111
+ # A step's arguments keyed by the step operation's argument names.
112
+ def step_args(m, step, op, scope, pending)
113
+ source = step["args"].nil? ? {} : step["args"]
114
+ evaluated = pending ? Expr.evaluate_dry(source, scope, pending) : Expr.evaluate(source, scope)
115
+ evaluated = {} if UNSET.equal?(evaluated) || evaluated.nil?
116
+ evaluated = Json.copy(evaluated)
117
+ return evaluated unless evaluated.is_a?(Hash)
118
+
119
+ m.added.each_key { |key| evaluated.delete(key) } if op.id == m.extends
120
+ evaluated.to_h { |k, v| [op.name_of_key(k), v] }
121
+ end
122
+
123
+ def page_limit(step) = Util.bounded(step["max_pages"], MACRO_PAGE_LIMIT, 1, 10_000).floor
124
+
125
+ def poll_numbers(step, scope)
126
+ budget = Util.bounded(Expr.evaluate(step["budget_ms"], scope), DEFAULT_MACRO_BUDGET_MS, 0, Util::MAX_SAFE_INTEGER)
127
+ interval = Util.bounded(step["interval_ms"], DEFAULT_POLL_INTERVAL_MS, 0, Util::MAX_SAFE_INTEGER)
128
+ [budget, interval]
129
+ end
130
+
131
+ def preview_macro_with(m, input, opts)
132
+ name = m.name
133
+ plan = macro_plan(m)
134
+ return plan if plan.is_a?(Err)
135
+
136
+ steps, safety = plan
137
+ effective = macro_input(m, input)
138
+ return effective if effective.is_a?(Err)
139
+ return macro_invalid(name, "#{name} has no steps; regenerate the SDK.") if steps.empty?
140
+
141
+ rest = opts.with(confirm: nil, verify: false)
142
+ scope = { "input" => effective }
143
+ pending = []
144
+ previews = []
145
+ effects = []
146
+ effects << m.summary unless m.summary.empty?
147
+ key_used = false
148
+ steps.each_with_index do |(step, op), index|
149
+ where = "step #{index + 1} of #{name}: #{op.id}"
150
+ args = step_args(m, step, op, scope, pending)
151
+ step_opts = rest
152
+ if !uses_key?(op) || key_used
153
+ step_opts = step_opts.with(idempotency_key: nil)
154
+ elsif opts.key?
155
+ key_used = true
156
+ end
157
+ request = nil
158
+ if args.is_a?(Hash)
159
+ unrenderable = op.body && %w[bytes multipart].include?(op.body.encoding) && Helpers.placeholder?(args)
160
+ unless unrenderable
161
+ p = prepare(op, args, step_opts, :macro_preview, nil, nil)
162
+ if p.is_a?(Err)
163
+ d = p.error.with(operation: name, remediation: "#{p.error.remediation} (#{where})")
164
+ return Err.new(error: d, partial: p.partial)
165
+ end
166
+ request = RenderedRequest.new(http_method: p.http_method, url: Helpers.unescape_placeholders(p.display_url, args),
167
+ headers: p.headers.to_h(true), body: p.body.display)
168
+ end
169
+ elsif !Helpers.placeholder?(args)
170
+ return Runtime.diag(name, "VALIDATION_FAILED").param("input").expected("an object")
171
+ .remedy("Step #{index + 1} of #{name} does not evaluate to an argument object.").err
172
+ end
173
+ own = []
174
+ conf = op.agent["confirmation"]
175
+ own << Helpers.interpolate(conf["message"], args.is_a?(Hash) ? args : {}, op) if conf && !conf["message"].to_s.empty?
176
+ case step["kind"]
177
+ when "poll"
178
+ until_text = step["until"] ? Expr.describe(step["until"]) : ""
179
+ budget, interval = poll_numbers(step, scope)
180
+ own << "Repeats #{op.id} every #{Json.number_text(interval.to_f)} ms#{until_text.empty? ? '' : " until #{until_text}"}, for at most #{Json.number_text(budget.to_f)} ms."
181
+ when "paginate"
182
+ own << "Reads up to #{page_limit(step)} pages of #{op.id}."
183
+ end
184
+ own << op.agent["remediation_note"] unless op.agent["remediation_note"].to_s.empty?
185
+ effects << "Step #{index + 1}: #{step['kind']} #{op.id} (#{op.agent['safety']})."
186
+ effects.concat(own)
187
+ previews << MacroStepPreview.new(step: index + 1, kind: step["kind"], operation: op.id, as: step["as"],
188
+ safety: op.agent["safety"], request: request, effects: own)
189
+ pending << step["as"] if step["as"]
190
+ end
191
+ effects << "The result contains values the API shows only once; store them immediately." if m.shown_once
192
+ unless previews.first.request
193
+ return Runtime.diag(name, "VALIDATION_FAILED").param("input").expected("an object")
194
+ .remedy("Step 1 of #{name} cannot be rendered.").err
195
+ end
196
+
197
+ mutating = safety != "read_only"
198
+ result = PreviewResult.new(
199
+ operation: name, safety: safety, request: previews.first.request, effects: effects,
200
+ confirmation_token: mutating ? Keys.issue_token(@confirmation_key, "macro:#{name}", effective, now) : nil,
201
+ expires_in_ms: mutating ? Keys::TTL_MS : nil, server_preview: UNSET, steps: previews
202
+ )
203
+ Ok.new(value: result, meta: ResponseMeta.local)
204
+ end
205
+
206
+ # Running the step again (by rerunning its macro with the same input and
207
+ # options) answers the first result instead of applying it twice.
208
+ def rerun_protected?(op, opts)
209
+ return true if %w[content_identity content_hash auto].include?(op.agent["idempotency"]["policy"])
210
+ return false unless opts.key?
211
+
212
+ op.agent["idempotency"]["policy"] == "caller_owned" || op.params.any? { |p| p.role == "idempotency_key" }
213
+ end
214
+
215
+ # The replay protection a macro's token is bound to: the run's key when
216
+ # a step sends it and every mutating step is protected against a rerun.
217
+ def macro_bind(steps, opts)
218
+ return nil unless opts.key?
219
+
220
+ sent = false
221
+ steps.each do |_, op|
222
+ step_opts = opts
223
+ if !uses_key?(op) || sent
224
+ step_opts = opts.with(idempotency_key: nil)
225
+ else
226
+ sent = true
227
+ end
228
+ return nil if op.mutation? && !rerun_protected?(op, step_opts)
229
+ end
230
+ sent ? opts.idempotency_key : nil
231
+ end
232
+
233
+ def run_macro_with(m, input, opts)
234
+ name = m.name
235
+ plan = macro_plan(m)
236
+ return plan if plan.is_a?(Err)
237
+
238
+ steps, safety = plan
239
+ effective = macro_input(m, input)
240
+ return effective if effective.is_a?(Err)
241
+
242
+ err = confirmed(name, "macro:#{name}", "the macro's preview(...)", safety, effective, opts)
243
+ return err if err
244
+
245
+ claim = MacroClaim.new(name, opts.token, macro_bind(steps, opts))
246
+ scope = { "input" => effective }
247
+ completed = []
248
+ produced = {}
249
+ unprotected = []
250
+ key_used = false
251
+ meta = ResponseMeta.local
252
+ steps.each_with_index do |(step, op), index|
253
+ args = step_args(m, step, op, scope, nil)
254
+ unless args.is_a?(Hash)
255
+ return Runtime.diag(name, "VALIDATION_FAILED").param("input").expected("an object")
256
+ .remedy("Step #{index + 1} of #{name} does not evaluate to an argument object.").err
257
+ end
258
+ step_opts = opts.with(confirm: nil, verify: false)
259
+ if !uses_key?(op) || key_used
260
+ step_opts = step_opts.with(idempotency_key: nil)
261
+ elsif opts.key?
262
+ key_used = true
263
+ end
264
+ value = UNSET
265
+ failure = nil
266
+ case step["kind"]
267
+ when "poll"
268
+ budget, interval = poll_numbers(step, scope)
269
+ until_ = step["until"].is_a?(Hash) ? step["until"] : {}
270
+ polled = poll_with(op, args, until_, interval, budget, step_opts, claim)
271
+ if polled.is_a?(Err)
272
+ failure = polled
273
+ else
274
+ value = polled.value["timed_out"] ? nil : polled.value["body"]
275
+ meta = polled.meta
276
+ end
277
+ when "paginate"
278
+ items = []
279
+ limit = page_limit(step)
280
+ pages = 0
281
+ state = new_page_state(args)
282
+ loop do
283
+ page = page_step(op, state, step_opts, claim)
284
+ break if page.nil?
285
+
286
+ if page.is_a?(Err)
287
+ failure = page
288
+ break
289
+ end
290
+ items.concat(page.value.items)
291
+ meta = page.meta
292
+ pages += 1
293
+ break if pages >= limit
294
+ end
295
+ value = items
296
+ else
297
+ response = call_with(op, args, step_opts, nil, claim)
298
+ if response.is_a?(Err)
299
+ failure = response
300
+ else
301
+ value = response.raw.nil? ? UNSET : response.raw
302
+ meta = response.meta
303
+ end
304
+ end
305
+ if failure
306
+ d = failure.error
307
+ produced[step["as"]] = failure.partial if failure.partial? && step["as"]
308
+ done = completed.empty? ? "no step completed before it" : "completed before it: #{completed.join(', ')}"
309
+ remediation = "#{d.remediation} Macro #{name} stopped at step #{index + 1} of #{steps.length} (#{op.id}); #{done}."
310
+ retryable = d.retryable
311
+ next_action = d.next_action
312
+ unless produced.empty?
313
+ once = m.shown_once ? ", including values the API shows only once: store them now" : ""
314
+ remediation += " The result's partial holds the completed steps' results (#{produced.keys.join(', ')})#{once}."
315
+ end
316
+ unless unprotected.empty?
317
+ remediation += " Do not run #{name} again: #{unprotected.join(', ')} already took effect and has no replay protection."
318
+ retryable = "after_remediation" if %w[after_delay same_key_only].include?(retryable)
319
+ finish = "Finish #{name} without rerunning it: call #{op.id} yourself with the values from the result's partial."
320
+ next_action = next_action ? "#{next_action} #{finish}" : finish
321
+ end
322
+ d = d.with(operation: name, remediation: remediation, retryable: retryable, next_action: next_action)
323
+ return Err.new(error: d, partial: produced.empty? ? UNSET : produced)
324
+ end
325
+ completed << op.id
326
+ if step["as"]
327
+ if UNSET.equal?(value)
328
+ scope.delete(step["as"])
329
+ produced[step["as"]] = nil
330
+ else
331
+ scope[step["as"]] = value
332
+ produced[step["as"]] = value
333
+ end
334
+ end
335
+ unprotected << op.id if op.mutation? && !rerun_protected?(op, step_opts)
336
+ end
337
+ output = Expr.evaluate(m.output, scope)
338
+ output = nil if UNSET.equal?(output)
339
+ Ok.new(value: output, meta: meta)
340
+ end
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ module Tungsten
5
+ module Runtime
6
+ # Tokens expiring within this many milliseconds count as expired.
7
+ EXPIRY_SKEW_MS = 30_000
8
+
9
+ # What a token store keeps for one scheme. `inspect` shows no secret.
10
+ StoredToken = Data.define(:access_token, :refresh_token, :expires_at, :token_type, :scope) do
11
+ def initialize(access_token:, refresh_token: nil, expires_at: nil, token_type: nil, scope: nil) = super
12
+ def inspect = "#<Tungsten::Runtime::StoredToken #{REDACTED}>"
13
+ alias_method :to_s, :inspect
14
+ end
15
+
16
+ # The default token store of the authorization-code schemes: in memory,
17
+ # per client. Any object with `get(scheme)`, `set(scheme, token)` and
18
+ # `delete(scheme)` can replace it (ClientOptions `token_store`).
19
+ class MemoryTokenStore
20
+ def initialize
21
+ @tokens = {}
22
+ @lock = Mutex.new
23
+ end
24
+
25
+ def get(scheme) = @lock.synchronize { @tokens[scheme] }
26
+ def set(scheme, token) = @lock.synchronize { @tokens[scheme] = token }
27
+ def delete(scheme) = @lock.synchronize { @tokens.delete(scheme) }
28
+ end
29
+
30
+ # A PKCE pair (RFC 7636, S256); the verifier is a secret until the
31
+ # exchange.
32
+ Pkce = Data.define(:verifier, :challenge) do
33
+ def inspect = "#<Tungsten::Runtime::Pkce challenge=#{challenge}>"
34
+ alias_method :to_s, :inspect
35
+
36
+ # base64url(SHA-256(ASCII(verifier))).
37
+ def self.challenge_of(verifier) = Util.base64url(Digest::SHA256.digest(verifier))
38
+
39
+ # A fresh pair: a 43 character verifier from 32 random bytes.
40
+ def self.generate
41
+ verifier = Util.base64url(SecureRandom.random_bytes(32))
42
+ new(verifier: verifier, challenge: challenge_of(verifier))
43
+ end
44
+ end
45
+
46
+ # What exchange_code and refresh report: no secrets.
47
+ TokenInfo = Data.define(:scheme, :expires_at, :scope, :refreshable)
48
+
49
+ # The authorization-code helpers of one OAuth2 scheme (`client.oauth(name)`).
50
+ # Failures are AUTH_FAILED envelopes in Err.
51
+ class OAuthFlow
52
+ attr_reader :scheme
53
+
54
+ def initialize(core, scheme)
55
+ @core = core
56
+ @scheme = scheme
57
+ end
58
+
59
+ def token_store = @core.token_store
60
+
61
+ # A fresh PKCE pair: pass `challenge` to authorization_url and keep
62
+ # `verifier` for exchange_code.
63
+ def pkce = Pkce.generate
64
+
65
+ # The URL to send the user to: Ok with the URL, or Err.
66
+ def authorization_url(redirect_uri: nil, scopes: nil, state: nil, code_challenge: nil, extra: {})
67
+ @core.send(:authorization_url, @scheme, redirect_uri: redirect_uri, scopes: scopes, state: state,
68
+ code_challenge: code_challenge, extra: extra)
69
+ end
70
+
71
+ # Trade the code from the redirect for tokens, stored for later calls.
72
+ def exchange_code(code:, redirect_uri: nil, code_verifier: nil)
73
+ @core.send(:exchange_code, @scheme, code: code, redirect_uri: redirect_uri, code_verifier: code_verifier)
74
+ end
75
+
76
+ # Refresh the stored token now (calls refresh on their own when it
77
+ # expires).
78
+ def refresh = @core.send(:refresh_now, @scheme)
79
+ end
80
+
81
+ class ClientCore
82
+ # The authorization-code helpers of a scheme.
83
+ def oauth(scheme) = OAuthFlow.new(self, scheme)
84
+
85
+ # The store of authorization-code tokens.
86
+ attr_reader :token_store
87
+
88
+ private
89
+
90
+ TokenFailure = Struct.new(:message, :rejected)
91
+
92
+ def form_body(fields) = fields.map { |k, v| "#{Serialize.percent_encode(k)}=#{Serialize.percent_encode(v)}" }.join("&")
93
+
94
+ def code_flow(scheme)
95
+ s = schemes.find { |x| x["name"] == scheme }
96
+ return [nil, "the API descriptor defines no scheme named #{scheme}"] unless s
97
+ return [s["authorization_code"], nil] if s["kind"] == "oauth2" && s["authorization_code"]
98
+
99
+ [nil, "the scheme #{scheme} has no authorizationCode flow"]
100
+ end
101
+
102
+ def code_client(scheme)
103
+ invalid = "auth.#{scheme} must be a Hash with flow \"authorizationCode\", client_id, and optionally client_secret and redirect_uri"
104
+ cred = credential(scheme)
105
+ return [nil, invalid] unless cred.is_a?(Hash) && part_of(cred, "flow", "flow") == "authorizationCode"
106
+
107
+ id = part_of(cred, "client_id", "clientId").to_s
108
+ return [nil, "auth.#{scheme} needs a client_id"] if id.empty?
109
+
110
+ [{ id: id, secret: part_of(cred, "client_secret", "clientSecret").to_s,
111
+ redirect: part_of(cred, "redirect_uri", "redirectUri").to_s }, nil]
112
+ end
113
+
114
+ def oauth_error(operation, remediation) = Runtime.diag(operation, "AUTH_FAILED").remedy(remediation).err
115
+
116
+ def read_token(scheme)
117
+ t = @token_store.get(scheme)
118
+ t.is_a?(StoredToken) && t.access_token.is_a?(String) && !t.access_token.empty? ? t : nil
119
+ end
120
+
121
+ def gate_of(scheme) = @lock.synchronize { @oauth_gates[scheme] ||= { mutex: Mutex.new, generation: 0 } }
122
+
123
+ # [token, nil] or [nil, remediation].
124
+ def stored_token(scheme, force)
125
+ token, failure = stored_token_inner(scheme, force)
126
+ failure ? [nil, failure.message] : [token, nil]
127
+ end
128
+
129
+ def stored_token_inner(scheme, force)
130
+ missing = TokenFailure.new(
131
+ "No OAuth2 token is stored for #{scheme}; send the user to authorization_url(), then call exchange_code() " \
132
+ "with the code, or put a token in ClientOptions.token_store.", false
133
+ )
134
+ stored = read_token(scheme)
135
+ return [nil, missing] unless stored
136
+
137
+ fresh = stored.expires_at.nil? || stored.expires_at > now + EXPIRY_SKEW_MS
138
+ return [stored.access_token, nil] if fresh && !force
139
+
140
+ gate = gate_of(scheme)
141
+ seen = @lock.synchronize { gate[:generation] }
142
+ gate[:mutex].synchronize do
143
+ if @lock.synchronize { gate[:generation] } != seen
144
+ t = read_token(scheme)
145
+ return t ? [t.access_token, nil] : [nil, missing]
146
+ end
147
+ current = read_token(scheme)
148
+ return [nil, missing] unless current
149
+
150
+ token, failure = refresh_stored(scheme, current, force)
151
+ return [nil, failure] if failure
152
+
153
+ @lock.synchronize { gate[:generation] += 1 }
154
+ [token, nil]
155
+ end
156
+ end
157
+
158
+ # The refreshed token after a 401 when the stored token has a refresh
159
+ # token, else nil.
160
+ def refresh_after_rejection(scheme, rejected)
161
+ stored = read_token(scheme)
162
+ return nil unless stored
163
+ return stored.access_token if stored.access_token != rejected
164
+ return nil if stored.refresh_token.to_s.empty?
165
+
166
+ token, problem = stored_token(scheme, true)
167
+ problem ? nil : token
168
+ end
169
+
170
+ def refresh_stored(scheme, stored, force)
171
+ flow, reason = code_flow(scheme)
172
+ return [nil, TokenFailure.new("Cannot refresh the OAuth2 token for #{scheme}: #{reason}.", false)] unless flow
173
+
174
+ client, reason = code_client(scheme)
175
+ return [nil, TokenFailure.new("Cannot refresh the OAuth2 token for #{scheme}: #{reason}.", false)] unless client
176
+
177
+ if stored.refresh_token.to_s.empty?
178
+ why = "#{force ? '' : 'expired and '}has no refresh token"
179
+ return [nil, TokenFailure.new("The OAuth2 token for #{scheme} #{why}; authorize again with authorization_url() and exchange_code().", false)]
180
+ end
181
+ url = flow["refresh_url"] || flow["token_url"]
182
+ renewed, failure = token_request(scheme, url, client, [["grant_type", "refresh_token"], ["refresh_token", stored.refresh_token]], stored.refresh_token)
183
+ if failure
184
+ @token_store.delete(scheme) if failure.rejected
185
+ return [nil, failure]
186
+ end
187
+ @token_store.set(scheme, renewed)
188
+ [renewed.access_token, nil]
189
+ end
190
+
191
+ def token_timeout_ms = [@timeout_ms, 1].max
192
+
193
+ def token_request(scheme, url, client, fields, previous)
194
+ headers = [["Content-Type", "application/x-www-form-urlencoded"], ["Accept", "application/json"]]
195
+ all = fields.dup
196
+ if client[:secret].empty?
197
+ all << ["client_id", client[:id]]
198
+ else
199
+ basic = Base64.strict_encode64("#{Serialize.percent_encode(client[:id])}:#{Serialize.percent_encode(client[:secret])}")
200
+ headers << ["Authorization", "Basic #{basic}"]
201
+ end
202
+ body = Serialize::Encoded.new(empty: false, bytes: form_body(all).b, parts: nil, content_type: nil, display: nil, hashed: nil)
203
+ out = Transport.attempt(url: url.to_s, method: "POST", headers: headers, body: body, timeout_ms: token_timeout_ms)
204
+ unless out.kind == :response && out.body
205
+ return [nil, TokenFailure.new("The OAuth2 token endpoint for #{scheme} could not be reached; check network access and the token URL.", false)]
206
+ end
207
+
208
+ decoded = Classify.decode_body(out.body, out.headers, "application/json")
209
+ unless out.status.between?(200, 299)
210
+ code = Util.get_path_str(decoded.value, "error")
211
+ named = code.is_a?(String) && code.match?(/\A[a-z_]{1,40}\z/) ? " (#{code})" : ""
212
+ return [nil, TokenFailure.new(
213
+ "The OAuth2 token endpoint for #{scheme} answered HTTP #{out.status}#{named}; check the code or refresh token, client credentials and redirect URI.",
214
+ out.status.between?(400, 499)
215
+ )]
216
+ end
217
+ text = ->(path) { Util.get_path_str(decoded.value, path).then { |v| v.is_a?(String) ? v : "" } }
218
+ access = text.call("access_token")
219
+ return [nil, TokenFailure.new("The OAuth2 token endpoint for #{scheme} returned no access_token.", false)] if access.empty?
220
+
221
+ refresh = text.call("refresh_token")
222
+ refresh = previous if refresh.empty?
223
+ expires_in = Util.get_path_str(decoded.value, "expires_in")
224
+ expires = expires_in.is_a?(Numeric) && expires_in.finite? ? now + ([expires_in, 0].max * 1000).to_i : nil
225
+ token = StoredToken.new(access_token: access, refresh_token: refresh.empty? ? nil : refresh, expires_at: expires,
226
+ token_type: text.call("token_type"), scope: text.call("scope"))
227
+ [token, nil]
228
+ end
229
+
230
+ # An OAuth2 client-credentials token, fetched once per scheme and
231
+ # cached until 30 s before it expires (one request at a time).
232
+ def client_credentials_token(sat)
233
+ @cc_lock.synchronize do
234
+ real_now = (Time.now.to_f * 1000).to_i
235
+ cached = @cc_tokens[sat.name]
236
+ return [cached[:token], nil] if cached && cached[:expires_at] > real_now + 30_000
237
+
238
+ form = ["grant_type=client_credentials"]
239
+ form << "scope=#{Serialize.encode_form_component(sat.scopes.join(' '))}" unless sat.scopes.empty?
240
+ basic = Base64.strict_encode64("#{Serialize.encode_component(sat.client_id)}:#{Serialize.encode_component(sat.client_secret)}")
241
+ headers = [["Content-Type", "application/x-www-form-urlencoded"], ["Accept", "application/json"], ["Authorization", "Basic #{basic}"]]
242
+ body = Serialize::Encoded.new(empty: false, bytes: form.join("&").b, parts: nil, content_type: nil, display: nil, hashed: nil)
243
+ out = Transport.attempt(url: sat.token_url, method: "POST", headers: headers, body: body, timeout_ms: token_timeout_ms)
244
+ unless out.kind == :response && out.body
245
+ return [nil, "The OAuth2 token endpoint for #{sat.name} could not be reached; check network access and the token URL."]
246
+ end
247
+ unless out.status.between?(200, 299)
248
+ return [nil, "The OAuth2 token endpoint for #{sat.name} answered HTTP #{out.status}; check client_id, client_secret and scopes."]
249
+ end
250
+
251
+ decoded = Classify.decode_body(out.body, out.headers, "application/json")
252
+ token = Util.get_path_str(decoded.value, "access_token")
253
+ return [nil, "The OAuth2 token endpoint for #{sat.name} returned no access_token."] unless token.is_a?(String) && !token.empty?
254
+
255
+ lifetime = 3_600_000
256
+ e = Util.get_path_str(decoded.value, "expires_in")
257
+ lifetime = [(e * 1000).to_i, 0].max if e.is_a?(Numeric) && e.finite?
258
+ @cc_tokens[sat.name] = { token: token, expires_at: real_now + lifetime }
259
+ [token, nil]
260
+ end
261
+ end
262
+
263
+ def oauth_operation(scheme, helper) = "oauth.#{scheme}.#{helper}"
264
+
265
+ def authorization_url(scheme, redirect_uri:, scopes:, state:, code_challenge:, extra:)
266
+ operation = oauth_operation(scheme, "authorizationUrl")
267
+ flow, reason = code_flow(scheme)
268
+ return oauth_error(operation, "Cannot build an authorization URL: #{reason}.") unless flow
269
+
270
+ client, reason = code_client(scheme)
271
+ return oauth_error(operation, "Cannot build an authorization URL: #{reason}.") unless client
272
+
273
+ redirect = redirect_uri.to_s.empty? ? client[:redirect] : redirect_uri.to_s
274
+ if redirect.empty?
275
+ return oauth_error(operation, "Pass redirect_uri, or set redirect_uri in auth.#{scheme}; the authorization URL needs one.")
276
+ end
277
+
278
+ scope = (scopes.nil? ? flow["scopes"] || [] : scopes).join(" ")
279
+ fields = [["response_type", "code"], ["client_id", client[:id]], ["redirect_uri", redirect]]
280
+ fields << ["scope", scope] unless scope.empty?
281
+ fields << ["state", state] unless state.to_s.empty?
282
+ fields.push(["code_challenge", code_challenge], %w[code_challenge_method S256]) unless code_challenge.to_s.empty?
283
+ reserved = fields.map(&:first)
284
+ (extra || {}).transform_keys(&:to_s).sort.each { |k, v| fields << [k, v.to_s] unless reserved.include?(k) }
285
+ base = flow["authorization_url"].to_s
286
+ sep = base.include?("?") ? (base.end_with?("?", "&") ? "" : "&") : "?"
287
+ Ok.new(value: base + sep + form_body(fields), meta: ResponseMeta.local)
288
+ end
289
+
290
+ def exchange_code(scheme, code:, redirect_uri:, code_verifier:)
291
+ operation = oauth_operation(scheme, "exchangeCode")
292
+ flow, reason = code_flow(scheme)
293
+ return oauth_error(operation, "Cannot exchange the code: #{reason}.") unless flow
294
+
295
+ client, reason = code_client(scheme)
296
+ return oauth_error(operation, "Cannot exchange the code: #{reason}.") unless client
297
+ return oauth_error(operation, "Pass the authorization code the server redirected back with as code.") if code.to_s.empty?
298
+
299
+ redirect = redirect_uri.to_s.empty? ? client[:redirect] : redirect_uri.to_s
300
+ if redirect.empty?
301
+ return oauth_error(operation, "Pass redirect_uri, or set redirect_uri in auth.#{scheme}; it must equal the one used for the authorization URL.")
302
+ end
303
+
304
+ fields = [["grant_type", "authorization_code"], ["code", code.to_s], ["redirect_uri", redirect]]
305
+ fields << ["code_verifier", code_verifier.to_s] unless code_verifier.to_s.empty?
306
+ stored, failure = token_request(scheme, flow["token_url"], client, fields, "")
307
+ return oauth_error(operation, failure.message) if failure
308
+
309
+ @token_store.set(scheme, stored)
310
+ Ok.new(value: token_info(scheme, stored), meta: ResponseMeta.local)
311
+ end
312
+
313
+ def refresh_now(scheme)
314
+ operation = oauth_operation(scheme, "refresh")
315
+ _, failure = stored_token_inner(scheme, true)
316
+ return oauth_error(operation, failure.message) if failure
317
+
318
+ stored = read_token(scheme)
319
+ return oauth_error(operation, "No token is stored for #{scheme}.") unless stored
320
+
321
+ Ok.new(value: token_info(scheme, stored), meta: ResponseMeta.local)
322
+ end
323
+
324
+ def token_info(scheme, token)
325
+ TokenInfo.new(scheme: scheme, expires_at: token.expires_at, scope: token.scope, refreshable: !token.refresh_token.to_s.empty?)
326
+ end
327
+ end
328
+ end
329
+ end