Polish up Passkey interface

- Fix indentation
- Split awkward initializer into separate methods
- Rename credentials to passkeys
- Simplify authenticator registry loading
- Flatten nested errors under ActionPack::WebAuthn
- Set passkey current params only requests that use it
- Inline anemic methods
- Replace custom validation with ActiveModel::Validation
- Rename identity to holder in Passkey
- Rename credentials to passkeys in JS
- Extract framework library out of controllers
- Pass params hashes down to ActionPack
- Attempt to simplify public interface
- Push data decoding down to the classes representing the data
- Introduce has_passkeys
- Add CBOR bigint support
- Rename ActionPack::WebAuthn::Passkey to ActionPack::Passkey
- Add create_passkey_button helper
- Rename public-key to creation-options
- Add sign_in_with_passkey_button helper
- Dispatch events for the whole Passkey lifecycle
- Add ED25519 support
- Prevent crash on missing meta tag
- Validate resident key options
- Don't clobber existing ActionPack config options
- Validate cryptographic params
- Move CurrentWebAuthnRequest into ActionPack::Passkey
- Use ActiveModel::Attributes for Options objects
- Implement expiring challanges
- Add lifecycle events
- Extract param helpers into Request
- Add passkey_creation_options and passkey_request_options helpers
- Add create_passkey_challenge to make it easier to override the create method if needed
- Prefix all view helpers with passkey_
- Auto-include ActionPack::Passkey::Holder
- Make the passkey challange url configurable
- Add a reminder about Passkeys to the magic link email
This commit is contained in:
Stanko K.R.
2026-03-04 14:12:40 +01:00
parent f4fbe17b22
commit 017bcc9ce1
73 changed files with 2326 additions and 1103 deletions
+143
View File
@@ -0,0 +1,143 @@
# 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_creation_options+ — merged into ActionPack::Passkey.creation_options
# - +passkey_request_options+ — merged into ActionPack::Passkey.request_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.creation_options { { name: email, display_name: name } }
# config.request_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_creation_options+ and +passkey_request_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_creation_options) do
{
id: id,
exclude_credentials: public_send(config.association_name)
}.merge(config.evaluate_creation_options(self))
end
define_method(:passkey_request_options) do
{ credentials: public_send(config.association_name) }.merge(config.evaluate_request_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)
@creation_options = options_to_proc(creation_opts)
end
if request_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::RequestOptions, options)
@request_options = options_to_proc(request_opts)
end
end
# Sets a block to evaluate in the holder's context to produce additional request options.
#
# config.request_options { { user_verification: "required" } }
def request_options(&block)
@request_options = block
end
# Sets a block to evaluate in the holder's context to produce additional creation options.
#
# config.creation_options { { name: email, display_name: name } }
def creation_options(&block)
@creation_options = block
end
# Evaluates the request options block (if any) in the context of the given +record+. Called
# internally by the +passkey_request_options+ method defined on the holder.
def evaluate_request_options(record)
if @request_options
record.instance_exec(&@request_options)
else
{}
end
end
# Evaluates the creation options block (if any) in the context of the given +record+. Called
# internally by the +passkey_creation_options+ method defined on the holder.
def evaluate_creation_options(record)
if @creation_options
record.instance_exec(&@creation_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