tungsten-runtime 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +191 -9
- data/lib/tungsten/runtime/auth.rb +35 -2
- data/lib/tungsten/runtime/client_core.rb +195 -20
- data/lib/tungsten/runtime/descriptors.rb +12 -3
- data/lib/tungsten/runtime/json.rb +10 -0
- data/lib/tungsten/runtime/macros.rb +1 -1
- data/lib/tungsten/runtime/oauth.rb +133 -36
- data/lib/tungsten/runtime/oauth_inputs.rb +136 -0
- data/lib/tungsten/runtime/oauth_sessions.rb +521 -0
- data/lib/tungsten/runtime/options.rb +35 -11
- data/lib/tungsten/runtime/pages.rb +3 -1
- data/lib/tungsten/runtime/schema.rb +49 -19
- data/lib/tungsten/runtime/serialize.rb +43 -8
- data/lib/tungsten/runtime/stream.rb +3 -3
- data/lib/tungsten/runtime/version.rb +1 -1
- data/lib/tungsten/runtime.rb +2 -0
- metadata +3 -1
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# One client's bounded managed OAuth state. Store predicates take this lock;
|
|
7
|
+
# no store, descriptor, clock hook or other application code runs under it.
|
|
8
|
+
class OAuthSessions
|
|
9
|
+
BEGIN_REFUSAL = "Cannot begin authorization: supply bounded valid configuration, S256 and issuer qualification, an owning session binding and an atomic fenced token store."
|
|
10
|
+
CALLBACK_REFUSAL = "Authorization callback refused: invalid, expired, consumed or mismatched attempt. Begin a new managed authorization attempt if needed."
|
|
11
|
+
SESSION_REFUSAL = "The managed OAuth session changed or its work deadline expired; begin a new managed authorization attempt if needed."
|
|
12
|
+
MISSING = "No OAuth2 token is stored; begin authorization_url(), then complete exchange_code() with its handle, full callback URL and owning session binding."
|
|
13
|
+
LOGOUT_REFUSAL = "Local OAuth work was fenced, but credential removal did not complete. Check the token store and retry logout before using stored credentials."
|
|
14
|
+
|
|
15
|
+
def initialize(core)
|
|
16
|
+
@core = core
|
|
17
|
+
@lock = Mutex.new
|
|
18
|
+
@condition = ConditionVariable.new
|
|
19
|
+
@sessions = {}
|
|
20
|
+
@attempts = {}
|
|
21
|
+
@flights = {}
|
|
22
|
+
@owners = 0
|
|
23
|
+
@admitted = 0
|
|
24
|
+
@expiry = nil
|
|
25
|
+
# Scheme state cannot grow through arbitrary helper names or descriptor
|
|
26
|
+
# mutation. Bound and capture the initial known code-flow names once.
|
|
27
|
+
budget = OAuthInputs::Budget.new
|
|
28
|
+
core.api.fetch("auth", []).each do |scheme|
|
|
29
|
+
next unless scheme.is_a?(Hash) && scheme["kind"] == "oauth2" && scheme["authorization_code"]
|
|
30
|
+
name = budget.text(scheme["name"])
|
|
31
|
+
OAuthInputs.require_valid(!name.empty? && @sessions.length < OAuthInputs::CAPACITY && !@sessions.key?(name))
|
|
32
|
+
@sessions[name] = { generation: 0, epoch: 0, logging_out: false, selected: true }
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError
|
|
35
|
+
@sessions.clear
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inspect = "#<Tungsten::Runtime::OAuthSessions #{REDACTED}>"
|
|
39
|
+
alias_method :to_s, :inspect
|
|
40
|
+
|
|
41
|
+
def capable?
|
|
42
|
+
@core.token_store.respond_to?(:set_if_current) && @core.token_store.respond_to?(:delete_if_current)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def version_locked(scheme)
|
|
46
|
+
state = @sessions[scheme]
|
|
47
|
+
state && [state[:generation], state[:epoch]].freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def current_locked?(scheme, version, selected: false)
|
|
51
|
+
state = @sessions[scheme]
|
|
52
|
+
state && !state[:logging_out] && version == [state[:generation], state[:epoch]] && (!selected || state[:selected])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def current?(scheme, version, selected: false)
|
|
56
|
+
@lock.synchronize { current_locked?(scheme, version, selected: selected) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clock = Transport.monotonic_ms
|
|
60
|
+
|
|
61
|
+
def timely?(job) = clock < job[:deadline]
|
|
62
|
+
|
|
63
|
+
def owner(scheme)
|
|
64
|
+
duration = @core.timeout_ms
|
|
65
|
+
OAuthInputs.require_valid(OAuthInputs.safe_integer?(duration))
|
|
66
|
+
started = clock
|
|
67
|
+
@lock.synchronize do
|
|
68
|
+
version = version_locked(scheme)
|
|
69
|
+
OAuthInputs.require_valid(version && current_locked?(scheme, version) && @owners < OAuthInputs::CAPACITY)
|
|
70
|
+
@owners += 1
|
|
71
|
+
{ version: version, deadline: started + duration }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def release_owner
|
|
76
|
+
@lock.synchronize { @owners -= 1 }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def failure = ClientCore::TokenFailure.new(SESSION_REFUSAL, false)
|
|
80
|
+
|
|
81
|
+
def error(scheme, helper, message)
|
|
82
|
+
# Never interpolate caller, provider or custom exception strings.
|
|
83
|
+
@core.send(:oauth_error, "oauth.#{scheme}.#{helper}", message)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def operation_scheme(scheme)
|
|
87
|
+
copied = OAuthInputs.text(scheme, OAuthInputs::INPUT_BYTES)
|
|
88
|
+
known = @lock.synchronize { @sessions.key?(copied) }
|
|
89
|
+
known && copied.match?(/\A[a-zA-Z0-9_.-]{1,64}\z/) ? copied : "managed"
|
|
90
|
+
rescue StandardError
|
|
91
|
+
"managed"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def raw_part(parts, *names)
|
|
95
|
+
key = names.find { |name| parts.key?(name) }
|
|
96
|
+
key ? parts[key] : nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def configuration(scheme, budget)
|
|
100
|
+
budget.text(scheme)
|
|
101
|
+
flow, = @core.send(:code_flow, scheme)
|
|
102
|
+
OAuthInputs.require_valid(flow.is_a?(Hash))
|
|
103
|
+
parts = @core.instance_variable_get(:@auth)[scheme]
|
|
104
|
+
OAuthInputs.require_valid(parts.is_a?(Hash) && raw_part(parts, "flow", :flow) == "authorizationCode")
|
|
105
|
+
id = budget.text(raw_part(parts, "client_id", :client_id, "clientId", :clientId))
|
|
106
|
+
OAuthInputs.require_valid(!id.empty?)
|
|
107
|
+
client = {
|
|
108
|
+
id: id, secret: budget.text(raw_part(parts, "client_secret", :client_secret, "clientSecret", :clientSecret) || ""),
|
|
109
|
+
redirect: budget.text(raw_part(parts, "redirect_uri", :redirect_uri, "redirectUri", :redirectUri) || "")
|
|
110
|
+
}.freeze
|
|
111
|
+
result = {
|
|
112
|
+
client: client, authorization_url: budget.text(flow["authorization_url"]),
|
|
113
|
+
token_url: budget.text(flow["token_url"]), refresh_url: budget.optional(flow["refresh_url"]),
|
|
114
|
+
configured_scopes: budget.scopes(flow["scopes"] || [])
|
|
115
|
+
}
|
|
116
|
+
OAuthInputs.endpoint(result[:authorization_url])
|
|
117
|
+
OAuthInputs.endpoint(result[:token_url])
|
|
118
|
+
OAuthInputs.endpoint(result[:refresh_url]) if result[:refresh_url] && !result[:refresh_url].empty?
|
|
119
|
+
result.freeze
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def configuration_current?(scheme, capture)
|
|
123
|
+
fresh = configuration(scheme, OAuthInputs::Budget.new)
|
|
124
|
+
fresh == capture[:configuration]
|
|
125
|
+
rescue StandardError
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def capture(scheme, parameters)
|
|
130
|
+
budget = OAuthInputs::Budget.new
|
|
131
|
+
config = configuration(scheme, budget)
|
|
132
|
+
binding = budget.text(parameters[:session_binding])
|
|
133
|
+
OAuthInputs.require_valid(!binding.empty? && parameters[:s256_supported] == true)
|
|
134
|
+
defense = parameters[:issuer_defense]
|
|
135
|
+
OAuthInputs.require_valid(defense.is_a?(IssuerDefense) && %i[response distinct].include?(defense.mode))
|
|
136
|
+
issuer = budget.text(defense.value)
|
|
137
|
+
OAuthInputs.require_valid(!issuer.empty?)
|
|
138
|
+
OAuthInputs.endpoint(issuer) if defense.mode == :response
|
|
139
|
+
redirect = budget.text(parameters[:redirect_uri] || config[:client][:redirect])
|
|
140
|
+
OAuthInputs.endpoint(redirect, redirect: true)
|
|
141
|
+
scopes = parameters[:scopes].nil? ? config[:configured_scopes] : budget.scopes(parameters[:scopes])
|
|
142
|
+
extra = budget.extra(parameters[:extra] || {})
|
|
143
|
+
resource, audience, context = %i[resource audience account_context].map { |key| budget.optional(parameters[key]) }
|
|
144
|
+
state = Util.base64url(SecureRandom.random_bytes(32)).freeze
|
|
145
|
+
verifier = Util.base64url(SecureRandom.random_bytes(32)).freeze
|
|
146
|
+
fields = [["response_type", "code"], ["client_id", config[:client][:id]], ["redirect_uri", redirect],
|
|
147
|
+
["state", state], ["code_challenge", Pkce.challenge_of(verifier)], ["code_challenge_method", "S256"]]
|
|
148
|
+
fields << ["scope", scopes.join(" ")] unless scopes.empty?
|
|
149
|
+
fields << ["resource", resource] if resource
|
|
150
|
+
fields << ["audience", audience] if audience
|
|
151
|
+
fields.concat(extra.to_a)
|
|
152
|
+
base = config[:authorization_url]
|
|
153
|
+
separator = base.include?("?") ? (base.end_with?("?", "&") ? "" : "&") : "?"
|
|
154
|
+
{
|
|
155
|
+
configuration: config, binding: binding, issuer_mode: defense.mode, issuer: issuer,
|
|
156
|
+
redirect: redirect, scopes: scopes, resource: resource, audience: audience, account_context: context,
|
|
157
|
+
state: state, verifier: verifier, url: (base + separator + @core.send(:form_body, fields)).freeze
|
|
158
|
+
}.freeze
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def live_locked?(attempt, at)
|
|
162
|
+
state = @sessions[attempt[:scheme]]
|
|
163
|
+
!attempt[:invalid] && at < attempt[:expires] &&
|
|
164
|
+
state && !state[:logging_out] && state[:generation] == attempt[:generation]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def remove_locked(handle)
|
|
168
|
+
if @attempts.delete(handle)
|
|
169
|
+
@admitted -= 1
|
|
170
|
+
@condition.broadcast
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def invalidate_locked(handle, attempt)
|
|
175
|
+
attempt[:invalid] = true
|
|
176
|
+
attempt[:capture] = nil
|
|
177
|
+
attempt[:binding] = nil
|
|
178
|
+
remove_locked(handle) unless attempt[:claimed]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def sweep_locked(at)
|
|
182
|
+
@attempts.to_a.each do |handle, attempt|
|
|
183
|
+
invalidate_locked(handle, attempt) unless live_locked?(attempt, at)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def start_expiry_locked
|
|
188
|
+
return if @expiry
|
|
189
|
+
@expiry = Thread.new do
|
|
190
|
+
@lock.synchronize do
|
|
191
|
+
loop do
|
|
192
|
+
at = clock
|
|
193
|
+
sweep_locked(at)
|
|
194
|
+
deadlines = @attempts.values.filter_map { |attempt| attempt[:expires] unless attempt[:invalid] }
|
|
195
|
+
break if deadlines.empty?
|
|
196
|
+
@condition.wait(@lock, [(deadlines.min - at) / 1000.0, 0.001].max)
|
|
197
|
+
end
|
|
198
|
+
@expiry = nil
|
|
199
|
+
end
|
|
200
|
+
rescue StandardError
|
|
201
|
+
@lock.synchronize do
|
|
202
|
+
@attempts.to_a.each { |handle, attempt| invalidate_locked(handle, attempt) }
|
|
203
|
+
@expiry = nil
|
|
204
|
+
@condition.broadcast
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
@expiry.report_on_exception = false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def begin_authorization(scheme, parameters)
|
|
211
|
+
reserved = false
|
|
212
|
+
handle = nil
|
|
213
|
+
published = false
|
|
214
|
+
generation = nil
|
|
215
|
+
scheme = OAuthInputs.text(scheme, OAuthInputs::INPUT_BYTES)
|
|
216
|
+
safe_scheme = operation_scheme(scheme)
|
|
217
|
+
at = clock
|
|
218
|
+
@lock.synchronize do
|
|
219
|
+
sweep_locked(at)
|
|
220
|
+
state = @sessions[scheme]
|
|
221
|
+
OAuthInputs.require_valid(state && !state[:logging_out] && @admitted < OAuthInputs::CAPACITY)
|
|
222
|
+
@admitted += 1
|
|
223
|
+
reserved = true
|
|
224
|
+
generation = state[:generation]
|
|
225
|
+
end
|
|
226
|
+
OAuthInputs.require_valid(capable? && OAuthInputs.safe_integer?(@core.max_oauth_token_response_bytes))
|
|
227
|
+
captured = capture(scheme, parameters)
|
|
228
|
+
# No provider work is allowed while a begin is still capturing inputs.
|
|
229
|
+
OAuthInputs.require_valid(configuration_current?(scheme, captured))
|
|
230
|
+
candidate = Util.base64url(SecureRandom.random_bytes(32)).freeze
|
|
231
|
+
at = clock
|
|
232
|
+
@lock.synchronize do
|
|
233
|
+
state = @sessions[scheme]
|
|
234
|
+
OAuthInputs.require_valid(!state[:logging_out] && state[:generation] == generation)
|
|
235
|
+
OAuthInputs.require_valid(!@attempts.key?(candidate))
|
|
236
|
+
handle = candidate
|
|
237
|
+
@attempts[handle] = { scheme: scheme, generation: state[:generation], expires: at + OAuthInputs::LIFETIME_MS,
|
|
238
|
+
capture: captured, binding: captured[:binding], invalid: false, claimed: false }
|
|
239
|
+
reserved = false
|
|
240
|
+
start_expiry_locked
|
|
241
|
+
@condition.broadcast
|
|
242
|
+
published = true
|
|
243
|
+
end
|
|
244
|
+
Ok.new(value: AuthorizationAttempt.new(attempt_handle: handle, url: captured[:url], expires_in_ms: OAuthInputs::LIFETIME_MS),
|
|
245
|
+
meta: ResponseMeta.local)
|
|
246
|
+
rescue StandardError
|
|
247
|
+
error(safe_scheme || "managed", "authorizationUrl", BEGIN_REFUSAL)
|
|
248
|
+
ensure
|
|
249
|
+
@lock.synchronize do
|
|
250
|
+
remove_locked(handle) if handle && !published
|
|
251
|
+
@admitted -= 1 if reserved
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def cancel(scheme, handle, binding)
|
|
256
|
+
scheme = OAuthInputs.text(scheme, OAuthInputs::INPUT_BYTES)
|
|
257
|
+
handle = OAuthInputs.text(handle, OAuthInputs::INPUT_BYTES)
|
|
258
|
+
binding = OAuthInputs.text(binding, OAuthInputs::INPUT_BYTES)
|
|
259
|
+
@lock.synchronize do
|
|
260
|
+
attempt = @attempts[handle]
|
|
261
|
+
OAuthInputs.require_valid(attempt && attempt[:scheme] == scheme && !attempt[:invalid] && attempt[:binding] == binding)
|
|
262
|
+
invalidate_locked(handle, attempt)
|
|
263
|
+
@condition.broadcast
|
|
264
|
+
end
|
|
265
|
+
Ok.new(value: nil, meta: ResponseMeta.local)
|
|
266
|
+
rescue StandardError
|
|
267
|
+
error(operation_scheme(scheme), "cancelAuthorization", CALLBACK_REFUSAL)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def complete(scheme, handle, callback_url, binding)
|
|
271
|
+
scheme = OAuthInputs.text(scheme, OAuthInputs::INPUT_BYTES)
|
|
272
|
+
safe_scheme = operation_scheme(scheme)
|
|
273
|
+
handle = OAuthInputs.text(handle, OAuthInputs::INPUT_BYTES)
|
|
274
|
+
binding = OAuthInputs.text(binding, OAuthInputs::INPUT_BYTES)
|
|
275
|
+
captured = nil
|
|
276
|
+
claimed = false
|
|
277
|
+
job = nil
|
|
278
|
+
at = clock
|
|
279
|
+
@lock.synchronize do
|
|
280
|
+
sweep_locked(at)
|
|
281
|
+
attempt = @attempts[handle]
|
|
282
|
+
OAuthInputs.require_valid(attempt && attempt[:scheme] == scheme && !attempt[:claimed] && live_locked?(attempt, at))
|
|
283
|
+
captured = attempt[:capture]
|
|
284
|
+
OAuthInputs.require_valid(captured[:binding] == binding)
|
|
285
|
+
end
|
|
286
|
+
kind, code = OAuthInputs.callback(callback_url, captured)
|
|
287
|
+
OAuthInputs.require_valid(configuration_current?(scheme, captured))
|
|
288
|
+
job = owner(scheme)
|
|
289
|
+
at = clock
|
|
290
|
+
@lock.synchronize do
|
|
291
|
+
attempt = @attempts[handle]
|
|
292
|
+
OAuthInputs.require_valid(attempt && !attempt[:claimed] && live_locked?(attempt, at) && attempt[:capture].equal?(captured))
|
|
293
|
+
attempt[:claimed] = true
|
|
294
|
+
attempt[:capture] = nil # The physical owner's local immutable capture retains it until settlement.
|
|
295
|
+
claimed = true
|
|
296
|
+
end
|
|
297
|
+
return error(safe_scheme, "exchangeCode", CALLBACK_REFUSAL) if kind == :denied
|
|
298
|
+
OAuthInputs.require_valid(timely?(job) && configuration_current?(scheme, captured))
|
|
299
|
+
at = clock
|
|
300
|
+
# Dispatch commits at this gate after the last application descriptor
|
|
301
|
+
# access. A cancel/logout winning first sends nothing; after this gate
|
|
302
|
+
# a request may be sent, but admission and persistence fences remain.
|
|
303
|
+
@lock.synchronize do
|
|
304
|
+
attempt = @attempts[handle]
|
|
305
|
+
OAuthInputs.require_valid(at < job[:deadline] && attempt && !attempt[:invalid] && at < attempt[:expires] &&
|
|
306
|
+
attempt[:generation] == @sessions[scheme][:generation] && !@sessions[scheme][:logging_out])
|
|
307
|
+
end
|
|
308
|
+
fields = [["grant_type", "authorization_code"], ["code", code], ["redirect_uri", captured[:redirect]], ["code_verifier", captured[:verifier]]]
|
|
309
|
+
stored, problem = @core.send(:token_request, scheme, captured[:configuration][:token_url], captured[:configuration][:client], fields, "",
|
|
310
|
+
deadline: job[:deadline])
|
|
311
|
+
return error(safe_scheme, "exchangeCode", problem.message) if problem
|
|
312
|
+
committed = nil
|
|
313
|
+
changed = @core.token_store.set_if_current(scheme, stored) do |_previous|
|
|
314
|
+
configured = configuration_current?(scheme, captured)
|
|
315
|
+
at = clock
|
|
316
|
+
@lock.synchronize do
|
|
317
|
+
attempt = @attempts[handle]
|
|
318
|
+
state = @sessions[scheme]
|
|
319
|
+
if configured && at < job[:deadline] && attempt && !attempt[:invalid] && at < attempt[:expires] &&
|
|
320
|
+
state[:generation] == attempt[:generation] && !state[:logging_out]
|
|
321
|
+
state[:epoch] += 1
|
|
322
|
+
committed = version_locked(scheme)
|
|
323
|
+
true
|
|
324
|
+
else
|
|
325
|
+
false
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
configured = configuration_current?(scheme, captured)
|
|
330
|
+
at = clock
|
|
331
|
+
@lock.synchronize do
|
|
332
|
+
attempt = @attempts[handle]
|
|
333
|
+
OAuthInputs.require_valid(changed == true && committed && configured && at < job[:deadline] &&
|
|
334
|
+
attempt && !attempt[:invalid] && at < attempt[:expires] && current_locked?(scheme, committed))
|
|
335
|
+
@sessions[scheme][:selected] = true
|
|
336
|
+
remove_locked(handle) # Success publication and terminal retirement linearize together.
|
|
337
|
+
claimed = false # This owner has retired; ensure must not remove a reused handle.
|
|
338
|
+
end
|
|
339
|
+
Ok.new(value: @core.send(:token_info, scheme, stored), meta: ResponseMeta.local)
|
|
340
|
+
rescue StandardError
|
|
341
|
+
error(safe_scheme || "managed", "exchangeCode", CALLBACK_REFUSAL)
|
|
342
|
+
ensure
|
|
343
|
+
@lock.synchronize { remove_locked(handle) } if claimed
|
|
344
|
+
release_owner if job
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def logout(scheme)
|
|
348
|
+
version = nil
|
|
349
|
+
job = nil
|
|
350
|
+
scheme = OAuthInputs.text(scheme, OAuthInputs::INPUT_BYTES)
|
|
351
|
+
safe_scheme = operation_scheme(scheme)
|
|
352
|
+
# Fence first, even if cleanup capacity/configuration/capability fails.
|
|
353
|
+
@lock.synchronize do
|
|
354
|
+
state = @sessions[scheme]
|
|
355
|
+
OAuthInputs.require_valid(state)
|
|
356
|
+
state[:generation] += 1
|
|
357
|
+
state[:selected] = false
|
|
358
|
+
state[:logging_out] = true
|
|
359
|
+
version = version_locked(scheme)
|
|
360
|
+
@attempts.to_a.each { |handle, attempt| invalidate_locked(handle, attempt) if attempt[:scheme] == scheme }
|
|
361
|
+
@condition.broadcast
|
|
362
|
+
end
|
|
363
|
+
duration = @core.timeout_ms
|
|
364
|
+
OAuthInputs.require_valid(capable? && OAuthInputs.safe_integer?(duration))
|
|
365
|
+
started = clock
|
|
366
|
+
@lock.synchronize do
|
|
367
|
+
OAuthInputs.require_valid(@owners < OAuthInputs::CAPACITY)
|
|
368
|
+
@owners += 1
|
|
369
|
+
job = { deadline: started + duration }
|
|
370
|
+
end
|
|
371
|
+
changed = @core.token_store.delete_if_current(scheme) do |_previous|
|
|
372
|
+
at = clock
|
|
373
|
+
@lock.synchronize do
|
|
374
|
+
state = @sessions[scheme]
|
|
375
|
+
allowed = at < job[:deadline] && version == version_locked(scheme) && state[:logging_out]
|
|
376
|
+
if allowed
|
|
377
|
+
state[:epoch] += 1
|
|
378
|
+
version = version_locked(scheme)
|
|
379
|
+
end
|
|
380
|
+
allowed
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
OAuthInputs.require_valid(changed == true && timely?(job))
|
|
384
|
+
Ok.new(value: nil, meta: ResponseMeta.local)
|
|
385
|
+
rescue StandardError
|
|
386
|
+
error(safe_scheme || "managed", "logout", LOGOUT_REFUSAL)
|
|
387
|
+
ensure
|
|
388
|
+
if version
|
|
389
|
+
@lock.synchronize do
|
|
390
|
+
state = @sessions[scheme]
|
|
391
|
+
state[:logging_out] = false if state && state[:generation] == version[0]
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
release_owner if job
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def snapshot_token(stored)
|
|
398
|
+
return nil unless stored.is_a?(StoredToken)
|
|
399
|
+
limit = @core.max_oauth_token_response_bytes
|
|
400
|
+
access = OAuthInputs.text(stored.access_token, limit)
|
|
401
|
+
return nil if access.empty?
|
|
402
|
+
refresh = stored.refresh_token.nil? ? nil : OAuthInputs.text(stored.refresh_token, limit)
|
|
403
|
+
expires = stored.expires_at
|
|
404
|
+
OAuthInputs.require_valid(expires.nil? || expires.instance_of?(Integer))
|
|
405
|
+
kind = stored.token_type.nil? ? nil : OAuthInputs.text(stored.token_type, limit)
|
|
406
|
+
scope = stored.scope.nil? ? nil : OAuthInputs.text(stored.scope, limit)
|
|
407
|
+
StoredToken.new(access_token: access, refresh_token: refresh, expires_at: expires,
|
|
408
|
+
token_type: kind, scope: scope)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def same_token?(left, right)
|
|
412
|
+
left.is_a?(StoredToken) && left.access_token == right.access_token && left.refresh_token == right.refresh_token
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Returns the stored record as well as the access token, so explicit
|
|
416
|
+
# refresh never performs a second unowned custom-store read.
|
|
417
|
+
def token(scheme, force, rejected: nil)
|
|
418
|
+
job = nil
|
|
419
|
+
flight = nil
|
|
420
|
+
leader = false
|
|
421
|
+
key = nil
|
|
422
|
+
scheme = OAuthInputs.text(scheme, OAuthInputs::INPUT_BYTES)
|
|
423
|
+
job = owner(scheme) # Charge before custom configuration, Get and Now.
|
|
424
|
+
OAuthInputs.require_valid(capable? && OAuthInputs.safe_integer?(@core.max_oauth_token_response_bytes))
|
|
425
|
+
config = configuration(scheme, OAuthInputs::Budget.new)
|
|
426
|
+
capture = { configuration: config }.freeze
|
|
427
|
+
OAuthInputs.require_valid(timely?(job) && current?(scheme, job[:version], selected: true))
|
|
428
|
+
stored = snapshot_token(@core.token_store.get(scheme))
|
|
429
|
+
OAuthInputs.require_valid(timely?(job) && current?(scheme, job[:version], selected: true))
|
|
430
|
+
return [nil, ClientCore::TokenFailure.new(MISSING, false)] unless stored
|
|
431
|
+
fresh = stored.expires_at.nil? || stored.expires_at > @core.send(:now) + EXPIRY_SKEW_MS
|
|
432
|
+
OAuthInputs.require_valid(timely?(job) && current?(scheme, job[:version], selected: true))
|
|
433
|
+
return [stored, nil] if (!force && fresh) || (rejected && stored.access_token != rejected)
|
|
434
|
+
OAuthInputs.require_valid(stored.refresh_token && !stored.refresh_token.empty?)
|
|
435
|
+
@lock.synchronize do
|
|
436
|
+
OAuthInputs.require_valid(current_locked?(scheme, job[:version], selected: true))
|
|
437
|
+
key = [scheme, *job[:version]].freeze
|
|
438
|
+
flight = @flights[key]
|
|
439
|
+
unless flight
|
|
440
|
+
flight = { done: false, result: nil }
|
|
441
|
+
@flights[key] = flight
|
|
442
|
+
leader = true
|
|
443
|
+
end
|
|
444
|
+
unless leader
|
|
445
|
+
until flight[:done]
|
|
446
|
+
remaining = job[:deadline] - clock
|
|
447
|
+
OAuthInputs.require_valid(remaining.positive?)
|
|
448
|
+
@condition.wait(@lock, remaining / 1000.0)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
unless leader
|
|
453
|
+
result = flight[:result]
|
|
454
|
+
OAuthInputs.require_valid(timely?(job) && current?(scheme, result[:version], selected: true))
|
|
455
|
+
return [result[:stored], result[:failure]]
|
|
456
|
+
end
|
|
457
|
+
result = refresh(scheme, capture, stored, job)
|
|
458
|
+
in_time = timely?(job)
|
|
459
|
+
@lock.synchronize do
|
|
460
|
+
OAuthInputs.require_valid(!result[:stored] || (in_time && current_locked?(scheme, result[:version], selected: true)))
|
|
461
|
+
flight[:result] = result
|
|
462
|
+
flight[:done] = true
|
|
463
|
+
@condition.broadcast
|
|
464
|
+
end
|
|
465
|
+
[result[:stored], result[:failure]]
|
|
466
|
+
rescue StandardError
|
|
467
|
+
[nil, failure]
|
|
468
|
+
ensure
|
|
469
|
+
if leader
|
|
470
|
+
@lock.synchronize do
|
|
471
|
+
unless flight[:done]
|
|
472
|
+
flight[:result] = { stored: nil, failure: failure, version: job[:version] }.freeze
|
|
473
|
+
flight[:done] = true
|
|
474
|
+
end
|
|
475
|
+
@flights.delete(key) if @flights[key].equal?(flight)
|
|
476
|
+
@condition.broadcast
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
release_owner if job
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def refresh(scheme, capture, stored, job)
|
|
483
|
+
config = capture[:configuration]
|
|
484
|
+
url = config[:refresh_url]
|
|
485
|
+
url = config[:token_url] if url.nil? || url.empty?
|
|
486
|
+
OAuthInputs.endpoint(url) # Validate the actual selected endpoint.
|
|
487
|
+
OAuthInputs.require_valid(timely?(job) && configuration_current?(scheme, capture))
|
|
488
|
+
at = clock
|
|
489
|
+
@lock.synchronize do
|
|
490
|
+
OAuthInputs.require_valid(at < job[:deadline] && current_locked?(scheme, job[:version], selected: true))
|
|
491
|
+
end
|
|
492
|
+
renewed, problem = @core.send(:token_request, scheme, url, config[:client],
|
|
493
|
+
[["grant_type", "refresh_token"], ["refresh_token", stored.refresh_token]], stored.refresh_token,
|
|
494
|
+
deadline: job[:deadline])
|
|
495
|
+
committed = nil
|
|
496
|
+
predicate = lambda do |previous|
|
|
497
|
+
configured = configuration_current?(scheme, capture)
|
|
498
|
+
matches = same_token?(previous, stored)
|
|
499
|
+
at = clock
|
|
500
|
+
@lock.synchronize do
|
|
501
|
+
allowed = configured && at < job[:deadline] && current_locked?(scheme, job[:version], selected: true) &&
|
|
502
|
+
matches
|
|
503
|
+
if allowed
|
|
504
|
+
@sessions[scheme][:epoch] += 1
|
|
505
|
+
committed = version_locked(scheme)
|
|
506
|
+
end
|
|
507
|
+
allowed
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
if problem
|
|
511
|
+
@core.token_store.delete_if_current(scheme, &predicate) if problem.rejected
|
|
512
|
+
return { stored: nil, failure: problem, version: committed || job[:version] }.freeze
|
|
513
|
+
end
|
|
514
|
+
changed = @core.token_store.set_if_current(scheme, renewed, &predicate)
|
|
515
|
+
OAuthInputs.require_valid(changed == true && committed && timely?(job) &&
|
|
516
|
+
configuration_current?(scheme, capture) && timely?(job) && current?(scheme, committed, selected: true))
|
|
517
|
+
{ stored: renewed, failure: nil, version: committed }.freeze
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
|
@@ -44,6 +44,10 @@ module Tungsten
|
|
|
44
44
|
# `redirect_uri`);
|
|
45
45
|
# - `token_store`: where authorization-code tokens live (memory by default);
|
|
46
46
|
# - `timeout_ms`: the per-attempt timeout (30 s);
|
|
47
|
+
# - `deadline_ms`: the total deadline of one logical call (none), see
|
|
48
|
+
# CallOptions;
|
|
49
|
+
# - `max_oauth_token_response_bytes`: a positive safe integer cap for each
|
|
50
|
+
# code/refresh response (64 KiB by default); separate from ordinary bodies;
|
|
47
51
|
# - `retries`: RetryOptions over the API's per-tier defaults;
|
|
48
52
|
# - `idempotency_store`: anything with `get(scope, id)` and `put(scope, id,
|
|
49
53
|
# key)` (memory by default);
|
|
@@ -55,20 +59,27 @@ module Tungsten
|
|
|
55
59
|
# - `max_event_bytes` (1 MiB), `max_collect_bytes` (4 MiB), `max_collect_ms`
|
|
56
60
|
# (60 s), `max_reconnects` (3), `reconnect_max_ms` (30 s),
|
|
57
61
|
# `idle_timeout_ms` (none): event streams;
|
|
58
|
-
# - `
|
|
62
|
+
# - `allow_insecure_http` (false): explicitly permit credentials over
|
|
63
|
+
# non-loopback HTTP, including OAuth token requests; prefer HTTPS;
|
|
64
|
+
# - `confirmation_key`: at least 32 cryptographically random bytes, copied;
|
|
65
|
+
# shorter keys fail construction (nil generates a random per-client key);
|
|
59
66
|
# - `now` (epoch milliseconds) and `random` (in [0, 1)): callables for tests.
|
|
60
67
|
ClientOptions = Data.define(
|
|
61
|
-
:redirects, :base_url, :auth, :token_store, :timeout_ms, :retries, :idempotency_store, :middleware,
|
|
62
|
-
:validate_responses, :on_diagnostic, :headers, :max_event_bytes, :max_response_bytes, :max_collect_bytes, :max_collect_ms,
|
|
63
|
-
:max_reconnects, :reconnect_max_ms, :idle_timeout_ms, :confirmation_key, :now, :random
|
|
68
|
+
:redirects, :base_url, :auth, :token_store, :timeout_ms, :deadline_ms, :retries, :idempotency_store, :middleware,
|
|
69
|
+
:validate_responses, :on_diagnostic, :headers, :max_event_bytes, :max_response_bytes, :max_oauth_token_response_bytes, :max_collect_bytes, :max_collect_ms,
|
|
70
|
+
:max_reconnects, :reconnect_max_ms, :idle_timeout_ms, :confirmation_key, :now, :random, :allow_insecure_http
|
|
64
71
|
) do
|
|
65
|
-
def initialize(redirects: nil, base_url: nil, auth: {}, token_store: nil, timeout_ms: nil, retries: nil, idempotency_store: nil,
|
|
72
|
+
def initialize(redirects: nil, base_url: nil, auth: {}, token_store: nil, timeout_ms: nil, deadline_ms: nil, retries: nil, idempotency_store: nil,
|
|
66
73
|
middleware: [], validate_responses: "warn", on_diagnostic: nil, headers: {}, max_event_bytes: nil, max_response_bytes: nil,
|
|
74
|
+
max_oauth_token_response_bytes: nil,
|
|
67
75
|
max_collect_bytes: nil, max_collect_ms: nil, max_reconnects: nil, reconnect_max_ms: nil,
|
|
68
|
-
idle_timeout_ms: nil, confirmation_key: nil, now: nil, random: nil)
|
|
76
|
+
idle_timeout_ms: nil, confirmation_key: nil, now: nil, random: nil, allow_insecure_http: false)
|
|
69
77
|
super
|
|
70
78
|
end
|
|
71
79
|
|
|
80
|
+
def inspect = "#<Tungsten::Runtime::ClientOptions #{REDACTED}>"
|
|
81
|
+
alias_method :to_s, :inspect
|
|
82
|
+
|
|
72
83
|
# Options from nil, a Hash or ClientOptions, with keywords over them.
|
|
73
84
|
def self.from(options = nil, **keywords)
|
|
74
85
|
base =
|
|
@@ -95,11 +106,24 @@ module Tungsten
|
|
|
95
106
|
# - `refuse_confirm_yes`: `confirm: true` confirms nothing and
|
|
96
107
|
# remediation never offers it (callers that only forward tokens);
|
|
97
108
|
# - `timeout_ms`: the per-attempt timeout;
|
|
109
|
+
# - `deadline_ms`: the total deadline of this call in milliseconds, over
|
|
110
|
+
# the client's (none by default). Measured on a monotonic clock from
|
|
111
|
+
# the call, it bounds authentication, idempotency-key reservation,
|
|
112
|
+
# every attempt (each one's timeout is cut to the time left), retry
|
|
113
|
+
# waits, response bodies and verification; for pages, poll and macros
|
|
114
|
+
# the whole traversal, poll or macro. No attempt starts after it
|
|
115
|
+
# expires (code DEADLINE_EXCEEDED); a mutation already sent reports
|
|
116
|
+
# OUTCOME_UNKNOWN. For a stream it bounds connecting and each
|
|
117
|
+
# reconnect, never an open stream;
|
|
98
118
|
# - `verify`: run the operation's verification hook after success;
|
|
99
119
|
# - `headers`: sent with this call.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
#
|
|
121
|
+
# `call_deadline` is internal: the absolute monotonic deadline (ms) the
|
|
122
|
+
# runtime fixes when a call starts, inherited by nested work.
|
|
123
|
+
CallOptions = Data.define(:idempotency_key, :confirm, :refuse_confirm_yes, :timeout_ms, :deadline_ms, :verify, :headers,
|
|
124
|
+
:max_response_bytes, :call_deadline) do
|
|
125
|
+
def initialize(idempotency_key: nil, confirm: nil, refuse_confirm_yes: false, timeout_ms: nil, deadline_ms: nil, verify: false,
|
|
126
|
+
headers: {}, max_response_bytes: nil, call_deadline: nil)
|
|
103
127
|
super
|
|
104
128
|
end
|
|
105
129
|
|
|
@@ -110,7 +134,7 @@ module Tungsten
|
|
|
110
134
|
when CallOptions then value
|
|
111
135
|
when Hash
|
|
112
136
|
given = value.transform_keys(&:to_sym)
|
|
113
|
-
unknown = given.keys - members
|
|
137
|
+
unknown = given.keys - members + (given.key?(:call_deadline) ? [:call_deadline] : [])
|
|
114
138
|
raise ArgumentError, "unknown call option #{unknown.first}" unless unknown.empty?
|
|
115
139
|
|
|
116
140
|
new(**given)
|
|
@@ -125,7 +149,7 @@ module Tungsten
|
|
|
125
149
|
# Never shows keys or tokens.
|
|
126
150
|
def inspect
|
|
127
151
|
shown = { idempotency_key: key? ? REDACTED : nil, confirm: confirm == true ? true : (token ? REDACTED : nil),
|
|
128
|
-
timeout_ms: timeout_ms, verify: verify, headers: (headers || {}).keys }
|
|
152
|
+
timeout_ms: timeout_ms, deadline_ms: deadline_ms, verify: verify, headers: (headers || {}).keys }
|
|
129
153
|
"#<Tungsten::Runtime::CallOptions #{shown}>"
|
|
130
154
|
end
|
|
131
155
|
alias_method :to_s, :inspect
|
|
@@ -167,8 +167,10 @@ module Tungsten
|
|
|
167
167
|
def check_items(op, items_field, items, response)
|
|
168
168
|
return nil if op.page_item.nil? || @validate_responses == "off"
|
|
169
169
|
|
|
170
|
+
seen = {}
|
|
170
171
|
items.each_with_index do |item, index|
|
|
171
|
-
|
|
172
|
+
root = "response#{items_field.empty? ? '' : ".#{items_field}"}[#{index}]"
|
|
173
|
+
issues = judge(op.page_item, item, op.id, root, seen)
|
|
172
174
|
next if issues.empty?
|
|
173
175
|
|
|
174
176
|
issue = issues.first
|