Files
fizzy/lib/action_pack/passkey/holder.rb
T
2026-03-25 17:26:49 +01:00

144 lines
5.1 KiB
Ruby

# Adds passkey support to an Active Record model (the "holder" of passkeys).
#
# == Usage
#
# class User < ApplicationRecord
# has_passkeys name: :email_address, display_name: :name
# end
#
# This sets up a polymorphic +has_many :passkeys+ association and defines two methods on the
# model that supply holder-specific options for the WebAuthn ceremonies:
#
# - +passkey_registration_options+ — merged into ActionPack::Passkey.registration_options
# - +passkey_authentication_options+ — merged into ActionPack::Passkey.authentication_options
#
# == Options
#
# +has_passkeys+ accepts keyword arguments that map to WebAuthn creation or request option
# fields. Values can be symbols (sent to the record), procs (evaluated in the record's context),
# or plain values:
#
# [+name+]
# A human-readable account identifier (typically an email or username) shown by the
# authenticator when the user selects a passkey. Maps to the WebAuthn +user.name+ field.
#
# [+display_name+]
# A friendly label for the user (typically their full name) shown by the authenticator
# during passkey registration. Maps to the WebAuthn +user.displayName+ field.
#
# has_passkeys name: :email, display_name: :name
#
# For more complex configuration, pass a block that receives a ActionPack::Passkey::Holder::Config:
#
# has_passkeys do |config|
# config.registration_options { { name: email, display_name: name } }
# config.authentication_options { { user_verification: "required" } }
# end
module ActionPack::Passkey::Holder
extend ActiveSupport::Concern
class_methods do
# Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association
# and defines +passkey_registration_options+ and +passkey_authentication_options+ instance methods used
# by ActionPack::Passkey to build ceremony options.
#
# Keyword arguments matching CreationOptions or RequestOptions fields are extracted and
# turned into holder-scoped option procs automatically. An optional block yields a Config
# for more complex setup.
def has_passkeys(**options, &block)
config = Config.new(**options)
block&.call(config)
has_many config.association_name,
as: :holder,
dependent: config.dependent,
class_name: "ActionPack::Passkey"
define_method(:passkey_registration_options) do
{
id: id,
exclude_credentials: public_send(config.association_name)
}.merge(config.evaluate_registration_options(self))
end
define_method(:passkey_authentication_options) do
{ credentials: public_send(config.association_name) }.merge(config.evaluate_authentication_options(self))
end
end
end
# Configuration object yielded by +has_passkeys+ when a block is given. Allows setting
# custom association options and ceremony option blocks.
class Config
attr_accessor :association_name, :dependent
def initialize(**options)
@association_name = options.delete(:association_name) || :passkeys
@dependent = options.delete(:dependent) || :destroy
if creation_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::CreationOptions, options)
@registration_options = options_to_proc(creation_opts)
end
if request_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::RequestOptions, options)
@authentication_options = options_to_proc(request_opts)
end
end
# Sets a block to evaluate in the holder's context to produce additional authentication options.
#
# config.authentication_options { { user_verification: "required" } }
def authentication_options(&block)
@authentication_options = block
end
# Sets a block to evaluate in the holder's context to produce additional creation options.
#
# config.registration_options { { name: email, display_name: name } }
def registration_options(&block)
@registration_options = block
end
# Evaluates the request options block (if any) in the context of the given +record+. Called
# internally by the +passkey_authentication_options+ method defined on the holder.
def evaluate_authentication_options(record)
if @authentication_options
record.instance_exec(&@authentication_options)
else
{}
end
end
# Evaluates the creation options block (if any) in the context of the given +record+. Called
# internally by the +passkey_registration_options+ method defined on the holder.
def evaluate_registration_options(record)
if @registration_options
record.instance_exec(&@registration_options)
else
{}
end
end
private
def extract_options_for(klass, options)
keys = klass.attribute_names.map(&:to_sym)
extracted = options.slice(*keys)
options.except!(*keys)
extracted if extracted.any?
end
def options_to_proc(options)
proc do
options.transform_values do |value|
case value
when Symbol then send(value)
when Proc then instance_exec(&value)
else value
end
end
end
end
end
end