basecamp-sdk 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -10
  3. data/lib/basecamp/client.rb +13 -8
  4. data/lib/basecamp/config.rb +4 -3
  5. data/lib/basecamp/generated/metadata.json +280 -12
  6. data/lib/basecamp/generated/services/account_service.rb +1 -1
  7. data/lib/basecamp/generated/services/automation_service.rb +2 -2
  8. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  9. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  10. data/lib/basecamp/generated/services/card_columns_service.rb +1 -1
  11. data/lib/basecamp/generated/services/card_steps_service.rb +1 -1
  12. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  13. data/lib/basecamp/generated/services/cards_service.rb +4 -4
  14. data/lib/basecamp/generated/services/checkins_service.rb +8 -8
  15. data/lib/basecamp/generated/services/client_approvals_service.rb +2 -2
  16. data/lib/basecamp/generated/services/client_correspondences_service.rb +2 -2
  17. data/lib/basecamp/generated/services/client_replies_service.rb +2 -2
  18. data/lib/basecamp/generated/services/comments_service.rb +2 -2
  19. data/lib/basecamp/generated/services/documents_service.rb +2 -2
  20. data/lib/basecamp/generated/services/events_service.rb +1 -1
  21. data/lib/basecamp/generated/services/everything_service.rb +169 -0
  22. data/lib/basecamp/generated/services/forwards_service.rb +5 -5
  23. data/lib/basecamp/generated/services/gauges_service.rb +3 -3
  24. data/lib/basecamp/generated/services/hill_charts_service.rb +1 -1
  25. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  26. data/lib/basecamp/generated/services/message_types_service.rb +20 -15
  27. data/lib/basecamp/generated/services/messages_service.rb +2 -2
  28. data/lib/basecamp/generated/services/my_assignments_service.rb +5 -5
  29. data/lib/basecamp/generated/services/my_notifications_service.rb +16 -2
  30. data/lib/basecamp/generated/services/people_service.rb +9 -9
  31. data/lib/basecamp/generated/services/projects_service.rb +2 -2
  32. data/lib/basecamp/generated/services/recordings_service.rb +3 -3
  33. data/lib/basecamp/generated/services/reports_service.rb +5 -5
  34. data/lib/basecamp/generated/services/schedules_service.rb +13 -5
  35. data/lib/basecamp/generated/services/search_service.rb +2 -2
  36. data/lib/basecamp/generated/services/subscriptions_service.rb +1 -1
  37. data/lib/basecamp/generated/services/templates_service.rb +3 -3
  38. data/lib/basecamp/generated/services/timeline_service.rb +1 -1
  39. data/lib/basecamp/generated/services/timesheets_service.rb +5 -5
  40. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  41. data/lib/basecamp/generated/services/todolists_service.rb +2 -2
  42. data/lib/basecamp/generated/services/todos_service.rb +2 -2
  43. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  44. data/lib/basecamp/generated/services/tools_service.rb +4 -3
  45. data/lib/basecamp/generated/services/uploads_service.rb +3 -3
  46. data/lib/basecamp/generated/services/vaults_service.rb +2 -2
  47. data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
  48. data/lib/basecamp/generated/types.rb +374 -18
  49. data/lib/basecamp/http.rb +47 -18
  50. data/lib/basecamp/oauth/device_authorization.rb +37 -0
  51. data/lib/basecamp/oauth/device_flow.rb +873 -0
  52. data/lib/basecamp/oauth/device_flow_error.rb +46 -0
  53. data/lib/basecamp/oauth/discovery.rb +13 -11
  54. data/lib/basecamp/oauth/exchange.rb +44 -5
  55. data/lib/basecamp/oauth/fetcher.rb +626 -56
  56. data/lib/basecamp/oauth/oauth_error.rb +3 -1
  57. data/lib/basecamp/oauth/refresh_request.rb +8 -2
  58. data/lib/basecamp/oauth/resource.rb +13 -10
  59. data/lib/basecamp/oauth/token.rb +10 -3
  60. data/lib/basecamp/oauth.rb +47 -2
  61. data/lib/basecamp/services/cards_extensions.rb +67 -0
  62. data/lib/basecamp/version.rb +2 -2
  63. data/lib/basecamp.rb +5 -0
  64. data/scripts/generate-services.rb +24 -6
  65. data/scripts/generate-types.rb +29 -2
  66. metadata +7 -2
@@ -0,0 +1,873 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module Basecamp
9
+ module Oauth
10
+ # RFC 8628 device authorization grant — request, poll, and orchestrate.
11
+ #
12
+ # {request_device_authorization} obtains a device/user code pair;
13
+ # {poll_device_token} runs the §3.5 polling loop against the token endpoint;
14
+ # {perform_device_login} guards capability on an already-selected {Config},
15
+ # surfaces the code through a display hook, and polls. All device-auth and
16
+ # token requests are TLS-guarded (SPEC.md §9). The polling clock and sleeper
17
+ # are injectable so tests run without real delays.
18
+ module DeviceFlow
19
+ # URN grant type for the device authorization grant.
20
+ DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
21
+
22
+ # Default polling interval when the server omits +interval+ (RFC 8628 §3.2).
23
+ DEFAULT_INTERVAL_SECONDS = 5
24
+
25
+ # +slow_down+ bumps the interval by this many seconds, sustained (§3.5).
26
+ SLOW_DOWN_INCREMENT_SECONDS = 5
27
+
28
+ # Default per-request timeout for every device-flow HTTP round-trip. Also the
29
+ # fallback Fetcher.normalize_timeout uses for an invalid device timeout, so an
30
+ # invalid value can't silently borrow discovery's shorter budget.
31
+ DEVICE_REQUEST_TIMEOUT = 30
32
+
33
+ # Granularity (seconds) for polling the +cancelled+ probe while waiting.
34
+ CANCEL_POLL_INTERVAL_SECONDS = 0.1
35
+
36
+ # Cap on exponential backoff after connection timeouts.
37
+ MAX_BACKOFF_SECONDS = 60
38
+
39
+ # Ceiling for +expires_in+/+interval+: 2147483 s (~24.8 days) is the
40
+ # largest whole-second duration whose millisecond form fits a 32-bit
41
+ # signed timer. Shared across all five SDKs (SPEC.md) — an unbounded
42
+ # value such as 1e100 is a malformed response, not a schedulable deadline.
43
+ MAX_DEVICE_SECONDS = 2_147_483
44
+
45
+ # Ceiling for an OAuth token's +expires_in+ (2_147_483_647 s ~= 68 years):
46
+ # cross-runtime safe and vastly beyond any realistic token lifetime.
47
+ # Unlike +MAX_DEVICE_SECONDS+ this bounds +Time+ arithmetic rather than a
48
+ # timer, so a non-finite value (+1e400+ parses to +Float::INFINITY+, which
49
+ # would raise a raw +FloatDomainError+) or an absurd one is a malformed
50
+ # response — never a schedulable deadline. Shared across all five SDKs.
51
+ MAX_TOKEN_LIFETIME_SECONDS = 2_147_483_647
52
+
53
+ # Monotonic clock (seconds). Injectable so tests can advance time.
54
+ DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
55
+
56
+ # Real sleeper (seconds). Injectable so tests assert the wait schedule.
57
+ DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
58
+
59
+ # Cooperative cancellation probe. Injectable; default never cancels.
60
+ DEFAULT_CANCELLED = -> { false }
61
+
62
+ class << self
63
+ # Requests a device/user code pair (RFC 8628 §3.1–3.2).
64
+ #
65
+ # POSTs +client_id+ and, only when set, +scope+ (an omitted scope lets the
66
+ # server apply its default, +read+). Validates that the codes are present,
67
+ # +expires_in+ is positive, and +interval+ (default 5) is positive.
68
+ #
69
+ # @param device_authorization_endpoint [String] the endpoint from discovery
70
+ # @param client_id [String] the public client id (e.g. +basecamp-cli+)
71
+ # @param scope [String, nil] requested scope; omitted from the request when nil
72
+ # @param http_client [Faraday::Connection, nil] HTTP client (default if nil)
73
+ # @param timeout [Integer] request timeout in seconds
74
+ # @param max_body_bytes [Integer] bounded read cap in bytes
75
+ # @return [DeviceAuthorization]
76
+ # @raise [OauthError] +validation+ on a missing client id or a redirect-
77
+ # following injected client; +api_error+ on a non-2xx response, oversized
78
+ # body, or invalid metadata
79
+ # @raise [DeviceFlowError] +:transport+ on a network failure
80
+ def request_device_authorization(
81
+ device_authorization_endpoint:, client_id:, scope: nil,
82
+ http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES
83
+ )
84
+ Basecamp::Security.require_https_unless_localhost!(device_authorization_endpoint, "device authorization endpoint")
85
+ raise OauthError.new("validation", "Client ID is required for device authorization") if client_id.to_s.empty?
86
+ # SSRF: an injected client must not chase an attacker-controlled Location,
87
+ # exactly as the discovery hops require (SPEC.md §16).
88
+ Fetcher.ensure_redirects_suppressed!(http_client) if http_client
89
+
90
+ params = { "client_id" => client_id }
91
+ # Omit scope entirely when unset OR blank so the server applies its
92
+ # default (read) — Ruby treats "" as truthy, so guard on emptiness too.
93
+ params["scope"] = scope unless scope.nil? || scope.empty?
94
+
95
+ # Normalize ONCE at operation entry and thread the SAME value to every
96
+ # request, so a non-finite/non-positive input cannot leave the socket
97
+ # timeout unbounded. The body cap gets the same discipline: an invalid
98
+ # value (nil, Infinity, negative) would disable the streaming bound.
99
+ timeout = Fetcher.normalize_timeout(timeout, default: DEVICE_REQUEST_TIMEOUT)
100
+ max_body_bytes = Fetcher.normalize_body_cap(max_body_bytes)
101
+ status, body = begin
102
+ post_form(
103
+ http_client, device_authorization_endpoint, params,
104
+ timeout: timeout, max_body_bytes: max_body_bytes,
105
+ # A non-2xx device-auth response is a hard failure whose body is unused.
106
+ skip_status: ->(s) { !(200..299).cover?(s) }
107
+ )
108
+ rescue Faraday::Error => e
109
+ raise DeviceFlowError.new(:transport, "Device authorization request failed: #{e.message}")
110
+ end
111
+
112
+ parse_device_authorization(status, body)
113
+ end
114
+
115
+ # Polls the token endpoint until the user approves, denies, or the codes
116
+ # expire (RFC 8628 §3.4–3.5).
117
+ #
118
+ # Waits at least +interval+ seconds between polls against a MONOTONIC
119
+ # deadline. Handles +authorization_pending+ (keep polling), sustained
120
+ # +slow_down+ (+5s for this and every later poll), +access_denied+ and
121
+ # +expired_token+ (terminal), connection timeouts (exponential backoff),
122
+ # and cooperative cancellation.
123
+ #
124
+ # @param token_endpoint [String] the token endpoint from discovery
125
+ # @param client_id [String] the public client id
126
+ # @param device_code [String] the device code from {request_device_authorization}
127
+ # @param interval [Integer] polling interval in seconds
128
+ # @param expires_in [Numeric] code lifetime in seconds until the monotonic
129
+ # deadline; may be fractional (perform_device_login passes the remaining
130
+ # lifetime after deducting display-hook time)
131
+ # @param clock [#call] monotonic clock returning seconds
132
+ # @param sleeper [#call] receives the wait in seconds
133
+ # @param cancelled [#call] cancellation probe; a truthy result ends the flow
134
+ # @param http_client [Faraday::Connection, nil] HTTP client (default if nil)
135
+ # @param timeout [Integer] per-request timeout in seconds
136
+ # @param max_body_bytes [Integer] bounded read cap in bytes
137
+ # @return [Token]
138
+ # @raise [DeviceFlowError] +:access_denied+, +:expired+, +:transport+, or
139
+ # +:cancelled+
140
+ # @raise [OauthError] +api_error+ on an unrecognized token error, oversized
141
+ # body, or +validation+ on a redirect-following injected client; +usage+
142
+ # on an out-of-range +interval+/+expires_in+
143
+ def poll_device_token(
144
+ token_endpoint:, client_id:, device_code:, interval:, expires_in:,
145
+ clock: DEFAULT_CLOCK, sleeper: DEFAULT_SLEEPER, cancelled: DEFAULT_CANCELLED,
146
+ http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES,
147
+ deadline_at: nil
148
+ )
149
+ Basecamp::Security.require_https_unless_localhost!(token_endpoint, "token endpoint")
150
+ Fetcher.ensure_redirects_suppressed!(http_client) if http_client
151
+
152
+ # Caller-input sanity for this public entry point (usage, not the RFC
153
+ # response validation request_device_authorization applies): a nil or
154
+ # non-numeric duration would raise NoMethodError/TypeError below, a
155
+ # non-finite expires_in builds a deadline that NEVER passes (an unbounded
156
+ # poll loop), and an oversized/non-positive interval is not a schedulable
157
+ # wait. Fractional values are accepted — perform_device_login legitimately
158
+ # passes a fractional remaining lifetime after deducting display-hook
159
+ # time. Mirrors the Go/TS/Python/Kotlin caller guards.
160
+ unless valid_device_seconds?(expires_in)
161
+ raise OauthError.new(
162
+ "usage",
163
+ "poll_device_token: expires_in must be a positive number of seconds " \
164
+ "no greater than #{MAX_DEVICE_SECONDS}"
165
+ )
166
+ end
167
+ # The polling interval is additionally whole seconds (RFC 8628),
168
+ # matching the response validation and the integer-typed Go/Kotlin
169
+ # APIs — a fractional interval (0.001) would otherwise permit ~1000
170
+ # polls per second. real? in valid_device_seconds? screens Complex
171
+ # before the modulo runs.
172
+ unless valid_device_seconds?(interval) && (interval % 1).zero?
173
+ raise OauthError.new(
174
+ "usage",
175
+ "poll_device_token: interval must be a positive whole number of seconds " \
176
+ "no greater than #{MAX_DEVICE_SECONDS}"
177
+ )
178
+ end
179
+
180
+ interval_seconds = interval
181
+ backoff_seconds = interval_seconds
182
+ # One-shot next-wait override from a 429 too_many_requests Retry-After
183
+ # (SPEC.md §16): consumed by the next wait, never inflating the
184
+ # slow_down interval. 0 = none.
185
+ override_seconds = 0
186
+ # An absolute issuance-anchored deadline (perform_device_login
187
+ # passes issued_at + expires_in) beats re-anchoring: clock time
188
+ # elapsing between the caller's remaining-lifetime computation and
189
+ # this entry — a process suspension above all — must never be handed
190
+ # back to the code. It can only SHORTEN the validated lifetime.
191
+ deadline =
192
+ if deadline_at.nil?
193
+ sample_clock(clock, "poll_device_token") + expires_in
194
+ else
195
+ unless deadline_at.is_a?(Numeric) && deadline_at.real? \
196
+ && deadline_at.to_f.finite? && deadline_at <= sample_clock(clock, "poll_device_token") + expires_in
197
+ raise OauthError.new(
198
+ "usage",
199
+ "poll_device_token deadline_at must be a finite monotonic timestamp " \
200
+ "no later than expires_in seconds from now"
201
+ )
202
+ end
203
+ deadline_at
204
+ end
205
+
206
+ # Normalize ONCE, outside the polling loop, and reuse for every per-poll
207
+ # request (see request_device_authorization). The body cap gets the same
208
+ # discipline — an invalid value would disable the bound.
209
+ timeout = Fetcher.normalize_timeout(timeout, default: DEVICE_REQUEST_TIMEOUT)
210
+ max_body_bytes = Fetcher.normalize_body_cap(max_body_bytes)
211
+ params = {
212
+ "grant_type" => DEVICE_CODE_GRANT_TYPE,
213
+ "device_code" => device_code,
214
+ "client_id" => client_id
215
+ }
216
+
217
+ loop do
218
+ raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call
219
+
220
+ # Check the monotonic deadline BEFORE waiting, then clamp the wait so a
221
+ # long interval or timeout backoff can never overshoot expiry. The
222
+ # per-request timeout (set on every request in +post_form+) bounds a
223
+ # stalled socket, so nothing here blows past the deadline. The wait is
224
+ # the LARGER of the server-driven interval and the transient timeout
225
+ # backoff — the two schedules stay separate so a backoff can drain
226
+ # back down to the server interval once round-trips resume.
227
+ now = sample_clock(clock, "poll_device_token")
228
+ raise DeviceFlowError.new(:expired, "Device code expired before authorization completed") if now >= deadline
229
+
230
+ wait = [ [ interval_seconds, backoff_seconds, override_seconds ].max, deadline - now ].min
231
+ override_seconds = 0 # one-shot: consumed by this wait, then gone
232
+ wait_cancellable(wait, cancelled, sleeper)
233
+
234
+ raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call
235
+
236
+ post_remaining = deadline - sample_clock(clock, "poll_device_token")
237
+ raise DeviceFlowError.new(:expired, "Device code expired before authorization completed") if post_remaining <= 0
238
+
239
+ outcome = begin
240
+ # Bound the request by the REMAINING code lifetime as well as the
241
+ # per-request timeout: near expiry, a stalled token POST must not
242
+ # hold the flow past the monotonic deadline for the full budget.
243
+ post_device_token(http_client, token_endpoint, params,
244
+ timeout: [ timeout, post_remaining ].min, max_body_bytes: max_body_bytes)
245
+ rescue Faraday::TimeoutError
246
+ # A connection timeout is transient: back off exponentially and
247
+ # keep polling rather than ending the flow. Only the backoff grows —
248
+ # the server-driven interval is left untouched so it can govern
249
+ # again once a round-trip completes. The next wait is still clamped
250
+ # to the deadline at the top of the loop.
251
+ backoff_seconds = [ backoff_seconds * 2, MAX_BACKOFF_SECONDS ].min
252
+ next
253
+ rescue Faraday::Error => e
254
+ # Cancellation-beats-classification on the error path too: a
255
+ # cancel that flipped while the doomed request was in flight must
256
+ # surface as cancelled, not as the transport fault it raised.
257
+ raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call
258
+
259
+ raise DeviceFlowError.new(:transport, "Device token poll failed: #{e.message}")
260
+ end
261
+
262
+ # Re-check cancellation the moment the round-trip completes: the
263
+ # sync POST cannot observe the probe while in flight (bounded only
264
+ # by its timeout), so a cancel raised mid-request must surface
265
+ # here — never a token returned after the caller asked to stop.
266
+ raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call
267
+
268
+ # ANY completed HTTP round-trip resets the timeout backoff to the
269
+ # current server-driven interval.
270
+ backoff_seconds = interval_seconds
271
+
272
+ kind, value, status, retry_after = outcome
273
+ return value if kind == :token
274
+
275
+ case value
276
+ when "authorization_pending"
277
+ next
278
+ when "too_many_requests"
279
+ # Retryable ONLY as the exact 429 + too_many_requests pair
280
+ # (SPEC.md §16). The next wait honors a positive integral
281
+ # Retry-After delta via a one-shot max(interval, Retry-After)
282
+ # override — a missing/malformed header falls back to the current
283
+ # interval, and the override decays after one wait.
284
+ unless status == 429
285
+ raise OauthError.new("api_error", "Device token request failed: #{value}", http_status: status)
286
+ end
287
+
288
+ override_seconds = parse_retry_after_seconds(retry_after)
289
+ when "slow_down"
290
+ interval_seconds += SLOW_DOWN_INCREMENT_SECONDS
291
+ # Re-sync the backoff to the GROWN interval (the reset above used the
292
+ # pre-increment value) so a later timeout doubles from the new
293
+ # interval, not the stale one.
294
+ backoff_seconds = interval_seconds
295
+ when "access_denied"
296
+ raise DeviceFlowError.new(:access_denied, "The authorization request was denied")
297
+ when "expired_token"
298
+ raise DeviceFlowError.new(:expired, "Device code expired before authorization completed")
299
+ else
300
+ raise OauthError.new("api_error", "Device token request failed: #{value}", http_status: status)
301
+ end
302
+ end
303
+ end
304
+
305
+ # Runs the full device authorization grant against an already-selected
306
+ # {Config} (RFC 8628; SPEC.md §16).
307
+ #
308
+ # The capability guard requires BOTH +device_authorization_endpoint+ AND
309
+ # the device_code grant in +grant_types_supported+; otherwise it raises
310
+ # +:unavailable+ before any request is issued.
311
+ #
312
+ # @param config [Config] the already-selected authorization-server config
313
+ # @param client_id [String] the public client id
314
+ # @param display [#call] receives the {DeviceAuthorization} once, before polling
315
+ # @param scope [String, nil] requested scope; omitted when nil
316
+ # @param clock [#call] monotonic clock returning seconds
317
+ # @param sleeper [#call] receives the wait in seconds
318
+ # @param cancelled [#call] cancellation probe
319
+ # @param http_client [Faraday::Connection, nil] HTTP client (default if nil)
320
+ # @param timeout [Integer] request timeout in seconds
321
+ # @return [Token]
322
+ # @raise [DeviceFlowError] +:unavailable+ when the config cannot do device
323
+ # flow; other reasons on denial/expiry/transport/cancellation
324
+ # @raise [OauthError] +usage+ when +display+ is not callable (checked
325
+ # before any request — DeviceFlowError subclasses OauthError, so a
326
+ # rescue of the former alone would miss this)
327
+ def perform_device_login(
328
+ config:, client_id:, display:, scope: nil,
329
+ clock: DEFAULT_CLOCK, sleeper: DEFAULT_SLEEPER, cancelled: DEFAULT_CANCELLED,
330
+ http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES
331
+ )
332
+ unless device_grant_available?(config)
333
+ raise DeviceFlowError.new(
334
+ :unavailable,
335
+ "The selected authorization server does not support the device authorization grant"
336
+ )
337
+ end
338
+
339
+ # A non-callable display is a usage error, not a late NoMethodError:
340
+ # it is the only mechanism surfacing the verification code, so
341
+ # dereferencing it AFTER the request would mint a code nobody can
342
+ # approve. Reject before any network activity (matching Go).
343
+ unless display.respond_to?(:call)
344
+ raise OauthError.new("usage", "perform_device_login requires a callable display hook")
345
+ end
346
+
347
+ # Honor a cancellation raised BEFORE the flow does any work: the
348
+ # sync authorization POST cannot observe the probe in flight, so
349
+ # without this entry check an already-cancelled flow still performs
350
+ # the request and invokes the display hook.
351
+ raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call
352
+
353
+ auth = begin
354
+ request_device_authorization(
355
+ device_authorization_endpoint: config.device_authorization_endpoint,
356
+ client_id: client_id, scope: scope,
357
+ http_client: http_client, timeout: timeout, max_body_bytes: max_body_bytes
358
+ )
359
+ rescue DeviceFlowError, OauthError
360
+ # Cancellation-beats-classification on the error path too: a cancel
361
+ # that flipped while the authorization request was in flight wins
362
+ # over whatever fault the doomed request raised.
363
+ raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call
364
+
365
+ raise
366
+ end
367
+
368
+ # Anchor the code's lifetime at ISSUANCE — the response's arrival,
369
+ # per SPEC §16 — before the display hook, so a slow display eats
370
+ # into the deadline instead of resetting it. Expiry past this point
371
+ # is arbitrated by the server (expired_token), so receipt-anchoring
372
+ # fails safe.
373
+ issued_at = sample_clock(clock, "perform_device_login")
374
+
375
+ # Re-check after the round-trip AND after the anchor sample (itself
376
+ # a cancellation-capable callback seam), before surfacing the code:
377
+ # a cancel set in either window must not reach the display hook.
378
+ raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call
379
+
380
+ display.call(auth)
381
+ remaining = auth.expires_in - (sample_clock(clock, "perform_device_login") - issued_at)
382
+ # Cancellation raised DURING the display hook OR during the clock
383
+ # sample just above (the clock is a cancellation-capable callback
384
+ # seam, exactly like the issuance anchor) wins over expiry:
385
+ # checked after the sample and before the expiry branch, matching
386
+ # the TS orchestrator's ordering.
387
+ raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call
388
+
389
+ if remaining <= 0
390
+ raise DeviceFlowError.new(:expired, "Device code expired before authorization completed")
391
+ end
392
+
393
+ poll_device_token(
394
+ token_endpoint: config.token_endpoint,
395
+ client_id: client_id, device_code: auth.device_code,
396
+ interval: auth.interval, expires_in: remaining,
397
+ # The EXACT issuance-anchored deadline: a clock advance between
398
+ # the remaining computation above and the poller's entry must not
399
+ # extend the code lifetime.
400
+ deadline_at: issued_at + auth.expires_in,
401
+ clock: clock, sleeper: sleeper, cancelled: cancelled,
402
+ http_client: http_client, timeout: timeout, max_body_bytes: max_body_bytes
403
+ )
404
+ end
405
+
406
+ private
407
+
408
+ # Capability guard: BOTH a present endpoint AND the advertised grant
409
+ # type. +grant_types_supported+ must be an Array checked for exact
410
+ # membership — a String (or a superstring containing the grant URN)
411
+ # must never satisfy the guard via +String#include?+ substring matching.
412
+ # A blank endpoint is treated as absent.
413
+ def device_grant_available?(config)
414
+ has_endpoint = !config.device_authorization_endpoint.to_s.strip.empty?
415
+ grants = config.grant_types_supported
416
+ has_endpoint && grants.is_a?(Array) && grants.include?(DEVICE_CODE_GRANT_TYPE)
417
+ end
418
+
419
+ # A positive, finite, real number of seconds within the shared device
420
+ # ceiling. +real?+ gates out Complex before +finite?+/+positive?+ (which
421
+ # Complex does not define), matching {Fetcher.valid_timeout?}.
422
+ # EVERY sample of the injected clock seam is validated: a String or
423
+ # Complex sample would raise a raw TypeError out of the deadline
424
+ # arithmetic, and a NaN sample makes every deadline comparison
425
+ # permanently false — polling an authorization_pending endpoint
426
+ # forever. A malformed sample is the typed usage fault instead.
427
+ def sample_clock(clock, entry)
428
+ value = clock.call
429
+ unless value.is_a?(Numeric) && value.real? && value.to_f.finite?
430
+ raise OauthError.new("usage", "#{entry} clock must return a finite number of seconds")
431
+ end
432
+ value
433
+ end
434
+
435
+ def valid_device_seconds?(value)
436
+ value.is_a?(Numeric) && value.real? && value.finite? && value.positive? && value <= MAX_DEVICE_SECONDS
437
+ end
438
+
439
+ # Waits +seconds+ while observing cancellation DURING the wait. A plain
440
+ # +sleep+ is not interruptible, so a cancellation set mid-wait would not be
441
+ # noticed until the whole (possibly grown +slow_down+) interval elapses.
442
+ # With the default no-op probe a single sleep preserves the exact wait
443
+ # schedule; only a real probe needs the finer-grained interrupt polling —
444
+ # matching the ctx/AbortSignal/coroutine cancellation Go/TS/Kotlin waits have.
445
+ def wait_cancellable(seconds, cancelled, sleeper)
446
+ if cancelled.equal?(DEFAULT_CANCELLED)
447
+ sleeper.call(seconds)
448
+ return
449
+ end
450
+
451
+ remaining = seconds
452
+ while remaining.positive?
453
+ raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call
454
+
455
+ step = [ remaining, CANCEL_POLL_INTERVAL_SECONDS ].min
456
+ sleeper.call(step)
457
+ remaining -= step
458
+ end
459
+ end
460
+
461
+ # POSTs a form body and returns +[status, body]+, reading under the same
462
+ # bounded/streaming cap as discovery (SPEC.md §9).
463
+ #
464
+ # With a nil +client+ the POST runs on the headers-first
465
+ # {Fetcher.stream_http} primitive: +skip_status+ classifies by status at
466
+ # HEADER time (a skipped body is never read, even one that stalls
467
+ # forever), and a watchdog bounds the whole request — including a
468
+ # stalled or byte-dripped header phase — at the timeout. An INJECTED
469
+ # Faraday connection keeps the Faraday path below.
470
+ #
471
+ # +on_headers+, when given, receives a case-insensitive header lookup
472
+ # (+#[]+) once response headers are available — the poll loop reads
473
+ # Retry-After through it without widening the return shape.
474
+ def post_form(client, url, params, timeout:, max_body_bytes:, skip_status: nil, on_headers: nil)
475
+ if client.nil?
476
+ return Fetcher.stream_http(
477
+ :post, url,
478
+ headers: { "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "application/json" },
479
+ form: params, timeout: timeout, max_body_bytes: max_body_bytes, skip_status: skip_status,
480
+ on_headers: on_headers
481
+ )
482
+ end
483
+
484
+ # Injected-client (Faraday) path. +timeout+ is already normalized by the
485
+ # caller (request/poll entry). The wall-clock deadline bounds the WHOLE
486
+ # read: req.options.timeout below bounds only each socket read and resets
487
+ # on every on_data chunk, so a slow-drip peer could otherwise hang a
488
+ # device request past the timeout / code expiry while staying under the
489
+ # cap. +skip_status+ stops the read for a status whose body the caller
490
+ # doesn't use (non-2xx / 3xx).
491
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
492
+ chunks, on_data = Fetcher.bounded_reader(max_body_bytes, deadline: deadline, skip_status: skip_status)
493
+ # Timeout.timeout wraps the WHOLE call: the per-read timeout resets
494
+ # on every socket read, so a peer dripping HEADER bytes under it
495
+ # would otherwise hold the POST open indefinitely — on_data (a
496
+ # body callback) never runs during the header phase, leaving
497
+ # nothing else to enforce the wall clock on an injected client.
498
+ # The window is the REMAINING budget, not a fresh +timeout+: time
499
+ # spent before dispatch (descheduling included) already counts
500
+ # against the deadline, so the request can never run past it.
501
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
502
+ raise Faraday::TimeoutError, "request budget exhausted before dispatch" if remaining <= 0
503
+
504
+ response = Timeout.timeout(remaining, Faraday::TimeoutError) do
505
+ client.post(url) do |req|
506
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
507
+ req.headers["Accept"] = "application/json"
508
+ req.body = URI.encode_www_form(params)
509
+ req.options.timeout = timeout
510
+ req.options.open_timeout = timeout
511
+ req.options.on_data = on_data
512
+ end
513
+ end
514
+
515
+ # Status-first backstop on the completed response. The +on_data+ SkipBody
516
+ # fast-path only fires when the adapter streams AND passes +env+ (Faraday
517
+ # >= 2.5). A buffered adapter that ignores +on_data+, an older Faraday
518
+ # (2.0–2.4) that omits +env+, or a header-only response reaches here with
519
+ # the skip-status body un-skipped — re-apply +skip_status+ to the final
520
+ # +response.status+ so a 3xx/non-2xx fault is classified by status for
521
+ # every client shape and supported Faraday version, never buffered into
522
+ # a size-cap error the caller must then untangle.
523
+ #
524
+ # Known residual — INJECTED clients only (the default path above is
525
+ # exact): Faraday exposes no headers-time callback, so a response whose
526
+ # headers arrive but whose body then stalls PAST the read timeout never
527
+ # returns from +client.post+ — it surfaces as a bounded transport timeout
528
+ # (request path: :transport; poll path: backoff, capped by code expiry)
529
+ # rather than this status-first classification. Bounded and
530
+ # redirect-safe (3xx Locations are never followed).
531
+ # +|| {}+: an injected duck-typed double may omit +headers+ —
532
+ # absent headers mean no Retry-After, never a nil deref.
533
+ on_headers&.call(response.headers || {})
534
+ return [ response.status, "" ] if skip_status && skip_status.call(response.status)
535
+
536
+ # Timeout.timeout's interrupt can be delivered late: a response
537
+ # whose final byte lands just before the deadline but whose block
538
+ # returns just after it would otherwise be accepted past the wall
539
+ # clock. Status-first classification above outranks this re-check
540
+ # (a completed skip-status is definitive); everything else past
541
+ # the deadline is refused as the same transport-shaped timeout.
542
+ raise Faraday::TimeoutError, "response completed after the deadline" \
543
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
544
+
545
+ body =
546
+ if chunks.empty?
547
+ raw = response.body.to_s
548
+ raise Fetcher::BodyTooLarge if raw.bytesize > max_body_bytes
549
+
550
+ raw
551
+ else
552
+ chunks.join
553
+ end
554
+
555
+ [ response.status, body.dup.force_encoding(Encoding::UTF_8) ]
556
+ rescue Fetcher::SkipBody => e
557
+ # The body was intentionally not drained (its status is unused) — return
558
+ # it empty and let the caller classify by status.
559
+ [ e.status, "" ]
560
+ rescue Fetcher::BodyTooLarge
561
+ raise OauthError.new("api_error", "Device flow response exceeds size cap")
562
+ rescue Fetcher::ReadDeadlineExceeded
563
+ # Surface as a Faraday timeout so the caller's existing transport/timeout
564
+ # rescues classify it (request → :transport, poll → backoff) — a slow-drip
565
+ # read is a transport timeout, not an api_error.
566
+ raise Faraday::TimeoutError, "Device flow read exceeded the timeout deadline"
567
+ end
568
+
569
+ def parse_device_authorization(status, body)
570
+ # Check status BEFORE parsing (as discovery does): a non-2xx here is a
571
+ # hard failure with no OAuth error semantics, so a non-JSON error body
572
+ # (common for 500/502) must surface as "failed with status …", not a
573
+ # misleading parse error. The token poll is different — it parses non-2xx
574
+ # bodies to read authorization_pending / slow_down.
575
+ unless (200..299).cover?(status)
576
+ raise OauthError.new(
577
+ "api_error",
578
+ "Device authorization failed with status #{status}",
579
+ http_status: status
580
+ )
581
+ end
582
+
583
+ data = parse_json_object(body, status, "device authorization")
584
+ build_device_authorization(data, status)
585
+ end
586
+
587
+ # Parse a JSON object, type-checking rather than trusting truthiness: a
588
+ # non-object body (array, number, string) is malformed and must not be
589
+ # indexed as if it were a Hash.
590
+ def parse_json_object(body, status, label)
591
+ data = JSON.parse(body)
592
+ unless data.is_a?(Hash)
593
+ raise OauthError.new("api_error", "Invalid #{label} response: not a JSON object", http_status: status)
594
+ end
595
+
596
+ data
597
+ rescue JSON::ParserError
598
+ # cause: nil — the parser error's message embeds the offending
599
+ # input, and these bodies carry device codes and access tokens:
600
+ # full_message and cause-aware loggers must not disclose them.
601
+ raise OauthError.new("api_error", "Failed to parse #{label} response", http_status: status), cause: nil
602
+ end
603
+
604
+ # Every validation error carries the (2xx) status so a malformed success
605
+ # body is diagnosable as such — uniform with the token raises and the
606
+ # other SDKs.
607
+ def build_device_authorization(data, status)
608
+ device_code = data["device_code"]
609
+ user_code = data["user_code"]
610
+ verification_uri = data["verification_uri"]
611
+ # Type-check the required strings — don't coerce via +.to_s+, which
612
+ # would silently accept a numeric or boolean field as a "present" code.
613
+ unless [ device_code, user_code, verification_uri ].all? { |field| field.is_a?(String) && !field.empty? }
614
+ raise OauthError.new(
615
+ "api_error",
616
+ "Invalid device authorization response: missing or malformed required fields",
617
+ http_status: status
618
+ )
619
+ end
620
+
621
+ complete = data["verification_uri_complete"]
622
+ unless complete.nil? || complete.is_a?(String)
623
+ raise OauthError.new(
624
+ "api_error",
625
+ "Invalid device authorization response: verification_uri_complete must be a string",
626
+ http_status: status
627
+ )
628
+ end
629
+
630
+ expires_in = data["expires_in"]
631
+ unless positive_integer_seconds?(expires_in)
632
+ raise OauthError.new(
633
+ "api_error",
634
+ "Invalid device authorization response: expires_in must be a positive integer " \
635
+ "no greater than #{MAX_DEVICE_SECONDS}",
636
+ http_status: status
637
+ )
638
+ end
639
+
640
+ DeviceAuthorization.new(
641
+ device_code: device_code,
642
+ user_code: user_code,
643
+ verification_uri: verification_uri,
644
+ verification_uri_complete: complete,
645
+ # Coerce an integer-valued Float (900.0) to Integer so the model always
646
+ # carries whole seconds, matching +build_token+ and the other SDKs.
647
+ expires_in: expires_in.to_i,
648
+ interval: resolve_interval(data["interval"], status)
649
+ )
650
+ end
651
+
652
+ # Default 5 when absent; any present value must be a positive integer
653
+ # number of seconds (RFC 8628). Integer-valued floats (5.0) are fine;
654
+ # fractional values (2.5) are malformed and rejected for cross-SDK parity.
655
+ def resolve_interval(raw, status)
656
+ if raw.nil?
657
+ DEFAULT_INTERVAL_SECONDS
658
+ elsif positive_integer_seconds?(raw)
659
+ # Coerce an integer-valued Float (5.0) to Integer for whole-second parity.
660
+ raw.to_i
661
+ else
662
+ raise OauthError.new(
663
+ "api_error",
664
+ "Invalid device authorization response: interval must be a positive integer " \
665
+ "no greater than #{MAX_DEVICE_SECONDS}",
666
+ http_status: status
667
+ )
668
+ end
669
+ end
670
+
671
+ # RFC 8628 durations are integer seconds. Accept a positive Numeric with
672
+ # no fractional part (5 or 5.0) up to MAX_DEVICE_SECONDS; reject
673
+ # fractional (2.5), oversized (1e100), and non-numeric values.
674
+ def positive_integer_seconds?(value)
675
+ value.is_a?(Numeric) && value.positive? && value <= MAX_DEVICE_SECONDS && (value % 1).zero?
676
+ end
677
+
678
+ # Returns +[:token, Token, status]+ on success or
679
+ # +[:error, code, status, retry_after]+ when the server reports an
680
+ # OAuth error — +retry_after+ is the raw Retry-After header (or nil),
681
+ # consumed only by the poll loop's 429 handling. Raises +api_error+
682
+ # on a malformed, redirecting, or 200-but-tokenless response.
683
+ def post_device_token(client, token_endpoint, params, timeout:, max_body_bytes:)
684
+ retry_after = nil
685
+ status, body = post_form(
686
+ client, token_endpoint, params,
687
+ timeout: timeout, max_body_bytes: max_body_bytes,
688
+ # A 3xx token response is a redirect fault whose body is unused; a 4xx
689
+ # body IS read (it carries authorization_pending/slow_down).
690
+ skip_status: ->(s) { !(s == 200 || (400..499).cover?(s)) },
691
+ on_headers: ->(headers) { retry_after = headers["Retry-After"] }
692
+ )
693
+
694
+ # A redirect is never a valid token-endpoint outcome: it is not
695
+ # followed (redirect-following clients are rejected up front), and
696
+ # its body must not be classified as an OAuth error — a 3xx carrying
697
+ # {"error":"authorization_pending"} would otherwise poll forever.
698
+ if (300..399).cover?(status)
699
+ raise OauthError.new(
700
+ "api_error",
701
+ "Device token request failed: unexpected redirect (status #{status})",
702
+ http_status: status
703
+ )
704
+ end
705
+
706
+ # Every remaining status outside 200 and 4xx is terminal WITHOUT
707
+ # its body (only a 200 carries the token and only a 4xx the OAuth
708
+ # error code) — the skip_status above already refused the body, so
709
+ # a 201/500 that stalls while streaming can never time out
710
+ # mid-read and be retried as a transient failure until expiry.
711
+ unless status == 200 || (400..499).cover?(status)
712
+ raise OauthError.new(
713
+ "api_error",
714
+ "Device token request failed with status #{status}",
715
+ http_status: status
716
+ )
717
+ end
718
+
719
+ data = parse_json_object(body, status, "device token")
720
+
721
+ # Exactly HTTP 200, not any 2xx: RFC 8628/6749 token responses are
722
+ # 200, and SPEC.md §16 pins the contract. A nonstandard 201/202
723
+ # carrying an access_token must not prematurely complete polling —
724
+ # it falls through to the OAuth-error path below and terminates as
725
+ # api_error (http_<status>).
726
+ if status == 200
727
+ access_token = data["access_token"]
728
+ # Require a genuine non-empty String — not a truthy/coercible value.
729
+ unless access_token.is_a?(String) && !access_token.empty?
730
+ raise OauthError.new("api_error", "Device token response missing access_token", http_status: status)
731
+ end
732
+
733
+ [ :token, build_token(data, status), status ]
734
+ else
735
+ # Recognize OAuth protocol error codes ONLY on a 4xx (RFC 8628
736
+ # §3.5 error responses are 400-class): a nonstandard 2xx
737
+ # (201/202) or a 5xx carrying a crafted authorization_pending
738
+ # body must not keep the loop polling — only a 200 can produce a
739
+ # token and only a 4xx a protocol state. Everything else falls
740
+ # back to http_<status>, which the loop terminates as api_error.
741
+ error = data["error"]
742
+ recognized = (400..499).cover?(status) && error.is_a?(String) && !error.empty?
743
+ # A 429 recognizes ONLY too_many_requests (the exact retryable
744
+ # pair): a throttling endpoint whose body parrots
745
+ # authorization_pending/slow_down must not keep the loop polling
746
+ # until code expiry.
747
+ recognized &&= error == "too_many_requests" if status == 429
748
+ # Truncate at extraction (SPEC §9's 500-unit cap): the server
749
+ # controls this string and an unrecognized value is interpolated
750
+ # into the api_error message. Real protocol codes are short, so
751
+ # classification is unaffected.
752
+ [ :error, recognized ? Basecamp::Security.truncate(error) : "http_#{status}", status, retry_after ]
753
+ end
754
+ end
755
+
756
+ # Validates a Retry-After delta for the 429 poll contract (SPEC.md
757
+ # §16): a positive integral number of seconds. A representable delta
758
+ # beyond {MAX_DEVICE_SECONDS} (the shared 32-bit-ms timer bound)
759
+ # CLAMPS to the ceiling — the wait rule clips to the remaining code
760
+ # lifetime, honoring the throttle. Anything else — missing, an
761
+ # HTTP-date, fractional, non-positive, or unrepresentable (the digit
762
+ # bound below) — returns 0 so the caller falls back to the current
763
+ # interval.
764
+ def parse_retry_after_seconds(header)
765
+ # ASCII SP/HTAB only (RFC 9110 OWS) — NOT String#strip, which also
766
+ # removes \v \f \r \n \0 and would trim a malformed value into
767
+ # validity (SPEC §16 pins SP/HTAB-only trimming).
768
+ trimmed = header.is_a?(String) ? header.gsub(/\A[ \t]+|[ \t]+\z/, "") : ""
769
+ if trimmed.match?(/\A\d+\z/)
770
+ # Leading zeros stripped BEFORE the length bound so a padded
771
+ # in-range delta is honored; >10 significant digits is
772
+ # unrepresentable (matches the Python parser) -> fallback. A
773
+ # representable delta beyond the shared device ceiling CLAMPS:
774
+ # the wait rule clamps to the remaining code lifetime anyway, so
775
+ # an over-ceiling throttle waits out the rest of the lifetime
776
+ # instead of resending before the server's throttle.
777
+ significant = trimmed.sub(/\A0+/, "")
778
+ if significant.empty? || significant.length > 10
779
+ 0
780
+ else
781
+ [ significant.to_i, MAX_DEVICE_SECONDS ].min
782
+ end
783
+ else
784
+ 0
785
+ end
786
+ end
787
+
788
+ # Constructs the {Token}, type-checking every optional field first:
789
+ # {Token#initialize} performs +Time+ arithmetic on +expires_in+, so a
790
+ # malformed value (a String, a non-finite +Float::INFINITY+ from
791
+ # +1e400+, a value past {MAX_TOKEN_LIFETIME_SECONDS}) must surface as
792
+ # +api_error+ rather than escape as a TypeError or FloatDomainError.
793
+ # +token_type+/+refresh_token+/+scope+ must be Strings when present.
794
+ # Absent/nil +expires_in+ stays allowed (no expiry).
795
+ def build_token(data, status)
796
+ expires_in = data["expires_in"]
797
+ unless valid_token_expires_in?(expires_in)
798
+ raise OauthError.new(
799
+ "api_error",
800
+ "Invalid device token response: expires_in must be a finite positive whole number " \
801
+ "no greater than #{MAX_TOKEN_LIFETIME_SECONDS} seconds",
802
+ http_status: status
803
+ )
804
+ end
805
+ # Coerce an integer-valued Float (3600.0) to Integer so {Token} always
806
+ # carries whole seconds, matching the other SDKs' coercion.
807
+ expires_in = expires_in.to_i unless expires_in.nil?
808
+
809
+ token_type = data["token_type"]
810
+ unless token_type.nil? || (token_type.is_a?(String) && !token_type.empty?)
811
+ raise OauthError.new(
812
+ "api_error",
813
+ "Invalid device token response: token_type must be a non-empty string",
814
+ http_status: status
815
+ )
816
+ end
817
+
818
+ refresh_token = data["refresh_token"]
819
+ unless refresh_token.nil? || refresh_token.is_a?(String)
820
+ raise OauthError.new(
821
+ "api_error",
822
+ "Invalid device token response: refresh_token must be a string",
823
+ http_status: status
824
+ )
825
+ end
826
+
827
+ scope = data["scope"]
828
+ unless scope.nil? || scope.is_a?(String)
829
+ raise OauthError.new(
830
+ "api_error",
831
+ "Invalid device token response: scope must be a string",
832
+ http_status: status
833
+ )
834
+ end
835
+
836
+ # resource: absent and JSON null are unset; when present it must be
837
+ # a non-empty string (SPEC §16) — an empty binding is not a binding.
838
+ resource = data["resource"]
839
+ unless resource.nil? || (resource.is_a?(String) && !resource.empty?)
840
+ raise OauthError.new(
841
+ "api_error",
842
+ "Invalid device token response: resource must be a non-empty string when present",
843
+ http_status: status
844
+ )
845
+ end
846
+
847
+ Token.new(
848
+ access_token: data["access_token"],
849
+ refresh_token: refresh_token,
850
+ token_type: token_type || "Bearer",
851
+ expires_in: expires_in,
852
+ scope: scope,
853
+ resource: resource
854
+ )
855
+ end
856
+
857
+ # A token +expires_in+ is valid when absent/nil or a finite, positive,
858
+ # WHOLE-second Numeric within {MAX_TOKEN_LIFETIME_SECONDS}. An
859
+ # integer-valued float (+3600.0+) is accepted; a fractional value
860
+ # (+1.5+) is rejected — matching the device-duration rule and Go/Kotlin,
861
+ # whose integer/Long typing already rejects a fractional lifetime.
862
+ # +Float::INFINITY+ (from a JSON +1e400+) is Numeric and positive but not
863
+ # finite, so +finite?+ rejects it before it can poison deadline math.
864
+ def valid_token_expires_in?(value)
865
+ return true if value.nil?
866
+
867
+ value.is_a?(Numeric) && value.finite? && value.positive? &&
868
+ value <= MAX_TOKEN_LIFETIME_SECONDS && (value % 1).zero?
869
+ end
870
+ end
871
+ end
872
+ end
873
+ end