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.
- checksums.yaml +7 -0
- data/LICENSE +55 -0
- data/README.md +23 -0
- data/lib/tungsten/runtime/auth.rb +258 -0
- data/lib/tungsten/runtime/classify.rb +248 -0
- data/lib/tungsten/runtime/client_core.rb +1147 -0
- data/lib/tungsten/runtime/descriptors.rb +173 -0
- data/lib/tungsten/runtime/dispatcher.rb +83 -0
- data/lib/tungsten/runtime/envelope.rb +230 -0
- data/lib/tungsten/runtime/expr.rb +186 -0
- data/lib/tungsten/runtime/helpers.rb +185 -0
- data/lib/tungsten/runtime/idempotency.rb +110 -0
- data/lib/tungsten/runtime/json.rb +346 -0
- data/lib/tungsten/runtime/macros.rb +343 -0
- data/lib/tungsten/runtime/oauth.rb +329 -0
- data/lib/tungsten/runtime/options.rb +142 -0
- data/lib/tungsten/runtime/pages.rb +173 -0
- data/lib/tungsten/runtime/schema.rb +320 -0
- data/lib/tungsten/runtime/serialize.rb +353 -0
- data/lib/tungsten/runtime/sse.rb +148 -0
- data/lib/tungsten/runtime/stream.rb +500 -0
- data/lib/tungsten/runtime/transport.rb +386 -0
- data/lib/tungsten/runtime/typed.rb +107 -0
- data/lib/tungsten/runtime/util.rb +254 -0
- data/lib/tungsten/runtime/version.rb +9 -0
- data/lib/tungsten/runtime.rb +49 -0
- metadata +82 -0
|
@@ -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
|