devise-webauthn 0.3.1 → 0.4.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +2 -2
  3. data/.rubocop.yml +2 -0
  4. data/.rubocop_todo.yml +20 -0
  5. data/Appraisals +0 -1
  6. data/CHANGELOG.md +45 -0
  7. data/Gemfile +1 -0
  8. data/Gemfile.lock +4 -1
  9. data/README.md +17 -4
  10. data/app/assets/javascript/devise/webauthn.js +7 -5
  11. data/app/controllers/devise/passkey_authentication_options_controller.rb +17 -0
  12. data/app/controllers/devise/passkey_registration_options_controller.rb +41 -0
  13. data/app/controllers/devise/security_key_authentication_options_controller.rb +26 -0
  14. data/app/controllers/devise/security_key_registration_options_controller.rb +41 -0
  15. data/app/views/devise/passkeys/new.html.erb +1 -1
  16. data/app/views/devise/second_factor_webauthn_credentials/new.html.erb +1 -1
  17. data/app/views/devise/sessions/new.html.erb +3 -1
  18. data/app/views/devise/two_factor_authentications/new.html.erb +3 -1
  19. data/gemfiles/devise_5_0.gemfile +1 -1
  20. data/gemfiles/rails_7_1.gemfile +1 -2
  21. data/gemfiles/rails_7_2.gemfile +1 -1
  22. data/gemfiles/rails_8_0.gemfile +1 -1
  23. data/gemfiles/rails_8_1.gemfile +1 -1
  24. data/gemfiles/rails_edge.gemfile +1 -1
  25. data/lib/devise/models/webauthn_credential_authenticatable.rb +1 -1
  26. data/lib/devise/strategies/passkey_authenticatable.rb +21 -5
  27. data/lib/devise/strategies/webauthn_two_factor_authenticatable.rb +8 -0
  28. data/lib/devise/webauthn/helpers/credentials_helper.rb +21 -104
  29. data/lib/devise/webauthn/routes.rb +9 -0
  30. data/lib/devise/webauthn/url_helpers.rb +5 -1
  31. data/lib/devise/webauthn/version.rb +1 -1
  32. data/lib/generators/devise/webauthn/controllers_generator.rb +8 -1
  33. data/lib/generators/devise/webauthn/templates/controllers/passkey_authentication_options_controller.rb.tt +8 -0
  34. data/lib/generators/devise/webauthn/templates/controllers/passkey_registration_options_controller.rb.tt +8 -0
  35. data/lib/generators/devise/webauthn/templates/controllers/security_key_authentication_options_controller.rb.tt +8 -0
  36. data/lib/generators/devise/webauthn/templates/controllers/security_key_registration_options_controller.rb.tt +8 -0
  37. data/lib/generators/devise/webauthn/webauthn_id/templates/add_webauthn_id.rb.erb +41 -0
  38. data/lib/generators/devise/webauthn/webauthn_id/webauthn_id_generator.rb +9 -4
  39. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bedbd7cbfbce63ffd28dd38a828a34081880b037355c0fedb7ed0ae37921f105
4
- data.tar.gz: 5bfc78ad1e764096d35fbf31b56b08d6370b731e757bd64e2c6af2b54c90c1f7
3
+ metadata.gz: f958718af83cd2bdae3d3c08d8ac72678e2e313b93eabdc7effe1af70bda300f
4
+ data.tar.gz: ea997a0537ca6a68d97e7aedbff6993331eeebab1857b2d7a26177987efe7571
5
5
  SHA512:
6
- metadata.gz: 58786281bafd7c1032e331a09b48716aba0b49fd725856b33beb59708c2afc77310c174d0300a32c1294e0741fc6ff64fe0405f54d397e77be2853ec4ab0e925
7
- data.tar.gz: e6a5d6fa654fb35bc854f96aa7783861da4370d65dd1dd72c43e10baa6cbea0291524fdbf2a175225778d80e584c734b6f88707a7731e61056657ce4b19bbd3b
6
+ metadata.gz: e81d1880a7b6722c150b9cdd3fcabcbc07128ab5beaea22c580a6ff5d6338ac108ed55728f876eb9635e087787c875fca246de6ed307450f53907b14eadc5c5d
7
+ data.tar.gz: 0fe56be79a56f2ca46349ebd4e9ce1ed1f3f43df73cb7acde76a0aebce4dae4cf022c8cc01dba53c89c9d0b637145474f169cab0b4f7f4811788fdc7b0fe2479
@@ -79,8 +79,8 @@ jobs:
79
79
  uses: ruby/setup-ruby@v1
80
80
  with:
81
81
  ruby-version: ${{ matrix.ruby }}
82
+ rubygems: latest
82
83
  bundler-cache: true
83
84
 
84
85
  - name: Run tests
85
- run: |
86
- bundle exec rspec
86
+ run: bundle exec rspec
data/.rubocop.yml CHANGED
@@ -1,3 +1,5 @@
1
+ inherit_from: .rubocop_todo.yml
2
+
1
3
  plugins:
2
4
  - rubocop-rspec
3
5
  - rubocop-rails
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,20 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config`
3
+ # on 2026-01-02 20:36:46 UTC using RuboCop version 1.79.1.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 1
10
+ # Configuration parameters: Max, CountAsOne.
11
+ RSpec/ExampleLength:
12
+ Exclude:
13
+ - 'spec/requests/devise/two_factor_authentication_spec.rb'
14
+
15
+ # Offense count: 9
16
+ # Configuration parameters: Max.
17
+ RSpec/MultipleExpectations:
18
+ Exclude:
19
+ - 'spec/requests/devise/passkey_authentication_spec.rb'
20
+ - 'spec/requests/devise/two_factor_authentication_spec.rb'
data/Appraisals CHANGED
@@ -22,7 +22,6 @@ appraise "rails-7_1" do
22
22
  gem "capybara", "~> 3.39"
23
23
  gem "importmap-rails", "~> 2.0"
24
24
  gem "pry-byebug", "~> 3.10"
25
- gem "psych", "~> 4.0"
26
25
  gem "rack", "~> 2.2"
27
26
  gem "rspec-rails", "~> 7.1"
28
27
  gem "sqlite3", "~> 1.7"
data/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [v0.4.0](https://github.com/cedarcode/devise-webauthn/compare/v0.3.1...v0.4.0/) - 2026-04-06
6
+
7
+ ### Added
8
+
9
+ - Dispatch `webauthn:unsupported` for browsers missing `parseOptionsFromJSON`. [#127](https://github.com/cedarcode/devise-webauthn/pull/127) [@santiagorodriguez96]
10
+ - Scope `login_with_passkey_form_for` to the Devise resource so that form builder fields (e.g. `f.check_box :remember_me`) are properly namespaced (e.g. `account[remember_me]`). [#134](https://github.com/cedarcode/devise-webauthn/pull/134) [@RenzoMinelli]
11
+ - Allow passing a -c flag to the controller generator to specify which controller to override. [#110](https://github.com/cedarcode/devise-webauthn/pull/110) [@nicolastemciuc]
12
+
13
+ ### Changed
14
+
15
+ - Change `webauthn_id` generation from `after_initialize` to `before_validation` and add a data backfill to the `webauthn_id` migration generator for existing records. [#125](https://github.com/cedarcode/devise-webauthn/pull/125) [@santiagorodriguez96]
16
+ - Options for getting or creating passkeys and security keys are now served by dedicated Rails controllers and retrieved via JavaScript fetch requests. [#73](https://github.com/cedarcode/devise-webauthn/pull/73) [@nicolastemciuc]
17
+ - BREAKING!: Remove helpers for generating WebAuthn options. [#106](https://github.com/cedarcode/devise-webauthn/pull/115) [@nicolastemciuc]
18
+ - BREAKING!: `login_with_passkey_button` and `login_with_security_key_button` helpers have been renamed to `login_with_passkey_form_for` and `login_with_security_key_form_for`. They now take a block and no longer generate the submit button automatically. You need to explicitly add the button inside the block. [#112](https://github.com/cedarcode/devise-webauthn/pull/112) [@RenzoMinelli]
19
+ ```erb
20
+ <%# Before %>
21
+ <%= login_with_passkey_button(:user, "Log in with passkeys") %>
22
+
23
+ <%# After %>
24
+ <%= login_with_passkey_form_for(:user) do |form| %>
25
+ <%= form.submit "Log in with passkeys" %>
26
+ <% end %>
27
+ ```
28
+ - BREAKING!: Replace `form_classes:` keyword argument with direct keyword arguments in all form helper methods (`passkey_creation_form_for`, `login_with_passkey_form_for`, `security_key_creation_form_for`, `login_with_security_key_form_for`). All options are delegated to `form_with`, allowing you to pass any HTML attributes or form options directly. [#111](https://github.com/cedarcode/devise-webauthn/pull/111) [@RenzoMinelli]
29
+ ```erb
30
+ <%# Before %>
31
+ <%= passkey_creation_form_for(:user, form_classes: "my-class") do |form| %>
32
+ ...
33
+ <% end %>
34
+
35
+ <%# After %>
36
+ <%= passkey_creation_form_for(:user, class: "my-class", id: "my-form", data: { turbo: false }) do |form| %>
37
+ ...
38
+ <% end %>
39
+ ```
40
+
41
+ ### Fixed
42
+
43
+ - Validate `userHandle` from authenticator response against `webauthn_id`. [#131](https://github.com/cedarcode/devise-webauthn/pull/131) [@santiagorodriguez96]
44
+ - Fix `Remember me` checkbox not honored when logging in with passkeys. [#133](https://github.com/cedarcode/devise-webauthn/pull/133) [@santiagorodriguez96]
45
+ - Fix form helpers (`passkey_creation_form_for`, `login_with_passkey_button`, `security_key_creation_form_for`, `login_with_security_key_button`) to accept a `resource_name` instead of requiring the `resource` object from the view context. [#114](https://github.com/cedarcode/devise-webauthn/pull/114) [@RenzoMinelli]
46
+
5
47
  ## [v0.3.1](https://github.com/cedarcode/devise-webauthn/compare/v0.3.0...v0.3.1/) - 2026-02-10
6
48
 
7
49
  ### Fixed
@@ -22,6 +64,9 @@
22
64
  - Previously generated Stimulus controller for handling WebAuthn client logic are no longer generated.
23
65
  - Stimulus is no longer needed for this engine to work.
24
66
  - Make helpers for generating WebAuthn options public methods. [#106](https://github.com/cedarcode/devise-webauthn/pull/106) [@santiagorodriguez96]
67
+ - BREAKING!: Our controller for managing second factor credentials now uses a separate method for each endpoint for setting the URL to redirect to. [#80](https://github.com/cedarcode/devise-webauthn/pull/80) [@nicolastemciuc]
68
+ - What used to be just an `after_update_path` for all endpoints, now it's an `after_(create|update|destroy)_path` for each endpoint.
69
+ - If you had overriden the controller to change the `after_update_path`, be mindful that now `create` and `destroy` endpoints will call its own method.
25
70
 
26
71
  ### Fixed
27
72
 
data/Gemfile CHANGED
@@ -17,6 +17,7 @@ gem "pry-byebug", "~> 3.11"
17
17
  gem "puma", "~> 6.6"
18
18
  gem "rails", "~> 8.0"
19
19
  gem "rspec-rails", "~> 8.0"
20
+ gem "rspec-retry"
20
21
  gem "rubocop", "~> 1.79"
21
22
  gem "rubocop-rails", "~> 2.32"
22
23
  gem "rubocop-rspec", "~> 3.6"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- devise-webauthn (0.3.1)
4
+ devise-webauthn (0.4.0)
5
5
  devise (>= 4.9)
6
6
  webauthn (~> 3.0)
7
7
 
@@ -266,6 +266,8 @@ GEM
266
266
  rspec-expectations (~> 3.13)
267
267
  rspec-mocks (~> 3.13)
268
268
  rspec-support (~> 3.13)
269
+ rspec-retry (0.6.2)
270
+ rspec-core (> 3.3)
269
271
  rspec-support (3.13.4)
270
272
  rubocop (1.79.1)
271
273
  json (~> 2.3)
@@ -351,6 +353,7 @@ DEPENDENCIES
351
353
  puma (~> 6.6)
352
354
  rails (~> 8.0)
353
355
  rspec-rails (~> 8.0)
356
+ rspec-retry
354
357
  rubocop (~> 1.79)
355
358
  rubocop-rails (~> 2.32)
356
359
  rubocop-rspec (~> 3.6)
data/README.md CHANGED
@@ -73,6 +73,8 @@ Then, follow these steps to integrate Devise::Webauthn:
73
73
  config.rp_name = "Your App Name"
74
74
  end
75
75
  ```
76
+ > [!TIP]
77
+ > You can find a working example on how to use this gem for passwordless and two factor authentication in [`devise-webauthn-rails-demo`](https://github.com/cedarcode/devise-webauthn-demo-app).
76
78
 
77
79
  5. **Include bundled WebAuthn JavaScript in your application:**
78
80
  The install generator automatically configures JavaScript loading based on your setup:
@@ -143,16 +145,27 @@ $ bin/rails generate devise:webauthn:views -v passkeys
143
145
  ```
144
146
 
145
147
  ### Helper methods
146
- Devise::Webauthn provides helpers that can be used in your views. For example, for a resource named `user`, you can use the following helpers:
148
+ Devise::Webauthn provides helpers that can be used in your views. These helpers accept either a resource name (e.g., `:user`) or a resource object (e.g., `@user`) as the first argument.
147
149
 
148
- To add a button for logging in with passkeys:
150
+ For example, for a resource named `user`, you can use the following helpers:
151
+
152
+ To add a form for logging in with passkeys:
153
+ ```erb
154
+ <%= login_with_passkey_form_for(:user) do |form| %>
155
+ <%= form.submit "Log in with passkeys" %>
156
+ <% end %>
157
+ ```
158
+
159
+ To add a form for logging in with security keys (2FA):
149
160
  ```erb
150
- <%= login_with_passkey_button("Log in with passkeys", session_path: user_session_path) %>
161
+ <%= login_with_security_key_form_for(@resource) do |form| %>
162
+ <%= form.submit "Use security key" %>
163
+ <% end %>
151
164
  ```
152
165
 
153
166
  To add a passkeys creation form:
154
167
  ```erb
155
- <%= passkey_creation_form_for(current_user) do |form| %>
168
+ <%= passkey_creation_form_for(:user) do |form| %>
156
169
  <%= form.label :name, 'Passkey name' %>
157
170
  <%= form.text_field :name, required: true %>
158
171
  <%= form.submit 'Create Passkey' %>
@@ -3,7 +3,9 @@ function isWebAuthnSupported() {
3
3
  navigator.credentials &&
4
4
  navigator.credentials.create &&
5
5
  navigator.credentials.get &&
6
- window.PublicKeyCredential
6
+ window.PublicKeyCredential &&
7
+ PublicKeyCredential.parseCreationOptionsFromJSON &&
8
+ PublicKeyCredential.parseRequestOptionsFromJSON
7
9
  );
8
10
  }
9
11
 
@@ -20,8 +22,8 @@ export class WebauthnCreateElement extends HTMLElement {
20
22
  event.preventDefault();
21
23
 
22
24
  try {
23
- const options = JSON.parse(this.getAttribute('data-options-json'));
24
- const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options);
25
+ const response = await fetch(this.getAttribute('data-options-url'));
26
+ const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(await response.json());
25
27
  const credential = await navigator.credentials.create({ publicKey });
26
28
 
27
29
  this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyRegistrationCredentialWithGracefullyHandlingAuthenticatorIssues(credential);
@@ -101,8 +103,8 @@ export class WebauthnGetElement extends HTMLElement {
101
103
  event.preventDefault();
102
104
 
103
105
  try {
104
- const options = JSON.parse(this.getAttribute('data-options-json'));
105
- const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
106
+ const response = await fetch(this.getAttribute('data-options-url'));
107
+ const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(await response.json());
106
108
  const credential = await navigator.credentials.get({ publicKey });
107
109
 
108
110
  this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyAuthenticationCredentialWithGracefullyHandlingAuthenticatorIssues(credential);
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ class PasskeyAuthenticationOptionsController < DeviseController
5
+ def index
6
+ passkey_options =
7
+ WebAuthn::Credential.options_for_get(
8
+ user_verification: "required"
9
+ )
10
+
11
+ # Store challenge in session for later verification
12
+ session[:authentication_challenge] = passkey_options.challenge
13
+
14
+ render json: passkey_options
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ class PasskeyRegistrationOptionsController < DeviseController
5
+ before_action :authenticate_scope!
6
+
7
+ def index
8
+ passkey_options =
9
+ WebAuthn::Credential.options_for_create(
10
+ user: {
11
+ id: resource.webauthn_id,
12
+ name: resource_human_palatable_identifier
13
+ },
14
+ exclude: resource.passkeys.pluck(:external_id),
15
+ authenticator_selection: {
16
+ resident_key: "required",
17
+ user_verification: "required"
18
+ }
19
+ )
20
+
21
+ # Store challenge in session for later verification
22
+ session[:webauthn_challenge] = passkey_options.challenge
23
+
24
+ render json: passkey_options
25
+ end
26
+
27
+ private
28
+
29
+ def authenticate_scope!
30
+ send(:"authenticate_#{resource_name}!", force: true)
31
+ self.resource = send(:"current_#{resource_name}")
32
+ end
33
+
34
+ def resource_human_palatable_identifier
35
+ authentication_keys = resource.class.authentication_keys
36
+ authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
37
+
38
+ authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ class SecurityKeyAuthenticationOptionsController < DeviseController
5
+ before_action :set_resource
6
+
7
+ def index
8
+ security_key_authentication_options =
9
+ WebAuthn::Credential.options_for_get(
10
+ allow: @resource.webauthn_credentials.pluck(:external_id),
11
+ user_verification: "discouraged"
12
+ )
13
+
14
+ # Store challenge in session for later verification
15
+ session[:two_factor_authentication_challenge] = security_key_authentication_options.challenge
16
+
17
+ render json: security_key_authentication_options
18
+ end
19
+
20
+ private
21
+
22
+ def set_resource
23
+ @resource = resource_class.find(session[:current_authentication_resource_id])
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ class SecurityKeyRegistrationOptionsController < DeviseController
5
+ before_action :authenticate_scope!
6
+
7
+ def index
8
+ create_security_key_options =
9
+ WebAuthn::Credential.options_for_create(
10
+ user: {
11
+ id: resource.webauthn_id,
12
+ name: resource_human_palatable_identifier
13
+ },
14
+ exclude: resource.webauthn_credentials.pluck(:external_id),
15
+ authenticator_selection: {
16
+ resident_key: "discouraged",
17
+ user_verification: "discouraged"
18
+ }
19
+ )
20
+
21
+ # Store challenge in session for later verification
22
+ session[:webauthn_challenge] = create_security_key_options.challenge
23
+
24
+ render json: create_security_key_options
25
+ end
26
+
27
+ private
28
+
29
+ def authenticate_scope!
30
+ send(:"authenticate_#{resource_name}!", force: true)
31
+ self.resource = send(:"current_#{resource_name}")
32
+ end
33
+
34
+ def resource_human_palatable_identifier
35
+ authentication_keys = resource.class.authentication_keys
36
+ authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
37
+
38
+ authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
39
+ end
40
+ end
41
+ end
@@ -1,4 +1,4 @@
1
- <%= passkey_creation_form_for(resource) do |form| %>
1
+ <%= passkey_creation_form_for(resource_name) do |form| %>
2
2
  <%= form.label :name, 'Passkey name' %>
3
3
  <%= form.text_field :name, required: true %>
4
4
  <%= form.submit 'Create Passkey' %>
@@ -1,4 +1,4 @@
1
- <%= security_key_creation_form_for(resource) do |form| %>
1
+ <%= security_key_creation_form_for(resource_name) do |form| %>
2
2
  <%= form.label :name, 'Security Key name' %>
3
3
  <%= form.text_field :name, required: true %>
4
4
  <%= form.submit 'Create Security Key' %>
@@ -23,6 +23,8 @@
23
23
  </div>
24
24
  <% end %>
25
25
 
26
- <%= login_with_passkey_button("Log in with passkeys", session_path: session_path(resource_name)) %>
26
+ <%= login_with_passkey_form_for(resource_name) do |form| %>
27
+ <%= form.submit "Log in with passkeys" %>
28
+ <% end %>
27
29
 
28
30
  <%= render "devise/shared/links" %>
@@ -1 +1,3 @@
1
- <%= login_with_security_key_button('Use security key', resource: @resource) %>
1
+ <%= login_with_security_key_form_for(resource_name) do |form| %>
2
+ <%= form.submit 'Use security key' %>
3
+ <% end %>
@@ -12,12 +12,12 @@ gem "pry-byebug", "~> 3.10"
12
12
  gem "puma", "~> 6.6"
13
13
  gem "rails", ">= 7.1"
14
14
  gem "rspec-rails", ">= 7.1"
15
+ gem "rspec-retry"
15
16
  gem "rubocop", "~> 1.79"
16
17
  gem "rubocop-rails", "~> 2.32"
17
18
  gem "rubocop-rspec", "~> 3.6"
18
19
  gem "selenium-webdriver"
19
20
  gem "sqlite3", ">= 1.6", "!= 1.7.0", "!= 1.7.1", "!= 1.7.2", "!= 1.7.3"
20
- gem "stimulus-rails", "~> 1.3"
21
21
 
22
22
  install_if -> { RUBY_VERSION < "3.0" } do
23
23
  gem "rack", "~> 2.2"
@@ -12,13 +12,12 @@ gem "pry-byebug", "~> 3.10"
12
12
  gem "puma", "~> 6.6"
13
13
  gem "rails", "~> 7.1.x"
14
14
  gem "rspec-rails", "~> 7.1"
15
+ gem "rspec-retry"
15
16
  gem "rubocop", "~> 1.79"
16
17
  gem "rubocop-rails", "~> 2.32"
17
18
  gem "rubocop-rspec", "~> 3.6"
18
19
  gem "selenium-webdriver"
19
20
  gem "sqlite3", "~> 1.7"
20
- gem "stimulus-rails", "~> 1.3"
21
- gem "psych", "~> 4.0"
22
21
  gem "rack", "~> 2.2"
23
22
 
24
23
  gemspec path: "../"
@@ -12,11 +12,11 @@ gem "pry-byebug", "~> 3.11"
12
12
  gem "puma", "~> 6.6"
13
13
  gem "rails", "~> 7.2.x"
14
14
  gem "rspec-rails", "~> 8.0"
15
+ gem "rspec-retry"
15
16
  gem "rubocop", "~> 1.79"
16
17
  gem "rubocop-rails", "~> 2.32"
17
18
  gem "rubocop-rspec", "~> 3.6"
18
19
  gem "selenium-webdriver"
19
20
  gem "sqlite3", "~> 2.7"
20
- gem "stimulus-rails", "~> 1.3"
21
21
 
22
22
  gemspec path: "../"
@@ -12,11 +12,11 @@ gem "pry-byebug", "~> 3.11"
12
12
  gem "puma", "~> 6.6"
13
13
  gem "rails", "~> 8.0.x"
14
14
  gem "rspec-rails", "~> 8.0"
15
+ gem "rspec-retry"
15
16
  gem "rubocop", "~> 1.79"
16
17
  gem "rubocop-rails", "~> 2.32"
17
18
  gem "rubocop-rspec", "~> 3.6"
18
19
  gem "selenium-webdriver"
19
20
  gem "sqlite3", "~> 2.7"
20
- gem "stimulus-rails", "~> 1.3"
21
21
 
22
22
  gemspec path: "../"
@@ -12,11 +12,11 @@ gem "pry-byebug", "~> 3.11"
12
12
  gem "puma", "~> 6.6"
13
13
  gem "rails", "~> 8.1.x"
14
14
  gem "rspec-rails", "~> 8.0"
15
+ gem "rspec-retry"
15
16
  gem "rubocop", "~> 1.79"
16
17
  gem "rubocop-rails", "~> 2.32"
17
18
  gem "rubocop-rspec", "~> 3.6"
18
19
  gem "selenium-webdriver"
19
20
  gem "sqlite3", "~> 2.7"
20
- gem "stimulus-rails", "~> 1.3"
21
21
 
22
22
  gemspec path: "../"
@@ -12,11 +12,11 @@ gem "pry-byebug", "~> 3.11"
12
12
  gem "puma", "~> 6.6"
13
13
  gem "rails", branch: "main", git: "https://github.com/rails/rails"
14
14
  gem "rspec-rails", "~> 8.0"
15
+ gem "rspec-retry"
15
16
  gem "rubocop", "~> 1.79"
16
17
  gem "rubocop-rails", "~> 2.32"
17
18
  gem "rubocop-rspec", "~> 3.6"
18
19
  gem "selenium-webdriver"
19
20
  gem "sqlite3", "~> 2.7"
20
- gem "stimulus-rails", "~> 1.3"
21
21
 
22
22
  gemspec path: "../"
@@ -12,7 +12,7 @@ module Devise
12
12
 
13
13
  validates :webauthn_id, uniqueness: true, allow_blank: true
14
14
 
15
- after_initialize do
15
+ before_validation do
16
16
  self.webauthn_id ||= WebAuthn.generate_user_id
17
17
  end
18
18
  end
@@ -7,15 +7,19 @@ module Devise
7
7
  passkey_param.present? && session[:authentication_challenge].present?
8
8
  end
9
9
 
10
- def authenticate!
10
+ def authenticate! # rubocop:disable Metrics/AbcSize
11
11
  passkey_from_params = WebAuthn::Credential.from_get(JSON.parse(passkey_param))
12
- stored_passkey = WebauthnCredential.passkey.find_by(external_id: passkey_from_params.id)
12
+
13
+ return fail!(:passkey_not_found) if passkey_from_params.user_handle.nil?
14
+
15
+ resource = resource_class.find_by(webauthn_id: passkey_from_params.user_handle)
16
+ stored_passkey = resource&.passkeys&.find_by(external_id: passkey_from_params.id)
13
17
 
14
18
  return fail!(:passkey_not_found) if stored_passkey.blank?
15
19
 
16
20
  verify_passkeys(passkey_from_params, stored_passkey)
17
21
 
18
- resource = stored_passkey.public_send(resource_name)
22
+ remember_me(resource)
19
23
  success!(resource)
20
24
  rescue WebAuthn::Error
21
25
  fail!(:passkey_verification_failed)
@@ -40,8 +44,20 @@ module Devise
40
44
  stored_passkey.update!(sign_count: passkey_from_params.sign_count)
41
45
  end
42
46
 
43
- def resource_name
44
- mapping.to.name.underscore
47
+ def remember_me(resource)
48
+ resource.remember_me = remember_me? if resource.respond_to?(:remember_me=)
49
+ end
50
+
51
+ def remember_me?
52
+ params_auth_hash.is_a?(Hash) && Devise::TRUE_VALUES.include?(params_auth_hash[:remember_me])
53
+ end
54
+
55
+ def params_auth_hash
56
+ params[scope]
57
+ end
58
+
59
+ def resource_class
60
+ mapping.to
45
61
  end
46
62
  end
47
63
  end
@@ -16,6 +16,9 @@ module Devise
16
16
  stored_credential = resource&.webauthn_credentials&.find_by(external_id: credential_from_params.id)
17
17
 
18
18
  return fail!(:webauthn_credential_not_found) if stored_credential.blank?
19
+ if user_handle_mismatch?(credential_from_params, resource)
20
+ return fail!(:webauthn_credential_verification_failed)
21
+ end
19
22
 
20
23
  verify_credential(credential_from_params, stored_credential)
21
24
 
@@ -47,6 +50,11 @@ module Devise
47
50
  stored_credential.update!(sign_count: credential_from_params.sign_count)
48
51
  end
49
52
 
53
+ def user_handle_mismatch?(credential_from_params, resource)
54
+ credential_from_params.user_handle.present? &&
55
+ credential_from_params.user_handle != resource.webauthn_id
56
+ end
57
+
50
58
  def resource_class
51
59
  mapping.to
52
60
  end
@@ -1,140 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/ModuleLength
4
3
  module Devise
5
4
  module Webauthn
6
5
  module CredentialsHelper
7
- def passkey_creation_form_for(resource, form_classes: nil, &block)
6
+ def passkey_creation_form_for(resource_or_resource_name, **options, &block)
8
7
  form_with(
9
- url: passkeys_path(resource),
10
- method: :post,
11
- class: form_classes
8
+ **options, url: passkeys_path(resource_or_resource_name), method: :post
12
9
  ) do |f|
13
- tag.webauthn_create(data: { options_json: create_passkey_options(resource) }) do
10
+ tag.webauthn_create(data: { options_url: passkey_registration_options_path(resource_or_resource_name) }) do
14
11
  concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
15
12
  concat capture(f, &block)
16
13
  end
17
14
  end
18
15
  end
19
16
 
20
- def login_with_passkey_button(text = nil, session_path:, button_classes: nil, form_classes: nil, &block)
17
+ def login_with_passkey_form_for(resource_or_resource_name, **options, &block)
18
+ scope = Devise::Mapping.find_scope!(resource_or_resource_name)
19
+
21
20
  form_with(
22
- url: session_path,
23
- method: :post,
24
- class: form_classes
21
+ **options, scope: scope, url: session_path(resource_or_resource_name), method: :post
25
22
  ) do |f|
26
- tag.webauthn_get(data: { options_json: passkey_authentication_options }) do
27
- concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
28
-
29
- concat f.button(text, type: "submit", class: button_classes, &block)
23
+ tag.webauthn_get(data: { options_url: passkey_authentication_options_path(resource_or_resource_name) }) do
24
+ concat hidden_field_tag(:public_key_credential, nil, data: { webauthn_target: "response" })
25
+ concat capture(f, &block)
30
26
  end
31
27
  end
32
28
  end
33
29
 
34
- def security_key_creation_form_for(resource, form_classes: nil, &block)
30
+ def security_key_creation_form_for(resource_or_resource_name, **options, &block)
35
31
  form_with(
36
- url: second_factor_webauthn_credentials_path(resource),
37
- method: :post,
38
- class: form_classes
32
+ **options, url: second_factor_webauthn_credentials_path(resource_or_resource_name), method: :post
39
33
  ) do |f|
40
- tag.webauthn_create(data: { options_json: create_security_key_options(resource) }) do
34
+ tag.webauthn_create(
35
+ data: { options_url: security_key_registration_options_path(resource_or_resource_name) }
36
+ ) do
41
37
  concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
42
38
  concat capture(f, &block)
43
39
  end
44
40
  end
45
41
  end
46
42
 
47
- def login_with_security_key_button(text = nil, resource:, button_classes: nil, form_classes: nil, &block)
43
+ def login_with_security_key_form_for(resource_or_resource_name, **options, &block)
48
44
  form_with(
49
- url: two_factor_authentication_path(resource),
50
- method: :post,
51
- class: form_classes
45
+ **options, url: two_factor_authentication_path(resource_or_resource_name), method: :post
52
46
  ) do |f|
53
- tag.webauthn_get(data: { options_json: security_key_authentication_options(resource) }) do
47
+ tag.webauthn_get(data: {
48
+ options_url: security_key_authentication_options_path(resource_or_resource_name)
49
+ }) do
54
50
  concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
55
- concat f.button(text, type: "submit", class: button_classes, &block)
51
+ concat capture(f, &block)
56
52
  end
57
53
  end
58
54
  end
59
-
60
- def create_passkey_options(resource)
61
- @create_passkey_options ||= begin
62
- options = WebAuthn::Credential.options_for_create(
63
- user: {
64
- id: resource.webauthn_id,
65
- name: resource_human_palatable_identifier
66
- },
67
- exclude: resource.passkeys.pluck(:external_id),
68
- authenticator_selection: {
69
- resident_key: "required",
70
- user_verification: "required"
71
- }
72
- )
73
-
74
- # Store challenge in session for later verification
75
- session[:webauthn_challenge] = options.challenge
76
-
77
- options
78
- end
79
- end
80
-
81
- def passkey_authentication_options
82
- @passkey_authentication_options ||= begin
83
- options = WebAuthn::Credential.options_for_get(
84
- user_verification: "required"
85
- )
86
-
87
- # Store challenge in session for later verification
88
- session[:authentication_challenge] = options.challenge
89
-
90
- options
91
- end
92
- end
93
-
94
- def create_security_key_options(resource)
95
- @create_security_key_options ||= begin
96
- options = WebAuthn::Credential.options_for_create(
97
- user: {
98
- id: resource.webauthn_id,
99
- name: resource_human_palatable_identifier
100
- },
101
- exclude: resource.webauthn_credentials.pluck(:external_id),
102
- authenticator_selection: {
103
- resident_key: "discouraged",
104
- user_verification: "discouraged"
105
- }
106
- )
107
-
108
- # Store challenge in session for later verification
109
- session[:webauthn_challenge] = options.challenge
110
-
111
- options
112
- end
113
- end
114
-
115
- def security_key_authentication_options(resource)
116
- @security_key_authentication_options ||= begin
117
- options = WebAuthn::Credential.options_for_get(
118
- allow: resource.webauthn_credentials.pluck(:external_id),
119
- user_verification: "discouraged"
120
- )
121
-
122
- # Store challenge in session for later verification
123
- session[:two_factor_authentication_challenge] = options.challenge
124
-
125
- options
126
- end
127
- end
128
-
129
- private
130
-
131
- def resource_human_palatable_identifier
132
- authentication_keys = resource.class.authentication_keys
133
- authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
134
-
135
- authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
136
- end
137
55
  end
138
56
  end
139
57
  end
140
- # rubocop:enable Metrics/ModuleLength
@@ -7,6 +7,10 @@ module ActionDispatch
7
7
 
8
8
  def devise_passkey_authentication(_mapping, controllers)
9
9
  resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys]
10
+
11
+ resources :passkey_authentication_options, only: :index,
12
+ controller: controllers[:passkey_authentication_options]
13
+ resources :passkey_registration_options, only: :index, controller: controllers[:passkey_registration_options]
10
14
  end
11
15
 
12
16
  def devise_two_factor_authentication(_mapping, controllers)
@@ -17,6 +21,11 @@ module ActionDispatch
17
21
  resources :second_factor_webauthn_credentials,
18
22
  only: %i[new create update destroy],
19
23
  controller: controllers[:second_factor_webauthn_credentials]
24
+
25
+ resources :security_key_authentication_options, only: %i[index],
26
+ controller: controllers[:security_key_authentication_options]
27
+ resources :security_key_registration_options, only: %i[index],
28
+ controller: controllers[:security_key_registration_options]
20
29
  end
21
30
  end
22
31
  end
@@ -24,9 +24,13 @@ module Devise
24
24
  {
25
25
  passkeys: [nil],
26
26
  passkey: [nil, :new],
27
+ passkey_authentication_options: [nil],
28
+ passkey_registration_options: [nil],
27
29
  two_factor_authentication: [nil, :new],
28
30
  second_factor_webauthn_credentials: [nil],
29
- second_factor_webauthn_credential: [nil, :new]
31
+ second_factor_webauthn_credential: [nil, :new],
32
+ security_key_authentication_options: [nil],
33
+ security_key_registration_options: [nil]
30
34
  }.each do |route, actions|
31
35
  %i[path url].each do |path_or_url|
32
36
  actions.each do |action|
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Devise
4
4
  module Webauthn
5
- VERSION = "0.3.1"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -9,6 +9,10 @@ module Devise
9
9
  passkeys
10
10
  second_factor_webauthn_credentials
11
11
  two_factor_authentications
12
+ passkey_authentication_options
13
+ passkey_registration_options
14
+ security_key_authentication_options
15
+ security_key_registration_options
12
16
  ].freeze
13
17
 
14
18
  desc "Create inherited Devise::Webauthn controllers in your app/controllers folder."
@@ -16,10 +20,13 @@ module Devise
16
20
  source_root File.expand_path("templates/controllers", __dir__)
17
21
  argument :scope, required: true,
18
22
  desc: "The scope to create controllers in, e.g. users, admins"
23
+ class_option :controllers, aliases: "-c", type: :array,
24
+ desc: "Select specific controllers to generate (#{CONTROLLERS.join(', ')})"
19
25
 
20
26
  def create_controllers
21
27
  @scope_prefix = scope.blank? ? "" : "#{scope.camelize}::"
22
- CONTROLLERS.each do |name|
28
+ controllers = options[:controllers] || CONTROLLERS
29
+ controllers.each do |name|
23
30
  template "#{name}_controller.rb",
24
31
  "app/controllers/#{scope}/#{name}_controller.rb"
25
32
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @scope_prefix %>PasskeyAuthenticationOptionsController < Devise::PasskeyAuthenticationOptionsController
4
+ # GET /resource/passkey_authentication_options
5
+ # def index
6
+ # super
7
+ # end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @scope_prefix %>PasskeyRegistrationOptionsController < Devise::PasskeyRegistrationOptionsController
4
+ # GET /resource/passkey_registration_options
5
+ # def index
6
+ # super
7
+ # end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @scope_prefix %>SecurityKeyAuthenticationOptionsController < Devise::SecurityKeyAuthenticationOptionsController
4
+ # GET /resource/security_key_authentication_options
5
+ # def index
6
+ # super
7
+ # end
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @scope_prefix %>SecurityKeyRegistrationOptionsController < Devise::SecurityKeyRegistrationOptionsController
4
+ # GET /resource/securiy_key_registration_options
5
+ # def index
6
+ # super
7
+ # end
8
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddWebauthnIdTo<%= user_table_name.camelize %> < ActiveRecord::Migration[<%= Rails.version.to_f %>]
4
+ def up
5
+ add_column :<%= user_table_name %>, :webauthn_id, :string
6
+ add_index :<%= user_table_name %>, :webauthn_id, unique: true
7
+
8
+ # WARNING: The code below backfills webauthn_id for all existing records
9
+ # one row at a time. For larger tables, consider removing it and running
10
+ # the backfill separately (e.g., in a background job or maintenance task).
11
+ #
12
+ # Worth noting: PostgreSQL and MySQL support single-query backfills:
13
+ #
14
+ # PostgreSQL:
15
+ # UPDATE <%= user_table_name %> SET webauthn_id = encode(gen_random_bytes(64), 'base64') WHERE webauthn_id IS NULL
16
+ #
17
+ # MySQL:
18
+ # UPDATE <%= user_table_name %> SET webauthn_id = TO_BASE64(RANDOM_BYTES(64)) WHERE webauthn_id IS NULL
19
+ #
20
+ execute("SELECT id FROM <%= user_table_name %> WHERE webauthn_id IS NULL").each do |row|
21
+ webauthn_id = WebAuthn.generate_user_id
22
+ execute(ActiveRecord::Base.sanitize_sql_array(
23
+ ["UPDATE <%= user_table_name %> SET webauthn_id = ? WHERE id = ?", webauthn_id, row["id"]]
24
+ ))
25
+ end
26
+ # Note: if your application creates records using methods that skip
27
+ # callbacks (e.g., insert_all), consider adding a database default
28
+ # to ensure webauthn_id is always set. For example, in PostgreSQL:
29
+ #
30
+ # change_column_default :<%= user_table_name %>, :webauthn_id, from: nil, to: -> { "encode(gen_random_bytes(64), 'base64')" }
31
+ #
32
+ # For the same reason, you may want to add a NOT NULL constraint in a
33
+ # separate migration after the backfill is complete:
34
+ #
35
+ # change_column_null :<%= user_table_name %>, :webauthn_id, false
36
+ end
37
+
38
+ def down
39
+ remove_column :<%= user_table_name %>, :webauthn_id
40
+ end
41
+ end
@@ -6,17 +6,22 @@ require "rails/generators/active_record"
6
6
  module Devise
7
7
  module Webauthn
8
8
  class WebauthnIdGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
9
11
  hide!
10
12
  namespace "devise:webauthn:webauthn_id"
11
13
 
14
+ source_root File.expand_path("templates", __dir__)
15
+
12
16
  desc "Add webauthn_id field to User model"
13
17
  class_option :resource_name, type: :string, default: "user", desc: "The resource name for Devise (default: user)"
14
18
 
19
+ def self.next_migration_number(dirname)
20
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
21
+ end
22
+
15
23
  def generate_migration
16
- invoke "active_record:migration", [
17
- "add_webauthn_id_to_#{user_table_name}",
18
- "webauthn_id:string:uniq"
19
- ]
24
+ migration_template "add_webauthn_id.rb.erb", "db/migrate/add_webauthn_id_to_#{user_table_name}.rb"
20
25
  end
21
26
 
22
27
  def show_instructions
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: devise-webauthn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedarcode
@@ -50,6 +50,7 @@ files:
50
50
  - ".gitignore"
51
51
  - ".rspec"
52
52
  - ".rubocop.yml"
53
+ - ".rubocop_todo.yml"
53
54
  - ".ruby-version"
54
55
  - Appraisals
55
56
  - CHANGELOG.md
@@ -59,8 +60,12 @@ files:
59
60
  - README.md
60
61
  - Rakefile
61
62
  - app/assets/javascript/devise/webauthn.js
63
+ - app/controllers/devise/passkey_authentication_options_controller.rb
64
+ - app/controllers/devise/passkey_registration_options_controller.rb
62
65
  - app/controllers/devise/passkeys_controller.rb
63
66
  - app/controllers/devise/second_factor_webauthn_credentials_controller.rb
67
+ - app/controllers/devise/security_key_authentication_options_controller.rb
68
+ - app/controllers/devise/security_key_registration_options_controller.rb
64
69
  - app/controllers/devise/two_factor_authentications_controller.rb
65
70
  - app/views/devise/passkeys/new.html.erb
66
71
  - app/views/devise/second_factor_webauthn_credentials/new.html.erb
@@ -96,12 +101,17 @@ files:
96
101
  - lib/generators/devise/webauthn/install/templates/webauthn.rb
97
102
  - lib/generators/devise/webauthn/javascript_configuration/javascript_configuration_generator.rb
98
103
  - lib/generators/devise/webauthn/templates/controllers/README
104
+ - lib/generators/devise/webauthn/templates/controllers/passkey_authentication_options_controller.rb.tt
105
+ - lib/generators/devise/webauthn/templates/controllers/passkey_registration_options_controller.rb.tt
99
106
  - lib/generators/devise/webauthn/templates/controllers/passkeys_controller.rb.tt
100
107
  - lib/generators/devise/webauthn/templates/controllers/second_factor_webauthn_credentials_controller.rb.tt
108
+ - lib/generators/devise/webauthn/templates/controllers/security_key_authentication_options_controller.rb.tt
109
+ - lib/generators/devise/webauthn/templates/controllers/security_key_registration_options_controller.rb.tt
101
110
  - lib/generators/devise/webauthn/templates/controllers/two_factor_authentications_controller.rb.tt
102
111
  - lib/generators/devise/webauthn/views_generator.rb
103
112
  - lib/generators/devise/webauthn/webauthn_credential_model/templates/webauthn_credential_migration.rb.erb
104
113
  - lib/generators/devise/webauthn/webauthn_credential_model/webauthn_credential_model_generator.rb
114
+ - lib/generators/devise/webauthn/webauthn_id/templates/add_webauthn_id.rb.erb
105
115
  - lib/generators/devise/webauthn/webauthn_id/webauthn_id_generator.rb
106
116
  homepage: https://github.com/cedarcode/devise-webauthn
107
117
  licenses: