devise-webauthn 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f958718af83cd2bdae3d3c08d8ac72678e2e313b93eabdc7effe1af70bda300f
4
- data.tar.gz: ea997a0537ca6a68d97e7aedbff6993331eeebab1857b2d7a26177987efe7571
3
+ metadata.gz: 2c1fc0044f5c2bf947d79fd3db30ea05907980dac2abeacd4d43af55c7fc8fc3
4
+ data.tar.gz: d9513c05eaad396f2d6f8764a8f8964086678fa03e55b98375406e1de7303b67
5
5
  SHA512:
6
- metadata.gz: e81d1880a7b6722c150b9cdd3fcabcbc07128ab5beaea22c580a6ff5d6338ac108ed55728f876eb9635e087787c875fca246de6ed307450f53907b14eadc5c5d
7
- data.tar.gz: 0fe56be79a56f2ca46349ebd4e9ce1ed1f3f43df73cb7acde76a0aebce4dae4cf022c8cc01dba53c89c9d0b637145474f169cab0b4f7f4811788fdc7b0fe2479
6
+ metadata.gz: 53c013359a84a25616c4a4778a4e0c766916665d7c31192b74feab1f471f36ffe4867ef1ed74f82f80119c1bdde16c996df2e2782df1b4c3de3ac67e49ce9f60
7
+ data.tar.gz: afdd185b69e7208bcc489c6f707c2d3c77a3a44447f27650db5f985958dd2856b7ce117a684ba2273639d469bf4c39d02a0abe523d7d943b22b1c438d3e076c6
@@ -17,7 +17,7 @@ jobs:
17
17
  id-token: write
18
18
 
19
19
  steps:
20
- - uses: actions/checkout@v6
20
+ - uses: actions/checkout@v7
21
21
 
22
22
  - name: Setup Ruby
23
23
  uses: ruby/setup-ruby@v1
@@ -8,7 +8,7 @@ jobs:
8
8
 
9
9
  steps:
10
10
  - name: Check out repository code
11
- uses: actions/checkout@v6
11
+ uses: actions/checkout@v7
12
12
 
13
13
  - name: Set up Ruby
14
14
  uses: ruby/setup-ruby@v1
@@ -24,6 +24,7 @@ jobs:
24
24
  fail-fast: false
25
25
  matrix:
26
26
  ruby:
27
+ - '4.0'
27
28
  - 3.4
28
29
  - 3.3
29
30
  - 3.2
@@ -73,7 +74,7 @@ jobs:
73
74
 
74
75
  steps:
75
76
  - name: Check out repository code
76
- uses: actions/checkout@v6
77
+ uses: actions/checkout@v7
77
78
 
78
79
  - name: Set up Ruby
79
80
  uses: ruby/setup-ruby@v1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [v0.5.0](https://github.com/cedarcode/devise-webauthn/compare/v0.4.0...v0.5.0/) - 2026-07-13
6
+
7
+ ### Added
8
+
9
+ - Submit WebAuthn forms with `requestSubmit` so Turbo can intercept. [#140](https://github.com/cedarcode/devise-webauthn/pull/140) [@santiagorodriguez96]
10
+
11
+ ### Changed
12
+
13
+ - BREAKING!: The WebAuthn options endpoints (`passkey_registration_options`, `passkey_authentication_options`, `security_key_registration_options` and `security_key_authentication_options`) now respond to `POST` instead of `GET`, since they mutate server state (they store the WebAuthn challenge in the session). If you overrode any of these controllers, rename the overridden `index` action to `create`; if you copied the bundled JavaScript into your app, update your copy. [#147](https://github.com/cedarcode/devise-webauthn/pull/147)[@santiagorodriguez96]
14
+ - BREAKING!: `webauthn_id` is now generated and persisted when WebAuthn registration options are requested, instead of eagerly in a `before_validation` callback, so backfilling existing records is no longer needed and the migration generated by `devise:webauthn:webauthn_id` no longer includes one. If your app relies on `webauthn_id` being set on create (e.g. you added a `NOT NULL` constraint to the column), add your own callback or database default, or drop the constraint. [#148](https://github.com/cedarcode/devise-webauthn/pull/148) [@santiagorodriguez96]
15
+
5
16
  ## [v0.4.0](https://github.com/cedarcode/devise-webauthn/compare/v0.3.1...v0.4.0/) - 2026-04-06
6
17
 
7
18
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- devise-webauthn (0.4.0)
4
+ devise-webauthn (0.5.0)
5
5
  devise (>= 4.9)
6
6
  webauthn (~> 3.0)
7
7
 
data/README.md CHANGED
@@ -283,7 +283,7 @@ Used for registering new credentials (passkeys or security keys).
283
283
 
284
284
  ```html
285
285
  <form action="/passkeys" method="post">
286
- <webauthn-create data-options-json="<%= create_passkey_options(@user).to_json %>">
286
+ <webauthn-create data-options-url="<%= passkey_registration_options_path(:user) %>">
287
287
  <input type="hidden" name="public_key_credential" data-webauthn-target="response">
288
288
  <input type="text" name="name" placeholder="Passkey name">
289
289
  <button type="submit">Create Passkey</button>
@@ -296,9 +296,11 @@ Used for registering new credentials (passkeys or security keys).
296
296
  - The form's action should point to the appropriate endpoint – you can use the provided url helpers:
297
297
  - For creating passkeys: `passkeys_path(resource_name)`
298
298
  - For creating 2FA security keys: `second_factor_webauthn_credentials_path(resource_name)`
299
- - Requires a `data-options-json` attribute containing JSON-serialized WebAuthn creation options
299
+ - Requires a `data-options-url` attribute pointing to the endpoint serving the WebAuthn creation options – you can use the provided url helpers:
300
+ - For passkey creation options: `passkey_registration_options_path(resource_name)`
301
+ - For 2FA security key creation options: `security_key_registration_options_path(resource_name)`
300
302
  - Must contain a hidden input with `data-webauthn-target="response"` to store the credential response
301
- - Must contain the submit button the element intercepts form submission, calls the WebAuthn API, stores the credential in the hidden input, and then re-submits the form
303
+ - Must contain the submit button the element intercepts form submission, fetches the options, calls the WebAuthn API, stores the credential in the hidden input, and then re-submits the form
302
304
 
303
305
  #### `<webauthn-get>`
304
306
 
@@ -306,7 +308,7 @@ Used for authenticating with existing credentials.
306
308
 
307
309
  ```html
308
310
  <form action="/users/sign_in" method="post">
309
- <webauthn-get data-options-json="<%= passkey_authentication_options.to_json %>">
311
+ <webauthn-get data-options-url="<%= passkey_authentication_options_path(:user) %>">
310
312
  <input type="hidden" name="public_key_credential" data-webauthn-target="response">
311
313
  <button type="submit">Sign in with Passkey</button>
312
314
  </webauthn-get>
@@ -318,9 +320,11 @@ Used for authenticating with existing credentials.
318
320
  - The form's action should point to the appropriate endpoint – you can use the provided url helpers:
319
321
  - For passkey sign-in: `session_path(resource_name)`
320
322
  - For 2FA with WebAuthn: `two_factor_authentication_path(resource_name)`
321
- - Requires a `data-options-json` attribute containing JSON-serialized WebAuthn request options
323
+ - Requires a `data-options-url` attribute pointing to the endpoint serving the WebAuthn request options – you can use the provided url helpers:
324
+ - For passkey authentication options: `passkey_authentication_options_path(resource_name)`
325
+ - For 2FA security key authentication options: `security_key_authentication_options_path(resource_name)`
322
326
  - Must contain a hidden input with `data-webauthn-target="response"` to store the credential response
323
- - Must contain the submit button the element intercepts form submission, calls the WebAuthn API, stores the credential in the hidden input, and then re-submits the form
327
+ - Must contain the submit button the element intercepts form submission, fetches the options, calls the WebAuthn API, stores the credential in the hidden input, and then re-submits the form
324
328
 
325
329
  ## Development
326
330
 
@@ -19,17 +19,21 @@ export class WebauthnCreateElement extends HTMLElement {
19
19
  }
20
20
 
21
21
  this.closest('form').addEventListener('submit', async (event) => {
22
+ if (this.dataset.ceremonyCompleted) return;
23
+
22
24
  event.preventDefault();
23
25
 
24
26
  try {
25
- const response = await fetch(this.getAttribute('data-options-url'));
26
- const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(await response.json());
27
+ const options = await fetchOptions(this.getAttribute('data-options-url'));
28
+ const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options);
27
29
  const credential = await navigator.credentials.create({ publicKey });
28
30
 
29
31
  this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyRegistrationCredentialWithGracefullyHandlingAuthenticatorIssues(credential);
30
32
 
31
- this.closest('form').submit();
33
+ this.dataset.ceremonyCompleted = "true";
34
+ this.closest('form').requestSubmit();
32
35
  } catch (error) {
36
+ delete this.dataset.ceremonyCompleted;
33
37
  this.handleError(error);
34
38
  }
35
39
  });
@@ -100,17 +104,21 @@ export class WebauthnGetElement extends HTMLElement {
100
104
  }
101
105
 
102
106
  this.closest('form').addEventListener('submit', async (event) => {
107
+ if (this.dataset.ceremonyCompleted) return;
108
+
103
109
  event.preventDefault();
104
110
 
105
111
  try {
106
- const response = await fetch(this.getAttribute('data-options-url'));
107
- const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(await response.json());
112
+ const options = await fetchOptions(this.getAttribute('data-options-url'));
113
+ const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
108
114
  const credential = await navigator.credentials.get({ publicKey });
109
115
 
110
116
  this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyAuthenticationCredentialWithGracefullyHandlingAuthenticatorIssues(credential);
111
117
 
112
- this.closest('form').submit();
118
+ this.dataset.ceremonyCompleted = "true";
119
+ this.closest('form').requestSubmit();
113
120
  } catch (error) {
121
+ delete this.dataset.ceremonyCompleted;
114
122
  this.handleError(error);
115
123
  }
116
124
  });
@@ -168,6 +176,19 @@ export class WebauthnGetElement extends HTMLElement {
168
176
  }
169
177
  }
170
178
 
179
+ async function fetchOptions(url) {
180
+ const response = await fetch(url, {
181
+ method: 'POST',
182
+ headers: { 'Accept': 'application/json' }
183
+ });
184
+
185
+ if (!response.ok) {
186
+ throw new Error(`Fetching WebAuthn options failed with status ${response.status}`);
187
+ }
188
+
189
+ return response.json();
190
+ }
191
+
171
192
  function toBase64Url(buffer) {
172
193
  if (!buffer) return null;
173
194
 
@@ -2,7 +2,9 @@
2
2
 
3
3
  module Devise
4
4
  class PasskeyAuthenticationOptionsController < DeviseController
5
- def index
5
+ skip_forgery_protection
6
+
7
+ def create
6
8
  passkey_options =
7
9
  WebAuthn::Credential.options_for_get(
8
10
  user_verification: "required"
@@ -2,9 +2,12 @@
2
2
 
3
3
  module Devise
4
4
  class PasskeyRegistrationOptionsController < DeviseController
5
+ skip_forgery_protection
6
+
5
7
  before_action :authenticate_scope!
8
+ before_action :ensure_webauthn_id!
6
9
 
7
- def index
10
+ def create
8
11
  passkey_options =
9
12
  WebAuthn::Credential.options_for_create(
10
13
  user: {
@@ -31,6 +34,10 @@ module Devise
31
34
  self.resource = send(:"current_#{resource_name}")
32
35
  end
33
36
 
37
+ def ensure_webauthn_id!
38
+ resource.ensure_webauthn_id!
39
+ end
40
+
34
41
  def resource_human_palatable_identifier
35
42
  authentication_keys = resource.class.authentication_keys
36
43
  authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
@@ -2,9 +2,11 @@
2
2
 
3
3
  module Devise
4
4
  class SecurityKeyAuthenticationOptionsController < DeviseController
5
+ skip_forgery_protection
6
+
5
7
  before_action :set_resource
6
8
 
7
- def index
9
+ def create
8
10
  security_key_authentication_options =
9
11
  WebAuthn::Credential.options_for_get(
10
12
  allow: @resource.webauthn_credentials.pluck(:external_id),
@@ -2,9 +2,12 @@
2
2
 
3
3
  module Devise
4
4
  class SecurityKeyRegistrationOptionsController < DeviseController
5
+ skip_forgery_protection
6
+
5
7
  before_action :authenticate_scope!
8
+ before_action :ensure_webauthn_id!
6
9
 
7
- def index
10
+ def create
8
11
  create_security_key_options =
9
12
  WebAuthn::Credential.options_for_create(
10
13
  user: {
@@ -31,6 +34,10 @@ module Devise
31
34
  self.resource = send(:"current_#{resource_name}")
32
35
  end
33
36
 
37
+ def ensure_webauthn_id!
38
+ resource.ensure_webauthn_id!
39
+ end
40
+
34
41
  def resource_human_palatable_identifier
35
42
  authentication_keys = resource.class.authentication_keys
36
43
  authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
@@ -11,10 +11,15 @@ module Devise
11
11
  has_many :webauthn_credentials, dependent: :destroy
12
12
 
13
13
  validates :webauthn_id, uniqueness: true, allow_blank: true
14
+ end
15
+
16
+ def ensure_webauthn_id!
17
+ return webauthn_id if webauthn_id.present?
18
+
19
+ self.webauthn_id = WebAuthn.generate_user_id
20
+ save!(validate: false)
14
21
 
15
- before_validation do
16
- self.webauthn_id ||= WebAuthn.generate_user_id
17
- end
22
+ webauthn_id
18
23
  end
19
24
  end
20
25
  end
@@ -8,9 +8,9 @@ module ActionDispatch
8
8
  def devise_passkey_authentication(_mapping, controllers)
9
9
  resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys]
10
10
 
11
- resources :passkey_authentication_options, only: :index,
11
+ resources :passkey_authentication_options, only: :create,
12
12
  controller: controllers[:passkey_authentication_options]
13
- resources :passkey_registration_options, only: :index, controller: controllers[:passkey_registration_options]
13
+ resources :passkey_registration_options, only: :create, controller: controllers[:passkey_registration_options]
14
14
  end
15
15
 
16
16
  def devise_two_factor_authentication(_mapping, controllers)
@@ -22,9 +22,9 @@ module ActionDispatch
22
22
  only: %i[new create update destroy],
23
23
  controller: controllers[:second_factor_webauthn_credentials]
24
24
 
25
- resources :security_key_authentication_options, only: %i[index],
25
+ resources :security_key_authentication_options, only: :create,
26
26
  controller: controllers[:security_key_authentication_options]
27
- resources :security_key_registration_options, only: %i[index],
27
+ resources :security_key_registration_options, only: :create,
28
28
  controller: controllers[:security_key_registration_options]
29
29
  end
30
30
  end
@@ -39,7 +39,7 @@ module Devise
39
39
  "privateKey" => encoded_private_key,
40
40
  "signCount" => 0
41
41
  }
42
- credential_json["userHandle"] = resource.webauthn_id if passkey
42
+ credential_json["userHandle"] = resource.ensure_webauthn_id! if passkey
43
43
 
44
44
  authenticator.add_credential(credential_json)
45
45
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Devise
4
4
  module Webauthn
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -6,22 +6,17 @@ 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
-
11
9
  hide!
12
10
  namespace "devise:webauthn:webauthn_id"
13
11
 
14
- source_root File.expand_path("templates", __dir__)
15
-
16
12
  desc "Add webauthn_id field to User model"
17
13
  class_option :resource_name, type: :string, default: "user", desc: "The resource name for Devise (default: user)"
18
14
 
19
- def self.next_migration_number(dirname)
20
- ActiveRecord::Generators::Base.next_migration_number(dirname)
21
- end
22
-
23
15
  def generate_migration
24
- migration_template "add_webauthn_id.rb.erb", "db/migrate/add_webauthn_id_to_#{user_table_name}.rb"
16
+ invoke "active_record:migration", [
17
+ "add_webauthn_id_to_#{user_table_name}",
18
+ "webauthn_id:string:uniq"
19
+ ]
25
20
  end
26
21
 
27
22
  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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedarcode
@@ -111,7 +111,6 @@ files:
111
111
  - lib/generators/devise/webauthn/views_generator.rb
112
112
  - lib/generators/devise/webauthn/webauthn_credential_model/templates/webauthn_credential_migration.rb.erb
113
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
115
114
  - lib/generators/devise/webauthn/webauthn_id/webauthn_id_generator.rb
116
115
  homepage: https://github.com/cedarcode/devise-webauthn
117
116
  licenses:
@@ -1,41 +0,0 @@
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