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
@@ -9,6 +9,11 @@ class OidcDeviceCode < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user, optional: true # nil until the request is approved
|
||||
|
||||
# Tokens minted from this code, so a replayed (already-redeemed) code can revoke
|
||||
# every token descended from it — mirrors OidcAuthorizationCode.
|
||||
has_many :oidc_access_tokens, dependent: :nullify
|
||||
has_many :oidc_refresh_tokens, dependent: :nullify
|
||||
|
||||
# Alphabet for the user_code: uppercase letters + digits, minus visually
|
||||
# ambiguous characters (0/O, 1/I, etc.) so it is easy to read and type.
|
||||
USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars.freeze
|
||||
@@ -83,6 +88,12 @@ class OidcDeviceCode < ApplicationRecord
|
||||
code_challenge.present?
|
||||
end
|
||||
|
||||
# True once the approved code has been exchanged for tokens. The row is kept
|
||||
# (not destroyed) so a replay is detectable and its tokens can be revoked.
|
||||
def redeemed?
|
||||
redeemed_at.present?
|
||||
end
|
||||
|
||||
# Grant the request: attach the approving user and capture their auth context.
|
||||
def approve!(user:, acr:, auth_time:)
|
||||
update!(status: "approved", user: user, acr: acr, auth_time: auth_time)
|
||||
@@ -100,8 +111,10 @@ class OidcDeviceCode < ApplicationRecord
|
||||
end
|
||||
|
||||
def generate_user_code
|
||||
# The user_code is a security credential (typing it + Approve grants tokens),
|
||||
# so draw from a CSPRNG rather than Ruby's global Mersenne Twister PRNG.
|
||||
self.user_code ||= USER_CODE_GROUPS.times.map do
|
||||
USER_CODE_GROUP_SIZE.times.map { USER_CODE_ALPHABET.sample }.join
|
||||
USER_CODE_GROUP_SIZE.times.map { USER_CODE_ALPHABET.sample(random: SecureRandom) }.join
|
||||
end.join
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user