devise-webauthn 0.0.0 → 0.1.1

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +75 -0
  3. data/.gitignore +14 -0
  4. data/.rspec +0 -1
  5. data/.rubocop.yml +38 -0
  6. data/.ruby-version +1 -0
  7. data/Appraisals +25 -0
  8. data/CHANGELOG.md +13 -0
  9. data/Gemfile +19 -1
  10. data/Gemfile.lock +363 -0
  11. data/LICENSE.txt +1 -1
  12. data/README.md +121 -7
  13. data/Rakefile +3 -1
  14. data/app/controllers/devise/passkeys_controller.rb +57 -0
  15. data/app/views/devise/passkeys/new.html.erb +5 -0
  16. data/app/views/devise/sessions/new.html.erb +28 -0
  17. data/bin/console +1 -0
  18. data/bin/rails +15 -0
  19. data/config/locales/en.yml +8 -0
  20. data/config.ru +10 -0
  21. data/devise-webauthn.gemspec +15 -10
  22. data/gemfiles/rails_7_1.gemfile +23 -0
  23. data/gemfiles/rails_7_2.gemfile +21 -0
  24. data/gemfiles/rails_8_0.gemfile +21 -0
  25. data/gemfiles/rails_edge.gemfile +21 -0
  26. data/lib/devise/models/passkey_authenticatable.rb +22 -0
  27. data/lib/devise/strategies/passkey_authenticatable.rb +45 -0
  28. data/lib/devise/webauthn/engine.rb +30 -0
  29. data/lib/devise/webauthn/helpers/credentials_helper.rb +77 -0
  30. data/lib/devise/webauthn/routes.rb +13 -0
  31. data/lib/devise/webauthn/test/authenticator_helpers.rb +17 -0
  32. data/lib/devise/webauthn/url_helpers.rb +44 -0
  33. data/lib/devise/webauthn/version.rb +3 -1
  34. data/lib/devise/webauthn.rb +13 -3
  35. data/lib/generators/devise/webauthn/controllers_generator.rb +29 -0
  36. data/lib/generators/devise/webauthn/install/install_generator.rb +39 -0
  37. data/lib/generators/devise/webauthn/install/templates/webauthn.rb +37 -0
  38. data/lib/generators/devise/webauthn/stimulus/stimulus_generator.rb +24 -0
  39. data/lib/generators/devise/webauthn/stimulus/templates/webauthn_credentials_controller.js +31 -0
  40. data/lib/generators/devise/webauthn/templates/controllers/README +14 -0
  41. data/lib/generators/devise/webauthn/templates/controllers/passkeys_controller.rb.tt +31 -0
  42. data/lib/generators/devise/webauthn/views_generator.rb +44 -0
  43. data/lib/generators/devise/webauthn/webauthn_credential_model/webauthn_credential_model_generator.rb +54 -0
  44. data/lib/generators/devise/webauthn/webauthn_id/webauthn_id_generator.rb +41 -0
  45. metadata +51 -34
  46. data/.travis.yml +0 -7
data/README.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # Devise::Webauthn
2
+ [![Gem Version](https://badge.fury.io/rb/devise-webauthn.svg)](https://badge.fury.io/rb/devise-webauthn)
2
3
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/devise/webauthn`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+ Devise::Webauthn is a [Devise](https://github.com/heartcombo/devise) extension that adds [WebAuthn](https://www.w3.org/TR/2025/WD-webauthn-3-20250127/) support to your Rails application, allowing users to authenticate with [passkeys](https://www.w3.org/TR/2025/WD-webauthn-3-20250127/#passkey).
4
5
 
5
- TODO: Delete this and the text above, and describe your gem
6
+ ## Requirements
7
+
8
+ - **Ruby**: 2.7+
9
+ - **Stimulus Rails**: This gem requires [stimulus-rails](https://github.com/hotwired/stimulus-rails) to be installed and configured in your application.
10
+ > **Note:** Stimulus Rails is needed for the generated code to work out of the box.
11
+ > If you prefer not to have this dependency, you’ll need to manually implement the JavaScript logic for WebAuthn interactions.
6
12
 
7
13
  ## Installation
8
14
 
@@ -14,7 +20,7 @@ gem 'devise-webauthn'
14
20
 
15
21
  And then execute:
16
22
 
17
- $ bundle
23
+ $ bundle install
18
24
 
19
25
  Or install it yourself as:
20
26
 
@@ -22,17 +28,125 @@ Or install it yourself as:
22
28
 
23
29
  ## Usage
24
30
 
25
- TODO: Write usage instructions here
31
+ First, ensure you have Devise set up in your Rails application. For a full guide on setting up Devise, refer to the [Devise documentation](https://github.com/heartcombo/devise?tab=readme-ov-file#getting-started).
32
+ Then, follow these steps to integrate Devise::Webauthn:
33
+ 1. **Run Devise::Webauthn Generator:**
34
+ Run the generator to set up necessary configurations, migrations, and Stimulus controller:
35
+ ```bash
36
+ $ bin/rails generate devise:webauthn:install
37
+ ```
38
+
39
+ You can optionally specify a different resource name (defaults to "user"):
40
+ ```bash
41
+ $ bin/rails generate devise:webauthn:install --resource-name=RESOURCE_NAME
42
+ ```
43
+
44
+ The generator will:
45
+ - Create the WebAuthn initializer (`config/initializers/webauthn.rb`)
46
+ - Generate the `WebauthnCredential` model and migration
47
+ - Add `webauthn_id` field to your devise model (e.g., `User`)
48
+ - Install the Stimulus controller
49
+
50
+ 2. **Run Migrations:**
51
+ After running the generator, execute the migrations to update your database schema:
52
+ ```bash
53
+ $ bin/rails db:migrate
54
+ ```
55
+
56
+ 3. **Update Your Devise Model:**
57
+ Add `:passkey_authenticatable` to your Devise model (e.g., `User`):
58
+ ```ruby
59
+ class User < ApplicationRecord
60
+ devise :database_authenticatable, :registerable,
61
+ :recoverable, :rememberable, :validatable, :passkey_authenticatable
62
+ end
63
+ ```
64
+
65
+ 4. **Configure WebAuthn Settings:**
66
+ Update the generated initializer file `config/initializers/webauthn.rb` with your application's specific settings, such as `rp_name`, and `allowed_origins`. For example:
67
+ ```ruby
68
+ WebAuthn.configure do |config|
69
+ # This value needs to match `window.location.origin` evaluated by
70
+ # the User Agent during registration and authentication ceremonies.
71
+ config.allowed_origins = ["https://yourapp.com"]
72
+
73
+ # Relying Party name for display purposes
74
+ config.rp_name = "Your App Name"
75
+ end
76
+ ```
77
+
78
+ ## How It Works
79
+
80
+ ### Adding Passkeys
81
+ Signed-in users can add passkeys by visiting `/users/passkeys/new`.
82
+
83
+ ### Sign In with Passkeys
84
+ When a user visits `/users/sign_in` they can choose to authenticate using a passkey. The authentication flow is handled by `PasskeysAuthenticatable` strategy.
85
+
86
+ The WebAuthn passkey sign-in flow works as follows:
87
+ 1. User clicks "Sign in with Passkey", starting a WebAuthn authentication ceremony.
88
+ 2. Browser shows available passkeys.
89
+ 3. User selects a passkey and verifies with their [authenticator](https://www.w3.org/TR/webauthn-3/#webauthn-authenticator).
90
+ 4. The server verifies the response and signs in the user.
91
+
92
+ ## Customization
93
+
94
+ ### Customizing Views
95
+ Similar to [views customization on Devise](https://github.com/heartcombo/devise?tab=readme-ov-file#configuring-views), to customize the views, you can copy the view files from the gem into your application. Run the following command:
96
+ ```bash
97
+ $ bin/rails generate devise:webauthn:views
98
+ ```
99
+
100
+ If you want to customize only specific views, you can copy them individually. For example, to copy only the passkeys views:
101
+ ```bash
102
+ $ bin/rails generate devise:webauthn:views -v passkeys
103
+ ```
104
+
105
+ ### Helper methods
106
+ Devise::Webauthn provides helpers that can be used in your views. For example, for a resource named `user`, you can use the following helpers:
107
+
108
+ To add a button for logging in with passkeys:
109
+ ```erb
110
+ <%= login_with_passkey_button("Log in with passkeys", session_path: user_session_path) %>
111
+ ```
112
+
113
+ To add a passkeys creation form:
114
+ ```erb
115
+ <%= passkey_creation_form_for(current_user) do |form| %>
116
+ <%= form.label :name, 'Passkey name' %>
117
+ <%= form.text_field :name, required: true %>
118
+ <%= form.submit 'Create Passkey' %>
119
+ <% end %>
120
+ ```
121
+
122
+ ### Customizing Controllers
123
+ Similar to [controllers customization on Devise](https://github.com/heartcombo/devise?tab=readme-ov-file#configuring-controllers), you can customize the Devise::Webauthn controllers.
124
+
125
+ 1. Create your custom controllers using the generator which requires a scope:
126
+ ```bash
127
+ $ bin/rails generate devise:webauthn:controllers [scope]
128
+ ```
129
+
130
+ 2. Tell the router to use your custom controllers. For example, if your scope is `users`:
131
+ ```ruby
132
+ devise_for :users, controllers: {
133
+ passkeys: 'users/passkeys'
134
+ }
135
+ ```
136
+
137
+ 3. Change or extend the generated controllers as needed.
138
+
26
139
 
27
140
  ## Development
28
141
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
142
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests.
143
+ To run the linter, use `bundle exec rubocop`.
30
144
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
145
+ Before submitting a pull request, ensure that tests and linter pass.
32
146
 
33
147
  ## Contributing
34
148
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/devise-webauthn.
149
+ Bug reports and pull requests are welcome on GitHub at https://github.com/cedarcode/devise-webauthn.
36
150
 
37
151
  ## License
38
152
 
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bundler/gem_tasks"
2
4
  require "rspec/core/rake_task"
3
5
 
4
6
  RSpec::Core::RakeTask.new(:spec)
5
7
 
6
- task :default => :spec
8
+ task default: :spec
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ class PasskeysController < DeviseController
5
+ before_action :authenticate_scope!
6
+
7
+ def new; end
8
+
9
+ def create
10
+ passkey_from_params = WebAuthn::Credential.from_create(JSON.parse(params[:public_key_credential]))
11
+
12
+ if verify_and_save_passkey(passkey_from_params)
13
+ set_flash_message! :notice, :passkey_created
14
+ else
15
+ set_flash_message! :alert, :passkey_verification_failed, scope: :"devise.failure"
16
+ end
17
+ redirect_to after_update_path
18
+ rescue WebAuthn::Error
19
+ set_flash_message! :alert, :passkey_verification_failed, scope: :"devise.failure"
20
+ redirect_to after_update_path
21
+ ensure
22
+ session.delete(:webauthn_challenge)
23
+ end
24
+
25
+ def destroy
26
+ resource.passkeys.destroy(params[:id])
27
+ redirect_to after_update_path
28
+ end
29
+
30
+ private
31
+
32
+ def authenticate_scope!
33
+ send(:"authenticate_#{resource_name}!", force: true)
34
+ self.resource = send(:"current_#{resource_name}")
35
+ end
36
+
37
+ def verify_and_save_passkey(passkey_from_params)
38
+ passkey_from_params.verify(
39
+ session[:webauthn_challenge],
40
+ user_verification: true
41
+ )
42
+
43
+ resource.passkeys.create(
44
+ external_id: passkey_from_params.id,
45
+ name: params[:name],
46
+ public_key: passkey_from_params.public_key,
47
+ sign_count: passkey_from_params.sign_count
48
+ )
49
+ end
50
+
51
+ # The default url to be used after creating a passkey. You can overwrite
52
+ # this method in your own PasskeysController.
53
+ def after_update_path
54
+ new_passkey_path(resource_name)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,5 @@
1
+ <%= passkey_creation_form_for(resource) do |form| %>
2
+ <%= form.label :name, 'Passkey name' %>
3
+ <%= form.text_field :name, required: true %>
4
+ <%= form.submit 'Create Passkey' %>
5
+ <% end %>
@@ -0,0 +1,28 @@
1
+ <h2>Log in</h2>
2
+
3
+ <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
4
+ <div class="field">
5
+ <%= f.label :email %><br />
6
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
7
+ </div>
8
+
9
+ <div class="field">
10
+ <%= f.label :password %><br />
11
+ <%= f.password_field :password, autocomplete: "current-password" %>
12
+ </div>
13
+
14
+ <% if devise_mapping.rememberable? %>
15
+ <div class="field">
16
+ <%= f.check_box :remember_me %>
17
+ <%= f.label :remember_me %>
18
+ </div>
19
+ <% end %>
20
+
21
+ <div class="actions">
22
+ <%= f.submit "Log in" %>
23
+ </div>
24
+ <% end %>
25
+
26
+ <%= login_with_passkey_button("Log in with passkeys", session_path: session_path(resource_name)) %>
27
+
28
+ <%= render "devise/shared/links" %>
data/bin/console CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "bundler/setup"
4
5
  require "devise/webauthn"
data/bin/rails ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # This command will automatically be run when you run "rails" with Rails gems
5
+ # installed from the root of your application.
6
+
7
+ ENGINE_ROOT = File.expand_path("..", __dir__)
8
+ ENGINE_PATH = File.expand_path("../lib/devise/webauthn/engine", __dir__)
9
+
10
+ # Set up gems listed in the Gemfile.
11
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
+ require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
13
+
14
+ require "rails/all"
15
+ require "rails/engine/commands"
@@ -0,0 +1,8 @@
1
+ en:
2
+ devise:
3
+ passkeys:
4
+ passkey_created: "Passkey created successfully."
5
+ passkey_creation_failed: "Passkey creation failed."
6
+ failure:
7
+ passkey_not_found: "Your passkey doesn't exist or is not valid."
8
+ passkey_verification_failed: "Passkey verification failed."
data/config.ru ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems"
4
+ require "bundler"
5
+ require "combustion"
6
+
7
+ Combustion.initialize! :active_model, :active_record, :action_controller, :action_view do
8
+ config.load_defaults Rails.version.to_f
9
+ end
10
+ run Combustion::Application
@@ -1,29 +1,34 @@
1
+ # frozen_string_literal: true
1
2
 
2
- lib = File.expand_path("../lib", __FILE__)
3
+ lib = File.expand_path("lib", __dir__)
3
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
5
  require "devise/webauthn/version"
5
6
 
6
7
  Gem::Specification.new do |spec|
7
8
  spec.name = "devise-webauthn"
8
9
  spec.version = Devise::Webauthn::VERSION
9
- spec.authors = ["Braulio Martinez"]
10
- spec.email = ["braulio@cedarcode.com"]
10
+ spec.authors = ["Cedarcode"]
11
+ spec.email = ["webauthn@cedarcode.com"]
11
12
 
12
- spec.summary = %q{Devise extension to support WebAuthn.}
13
+ spec.summary = "Devise extension to support WebAuthn."
13
14
  spec.license = "MIT"
14
15
 
16
+ spec.homepage = "https://github.com/cedarcode/devise-webauthn"
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+
15
21
  # Specify which files should be added to the gem when it is released.
16
22
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
17
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
23
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
18
24
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
19
25
  end
20
26
  spec.bindir = "exe"
21
27
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
28
  spec.require_paths = ["lib"]
29
+ spec.metadata["rubygems_mfa_required"] = "true"
30
+ spec.required_ruby_version = ">= 2.7"
23
31
 
24
- spec.required_ruby_version = ">= 2.3"
25
-
26
- spec.add_development_dependency "bundler", "~> 1.17"
27
- spec.add_development_dependency "rake", "~> 10.0"
28
- spec.add_development_dependency "rspec", "~> 3.0"
32
+ spec.add_dependency "devise", "~> 4.9"
33
+ spec.add_dependency "webauthn", "~> 3.0"
29
34
  end
@@ -0,0 +1,23 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal", "~> 2.5"
6
+ gem "capybara", "~> 3.39"
7
+ gem "combustion", "~> 1.3"
8
+ gem "importmap-rails", "~> 2.0"
9
+ gem "propshaft", "~> 1.2"
10
+ gem "pry-byebug", "~> 3.10"
11
+ gem "puma", "~> 6.6"
12
+ gem "rails", "~> 7.1"
13
+ gem "rspec-rails", "~> 7.1"
14
+ gem "rubocop", "~> 1.79"
15
+ gem "rubocop-rails", "~> 2.32"
16
+ gem "rubocop-rspec", "~> 3.6"
17
+ gem "selenium-webdriver"
18
+ gem "sqlite3", "~> 1.7"
19
+ gem "stimulus-rails", "~> 1.3"
20
+ gem "psych", "~> 4.0"
21
+ gem "rack", "~> 2.2"
22
+
23
+ gemspec path: "../"
@@ -0,0 +1,21 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal", "~> 2.5"
6
+ gem "capybara", "~> 3.40"
7
+ gem "combustion", "~> 1.3"
8
+ gem "importmap-rails", "~> 2.2"
9
+ gem "propshaft", "~> 1.2"
10
+ gem "pry-byebug", "~> 3.11"
11
+ gem "puma", "~> 6.6"
12
+ gem "rails", "~> 7.2"
13
+ gem "rspec-rails", "~> 8.0"
14
+ gem "rubocop", "~> 1.79"
15
+ gem "rubocop-rails", "~> 2.32"
16
+ gem "rubocop-rspec", "~> 3.6"
17
+ gem "selenium-webdriver"
18
+ gem "sqlite3", "~> 2.7"
19
+ gem "stimulus-rails", "~> 1.3"
20
+
21
+ gemspec path: "../"
@@ -0,0 +1,21 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal", "~> 2.5"
6
+ gem "capybara", "~> 3.40"
7
+ gem "combustion", "~> 1.3"
8
+ gem "importmap-rails", "~> 2.2"
9
+ gem "propshaft", "~> 1.2"
10
+ gem "pry-byebug", "~> 3.11"
11
+ gem "puma", "~> 6.6"
12
+ gem "rails", "~> 8.0"
13
+ gem "rspec-rails", "~> 8.0"
14
+ gem "rubocop", "~> 1.79"
15
+ gem "rubocop-rails", "~> 2.32"
16
+ gem "rubocop-rspec", "~> 3.6"
17
+ gem "selenium-webdriver"
18
+ gem "sqlite3", "~> 2.7"
19
+ gem "stimulus-rails", "~> 1.3"
20
+
21
+ gemspec path: "../"
@@ -0,0 +1,21 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal", "~> 2.5"
6
+ gem "capybara", "~> 3.40"
7
+ gem "combustion", "~> 1.3"
8
+ gem "importmap-rails", "~> 2.2"
9
+ gem "propshaft", "~> 1.2"
10
+ gem "pry-byebug", "~> 3.11"
11
+ gem "puma", "~> 6.6"
12
+ gem "rails", branch: "main", git: "https://github.com/rails/rails"
13
+ gem "rspec-rails", "~> 8.0"
14
+ gem "rubocop", "~> 1.79"
15
+ gem "rubocop-rails", "~> 2.32"
16
+ gem "rubocop-rspec", "~> 3.6"
17
+ gem "selenium-webdriver"
18
+ gem "sqlite3", "~> 2.7"
19
+ gem "stimulus-rails", "~> 1.3"
20
+
21
+ gemspec path: "../"
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "devise/strategies/passkey_authenticatable"
5
+
6
+ module Devise
7
+ module Models
8
+ module PasskeyAuthenticatable
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ has_many :passkeys, dependent: :destroy, class_name: "WebauthnCredential"
13
+
14
+ validates :webauthn_id, uniqueness: true, allow_blank: true
15
+
16
+ after_initialize do
17
+ self.webauthn_id ||= WebAuthn.generate_user_id
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Strategies
5
+ class PasskeyAuthenticatable < Warden::Strategies::Base
6
+ def valid?
7
+ passkey_param.present? && session[:authentication_challenge].present?
8
+ end
9
+
10
+ def authenticate!
11
+ passkey_from_params = WebAuthn::Credential.from_get(JSON.parse(passkey_param))
12
+ stored_passkey = WebauthnCredential.find_by(external_id: passkey_from_params.id)
13
+
14
+ return fail!(:passkey_not_found) if stored_passkey.blank?
15
+
16
+ verify_passkeys(passkey_from_params, stored_passkey)
17
+
18
+ success!(stored_passkey.user)
19
+ rescue WebAuthn::Error
20
+ fail!(:passkey_verification_failed)
21
+ ensure
22
+ session.delete(:authentication_challenge)
23
+ end
24
+
25
+ private
26
+
27
+ def passkey_param
28
+ params[:public_key_credential]
29
+ end
30
+
31
+ def verify_passkeys(passkey_from_params, stored_passkey)
32
+ passkey_from_params.verify(
33
+ session[:authentication_challenge],
34
+ public_key: stored_passkey.public_key,
35
+ sign_count: stored_passkey.sign_count,
36
+ user_verification: true
37
+ )
38
+
39
+ stored_passkey.update!(sign_count: passkey_from_params.sign_count)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ Warden::Strategies.add(:passkey_authenticatable, Devise::Strategies::PasskeyAuthenticatable)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Webauthn
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Devise::Webauthn
7
+
8
+ initializer "devise.webauthn.add_module" do
9
+ Devise.add_module(
10
+ :passkey_authenticatable,
11
+ {
12
+ model: "devise/models/passkey_authenticatable",
13
+ strategy: true,
14
+ route: { passkey_authentication: [] }
15
+ }
16
+ )
17
+ end
18
+
19
+ initializer "devise.webauthn.helpers" do
20
+ ActiveSupport.on_load(:action_view) do
21
+ include Devise::Webauthn::CredentialsHelper
22
+ end
23
+ end
24
+
25
+ initializer "devise.webauthn.url_helpers" do
26
+ Devise.include_helpers(Devise::Webauthn)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Webauthn
5
+ module CredentialsHelper
6
+ def passkey_creation_form_for(resource, form_classes: nil, &block)
7
+ form_with(
8
+ url: passkeys_path(resource),
9
+ method: :post,
10
+ class: form_classes,
11
+ data: {
12
+ action: "webauthn-credentials#create:prevent",
13
+ controller: "webauthn-credentials",
14
+ webauthn_credentials_options_param: create_passkey_options(resource)
15
+ }
16
+ ) do |f|
17
+ concat f.hidden_field(:public_key_credential,
18
+ data: { "webauthn-credentials-target": "credentialHiddenInput" })
19
+ concat capture(f, &block)
20
+ end
21
+ end
22
+
23
+ def login_with_passkey_button(text = nil, session_path:, button_classes: nil, form_classes: nil, &block)
24
+ form_with(
25
+ url: session_path,
26
+ method: :post,
27
+ data: {
28
+ action: "webauthn-credentials#get:prevent",
29
+ controller: "webauthn-credentials",
30
+ webauthn_credentials_options_param: webauthn_authentication_options
31
+ },
32
+ class: form_classes
33
+ ) do |f|
34
+ concat f.hidden_field(:public_key_credential,
35
+ data: { "webauthn-credentials-target": "credentialHiddenInput" })
36
+ concat f.button(text, type: "submit", class: button_classes, &block)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def create_passkey_options(resource)
43
+ @create_passkey_options ||= begin
44
+ options = WebAuthn::Credential.options_for_create(
45
+ user: {
46
+ id: resource.webauthn_id,
47
+ name: resource.email
48
+ },
49
+ exclude: resource.passkeys.pluck(:external_id),
50
+ authenticator_selection: {
51
+ resident_key: "required",
52
+ user_verification: "required"
53
+ }
54
+ )
55
+
56
+ # Store challenge in session for later verification
57
+ session[:webauthn_challenge] = options.challenge
58
+
59
+ options
60
+ end
61
+ end
62
+
63
+ def webauthn_authentication_options
64
+ @webauthn_authentication_options ||= begin
65
+ options = WebAuthn::Credential.options_for_get(
66
+ user_verification: "required"
67
+ )
68
+
69
+ # Store challenge in session for later verification
70
+ session[:authentication_challenge] = options.challenge
71
+
72
+ options
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionDispatch
4
+ module Routing
5
+ class Mapper
6
+ protected
7
+
8
+ def devise_passkey_authentication(_mapping, controllers)
9
+ resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys]
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Webauthn
5
+ module Test
6
+ module AuthenticatorHelpers
7
+ def add_virtual_authenticator
8
+ options = Selenium::WebDriver::VirtualAuthenticatorOptions.new
9
+ options.user_verification = true
10
+ options.user_verified = true
11
+ options.resident_key = true
12
+ page.driver.browser.add_virtual_authenticator(options)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end