Device flow: replay-revocation, user_code CSPRNG, /device rate limit

More device-flow security-review fixes.

Replay-revocation on device_code redemption (was: destroy on use):
- Redemption now marks the code redeemed_at and links the issued access/refresh
  tokens back to it (new oidc_device_code_id FKs, on_delete: :nullify) instead of
  destroying the row. A replayed redeemed code is detected as reuse — revoking
  every descended token and returning a distinguishable "already been used"
  invalid_grant rather than the generic "Invalid device_code" — mirroring the
  authorization-code reuse semantics (RFC 6749 §4.1.2). The device_code FK is
  carried forward across refresh rotation so revocation reaches the whole chain.
  Redeemed codes are reaped by the existing expiry cleanup sweep.

user_code CSPRNG (RFC 8628 §6.1):
- The 8-char user_code is a credential (type it + Approve mints tokens), so draw
  it from SecureRandom instead of Ruby's global Mersenne Twister (Array#sample).

Rate-limit the /device verification endpoint (RFC 8628 §5.1):
- Add the app's standard 10/min limit on show + verify so a signed-in user can't
  brute-force the short code space to deny or hijack a pending authorization.

Also carried in this commit (other agent's working-tree change):
- Give each OidcController rate_limit a distinct name: so frequent device polling
  on the token endpoint no longer shares one counter with (and 429s) unrelated
  token/refresh/revoke/introspect calls.

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:38:40 +10:00
co-authored by Claude Opus 4.8
parent 64410a0c50
commit 71ee301dd2
8 changed files with 124 additions and 11 deletions
@@ -159,10 +159,37 @@ class OidcDeviceFlowControllerTest < ActionDispatch::IntegrationTest
assert_equal "Bearer", body["token_type"]
assert_equal "openid groups", body["scope"]
# Replaying the (now consumed) device_code fails.
# Replaying the (now consumed) device_code fails, and is reported as reuse —
# distinguishable from the generic "Invalid device_code" for an unknown code.
poll(dc, code_verifier: CODE_VERIFIER)
assert_response :bad_request
assert_equal "invalid_grant", JSON.parse(@response.body)["error"]
replay = JSON.parse(@response.body)
assert_equal "invalid_grant", replay["error"]
assert_match(/already been used/i, replay["error_description"])
end
test "replaying a redeemed device_code revokes the tokens it issued" do
OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid", granted_at: Time.current)
dc = OidcDeviceCode.create!(
application: @cli, scope: "openid",
code_challenge: code_challenge_for(CODE_VERIFIER), code_challenge_method: "S256"
)
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
poll(dc, code_verifier: CODE_VERIFIER)
assert_response :success
access = OidcAccessToken.find_by_token(JSON.parse(@response.body)["access_token"])
refresh = OidcRefreshToken.where(oidc_device_code: dc).first
assert access.active?, "token should be live before the replay"
# The code is kept (not destroyed) so the replay is detectable...
assert dc.reload.redeemed?
poll(dc, code_verifier: CODE_VERIFIER)
assert_response :bad_request
# ...and every token descended from the replayed code is revoked.
assert access.reload.revoked?
assert refresh.reload.revoked?
end
test "token endpoint refuses a PKCE-required client whose device_code lacks a challenge" do