2 Commits

Author SHA1 Message Date
Dan Milne
3db466f5a2 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
2025-12-31 15:48:32 +11:00
Dan Milne
7c6ae7ab7e Store only HMAC'd Auth codes, rather than plain text auth codes. 2025-12-31 15:00:00 +11:00
7 changed files with 82 additions and 99 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** - BCrypt-hashed 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.
@@ -199,9 +199,9 @@ Configure different claims for different applications on a per-user basis:
- Many-to-many with Groups (allowlist) - Many-to-many with Groups (allowlist)
**OIDC Tokens** **OIDC Tokens**
- Authorization codes (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

@@ -154,11 +154,9 @@ class OidcController < ApplicationController
existing_consent = user.has_oidc_consent?(@application, requested_scopes) existing_consent = user.has_oidc_consent?(@application, requested_scopes)
if existing_consent if existing_consent
# User has already consented, generate authorization code directly # User has already consented, generate authorization code directly
code = SecureRandom.urlsafe_base64(32)
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: user, user: user,
code: code,
redirect_uri: redirect_uri, redirect_uri: redirect_uri,
scope: scope, scope: scope,
nonce: nonce, nonce: nonce,
@@ -167,8 +165,8 @@ class OidcController < ApplicationController
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
# Redirect back to client with authorization code # Redirect back to client with authorization code (plaintext)
redirect_uri = "#{redirect_uri}?code=#{code}" redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
redirect_uri += "&state=#{CGI.escape(state)}" if state.present? redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to redirect_uri, allow_other_host: true redirect_to redirect_uri, allow_other_host: true
return return
@@ -258,11 +256,9 @@ class OidcController < ApplicationController
) )
# Generate authorization code # Generate authorization code
code = SecureRandom.urlsafe_base64(32)
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: application, application: application,
user: user, user: user,
code: code,
redirect_uri: oauth_params['redirect_uri'], redirect_uri: oauth_params['redirect_uri'],
scope: oauth_params['scope'], scope: oauth_params['scope'],
nonce: oauth_params['nonce'], nonce: oauth_params['nonce'],
@@ -274,8 +270,8 @@ class OidcController < ApplicationController
# Clear OAuth params from session # Clear OAuth params from session
session.delete(:oauth_params) session.delete(:oauth_params)
# Redirect back to client with authorization code # Redirect back to client with authorization code (plaintext)
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}" redirect_uri = "#{oauth_params['redirect_uri']}?code=#{auth_code.plaintext_code}"
redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state'] redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
redirect_to redirect_uri, allow_other_host: true redirect_to redirect_uri, allow_other_host: true
@@ -335,12 +331,10 @@ class OidcController < ApplicationController
redirect_uri = params[:redirect_uri] redirect_uri = params[:redirect_uri]
code_verifier = params[:code_verifier] code_verifier = params[:code_verifier]
auth_code = OidcAuthorizationCode.find_by( # Find authorization code using HMAC verification
application: application, auth_code = OidcAuthorizationCode.find_by_plaintext(code)
code: code
)
unless auth_code unless auth_code && auth_code.application == application
render json: { error: "invalid_grant" }, status: :bad_request render json: { error: "invalid_grant" }, status: :bad_request
return return
end end

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

@@ -2,10 +2,12 @@ class OidcAuthorizationCode < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
attr_accessor :plaintext_code
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? }
@@ -13,6 +15,19 @@ class OidcAuthorizationCode < ApplicationRecord
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) } scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at <= ?", Time.current) } scope :expired, -> { where("expires_at <= ?", Time.current) }
# Find authorization code by plaintext code using HMAC verification
def self.find_by_plaintext(plaintext_code)
return nil if plaintext_code.blank?
code_hmac = compute_code_hmac(plaintext_code)
find_by(code_hmac: code_hmac)
end
# Compute HMAC for code lookup
def self.compute_code_hmac(plaintext_code)
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_code)
end
def expired? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
@@ -32,7 +47,10 @@ class OidcAuthorizationCode < ApplicationRecord
private private
def generate_code def generate_code
self.code ||= SecureRandom.urlsafe_base64(32) # Generate random plaintext code
self.plaintext_code ||= SecureRandom.urlsafe_base64(32)
# Store HMAC in database (not plaintext)
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