Switch Access / Refresh tokens / Auth Code from bcrypt ( and plain ) to hmac. BCrypt is for low entropy passwords and prevents dictionary attacks - HMAC is suitable for 256-bit random data.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-12-31 15:48:32 +11:00
parent 7c6ae7ab7e
commit 3db466f5a2
6 changed files with 57 additions and 86 deletions

View File

@@ -1,53 +0,0 @@
module TokenPrefixable
extend ActiveSupport::Concern
class_methods do
# Compute HMAC prefix from plaintext token
# Returns first 8 chars of Base64url-encoded HMAC
# Does NOT reveal anything about the token
def compute_token_prefix(plaintext_token)
return nil if plaintext_token.blank?
hmac = OpenSSL::HMAC.digest('SHA256', TokenHmac::KEY, plaintext_token)
Base64.urlsafe_encode64(hmac)[0..7]
end
# Find token using HMAC prefix lookup (fast, indexed)
def find_by_token(plaintext_token)
return nil if plaintext_token.blank?
prefix = compute_token_prefix(plaintext_token)
# Fast indexed lookup by HMAC prefix
where(token_prefix: prefix).find_each do |token|
return token if token.token_matches?(plaintext_token)
end
nil
end
end
# Check if a plaintext token matches the hashed token
def token_matches?(plaintext_token)
return false if plaintext_token.blank? || token_digest.blank?
BCrypt::Password.new(token_digest) == plaintext_token
rescue BCrypt::Errors::InvalidHash
false
end
# Generate new token with HMAC prefix
# Sets both virtual attribute (for returning to client) and digest (for storage)
def generate_token_with_prefix
plaintext = SecureRandom.urlsafe_base64(48)
self.token_prefix = self.class.compute_token_prefix(plaintext)
self.token_digest = BCrypt::Password.create(plaintext)
# Set the virtual attribute - different models use different names
if respond_to?(:plaintext_token=)
self.plaintext_token = plaintext # OidcAccessToken
elsif respond_to?(:token=)
self.token = plaintext # OidcRefreshToken
end
end
end

View File

@@ -1,15 +1,12 @@
class OidcAccessToken < ApplicationRecord
include TokenPrefixable
belongs_to :application
belongs_to :user
has_many :oidc_refresh_tokens, dependent: :destroy
before_validation :generate_token_with_prefix, on: :create
before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create
validates :token_digest, presence: true
validates :token_prefix, presence: true
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) }
@@ -18,6 +15,19 @@ class OidcAccessToken < ApplicationRecord
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
@@ -36,11 +46,15 @@ class OidcAccessToken < ApplicationRecord
oidc_refresh_tokens.each(&:revoke!)
end
# find_by_token, token_matches?, and generate_token_with_prefix
# are now provided by TokenPrefixable concern
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

View File

@@ -7,7 +7,7 @@ class OidcAuthorizationCode < ApplicationRecord
before_validation :generate_code, on: :create
before_validation :set_expiry, on: :create
validates :code, presence: true, uniqueness: true
validates :code_hmac, presence: true, uniqueness: true
validates :redirect_uri, presence: true
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
@@ -20,7 +20,7 @@ class OidcAuthorizationCode < ApplicationRecord
return nil if plaintext_code.blank?
code_hmac = compute_code_hmac(plaintext_code)
find_by(code: code_hmac)
find_by(code_hmac: code_hmac)
end
# Compute HMAC for code lookup
@@ -50,7 +50,7 @@ class OidcAuthorizationCode < ApplicationRecord
# Generate random plaintext code
self.plaintext_code ||= SecureRandom.urlsafe_base64(32)
# Store HMAC in database (not plaintext)
self.code ||= self.class.compute_code_hmac(plaintext_code)
self.code_hmac ||= self.class.compute_code_hmac(plaintext_code)
end
def set_expiry

View File

@@ -1,16 +1,13 @@
class OidcRefreshToken < ApplicationRecord
include TokenPrefixable
belongs_to :application
belongs_to :user
belongs_to :oidc_access_token
before_validation :generate_token_with_prefix, on: :create
before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create
before_validation :set_token_family_id, on: :create
validates :token_digest, presence: true, uniqueness: true
validates :token_prefix, presence: true
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) }
@@ -22,6 +19,19 @@ class OidcRefreshToken < ApplicationRecord
attr_accessor :token # Store plaintext token temporarily for returning to client
# Find refresh 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
@@ -45,11 +55,15 @@ class OidcRefreshToken < ApplicationRecord
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
end
# find_by_token, token_matches?, and generate_token_with_prefix
# are now provided by TokenPrefixable concern
private
def generate_token
# Generate random plaintext token
self.token ||= SecureRandom.urlsafe_base64(48)
# Store HMAC in database (not plaintext)
self.token_hmac ||= self.class.compute_token_hmac(token)
end
def set_expiry
# Use application's configured refresh token TTL
self.expires_at ||= application.refresh_token_expiry