144 lines
5.1 KiB
Ruby
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
|