diff --git a/README.md b/README.md index 0629a64..25c1498 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Features: - **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 - **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 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** - Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support) -- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable) -- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation) +- Access tokens (opaque, HMAC-SHA256 hashed, configurable expiry 5min-24hr, revocable) +- 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) --- diff --git a/app/models/concerns/token_prefixable.rb b/app/models/concerns/token_prefixable.rb deleted file mode 100644 index ec0c8b6..0000000 --- a/app/models/concerns/token_prefixable.rb +++ /dev/null @@ -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 diff --git a/app/models/oidc_access_token.rb b/app/models/oidc_access_token.rb index 3801202..596da20 100644 --- a/app/models/oidc_access_token.rb +++ b/app/models/oidc_access_token.rb @@ -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 diff --git a/app/models/oidc_authorization_code.rb b/app/models/oidc_authorization_code.rb index 788137e..aaf4f95 100644 --- a/app/models/oidc_authorization_code.rb +++ b/app/models/oidc_authorization_code.rb @@ -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 diff --git a/app/models/oidc_refresh_token.rb b/app/models/oidc_refresh_token.rb index aede896..8d83584 100644 --- a/app/models/oidc_refresh_token.rb +++ b/app/models/oidc_refresh_token.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 20b0d34..14327f3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.bigint "blob_id", 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 "revoked_at" t.string "scope" - t.string "token_digest" - t.string "token_prefix", limit: 8 + t.string "token_hmac" t.datetime "updated_at", 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"], name: "index_oidc_access_tokens_on_application_id" 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 ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true - t.index ["token_prefix"], name: "index_oidc_access_tokens_on_token_prefix" + t.index ["token_hmac"], name: "index_oidc_access_tokens_on_token_hmac", unique: true t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id" end create_table "oidc_authorization_codes", force: :cascade do |t| t.integer "application_id", null: false - t.string "code", null: false t.string "code_challenge" t.string "code_challenge_method" + t.string "code_hmac", null: false t.datetime "created_at", null: false t.datetime "expires_at", null: false 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.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 ["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_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 ["user_id"], name: "index_oidc_authorization_codes_on_user_id" 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.datetime "revoked_at" t.string "scope" - t.string "token_digest", null: false t.integer "token_family_id" - t.string "token_prefix", limit: 8 + t.string "token_hmac" t.datetime "updated_at", 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" @@ -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 ["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 ["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_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" end