Add API keys / bearer tokens for forward auth
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

Enables server-to-server authentication for forward auth applications
(e.g., video players accessing WebDAV) where browser cookies aren't
available. API keys use clk_ prefixed tokens stored as HMAC hashes.

Bearer token auth is checked before cookie auth in /api/verify.
Invalid tokens return 401 JSON (no redirect). Requests without
bearer tokens fall through to existing cookie flow unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-03-05 21:45:40 +11:00
parent 444ae6291c
commit fd8785a43d
15 changed files with 651 additions and 1 deletions

66
app/models/api_key.rb Normal file
View File

@@ -0,0 +1,66 @@
class ApiKey < ApplicationRecord
belongs_to :user
belongs_to :application
before_validation :generate_token, on: :create
validates :name, presence: true
validates :token_hmac, presence: true, uniqueness: true
validate :application_must_be_forward_auth
validate :user_must_have_access
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope :revoked, -> { where.not(revoked_at: nil) }
attr_accessor :plaintext_token
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
def self.compute_token_hmac(plaintext_token)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
end
def expired?
expires_at.present? && expires_at <= Time.current
end
def revoked?
revoked_at.present?
end
def active?
!expired? && !revoked?
end
def revoke!
update!(revoked_at: Time.current)
end
def touch_last_used!
update_column(:last_used_at, Time.current)
end
private
def generate_token
self.plaintext_token ||= "clk_#{SecureRandom.urlsafe_base64(48)}"
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
end
def application_must_be_forward_auth
if application && !application.forward_auth?
errors.add(:application, "must be a forward auth application")
end
end
def user_must_have_access
if user && application && !application.user_allowed?(user)
errors.add(:user, "does not have access to this application")
end
end
end

View File

@@ -34,6 +34,7 @@ class Application < ApplicationRecord
has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_refresh_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
has_many :api_keys, dependent: :destroy
validates :name, presence: true
validates :slug, presence: true, uniqueness: {case_sensitive: false},

View File

@@ -9,6 +9,7 @@ class User < ApplicationRecord
has_many :application_user_claims, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy
has_many :api_keys, dependent: :destroy
# Token generation for passwordless flows
generates_token_for :invitation_login, expires_in: 24.hours do