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_down bumps 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 cancelled probe 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. Unlike MAX_DEVICE_SECONDS this bounds Time arithmetic rather than a timer, so a non-finite value (+1e400+ parses to Float::INFINITY, which would raise a raw FloatDomainError) 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

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.

Parameters:

  • config (Config)

    the already-selected authorization-server config

  • client_id (String)

    the public client id

  • display (#call)

    receives the Basecamp::Oauth::DeviceAuthorization once, before polling

  • scope (String, nil) (defaults to: nil)

    requested scope; omitted when nil

  • clock (#call) (defaults to: DEFAULT_CLOCK)

    monotonic clock returning seconds

  • sleeper (#call) (defaults to: DEFAULT_SLEEPER)

    receives the wait in seconds

  • cancelled (#call) (defaults to: DEFAULT_CANCELLED)

    cancellation probe

  • http_client (Faraday::Connection, nil) (defaults to: nil)

    HTTP client (default if nil)

  • timeout (Integer) (defaults to: DEVICE_REQUEST_TIMEOUT)

    request timeout in seconds

Returns:

Raises:

  • (DeviceFlowError)

    :unavailable when the config cannot do device flow; other reasons on denial/expiry/transport/cancellation

  • (OauthError)

    usage when display is not callable (checked before any request — DeviceFlowError subclasses OauthError, so a rescue of the former alone would miss this)



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 (
  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
    request_device_authorization(
      device_authorization_endpoint: config.device_authorization_endpoint,
      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.

Parameters:

  • token_endpoint (String)

    the token endpoint from discovery

  • client_id (String)

    the public client id

  • device_code (String)

    the device code from request_device_authorization

  • interval (Integer)

    polling interval in seconds

  • expires_in (Numeric)

    code lifetime in seconds until the monotonic deadline; may be fractional (perform_device_login passes the remaining lifetime after deducting display-hook time)

  • clock (#call) (defaults to: DEFAULT_CLOCK)

    monotonic clock returning seconds

  • sleeper (#call) (defaults to: DEFAULT_SLEEPER)

    receives the wait in seconds

  • cancelled (#call) (defaults to: DEFAULT_CANCELLED)

    cancellation probe; a truthy result ends the flow

  • http_client (Faraday::Connection, nil) (defaults to: nil)

    HTTP client (default if nil)

  • timeout (Integer) (defaults to: DEVICE_REQUEST_TIMEOUT)

    per-request timeout in seconds

  • max_body_bytes (Integer) (defaults to: Fetcher::DEFAULT_MAX_BODY_BYTES)

    bounded read cap in bytes

Returns:

Raises:

  • (DeviceFlowError)

    :access_denied, :expired, :transport, or :cancelled

  • (OauthError)

    api_error on an unrecognized token error, oversized body, or validation on a redirect-following injected client; usage on an out-of-range +interval+/+expires_in+



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.message}")
    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.

Parameters:

  • device_authorization_endpoint (String)

    the endpoint from discovery

  • client_id (String)

    the public client id (e.g. basecamp-cli)

  • scope (String, nil) (defaults to: nil)

    requested scope; omitted from the request when nil

  • http_client (Faraday::Connection, nil) (defaults to: nil)

    HTTP client (default if nil)

  • timeout (Integer) (defaults to: DEVICE_REQUEST_TIMEOUT)

    request timeout in seconds

  • max_body_bytes (Integer) (defaults to: Fetcher::DEFAULT_MAX_BODY_BYTES)

    bounded read cap in bytes

Returns:

Raises:

  • (OauthError)

    validation on a missing client id or a redirect- following injected client; api_error on a non-2xx response, oversized body, or invalid metadata

  • (DeviceFlowError)

    :transport on a network failure



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 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
)
  Basecamp::Security.require_https_unless_localhost!(device_authorization_endpoint, "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, device_authorization_endpoint, 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.message}")
  end

  parse_device_authorization(status, body)
end