Module: Basecamp::Oauth::DeviceFlow
- Defined in:
- lib/basecamp/oauth/device_flow.rb
Overview
RFC 8628 device authorization grant — request, poll, and orchestrate.
DeviceFlow.request_device_authorization obtains a device/user code pair; DeviceFlow.poll_device_token runs the §3.5 polling loop against the token endpoint; DeviceFlow.perform_device_login guards capability on an already-selected Config, surfaces the code through a display hook, and polls. All device-auth and token requests are TLS-guarded (SPEC.md §9). The polling clock and sleeper are injectable so tests run without real delays.
Constant Summary collapse
- DEVICE_CODE_GRANT_TYPE =
URN grant type for the device authorization grant.
"urn:ietf:params:oauth:grant-type:device_code"- DEFAULT_INTERVAL_SECONDS =
Default polling interval when the server omits
interval(RFC 8628 §3.2). 5- SLOW_DOWN_INCREMENT_SECONDS =
slow_downbumps the interval by this many seconds, sustained (§3.5). 5- DEVICE_REQUEST_TIMEOUT =
Default per-request timeout for every device-flow HTTP round-trip. Also the fallback Fetcher.normalize_timeout uses for an invalid device timeout, so an invalid value can't silently borrow discovery's shorter budget.
30- CANCEL_POLL_INTERVAL_SECONDS =
Granularity (seconds) for polling the
cancelledprobe while waiting. 0.1- MAX_BACKOFF_SECONDS =
Cap on exponential backoff after connection timeouts.
60- MAX_DEVICE_SECONDS =
Ceiling for +expires_in+/+interval+: 2147483 s (~24.8 days) is the largest whole-second duration whose millisecond form fits a 32-bit signed timer. Shared across all five SDKs (SPEC.md) — an unbounded value such as 1e100 is a malformed response, not a schedulable deadline.
2_147_483- MAX_TOKEN_LIFETIME_SECONDS =
Ceiling for an OAuth token's
expires_in(2_147_483_647 s ~= 68 years): cross-runtime safe and vastly beyond any realistic token lifetime. UnlikeMAX_DEVICE_SECONDSthis boundsTimearithmetic rather than a timer, so a non-finite value (+1e400+ parses toFloat::INFINITY, which would raise a rawFloatDomainError) or an absurd one is a malformed response — never a schedulable deadline. Shared across all five SDKs. 2_147_483_647- DEFAULT_CLOCK =
Monotonic clock (seconds). Injectable so tests can advance time.
-> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
- DEFAULT_SLEEPER =
Real sleeper (seconds). Injectable so tests assert the wait schedule.
->(seconds) { sleep(seconds) }
- DEFAULT_CANCELLED =
Cooperative cancellation probe. Injectable; default never cancels.
-> { false }
Class Method Summary collapse
-
.perform_device_login(config:, client_id:, display:, scope: nil, clock: DEFAULT_CLOCK, sleeper: DEFAULT_SLEEPER, cancelled: DEFAULT_CANCELLED, http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES) ⇒ Token
Runs the full device authorization grant against an already-selected Config (RFC 8628; SPEC.md §16).
-
.poll_device_token(token_endpoint:, client_id:, device_code:, interval:, expires_in:, clock: DEFAULT_CLOCK, sleeper: DEFAULT_SLEEPER, cancelled: DEFAULT_CANCELLED, http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES, deadline_at: nil) ⇒ Token
Polls the token endpoint until the user approves, denies, or the codes expire (RFC 8628 §3.4–3.5).
-
.request_device_authorization(device_authorization_endpoint:, client_id:, scope: nil, http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES) ⇒ DeviceAuthorization
Requests a device/user code pair (RFC 8628 §3.1–3.2).
Class Method Details
.perform_device_login(config:, client_id:, display:, scope: nil, clock: DEFAULT_CLOCK, sleeper: DEFAULT_SLEEPER, cancelled: DEFAULT_CANCELLED, http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES) ⇒ Token
Runs the full device authorization grant against an already-selected Config (RFC 8628; SPEC.md §16).
The capability guard requires BOTH device_authorization_endpoint AND
the device_code grant in grant_types_supported; otherwise it raises
:unavailable before any request is issued.
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 |
# File 'lib/basecamp/oauth/device_flow.rb', line 327 def perform_device_login( config:, client_id:, display:, scope: nil, clock: DEFAULT_CLOCK, sleeper: DEFAULT_SLEEPER, cancelled: DEFAULT_CANCELLED, http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES ) unless device_grant_available?(config) raise DeviceFlowError.new( :unavailable, "The selected authorization server does not support the device authorization grant" ) end # A non-callable display is a usage error, not a late NoMethodError: # it is the only mechanism surfacing the verification code, so # dereferencing it AFTER the request would mint a code nobody can # approve. Reject before any network activity (matching Go). unless display.respond_to?(:call) raise OauthError.new("usage", "perform_device_login requires a callable display hook") end # Honor a cancellation raised BEFORE the flow does any work: the # sync authorization POST cannot observe the probe in flight, so # without this entry check an already-cancelled flow still performs # the request and invokes the display hook. raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call auth = begin ( device_authorization_endpoint: config., client_id: client_id, scope: scope, http_client: http_client, timeout: timeout, max_body_bytes: max_body_bytes ) rescue DeviceFlowError, OauthError # Cancellation-beats-classification on the error path too: a cancel # that flipped while the authorization request was in flight wins # over whatever fault the doomed request raised. raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call raise end # Anchor the code's lifetime at ISSUANCE — the response's arrival, # per SPEC §16 — before the display hook, so a slow display eats # into the deadline instead of resetting it. Expiry past this point # is arbitrated by the server (expired_token), so receipt-anchoring # fails safe. issued_at = sample_clock(clock, "perform_device_login") # Re-check after the round-trip AND after the anchor sample (itself # a cancellation-capable callback seam), before surfacing the code: # a cancel set in either window must not reach the display hook. raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call display.call(auth) remaining = auth.expires_in - (sample_clock(clock, "perform_device_login") - issued_at) # Cancellation raised DURING the display hook OR during the clock # sample just above (the clock is a cancellation-capable callback # seam, exactly like the issuance anchor) wins over expiry: # checked after the sample and before the expiry branch, matching # the TS orchestrator's ordering. raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call if remaining <= 0 raise DeviceFlowError.new(:expired, "Device code expired before authorization completed") end poll_device_token( token_endpoint: config.token_endpoint, client_id: client_id, device_code: auth.device_code, interval: auth.interval, expires_in: remaining, # The EXACT issuance-anchored deadline: a clock advance between # the remaining computation above and the poller's entry must not # extend the code lifetime. deadline_at: issued_at + auth.expires_in, clock: clock, sleeper: sleeper, cancelled: cancelled, http_client: http_client, timeout: timeout, max_body_bytes: max_body_bytes ) end |
.poll_device_token(token_endpoint:, client_id:, device_code:, interval:, expires_in:, clock: DEFAULT_CLOCK, sleeper: DEFAULT_SLEEPER, cancelled: DEFAULT_CANCELLED, http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES, deadline_at: nil) ⇒ Token
Polls the token endpoint until the user approves, denies, or the codes expire (RFC 8628 §3.4–3.5).
Waits at least interval seconds between polls against a MONOTONIC
deadline. Handles authorization_pending (keep polling), sustained
slow_down (+5s for this and every later poll), access_denied and
expired_token (terminal), connection timeouts (exponential backoff),
and cooperative cancellation.
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'lib/basecamp/oauth/device_flow.rb', line 143 def poll_device_token( token_endpoint:, client_id:, device_code:, interval:, expires_in:, clock: DEFAULT_CLOCK, sleeper: DEFAULT_SLEEPER, cancelled: DEFAULT_CANCELLED, http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES, deadline_at: nil ) Basecamp::Security.require_https_unless_localhost!(token_endpoint, "token endpoint") Fetcher.ensure_redirects_suppressed!(http_client) if http_client # Caller-input sanity for this public entry point (usage, not the RFC # response validation request_device_authorization applies): a nil or # non-numeric duration would raise NoMethodError/TypeError below, a # non-finite expires_in builds a deadline that NEVER passes (an unbounded # poll loop), and an oversized/non-positive interval is not a schedulable # wait. Fractional values are accepted — perform_device_login legitimately # passes a fractional remaining lifetime after deducting display-hook # time. Mirrors the Go/TS/Python/Kotlin caller guards. unless valid_device_seconds?(expires_in) raise OauthError.new( "usage", "poll_device_token: expires_in must be a positive number of seconds " \ "no greater than #{MAX_DEVICE_SECONDS}" ) end # The polling interval is additionally whole seconds (RFC 8628), # matching the response validation and the integer-typed Go/Kotlin # APIs — a fractional interval (0.001) would otherwise permit ~1000 # polls per second. real? in valid_device_seconds? screens Complex # before the modulo runs. unless valid_device_seconds?(interval) && (interval % 1).zero? raise OauthError.new( "usage", "poll_device_token: interval must be a positive whole number of seconds " \ "no greater than #{MAX_DEVICE_SECONDS}" ) end interval_seconds = interval backoff_seconds = interval_seconds # One-shot next-wait override from a 429 too_many_requests Retry-After # (SPEC.md §16): consumed by the next wait, never inflating the # slow_down interval. 0 = none. override_seconds = 0 # An absolute issuance-anchored deadline (perform_device_login # passes issued_at + expires_in) beats re-anchoring: clock time # elapsing between the caller's remaining-lifetime computation and # this entry — a process suspension above all — must never be handed # back to the code. It can only SHORTEN the validated lifetime. deadline = if deadline_at.nil? sample_clock(clock, "poll_device_token") + expires_in else unless deadline_at.is_a?(Numeric) && deadline_at.real? \ && deadline_at.to_f.finite? && deadline_at <= sample_clock(clock, "poll_device_token") + expires_in raise OauthError.new( "usage", "poll_device_token deadline_at must be a finite monotonic timestamp " \ "no later than expires_in seconds from now" ) end deadline_at end # Normalize ONCE, outside the polling loop, and reuse for every per-poll # request (see request_device_authorization). The body cap gets the same # discipline — an invalid value would disable the bound. timeout = Fetcher.normalize_timeout(timeout, default: DEVICE_REQUEST_TIMEOUT) max_body_bytes = Fetcher.normalize_body_cap(max_body_bytes) params = { "grant_type" => DEVICE_CODE_GRANT_TYPE, "device_code" => device_code, "client_id" => client_id } loop do raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call # Check the monotonic deadline BEFORE waiting, then clamp the wait so a # long interval or timeout backoff can never overshoot expiry. The # per-request timeout (set on every request in +post_form+) bounds a # stalled socket, so nothing here blows past the deadline. The wait is # the LARGER of the server-driven interval and the transient timeout # backoff — the two schedules stay separate so a backoff can drain # back down to the server interval once round-trips resume. now = sample_clock(clock, "poll_device_token") raise DeviceFlowError.new(:expired, "Device code expired before authorization completed") if now >= deadline wait = [ [ interval_seconds, backoff_seconds, override_seconds ].max, deadline - now ].min override_seconds = 0 # one-shot: consumed by this wait, then gone wait_cancellable(wait, cancelled, sleeper) raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call post_remaining = deadline - sample_clock(clock, "poll_device_token") raise DeviceFlowError.new(:expired, "Device code expired before authorization completed") if post_remaining <= 0 outcome = begin # Bound the request by the REMAINING code lifetime as well as the # per-request timeout: near expiry, a stalled token POST must not # hold the flow past the monotonic deadline for the full budget. post_device_token(http_client, token_endpoint, params, timeout: [ timeout, post_remaining ].min, max_body_bytes: max_body_bytes) rescue Faraday::TimeoutError # A connection timeout is transient: back off exponentially and # keep polling rather than ending the flow. Only the backoff grows — # the server-driven interval is left untouched so it can govern # again once a round-trip completes. The next wait is still clamped # to the deadline at the top of the loop. backoff_seconds = [ backoff_seconds * 2, MAX_BACKOFF_SECONDS ].min next rescue Faraday::Error => e # Cancellation-beats-classification on the error path too: a # cancel that flipped while the doomed request was in flight must # surface as cancelled, not as the transport fault it raised. raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call raise DeviceFlowError.new(:transport, "Device token poll failed: #{e.}") end # Re-check cancellation the moment the round-trip completes: the # sync POST cannot observe the probe while in flight (bounded only # by its timeout), so a cancel raised mid-request must surface # here — never a token returned after the caller asked to stop. raise DeviceFlowError.new(:cancelled, "Device flow cancelled") if cancelled.call # ANY completed HTTP round-trip resets the timeout backoff to the # current server-driven interval. backoff_seconds = interval_seconds kind, value, status, retry_after = outcome return value if kind == :token case value when "authorization_pending" next when "too_many_requests" # Retryable ONLY as the exact 429 + too_many_requests pair # (SPEC.md §16). The next wait honors a positive integral # Retry-After delta via a one-shot max(interval, Retry-After) # override — a missing/malformed header falls back to the current # interval, and the override decays after one wait. unless status == 429 raise OauthError.new("api_error", "Device token request failed: #{value}", http_status: status) end override_seconds = parse_retry_after_seconds(retry_after) when "slow_down" interval_seconds += SLOW_DOWN_INCREMENT_SECONDS # Re-sync the backoff to the GROWN interval (the reset above used the # pre-increment value) so a later timeout doubles from the new # interval, not the stale one. backoff_seconds = interval_seconds when "access_denied" raise DeviceFlowError.new(:access_denied, "The authorization request was denied") when "expired_token" raise DeviceFlowError.new(:expired, "Device code expired before authorization completed") else raise OauthError.new("api_error", "Device token request failed: #{value}", http_status: status) end end end |
.request_device_authorization(device_authorization_endpoint:, client_id:, scope: nil, http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES) ⇒ DeviceAuthorization
Requests a device/user code pair (RFC 8628 §3.1–3.2).
POSTs client_id and, only when set, scope (an omitted scope lets the
server apply its default, read). Validates that the codes are present,
expires_in is positive, and interval (default 5) is positive.
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/basecamp/oauth/device_flow.rb', line 80 def ( device_authorization_endpoint:, client_id:, scope: nil, http_client: nil, timeout: DEVICE_REQUEST_TIMEOUT, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES ) Basecamp::Security.require_https_unless_localhost!(, "device authorization endpoint") raise OauthError.new("validation", "Client ID is required for device authorization") if client_id.to_s.empty? # SSRF: an injected client must not chase an attacker-controlled Location, # exactly as the discovery hops require (SPEC.md §16). Fetcher.ensure_redirects_suppressed!(http_client) if http_client params = { "client_id" => client_id } # Omit scope entirely when unset OR blank so the server applies its # default (read) — Ruby treats "" as truthy, so guard on emptiness too. params["scope"] = scope unless scope.nil? || scope.empty? # Normalize ONCE at operation entry and thread the SAME value to every # request, so a non-finite/non-positive input cannot leave the socket # timeout unbounded. The body cap gets the same discipline: an invalid # value (nil, Infinity, negative) would disable the streaming bound. timeout = Fetcher.normalize_timeout(timeout, default: DEVICE_REQUEST_TIMEOUT) max_body_bytes = Fetcher.normalize_body_cap(max_body_bytes) status, body = begin post_form( http_client, , params, timeout: timeout, max_body_bytes: max_body_bytes, # A non-2xx device-auth response is a hard failure whose body is unused. skip_status: ->(s) { !(200..299).cover?(s) } ) rescue Faraday::Error => e raise DeviceFlowError.new(:transport, "Device authorization request failed: #{e.}") end (status, body) end |