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 +4 -4
- data/.github/workflows/release.yml +1 -1
- data/.github/workflows/ruby.yml +3 -2
- data/CHANGELOG.md +11 -0
- data/Gemfile.lock +1 -1
- data/README.md +10 -6
- data/app/assets/javascript/devise/webauthn.js +27 -6
- data/app/controllers/devise/passkey_authentication_options_controller.rb +3 -1
- data/app/controllers/devise/passkey_registration_options_controller.rb +8 -1
- data/app/controllers/devise/security_key_authentication_options_controller.rb +3 -1
- data/app/controllers/devise/security_key_registration_options_controller.rb +8 -1
- data/lib/devise/models/webauthn_credential_authenticatable.rb +8 -3
- data/lib/devise/webauthn/routes.rb +4 -4
- data/lib/devise/webauthn/test/authenticator_helpers.rb +1 -1
- data/lib/devise/webauthn/version.rb +1 -1
- data/lib/generators/devise/webauthn/webauthn_id/webauthn_id_generator.rb +4 -9
- metadata +1 -2
- data/lib/generators/devise/webauthn/webauthn_id/templates/add_webauthn_id.rb.erb +0 -41
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c1fc0044f5c2bf947d79fd3db30ea05907980dac2abeacd4d43af55c7fc8fc3
|
|
4
|
+
data.tar.gz: d9513c05eaad396f2d6f8764a8f8964086678fa03e55b98375406e1de7303b67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 53c013359a84a25616c4a4778a4e0c766916665d7c31192b74feab1f471f36ffe4867ef1ed74f82f80119c1bdde16c996df2e2782df1b4c3de3ac67e49ce9f60
|
|
7
|
+
data.tar.gz: afdd185b69e7208bcc489c6f707c2d3c77a3a44447f27650db5f985958dd2856b7ce117a684ba2273639d469bf4c39d02a0abe523d7d943b22b1c438d3e076c6
|
data/.github/workflows/ruby.yml
CHANGED
|
@@ -8,7 +8,7 @@ jobs:
|
|
|
8
8
|
|
|
9
9
|
steps:
|
|
10
10
|
- name: Check out repository code
|
|
11
|
-
uses: actions/checkout@
|
|
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@
|
|
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
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-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
26
|
-
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(
|
|
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.
|
|
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
|
|
107
|
-
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(
|
|
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.
|
|
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,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
|
|
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
|
|
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
|
|
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
|
-
|
|
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: :
|
|
11
|
+
resources :passkey_authentication_options, only: :create,
|
|
12
12
|
controller: controllers[:passkey_authentication_options]
|
|
13
|
-
resources :passkey_registration_options, only: :
|
|
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:
|
|
25
|
+
resources :security_key_authentication_options, only: :create,
|
|
26
26
|
controller: controllers[:security_key_authentication_options]
|
|
27
|
-
resources :security_key_registration_options, only:
|
|
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.
|
|
42
|
+
credential_json["userHandle"] = resource.ensure_webauthn_id! if passkey
|
|
43
43
|
|
|
44
44
|
authenticator.add_credential(credential_json)
|
|
45
45
|
|
|
@@ -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
|
-
|
|
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
|
+
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
|