basecamp-sdk 0.10.0 → 0.12.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.
- checksums.yaml +4 -4
- data/README.md +106 -10
- data/lib/basecamp/client.rb +28 -8
- data/lib/basecamp/config.rb +4 -3
- data/lib/basecamp/generated/metadata.json +178 -17
- data/lib/basecamp/generated/services/account_service.rb +1 -1
- data/lib/basecamp/generated/services/automation_service.rb +1 -1
- data/lib/basecamp/generated/services/bookmarks_service.rb +49 -0
- data/lib/basecamp/generated/services/boosts_service.rb +3 -3
- data/lib/basecamp/generated/services/calendars_service.rb +30 -0
- data/lib/basecamp/generated/services/campfires_service.rb +7 -7
- data/lib/basecamp/generated/services/card_columns_service.rb +1 -1
- data/lib/basecamp/generated/services/card_steps_service.rb +1 -1
- data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
- data/lib/basecamp/generated/services/cards_service.rb +2 -2
- data/lib/basecamp/generated/services/checkins_service.rb +8 -8
- data/lib/basecamp/generated/services/client_approvals_service.rb +2 -2
- data/lib/basecamp/generated/services/client_correspondences_service.rb +2 -2
- data/lib/basecamp/generated/services/client_replies_service.rb +2 -2
- data/lib/basecamp/generated/services/comments_service.rb +2 -2
- data/lib/basecamp/generated/services/documents_service.rb +2 -2
- data/lib/basecamp/generated/services/drafts_service.rb +21 -0
- data/lib/basecamp/generated/services/events_service.rb +1 -1
- data/lib/basecamp/generated/services/everything_service.rb +69 -46
- data/lib/basecamp/generated/services/forwards_service.rb +5 -5
- data/lib/basecamp/generated/services/gauges_service.rb +3 -3
- data/lib/basecamp/generated/services/hill_charts_service.rb +1 -1
- data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
- data/lib/basecamp/generated/services/message_types_service.rb +2 -2
- data/lib/basecamp/generated/services/messages_service.rb +2 -2
- data/lib/basecamp/generated/services/my_assignments_service.rb +34 -3
- data/lib/basecamp/generated/services/my_notes_service.rb +28 -0
- data/lib/basecamp/generated/services/my_notifications_service.rb +2 -2
- data/lib/basecamp/generated/services/people_service.rb +8 -8
- data/lib/basecamp/generated/services/projects_service.rb +2 -2
- data/lib/basecamp/generated/services/recordings_service.rb +2 -2
- data/lib/basecamp/generated/services/reports_service.rb +5 -5
- data/lib/basecamp/generated/services/schedules_service.rb +4 -4
- data/lib/basecamp/generated/services/search_service.rb +2 -2
- data/lib/basecamp/generated/services/subscriptions_service.rb +1 -1
- data/lib/basecamp/generated/services/templates_service.rb +3 -3
- data/lib/basecamp/generated/services/timeline_service.rb +1 -1
- data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
- data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
- data/lib/basecamp/generated/services/todolists_service.rb +2 -2
- data/lib/basecamp/generated/services/todos_service.rb +19 -2
- data/lib/basecamp/generated/services/todosets_service.rb +1 -1
- data/lib/basecamp/generated/services/tools_service.rb +1 -1
- data/lib/basecamp/generated/services/uploads_service.rb +3 -3
- data/lib/basecamp/generated/services/vaults_service.rb +2 -2
- data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
- data/lib/basecamp/generated/types.rb +313 -34
- data/lib/basecamp/http.rb +47 -19
- data/lib/basecamp/oauth/device_authorization.rb +37 -0
- data/lib/basecamp/oauth/device_flow.rb +873 -0
- data/lib/basecamp/oauth/device_flow_error.rb +46 -0
- data/lib/basecamp/oauth/discovery.rb +13 -11
- data/lib/basecamp/oauth/exchange.rb +44 -5
- data/lib/basecamp/oauth/fetcher.rb +626 -56
- data/lib/basecamp/oauth/oauth_error.rb +3 -1
- data/lib/basecamp/oauth/refresh_request.rb +8 -2
- data/lib/basecamp/oauth/resource.rb +13 -10
- data/lib/basecamp/oauth/token.rb +10 -3
- data/lib/basecamp/oauth.rb +47 -2
- data/lib/basecamp/version.rb +2 -2
- data/scripts/generate-services.rb +10 -7
- metadata +9 -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
|