unmagic-passkeys 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/NOTICE +9 -0
- data/README.md +151 -0
- data/app/assets/javascripts/unmagic/passkeys/passkey.js +236 -0
- data/app/assets/javascripts/unmagic/passkeys/webauthn.js +83 -0
- data/app/controllers/unmagic/passkeys/challenges_controller.rb +49 -0
- data/app/models/unmagic/passkeys/credential.rb +103 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +2 -0
- data/lib/generators/unmagic/passkeys/install_generator.rb +51 -0
- data/lib/generators/unmagic/passkeys/templates/POST_INSTALL +19 -0
- data/lib/generators/unmagic/passkeys/templates/create_unmagic_passkeys_credentials.rb.tt +19 -0
- data/lib/unmagic/passkeys/engine.rb +78 -0
- data/lib/unmagic/passkeys/form_helper.rb +128 -0
- data/lib/unmagic/passkeys/holder.rb +143 -0
- data/lib/unmagic/passkeys/request.rb +77 -0
- data/lib/unmagic/passkeys/version.rb +5 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/assertion_response.rb +88 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation.rb +73 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation_response.rb +71 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation_verifiers/none.rb +24 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/data.rb +174 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/response.rb +141 -0
- data/lib/unmagic/passkeys/web_authn/cbor_decoder.rb +269 -0
- data/lib/unmagic/passkeys/web_authn/cose_key.rb +183 -0
- data/lib/unmagic/passkeys/web_authn/current.rb +19 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/creation_options.rb +109 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/options.rb +80 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/request_options.rb +55 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential.rb +153 -0
- data/lib/unmagic/passkeys/web_authn/relying_party.rb +50 -0
- data/lib/unmagic/passkeys/web_authn.rb +84 -0
- data/lib/unmagic/passkeys.rb +41 -0
- metadata +152 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Unmagic
|
|
7
|
+
module Passkeys
|
|
8
|
+
module Generators
|
|
9
|
+
# Installs unmagic-passkeys into a host app: copies the credentials migration
|
|
10
|
+
# (matching the host's primary-key type), imports the JavaScript, and prints the
|
|
11
|
+
# remaining controller/route/view wiring to do.
|
|
12
|
+
class InstallGenerator < Rails::Generators::Base
|
|
13
|
+
include ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
desc "Copies the unmagic_passkeys_credentials migration and wires the JavaScript."
|
|
18
|
+
|
|
19
|
+
def create_migration_file
|
|
20
|
+
migration_template "create_unmagic_passkeys_credentials.rb.tt",
|
|
21
|
+
"db/migrate/create_unmagic_passkeys_credentials.rb"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def import_javascript
|
|
25
|
+
application_js = "app/javascript/application.js"
|
|
26
|
+
|
|
27
|
+
if File.exist?(File.join(destination_root, application_js))
|
|
28
|
+
append_to_file application_js, %(import "unmagic/passkeys"\n)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def show_post_install
|
|
33
|
+
readme "POST_INSTALL" if behavior == :invoke
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
def key_type
|
|
38
|
+
Rails.configuration.generators.options.dig(:active_record, :primary_key_type)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def table_id_option
|
|
42
|
+
", id: :#{key_type}" if key_type
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def holder_id_type
|
|
46
|
+
key_type || :bigint
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
unmagic-passkeys installed.
|
|
3
|
+
|
|
4
|
+
Next:
|
|
5
|
+
|
|
6
|
+
1. Run the migration:
|
|
7
|
+
bin/rails db:migrate
|
|
8
|
+
|
|
9
|
+
2. Add passkeys to your holder model:
|
|
10
|
+
class User < ApplicationRecord
|
|
11
|
+
has_passkeys name: :email_address, display_name: :name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
3. Wire up sign-in and registration controllers, routes and views.
|
|
15
|
+
See the README "Host wiring" section for copy-paste examples.
|
|
16
|
+
|
|
17
|
+
The challenge endpoint (POST /unmagic/passkeys/challenge) and the JavaScript
|
|
18
|
+
web components are already available.
|
|
19
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateUnmagicPasskeysCredentials < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :unmagic_passkeys_credentials<%= table_id_option %> do |t|
|
|
6
|
+
t.references :holder, polymorphic: true, null: false, type: <%= holder_id_type.to_sym.inspect %>
|
|
7
|
+
t.string :credential_id, null: false
|
|
8
|
+
t.binary :public_key, null: false
|
|
9
|
+
t.integer :sign_count, null: false, default: 0
|
|
10
|
+
t.string :name
|
|
11
|
+
t.text :transports
|
|
12
|
+
t.string :aaguid
|
|
13
|
+
t.boolean :backed_up
|
|
14
|
+
t.timestamps
|
|
15
|
+
|
|
16
|
+
t.index :credential_id, unique: true
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
module Passkeys
|
|
5
|
+
# Integrates the WebAuthn and passkey subsystems into a host Rails app. We deliberately do
|
|
6
|
+
# NOT isolate_namespace: passkeys is an injected concern — +has_passkeys+ goes onto the host's
|
|
7
|
+
# models, the form helpers into its views, and a stateless challenge endpoint into its routes.
|
|
8
|
+
#
|
|
9
|
+
# == Configuration
|
|
10
|
+
#
|
|
11
|
+
# # config/initializers/passkeys.rb
|
|
12
|
+
# config.unmagic_passkeys.web_authn.default_creation_options = { attestation: :none }
|
|
13
|
+
# config.unmagic_passkeys.web_authn.default_request_options = { user_verification: :required }
|
|
14
|
+
# config.unmagic_passkeys.routes_prefix = "/unmagic/passkeys"
|
|
15
|
+
class Engine < ::Rails::Engine
|
|
16
|
+
config.unmagic_passkeys = ActiveSupport::OrderedOptions.new
|
|
17
|
+
config.unmagic_passkeys.parent_class_name = "ApplicationRecord"
|
|
18
|
+
config.unmagic_passkeys.routes_prefix = "/unmagic/passkeys"
|
|
19
|
+
config.unmagic_passkeys.draw_routes = true
|
|
20
|
+
config.unmagic_passkeys.challenge_url = nil
|
|
21
|
+
|
|
22
|
+
config.unmagic_passkeys.web_authn = ActiveSupport::OrderedOptions.new
|
|
23
|
+
config.unmagic_passkeys.web_authn.default_request_options = {}
|
|
24
|
+
config.unmagic_passkeys.web_authn.default_creation_options = {}
|
|
25
|
+
config.unmagic_passkeys.web_authn.creation_challenge_expiration = 10.minutes
|
|
26
|
+
config.unmagic_passkeys.web_authn.request_challenge_expiration = 5.minutes
|
|
27
|
+
|
|
28
|
+
initializer "unmagic_passkeys.routes" do |app|
|
|
29
|
+
passkey_config = config.unmagic_passkeys
|
|
30
|
+
|
|
31
|
+
app.routes.prepend do
|
|
32
|
+
if passkey_config.draw_routes
|
|
33
|
+
scope passkey_config.routes_prefix, as: :passkey do
|
|
34
|
+
post "/challenge" => "unmagic/passkeys/challenges#create", as: :challenge
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
initializer "unmagic_passkeys.holder" do
|
|
41
|
+
ActiveSupport.on_load(:active_record) do
|
|
42
|
+
# Shim: Holder references the Credential model (itself Active Record), so it can't be
|
|
43
|
+
# mixed in until Active Record is ready. The first call swaps in the real macro.
|
|
44
|
+
def self.has_passkeys(**options, &block)
|
|
45
|
+
include Unmagic::Passkeys::Holder
|
|
46
|
+
has_passkeys(**options, &block)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
initializer "unmagic_passkeys.form_helper" do
|
|
52
|
+
ActiveSupport.on_load(:action_view) do
|
|
53
|
+
require "unmagic/passkeys/form_helper"
|
|
54
|
+
include Unmagic::Passkeys::FormHelper
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
initializer "unmagic_passkeys.request" do
|
|
59
|
+
ActiveSupport.on_load(:action_controller) do
|
|
60
|
+
require "unmagic/passkeys/request"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
initializer "unmagic_passkeys.assets" do |app|
|
|
65
|
+
if app.config.respond_to?(:assets)
|
|
66
|
+
app.config.assets.paths << root.join("app/assets/javascripts").to_s
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
initializer "unmagic_passkeys.importmap", before: "importmap" do |app|
|
|
71
|
+
if app.config.respond_to?(:importmap)
|
|
72
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
73
|
+
app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# View helpers for rendering passkey registration and sign-in buttons.
|
|
2
|
+
module Unmagic::Passkeys::FormHelper
|
|
3
|
+
REGISTRATION_ERROR_MESSAGE = "Something went wrong while registering your passkey."
|
|
4
|
+
REGISTRATION_CANCELLED_MESSAGE = "Passkey registration was cancelled. Try again when you are ready."
|
|
5
|
+
REGISTRATION_DUPLICATE_MESSAGE = "You already have a passkey registered on this device. Remove the existing one first and try again."
|
|
6
|
+
SIGN_IN_ERROR_MESSAGE = "Something went wrong while signing in with your passkey."
|
|
7
|
+
SIGN_IN_CANCELLED_MESSAGE = "Passkey sign in was cancelled. Try again when you are ready."
|
|
8
|
+
|
|
9
|
+
# Renders a button for registering a new passkey. Accepts a +label+ string or a block
|
|
10
|
+
# for button content.
|
|
11
|
+
#
|
|
12
|
+
# Options:
|
|
13
|
+
# - +options+: WebAuthn creation options (JSON-serializable hash)
|
|
14
|
+
# - +challenge_url+: endpoint to refresh the challenge nonce
|
|
15
|
+
# - +wrapper+: HTML attributes for the outer web component element
|
|
16
|
+
# - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
|
|
17
|
+
# to set the form parameter namespace (default: +:passkey+)
|
|
18
|
+
# - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
|
|
19
|
+
# to override the default error text
|
|
20
|
+
# - +cancellation+: HTML attributes for the cancellation message +<div>+. Supports a
|
|
21
|
+
# +:message+ key to override the default cancellation text
|
|
22
|
+
# - All other options are passed to the +<button>+ tag
|
|
23
|
+
def passkey_registration_button(name = nil, url = nil, **options, &block)
|
|
24
|
+
url, name = name, block ? capture(&block) : nil if block_given?
|
|
25
|
+
component_options, form_options, button_options, error_options = partition_passkey_options(url, options)
|
|
26
|
+
error_options[:error][:message] ||= REGISTRATION_ERROR_MESSAGE
|
|
27
|
+
error_options[:cancellation][:message] ||= REGISTRATION_CANCELLED_MESSAGE
|
|
28
|
+
error_options[:duplicate][:message] ||= REGISTRATION_DUPLICATE_MESSAGE
|
|
29
|
+
param = form_options.delete(:param)
|
|
30
|
+
|
|
31
|
+
content_tag("unmagic-passkey-registration-button", **component_options.transform_keys { |key| key.to_s.dasherize }) do
|
|
32
|
+
tag.form(**form_options) do
|
|
33
|
+
hidden_field_tag(:authenticity_token, form_authenticity_token) +
|
|
34
|
+
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
|
|
35
|
+
hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
|
|
36
|
+
hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
|
|
37
|
+
tag.button(name, type: :button, data: { passkey: "register" }, **button_options)
|
|
38
|
+
end + passkey_error_messages(**error_options)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Renders a button for signing in with a passkey. Accepts a +label+ string or a block
|
|
43
|
+
# for button content.
|
|
44
|
+
#
|
|
45
|
+
# Options:
|
|
46
|
+
# - +options+: WebAuthn request options (JSON-serializable hash)
|
|
47
|
+
# - +challenge_url+: endpoint to refresh the challenge nonce
|
|
48
|
+
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
|
|
49
|
+
# - +wrapper+: HTML attributes for the outer web component element
|
|
50
|
+
# - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
|
|
51
|
+
# to set the form parameter namespace (default: +:passkey+)
|
|
52
|
+
# - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
|
|
53
|
+
# to override the default error text
|
|
54
|
+
# - +cancellation+: HTML attributes for the cancellation message +<div>+. Supports a
|
|
55
|
+
# +:message+ key to override the default cancellation text
|
|
56
|
+
# - All other options are passed to the +<button>+ tag
|
|
57
|
+
def passkey_sign_in_button(name = nil, url = nil, **options, &block)
|
|
58
|
+
url, name = name, block ? capture(&block) : nil if block_given?
|
|
59
|
+
component_options, form_options, button_options, error_options = partition_passkey_options(url, options)
|
|
60
|
+
error_options[:error][:message] ||= SIGN_IN_ERROR_MESSAGE
|
|
61
|
+
error_options[:cancellation][:message] ||= SIGN_IN_CANCELLED_MESSAGE
|
|
62
|
+
param = form_options.delete(:param)
|
|
63
|
+
|
|
64
|
+
content_tag("unmagic-passkey-sign-in-button", **component_options.transform_keys { |key| key.to_s.dasherize }) do
|
|
65
|
+
tag.form(**form_options) do
|
|
66
|
+
hidden_field_tag(:authenticity_token, form_authenticity_token) +
|
|
67
|
+
hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
|
|
68
|
+
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
|
|
69
|
+
hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
|
|
70
|
+
hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
|
|
71
|
+
tag.button(name, type: :button, data: { passkey: "sign_in" }, **button_options)
|
|
72
|
+
end + passkey_error_messages(**error_options)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
def partition_passkey_options(url, options)
|
|
78
|
+
passkey_options = options.fetch(:options, {})
|
|
79
|
+
wrapper_options = options.fetch(:wrapper, {})
|
|
80
|
+
|
|
81
|
+
component_options = options
|
|
82
|
+
.slice(:challenge_url, :mediation)
|
|
83
|
+
.reverse_merge(challenge_url: default_passkey_challenge_url, options: passkey_options.to_json(except: :challenge))
|
|
84
|
+
|
|
85
|
+
form_options = options
|
|
86
|
+
.fetch(:form, {})
|
|
87
|
+
.reverse_merge(method: :post, action: url, class: "button_to", param: :passkey)
|
|
88
|
+
|
|
89
|
+
error_options = options.slice(:error, :cancellation, :duplicate).reverse_merge(error: {}, cancellation: {}, duplicate: {})
|
|
90
|
+
|
|
91
|
+
button_options = options.except(:options, :form, :wrapper, *component_options.keys, *error_options.keys)
|
|
92
|
+
|
|
93
|
+
[ wrapper_options.merge(component_options), form_options, button_options, error_options ]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def default_passkey_challenge_url
|
|
97
|
+
if challenge_url = Rails.configuration.unmagic_passkeys.challenge_url
|
|
98
|
+
instance_exec(&challenge_url)
|
|
99
|
+
else
|
|
100
|
+
passkey_challenge_path
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def passkey_error_messages(error: {}, cancellation: {}, duplicate: {})
|
|
105
|
+
error_message, error_attributes = build_passkey_error_options("error", error)
|
|
106
|
+
cancellation_message, cancellation_attributes = build_passkey_error_options("cancelled", cancellation)
|
|
107
|
+
|
|
108
|
+
messages = tag.div(error_message, hidden: true, **error_attributes) +
|
|
109
|
+
tag.div(cancellation_message, hidden: true, **cancellation_attributes)
|
|
110
|
+
|
|
111
|
+
if duplicate[:message]
|
|
112
|
+
duplicate_message, duplicate_attributes = build_passkey_error_options("duplicate", duplicate)
|
|
113
|
+
messages += tag.div(duplicate_message, hidden: true, **duplicate_attributes)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
messages
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_passkey_error_options(type, options)
|
|
120
|
+
message = options[:message]
|
|
121
|
+
|
|
122
|
+
attributes = options.except(:message)
|
|
123
|
+
attributes[:data] ||= {}
|
|
124
|
+
attributes[:data][:passkey_error] = type
|
|
125
|
+
|
|
126
|
+
[ message, attributes ]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Adds passkey support to an Active Record model (the "holder" of passkeys).
|
|
2
|
+
#
|
|
3
|
+
# == Usage
|
|
4
|
+
#
|
|
5
|
+
# class User < ApplicationRecord
|
|
6
|
+
# has_passkeys name: :email_address, display_name: :name
|
|
7
|
+
# end
|
|
8
|
+
#
|
|
9
|
+
# This sets up a polymorphic +has_many :passkeys+ association and defines two methods on the
|
|
10
|
+
# model that supply holder-specific options for the WebAuthn ceremonies:
|
|
11
|
+
#
|
|
12
|
+
# - +passkey_registration_options+ — merged into Unmagic::Passkeys::Credential.registration_options
|
|
13
|
+
# - +passkey_authentication_options+ — merged into Unmagic::Passkeys::Credential.authentication_options
|
|
14
|
+
#
|
|
15
|
+
# == Options
|
|
16
|
+
#
|
|
17
|
+
# +has_passkeys+ accepts keyword arguments that map to WebAuthn creation or request option
|
|
18
|
+
# fields. Values can be symbols (sent to the record), procs (evaluated in the record's context),
|
|
19
|
+
# or plain values:
|
|
20
|
+
#
|
|
21
|
+
# [+name+]
|
|
22
|
+
# A human-readable account identifier (typically an email or username) shown by the
|
|
23
|
+
# authenticator when the user selects a passkey. Maps to the WebAuthn +user.name+ field.
|
|
24
|
+
#
|
|
25
|
+
# [+display_name+]
|
|
26
|
+
# A friendly label for the user (typically their full name) shown by the authenticator
|
|
27
|
+
# during passkey registration. Maps to the WebAuthn +user.displayName+ field.
|
|
28
|
+
#
|
|
29
|
+
# has_passkeys name: :email, display_name: :name
|
|
30
|
+
#
|
|
31
|
+
# For more complex configuration, pass a block that receives a Unmagic::Passkeys::Holder::Config:
|
|
32
|
+
#
|
|
33
|
+
# has_passkeys do |config|
|
|
34
|
+
# config.registration_options { { name: email, display_name: name } }
|
|
35
|
+
# config.authentication_options { { user_verification: "required" } }
|
|
36
|
+
# end
|
|
37
|
+
module Unmagic::Passkeys::Holder
|
|
38
|
+
extend ActiveSupport::Concern
|
|
39
|
+
|
|
40
|
+
class_methods do
|
|
41
|
+
# Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association
|
|
42
|
+
# and defines +passkey_registration_options+ and +passkey_authentication_options+ instance methods used
|
|
43
|
+
# by Unmagic::Passkeys::Credential to build ceremony options.
|
|
44
|
+
#
|
|
45
|
+
# Keyword arguments matching CreationOptions or RequestOptions fields are extracted and
|
|
46
|
+
# turned into holder-scoped option procs automatically. An optional block yields a Config
|
|
47
|
+
# for more complex setup.
|
|
48
|
+
def has_passkeys(**options, &block)
|
|
49
|
+
config = Config.new(**options)
|
|
50
|
+
block&.call(config)
|
|
51
|
+
|
|
52
|
+
has_many config.association_name,
|
|
53
|
+
as: :holder,
|
|
54
|
+
dependent: config.dependent,
|
|
55
|
+
class_name: "Unmagic::Passkeys::Credential"
|
|
56
|
+
|
|
57
|
+
define_method(:passkey_registration_options) do
|
|
58
|
+
{
|
|
59
|
+
id: id,
|
|
60
|
+
exclude_credentials: public_send(config.association_name)
|
|
61
|
+
}.merge(config.evaluate_registration_options(self))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
define_method(:passkey_authentication_options) do
|
|
65
|
+
{ credentials: public_send(config.association_name) }.merge(config.evaluate_authentication_options(self))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Configuration object yielded by +has_passkeys+ when a block is given. Allows setting
|
|
71
|
+
# custom association options and ceremony option blocks.
|
|
72
|
+
class Config
|
|
73
|
+
attr_accessor :association_name, :dependent
|
|
74
|
+
|
|
75
|
+
def initialize(**options)
|
|
76
|
+
@association_name = options.delete(:association_name) || :passkeys
|
|
77
|
+
@dependent = options.delete(:dependent) || :destroy
|
|
78
|
+
|
|
79
|
+
if creation_opts = extract_options_for(Unmagic::Passkeys::WebAuthn::PublicKeyCredential::CreationOptions, options)
|
|
80
|
+
@registration_options = options_to_proc(creation_opts)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if request_opts = extract_options_for(Unmagic::Passkeys::WebAuthn::PublicKeyCredential::RequestOptions, options)
|
|
84
|
+
@authentication_options = options_to_proc(request_opts)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Sets a block to evaluate in the holder's context to produce additional authentication options.
|
|
89
|
+
#
|
|
90
|
+
# config.authentication_options { { user_verification: "required" } }
|
|
91
|
+
def authentication_options(&block)
|
|
92
|
+
@authentication_options = block
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Sets a block to evaluate in the holder's context to produce additional creation options.
|
|
96
|
+
#
|
|
97
|
+
# config.registration_options { { name: email, display_name: name } }
|
|
98
|
+
def registration_options(&block)
|
|
99
|
+
@registration_options = block
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Evaluates the request options block (if any) in the context of the given +record+. Called
|
|
103
|
+
# internally by the +passkey_authentication_options+ method defined on the holder.
|
|
104
|
+
def evaluate_authentication_options(record)
|
|
105
|
+
if @authentication_options
|
|
106
|
+
record.instance_exec(&@authentication_options)
|
|
107
|
+
else
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Evaluates the creation options block (if any) in the context of the given +record+. Called
|
|
113
|
+
# internally by the +passkey_registration_options+ method defined on the holder.
|
|
114
|
+
def evaluate_registration_options(record)
|
|
115
|
+
if @registration_options
|
|
116
|
+
record.instance_exec(&@registration_options)
|
|
117
|
+
else
|
|
118
|
+
{}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
def extract_options_for(klass, options)
|
|
124
|
+
keys = klass.attribute_names.map(&:to_sym)
|
|
125
|
+
|
|
126
|
+
extracted = options.slice(*keys)
|
|
127
|
+
options.except!(*keys)
|
|
128
|
+
extracted if extracted.any?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def options_to_proc(options)
|
|
132
|
+
proc do
|
|
133
|
+
options.transform_values do |value|
|
|
134
|
+
case value
|
|
135
|
+
when Symbol then send(value)
|
|
136
|
+
when Proc then instance_exec(&value)
|
|
137
|
+
else value
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# = Action Pack Passkey Request
|
|
2
|
+
#
|
|
3
|
+
# Controller concern that sets up the WebAuthn request context and provides
|
|
4
|
+
# helper methods for passkey registration and authentication. Include this
|
|
5
|
+
# in any controller that handles passkey form submissions.
|
|
6
|
+
#
|
|
7
|
+
# == Registration example
|
|
8
|
+
#
|
|
9
|
+
# class PasskeysController < ApplicationController
|
|
10
|
+
# include Unmagic::Passkeys::Request
|
|
11
|
+
#
|
|
12
|
+
# def new
|
|
13
|
+
# @registration_options = passkey_registration_options(holder: Current.user)
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# def create
|
|
17
|
+
# @passkey = Unmagic::Passkeys::Credential.register(
|
|
18
|
+
# passkey_registration_params, holder: Current.user
|
|
19
|
+
# )
|
|
20
|
+
# redirect_to settings_path
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# == Authentication example
|
|
25
|
+
#
|
|
26
|
+
# class SessionsController < ApplicationController
|
|
27
|
+
# include Unmagic::Passkeys::Request
|
|
28
|
+
#
|
|
29
|
+
# def new
|
|
30
|
+
# @authentication_options = passkey_authentication_options
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# def create
|
|
34
|
+
# if passkey = Unmagic::Passkeys::Credential.authenticate(passkey_authentication_params)
|
|
35
|
+
# sign_in passkey.holder
|
|
36
|
+
# redirect_to root_path
|
|
37
|
+
# else
|
|
38
|
+
# redirect_to new_session_path, alert: "Authentication failed"
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# == Before Action
|
|
44
|
+
#
|
|
45
|
+
# Automatically populates +Unmagic::Passkeys::WebAuthn::Current+ with the request
|
|
46
|
+
# host and origin.
|
|
47
|
+
#
|
|
48
|
+
module Unmagic::Passkeys::Request
|
|
49
|
+
extend ActiveSupport::Concern
|
|
50
|
+
|
|
51
|
+
included do
|
|
52
|
+
before_action do
|
|
53
|
+
Unmagic::Passkeys::WebAuthn::Current.host = request.host
|
|
54
|
+
Unmagic::Passkeys::WebAuthn::Current.origin = request.base_url
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns strong parameters for the passkey registration ceremony.
|
|
59
|
+
def passkey_registration_params(param: :passkey)
|
|
60
|
+
params.expect(param => [ :client_data_json, :attestation_object, transports: [] ])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns strong parameters for the passkey authentication ceremony.
|
|
64
|
+
def passkey_authentication_params(param: :passkey)
|
|
65
|
+
params.expect(param => [ :id, :client_data_json, :authenticator_data, :signature ])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns RequestOptions for the authentication ceremony.
|
|
69
|
+
def passkey_authentication_options(**options)
|
|
70
|
+
Unmagic::Passkeys::Credential.authentication_options(**options)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Returns RegistrationOptions for the registration ceremony.
|
|
74
|
+
def passkey_registration_options(**options)
|
|
75
|
+
Unmagic::Passkeys::Credential.registration_options(**options)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Assertion Response
|
|
2
|
+
#
|
|
3
|
+
# Handles the authenticator response from a WebAuthn authentication ceremony.
|
|
4
|
+
# When a user authenticates with an existing credential, the authenticator
|
|
5
|
+
# returns an assertion response containing a signature that proves possession
|
|
6
|
+
# of the private key.
|
|
7
|
+
#
|
|
8
|
+
# == Usage
|
|
9
|
+
#
|
|
10
|
+
# # Look up the credential by ID
|
|
11
|
+
# credential = user.credentials.find_by!(
|
|
12
|
+
# credential_id: params[:id]
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
# response = Unmagic::Passkeys::WebAuthn::Authenticator::AssertionResponse.new(
|
|
16
|
+
# client_data_json: params[:response][:clientDataJSON],
|
|
17
|
+
# authenticator_data: params[:response][:authenticatorData],
|
|
18
|
+
# signature: params[:response][:signature],
|
|
19
|
+
# credential: credential.to_public_key_credential,
|
|
20
|
+
# origin: "https://example.com"
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# response.validate!
|
|
24
|
+
#
|
|
25
|
+
# == Validation
|
|
26
|
+
#
|
|
27
|
+
# In addition to the base Response validations, this class verifies:
|
|
28
|
+
#
|
|
29
|
+
# * The client data type is "webauthn.get"
|
|
30
|
+
# * The signature is valid for the credential's public key
|
|
31
|
+
#
|
|
32
|
+
class Unmagic::Passkeys::WebAuthn::Authenticator::AssertionResponse < Unmagic::Passkeys::WebAuthn::Authenticator::Response
|
|
33
|
+
attr_reader :credential, :authenticator_data, :signature
|
|
34
|
+
|
|
35
|
+
validate :client_data_type_must_be_get
|
|
36
|
+
validate :signature_must_be_valid
|
|
37
|
+
validate :sign_count_must_increase
|
|
38
|
+
|
|
39
|
+
def initialize(credential:, authenticator_data:, signature:, **attributes)
|
|
40
|
+
super(**attributes)
|
|
41
|
+
@credential = credential
|
|
42
|
+
@signature = signature
|
|
43
|
+
@signature = Base64.urlsafe_decode64(@signature) unless @signature.encoding == Encoding::BINARY
|
|
44
|
+
@authenticator_data = Unmagic::Passkeys::WebAuthn::Authenticator::Data.wrap(authenticator_data)
|
|
45
|
+
rescue ArgumentError
|
|
46
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, "Invalid base64 encoding in signature"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
def challenge_purpose
|
|
51
|
+
"authentication"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def client_data_type_must_be_get
|
|
55
|
+
unless client_data["type"] == "webauthn.get"
|
|
56
|
+
errors.add(:base, "Client data type is not webauthn.get")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def signature_must_be_valid
|
|
61
|
+
client_data_hash = Digest::SHA256.digest(client_data_json)
|
|
62
|
+
signed_data = authenticator_data.bytes.pack("C*") + client_data_hash
|
|
63
|
+
digest = credential.public_key.oid == "ED25519" ? nil : "SHA256"
|
|
64
|
+
|
|
65
|
+
unless credential.public_key.verify(digest, signature, signed_data)
|
|
66
|
+
errors.add(:base, "Invalid signature")
|
|
67
|
+
end
|
|
68
|
+
rescue OpenSSL::PKey::PKeyError
|
|
69
|
+
errors.add(:base, "Invalid signature")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sign_count_must_increase
|
|
73
|
+
unless sign_count_increased?
|
|
74
|
+
errors.add(:base, "Sign count did not increase")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def sign_count_increased?
|
|
79
|
+
if authenticator_data.sign_count.zero? && credential.sign_count.zero?
|
|
80
|
+
# Some authenticators always return 0 for the sign count, even after multiple authentications.
|
|
81
|
+
# In that case, we have to check that both the stored and returned sign counts are 0,
|
|
82
|
+
# which indicates that the authenticator is likely not updating the sign count.
|
|
83
|
+
true
|
|
84
|
+
else
|
|
85
|
+
authenticator_data.sign_count > credential.sign_count
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Attestation
|
|
2
|
+
#
|
|
3
|
+
# Decodes and represents the attestation object returned by an authenticator
|
|
4
|
+
# during registration. The attestation object is CBOR-encoded and contains
|
|
5
|
+
# the authenticator data along with an optional attestation statement.
|
|
6
|
+
#
|
|
7
|
+
# == Usage
|
|
8
|
+
#
|
|
9
|
+
# attestation = Unmagic::Passkeys::WebAuthn::Authenticator::Attestation.decode(
|
|
10
|
+
# attestation_object_bytes
|
|
11
|
+
# )
|
|
12
|
+
#
|
|
13
|
+
# attestation.credential_id # => "abc123..."
|
|
14
|
+
# attestation.public_key # => OpenSSL::PKey::EC
|
|
15
|
+
# attestation.sign_count # => 0
|
|
16
|
+
#
|
|
17
|
+
# == Attributes
|
|
18
|
+
#
|
|
19
|
+
# [+authenticator_data+]
|
|
20
|
+
# The parsed Data containing credential information.
|
|
21
|
+
#
|
|
22
|
+
# [+format+]
|
|
23
|
+
# The attestation statement format (e.g., "none", "packed", "fido-u2f").
|
|
24
|
+
#
|
|
25
|
+
# [+attestation_statement+]
|
|
26
|
+
# The attestation statement, which may contain a signature from the
|
|
27
|
+
# authenticator manufacturer. Empty for "none" format.
|
|
28
|
+
#
|
|
29
|
+
# == Delegated Methods
|
|
30
|
+
#
|
|
31
|
+
# The following methods are delegated to +authenticator_data+:
|
|
32
|
+
#
|
|
33
|
+
# * +credential_id+ - Base64URL-encoded credential identifier
|
|
34
|
+
# * +public_key+ - OpenSSL public key object
|
|
35
|
+
# * +public_key_bytes+ - Raw COSE key bytes
|
|
36
|
+
# * +sign_count+ - Signature counter for replay detection
|
|
37
|
+
#
|
|
38
|
+
class Unmagic::Passkeys::WebAuthn::Authenticator::Attestation
|
|
39
|
+
attr_reader :authenticator_data, :format, :attestation_statement
|
|
40
|
+
|
|
41
|
+
delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data
|
|
42
|
+
|
|
43
|
+
# Wraps raw attestation data into an Attestation instance. Accepts an
|
|
44
|
+
# existing Attestation object (returned as-is), a Base64URL-encoded string,
|
|
45
|
+
# or raw binary.
|
|
46
|
+
def self.wrap(data)
|
|
47
|
+
if data.is_a?(self)
|
|
48
|
+
data
|
|
49
|
+
else
|
|
50
|
+
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
|
|
51
|
+
decode(data)
|
|
52
|
+
end
|
|
53
|
+
rescue ArgumentError
|
|
54
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, "Invalid base64 encoding in attestation object"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Decodes a CBOR-encoded attestation object into an Attestation instance.
|
|
58
|
+
def self.decode(bytes)
|
|
59
|
+
cbor = Unmagic::Passkeys::WebAuthn::CborDecoder.decode(bytes)
|
|
60
|
+
|
|
61
|
+
new(
|
|
62
|
+
authenticator_data: Unmagic::Passkeys::WebAuthn::Authenticator::Data.decode(cbor["authData"]),
|
|
63
|
+
format: cbor["fmt"],
|
|
64
|
+
attestation_statement: cbor["attStmt"]
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(authenticator_data:, format:, attestation_statement:)
|
|
69
|
+
@authenticator_data = authenticator_data
|
|
70
|
+
@format = format
|
|
71
|
+
@attestation_statement = attestation_statement
|
|
72
|
+
end
|
|
73
|
+
end
|