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

@@ -82,7 +82,7 @@ Features:
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation - **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
- **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families - **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application - **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
- **Token security** - HMAC-SHA256 hashed authorization codes, BCrypt-hashed access/refresh tokens, automatic cleanup of expired tokens - **Token security** - All tokens HMAC-SHA256 hashed (suitable for 256-bit random data), automatic cleanup of expired tokens
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy - **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens. Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
@@ -200,8 +200,8 @@ Configure different claims for different applications on a per-user basis:
**OIDC Tokens** **OIDC Tokens**
- Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support) - Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support)
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable) - Access tokens (opaque, HMAC-SHA256 hashed, configurable expiry 5min-24hr, revocable)
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation) - Refresh tokens (opaque, HMAC-SHA256 hashed, configurable expiry 1-90 days, single-use with rotation)
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr) - ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
--- ---

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 class OidcAccessToken < ApplicationRecord
include TokenPrefixable
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
has_many :oidc_refresh_tokens, dependent: :destroy 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 before_validation :set_expiry, on: :create
validates :token_digest, presence: true validates :token_hmac, presence: true, uniqueness: true
validates :token_prefix, presence: true
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) } scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) } 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 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? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
@@ -36,11 +46,15 @@ class OidcAccessToken < ApplicationRecord
oidc_refresh_tokens.each(&:revoke!) oidc_refresh_tokens.each(&:revoke!)
end end
# find_by_token, token_matches?, and generate_token_with_prefix
# are now provided by TokenPrefixable concern
private 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 def set_expiry
self.expires_at ||= application.access_token_expiry self.expires_at ||= application.access_token_expiry
end end

View File

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

View File

@@ -1,16 +1,13 @@
class OidcRefreshToken < ApplicationRecord class OidcRefreshToken < ApplicationRecord
include TokenPrefixable
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
belongs_to :oidc_access_token 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_expiry, on: :create
before_validation :set_token_family_id, on: :create before_validation :set_token_family_id, on: :create
validates :token_digest, presence: true, uniqueness: true validates :token_hmac, presence: true, uniqueness: true
validates :token_prefix, presence: true
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) } scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) } 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 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? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
@@ -45,11 +55,15 @@ class OidcRefreshToken < ApplicationRecord
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current) OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
end end
# find_by_token, token_matches?, and generate_token_with_prefix
# are now provided by TokenPrefixable concern
private 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 def set_expiry
# Use application's configured refresh token TTL # Use application's configured refresh token TTL
self.expires_at ||= application.refresh_token_expiry self.expires_at ||= application.refresh_token_expiry

18
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_12_30_073656) do ActiveRecord::Schema[8.1].define(version: 2025_12_31_043838) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false t.bigint "blob_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -101,24 +101,22 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_30_073656) do
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.string "token_digest" t.string "token_hmac"
t.string "token_prefix", limit: 8
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id", null: false t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id" t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id" t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at" t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at" t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at"
t.index ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true t.index ["token_hmac"], name: "index_oidc_access_tokens_on_token_hmac", unique: true
t.index ["token_prefix"], name: "index_oidc_access_tokens_on_token_prefix"
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id" t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
end end
create_table "oidc_authorization_codes", force: :cascade do |t| create_table "oidc_authorization_codes", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.string "code", null: false
t.string "code_challenge" t.string "code_challenge"
t.string "code_challenge_method" t.string "code_challenge_method"
t.string "code_hmac", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.string "nonce" t.string "nonce"
@@ -129,8 +127,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_30_073656) do
t.integer "user_id", null: false t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id" t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_authorization_codes_on_application_id" t.index ["application_id"], name: "index_oidc_authorization_codes_on_application_id"
t.index ["code"], name: "index_oidc_authorization_codes_on_code", unique: true
t.index ["code_challenge"], name: "index_oidc_authorization_codes_on_code_challenge" t.index ["code_challenge"], name: "index_oidc_authorization_codes_on_code_challenge"
t.index ["code_hmac"], name: "index_oidc_authorization_codes_on_code_hmac", unique: true
t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at" t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id" t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
end end
@@ -142,9 +140,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_30_073656) do
t.integer "oidc_access_token_id", null: false t.integer "oidc_access_token_id", null: false
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.string "token_digest", null: false
t.integer "token_family_id" t.integer "token_family_id"
t.string "token_prefix", limit: 8 t.string "token_hmac"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id", null: false t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id" t.index ["application_id", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id"
@@ -152,9 +149,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_30_073656) do
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at" t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id" t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id"
t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at" t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at"
t.index ["token_digest"], name: "index_oidc_refresh_tokens_on_token_digest", unique: true
t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id" t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
t.index ["token_prefix"], name: "index_oidc_refresh_tokens_on_token_prefix" t.index ["token_hmac"], name: "index_oidc_refresh_tokens_on_token_hmac", unique: true
t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id" t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
end end