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:
co-authored by
Claude Opus 4.8
parent
017dfdff0e
commit
64410a0c50
@@ -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)
|
||||
|
||||
@@ -48,4 +48,35 @@ class OidcTokenCleanupJobTest < ActiveJob::TestCase
|
||||
user&.destroy
|
||||
application&.destroy
|
||||
end
|
||||
|
||||
# Device codes (RFC 8628) are created by anonymous callers and would grow the
|
||||
# table without bound; the cleanup job must purge expired ones (pending, denied,
|
||||
# or abandoned) while leaving live codes alone.
|
||||
test "deletes expired device codes and keeps live ones" do
|
||||
application = Application.create!(
|
||||
name: "Device Cleanup App",
|
||||
slug: "device-cleanup-app",
|
||||
app_type: "oidc",
|
||||
is_public_client: true,
|
||||
active: true
|
||||
)
|
||||
|
||||
expired_pending = nil
|
||||
expired_denied = nil
|
||||
travel_to(2.hours.ago) do
|
||||
expired_pending = OidcDeviceCode.create!(application: application, scope: "openid")
|
||||
expired_denied = OidcDeviceCode.create!(application: application, scope: "openid")
|
||||
expired_denied.deny!
|
||||
end
|
||||
live = OidcDeviceCode.create!(application: application, scope: "openid")
|
||||
|
||||
OidcTokenCleanupJob.new.perform
|
||||
|
||||
assert_not OidcDeviceCode.exists?(expired_pending.id), "expired pending code should be deleted"
|
||||
assert_not OidcDeviceCode.exists?(expired_denied.id), "expired denied code should be deleted"
|
||||
assert OidcDeviceCode.exists?(live.id), "live code should be kept"
|
||||
ensure
|
||||
OidcDeviceCode.where(application: application).delete_all if application
|
||||
application&.destroy
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user