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:
co-authored by
Claude Opus 4.8
parent
64410a0c50
commit
71ee301dd2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user