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
64 lines
1.7 KiB
Ruby
64 lines
1.7 KiB
Ruby
class OidcAccessToken < ApplicationRecord
|
|
belongs_to :application
|
|
belongs_to :user
|
|
belongs_to :oidc_authorization_code, optional: true
|
|
belongs_to :oidc_device_code, optional: true
|
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
|
|
|
before_validation :generate_token, on: :create
|
|
before_validation :set_expiry, on: :create
|
|
|
|
validates :token_hmac, presence: true, uniqueness: true
|
|
|
|
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
scope :revoked, -> { where.not(revoked_at: nil) }
|
|
scope :active, -> { valid }
|
|
|
|
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
|
|
|
|
# Find access token by plaintext token using HMAC verification
|
|
def self.find_by_token(plaintext_token)
|
|
return nil if plaintext_token.blank?
|
|
|
|
token_hmac = compute_token_hmac(plaintext_token)
|
|
find_by(token_hmac: token_hmac)
|
|
end
|
|
|
|
# Compute HMAC for token lookup
|
|
def self.compute_token_hmac(plaintext_token)
|
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
|
end
|
|
|
|
def expired?
|
|
expires_at <= Time.current
|
|
end
|
|
|
|
def revoked?
|
|
revoked_at.present?
|
|
end
|
|
|
|
def active?
|
|
!expired? && !revoked?
|
|
end
|
|
|
|
def revoke!
|
|
update!(revoked_at: Time.current)
|
|
# Also revoke associated refresh tokens
|
|
oidc_refresh_tokens.each(&:revoke!)
|
|
end
|
|
|
|
private
|
|
|
|
def generate_token
|
|
# Generate random plaintext token
|
|
self.plaintext_token ||= SecureRandom.urlsafe_base64(48)
|
|
# Store HMAC in database (not plaintext)
|
|
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
|
|
end
|
|
|
|
def set_expiry
|
|
self.expires_at ||= application.access_token_expiry
|
|
end
|
|
end
|