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.
@@ -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
- # - `confirmation_key`: signs confirmation tokens (random per client);
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
- CallOptions = Data.define(:idempotency_key, :confirm, :refuse_confirm_yes, :timeout_ms, :verify, :headers, :max_response_bytes) do
101
- def initialize(idempotency_key: nil, confirm: nil, refuse_confirm_yes: false, timeout_ms: nil, verify: false,
102
- headers: {}, max_response_bytes: nil)
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
- issues = judge(op.page_item, item)
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