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
132 lines
4.6 KiB
Ruby
132 lines
4.6 KiB
Ruby
# OAuth 2.0 Device Authorization Grant code (RFC 8628).
|
|
#
|
|
# Mirrors OidcAuthorizationCode: the long device_code is opaque and stored as an
|
|
# HMAC, while the short user_code is stored in plaintext because the user types it
|
|
# back on the verification page. A record is created "pending" by the device
|
|
# authorization endpoint, moved to "approved" (with a user) or "denied" on the
|
|
# verification page, and consumed by the token endpoint once approved.
|
|
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
|
|
USER_CODE_GROUP_SIZE = 4
|
|
USER_CODE_GROUPS = 2 # e.g. "WDJB-MJHT"
|
|
|
|
STATUSES = %w[pending approved denied].freeze
|
|
|
|
# Polling interval, in seconds. The token endpoint bumps the persisted interval
|
|
# by INTERVAL_INCREMENT on each too-fast poll (RFC 8628 §3.5 slow_down), but
|
|
# clamps it to MAX_INTERVAL so a client polling slightly fast — or an attacker
|
|
# spamming a known device_code — can't balloon it past the expiry window and
|
|
# starve a well-behaved client of its token.
|
|
INTERVAL_INCREMENT = 5
|
|
MAX_INTERVAL = 30
|
|
|
|
attr_accessor :plaintext_device_code
|
|
|
|
before_validation :generate_device_code, on: :create
|
|
before_validation :generate_user_code, on: :create
|
|
before_validation :set_expiry, on: :create
|
|
|
|
validates :device_code_hmac, presence: true, uniqueness: true
|
|
validates :user_code, presence: true, uniqueness: true
|
|
validates :status, inclusion: {in: STATUSES}
|
|
validates :code_challenge_method, inclusion: {in: %w[S256], allow_nil: true}
|
|
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
|
|
|
scope :valid, -> { where(status: "pending").where("expires_at > ?", Time.current) }
|
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
|
|
# Find a device code by its plaintext device_code using HMAC verification.
|
|
def self.find_by_plaintext_device_code(plaintext_device_code)
|
|
return nil if plaintext_device_code.blank?
|
|
|
|
find_by(device_code_hmac: compute_device_code_hmac(plaintext_device_code))
|
|
end
|
|
|
|
# Look up a device code by the human-typed user_code. Normalizes case and
|
|
# strips separators/whitespace so "wdjb-mjht" and "WDJB MJHT" both match.
|
|
def self.find_by_user_code(user_code)
|
|
return nil if user_code.blank?
|
|
|
|
find_by(user_code: normalize_user_code(user_code))
|
|
end
|
|
|
|
def self.normalize_user_code(user_code)
|
|
user_code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
|
|
end
|
|
|
|
def self.compute_device_code_hmac(plaintext_device_code)
|
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_device_code)
|
|
end
|
|
|
|
def expired?
|
|
expires_at <= Time.current
|
|
end
|
|
|
|
def pending?
|
|
status == "pending"
|
|
end
|
|
|
|
def approved?
|
|
status == "approved"
|
|
end
|
|
|
|
def denied?
|
|
status == "denied"
|
|
end
|
|
|
|
def uses_pkce?
|
|
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)
|
|
end
|
|
|
|
def deny!
|
|
update!(status: "denied")
|
|
end
|
|
|
|
private
|
|
|
|
def generate_device_code
|
|
self.plaintext_device_code ||= SecureRandom.urlsafe_base64(48)
|
|
self.device_code_hmac ||= self.class.compute_device_code_hmac(plaintext_device_code)
|
|
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(random: SecureRandom) }.join
|
|
end.join
|
|
end
|
|
|
|
def set_expiry
|
|
self.expires_at ||= 10.minutes.from_now
|
|
end
|
|
|
|
def validate_code_challenge_format
|
|
# PKCE code challenge should be base64url-encoded, 43-128 characters.
|
|
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
|
errors.add(:code_challenge, "must be 43-128 characters of base64url encoding")
|
|
end
|
|
end
|
|
end
|