Cap device-code poll interval and sweep expired device codes

Two more fixes from the device-flow security review:

slow_down interval grew without bound (OidcController):
- Each too-fast poll bumped the persisted interval by 5s with no ceiling, so a
  client polling slightly fast — or an attacker spamming a known device_code —
  could balloon it (5→10→15→…) past the 10-minute expiry and starve a legitimate
  client of its token. Clamp the bump to OidcDeviceCode::MAX_INTERVAL (30s); a
  client polling at the advertised interval is never throttled.

Expired device codes accumulated forever (OidcTokenCleanupJob):
- Anonymous callers can create device codes via /oauth/device_authorization, and
  expired/denied/abandoned rows were never reaped. Sweep rows past expiry (with a
  1-hour grace to stay clear of in-flight redemption); redeemed codes are already
  destroyed at token issuance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
This commit is contained in:
Dan Milne
2026-07-19 13:28:55 +10:00
co-authored by Claude Opus 4.8
parent 017dfdff0e
commit 64410a0c50
5 changed files with 62 additions and 2 deletions
@@ -119,6 +119,14 @@ class OidcDeviceFlowControllerTest < ActionDispatch::IntegrationTest
assert_equal "slow_down", JSON.parse(@response.body)["error"]
end
test "slow_down interval is capped and does not grow without bound" do
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
# Hammer the code far more times than it would take to exceed the cap if the
# interval grew by 5 unbounded (20 * 5 = 100s >> MAX_INTERVAL).
20.times { poll(dc) }
assert_operator dc.reload.interval, :<=, OidcDeviceCode::MAX_INTERVAL
end
test "token endpoint returns expired_token for an expired code" do
dc = OidcDeviceCode.create!(application: @cli, scope: "openid", expires_at: 1.minute.ago)
poll(dc)