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.
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
18
db/schema.rb
generated
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user