devise-webauthn 0.2.2 → 0.3.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/ruby.yml +4 -0
- data/Appraisals +18 -4
- data/CHANGELOG.md +47 -11
- data/Gemfile +2 -2
- data/Gemfile.lock +3 -5
- data/README.md +158 -5
- data/app/assets/javascript/devise/webauthn.js +179 -0
- data/app/controllers/devise/second_factor_webauthn_credentials_controller.rb +25 -3
- data/config/locales/en.yml +2 -0
- data/devise-webauthn.gemspec +1 -1
- data/gemfiles/devise_5_0.gemfile +26 -0
- data/gemfiles/rails_7_1.gemfile +3 -2
- data/gemfiles/rails_7_2.gemfile +3 -2
- data/gemfiles/rails_8_0.gemfile +3 -2
- data/gemfiles/rails_8_1.gemfile +3 -2
- data/gemfiles/rails_edge.gemfile +2 -1
- data/lib/devise/strategies/database_authenticatable.rb +1 -0
- data/lib/devise/strategies/webauthn_two_factor_authenticatable.rb +11 -5
- data/lib/devise/webauthn/engine.rb +6 -0
- data/lib/devise/webauthn/helpers/credentials_helper.rb +21 -36
- data/lib/devise/webauthn/routes.rb +1 -1
- data/lib/devise/webauthn/test/authenticator_helpers.rb +6 -6
- data/lib/devise/webauthn/version.rb +1 -1
- data/lib/generators/devise/webauthn/install/install_generator.rb +2 -2
- data/lib/generators/devise/webauthn/javascript/javascript_configuration_generator.rb +64 -0
- data/lib/generators/devise/webauthn/templates/controllers/second_factor_webauthn_credentials_controller.rb.tt +17 -0
- metadata +6 -5
- data/lib/generators/devise/webauthn/stimulus/stimulus_generator.rb +0 -24
- data/lib/generators/devise/webauthn/stimulus/templates/webauthn_credentials_controller.js +0 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaa4817ea2af9200fa03ddf48357462fee35e64624b14d0de881b7805af7a34d
|
|
4
|
+
data.tar.gz: 88b7affbb03be29d61834f20924db27ecd8623c0f7c5463ce59554ebd727dcd7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 963eaa9ff4ce30759da2bb0b90fd2e8af1118c3c51caad4b574b7e28873033cdf16cdc9a91c29d21a4be2676e78255665df5db50d524c29a9321dfc864490a78
|
|
7
|
+
data.tar.gz: 758cb30bc99014c144ad7b8467cb9a24da62c7c0a1c7db1b7565436973ab145041ad3ac8eabf1951b648e56caa5682459ec0ebaf5f0fd21f0b8b07341c95b30a
|
data/.github/workflows/ruby.yml
CHANGED
data/Appraisals
CHANGED
|
@@ -5,19 +5,19 @@ appraise "rails-edge" do
|
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
appraise "rails-8_1" do
|
|
8
|
-
gem "rails", "~> 8.1"
|
|
8
|
+
gem "rails", "~> 8.1.x"
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
appraise "rails-8_0" do
|
|
12
|
-
gem "rails", "~> 8.0"
|
|
12
|
+
gem "rails", "~> 8.0.x"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
appraise "rails-7_2" do
|
|
16
|
-
gem "rails", "~> 7.2"
|
|
16
|
+
gem "rails", "~> 7.2.x"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
appraise "rails-7_1" do
|
|
20
|
-
gem "rails", "~> 7.1"
|
|
20
|
+
gem "rails", "~> 7.1.x"
|
|
21
21
|
|
|
22
22
|
gem "capybara", "~> 3.39"
|
|
23
23
|
gem "importmap-rails", "~> 2.0"
|
|
@@ -27,3 +27,17 @@ appraise "rails-7_1" do
|
|
|
27
27
|
gem "rspec-rails", "~> 7.1"
|
|
28
28
|
gem "sqlite3", "~> 1.7"
|
|
29
29
|
end
|
|
30
|
+
|
|
31
|
+
appraise "devise-5_0" do
|
|
32
|
+
gem "devise", "~> 5.0.0.rc"
|
|
33
|
+
|
|
34
|
+
gem "rails", ">= 7.1"
|
|
35
|
+
gem "capybara", "~> 3.39"
|
|
36
|
+
gem "importmap-rails", "~> 2.0"
|
|
37
|
+
gem "pry-byebug", "~> 3.10"
|
|
38
|
+
install_if "-> { RUBY_VERSION < \"3.0\" }" do
|
|
39
|
+
gem "rack", "~> 2.2"
|
|
40
|
+
end
|
|
41
|
+
gem "rspec-rails", ">= 7.1"
|
|
42
|
+
gem "sqlite3", ">= 1.6", "!= 1.7.0", "!= 1.7.1", "!= 1.7.2", "!= 1.7.3"
|
|
43
|
+
end
|
data/CHANGELOG.md
CHANGED
|
@@ -2,37 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## [v0.3.0](https://github.com/cedarcode/devise-webauthn/compare/v0.2.2...v0.3.0/) - 2026-01-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- WebAuthn JavaScript is now bundled as engine assets using custom HTML elements (`<webauthn-create>`, `<webauthn-get>`) instead of generating a Stimulus controller into the host application. [#84](https://github.com/cedarcode/devise-webauthn/pull/84) [@santiagorodriguez96]
|
|
10
|
+
- Add endpoint to `SecondFactorWebauthnCredentialsController` for "upgrading" second factor webauthn credentials (i. e., security keys) to passkeys. [#80](https://github.com/cedarcode/devise-webauthn/pull/80) [@nicolastemciuc]
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Loosen `devise` upper constraint to allow for v5. [#94](https://github.com/cedarcode/devise-webauthn/pull/94) [@santiagorodriguez96]
|
|
15
|
+
- BREAKING!: Our [Form helpers](https://github.com/cedarcode/devise-webauthn/blob/355a6836315439f71265bb368bff4e8067033072/lib/devise/webauthn/helpers/credentials_helper.rb#L7-L58) now use the bundled WebAuthn JS asset now instead of the Stimulus controllers, so they expect it to be included in your application. [#84](https://github.com/cedarcode/devise-webauthn/pull/84) [@santiagorodriguez96]
|
|
16
|
+
- Previously generated Stimulus controller for handling WebAuthn client logic are no longer generated.
|
|
17
|
+
- Stimulus is no longer needed for this engine to work.
|
|
18
|
+
- Make helpers for generating WebAuthn options public methods. [#106](https://github.com/cedarcode/devise-webauthn/pull/106) [@santiagorodriguez96]
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fix `Remember me` checkbox not honored when going through the 2FA challenge flow. [#87](https://github.com/cedarcode/devise-webauthn/pull/87) [@santiagorodriguez96]
|
|
23
|
+
|
|
5
24
|
## [v0.2.2](https://github.com/cedarcode/devise-webauthn/compare/v0.2.1...v0.2.2/) - 2025-12-11
|
|
6
25
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Update controllers and views generators to generate 2FA-related controllers and views. [#75](https://github.com/cedarcode/devise-webauthn/pull/75) [@santiagorodriguez96]
|
|
29
|
+
- Add flash messages when removing credentials. [#78](https://github.com/cedarcode/devise-webauthn/pull/78) [@nicolastemciuc]
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Generate webauthn credentials table with not null constraints in attributes that must be present. [#70](https://github.com/cedarcode/devise-webauthn/pull/70) [@santiagorodriguez96]
|
|
10
34
|
|
|
11
35
|
## [v0.2.1](https://github.com/cedarcode/devise-webauthn/compare/v0.2.0...v0.2.1/) - 2025-12-10
|
|
12
36
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- Add form helpers for security key registration and 2FA authentication. [#52](https://github.com/cedarcode/devise-webauthn/pull/52) [@santiagorodriguez96]
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Fix incorrect call to `resource_name` instead of using passed `resource` param in `login_with_security_key_button` helper. [#65](https://github.com/cedarcode/devise-webauthn/pull/65) [@santiagorodriguez96]
|
|
44
|
+
- Fix `NoMethodError` when calling `second_factor_enabled?` on resources without 2FA. [#62](https://github.com/cedarcode/devise-webauthn/pull/62) [@nicolastemciuc]
|
|
45
|
+
- Avoid assuming `email` as the authentication key of the resource in form helpers. [#66](https://github.com/cedarcode/devise-webauthn/pull/66) [@santiagorodriguez96]
|
|
17
46
|
|
|
18
47
|
## [v0.2.0](https://github.com/cedarcode/devise-webauthn/compare/v0.1.2...v0.2.0/) - 2025-12-03
|
|
19
48
|
|
|
20
|
-
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- Add new `webauthn_two_factor_authenticatable` module for enabling 2FA using WebAuthn credentials. [#49](https://github.com/cedarcode/devise-webauthn/pull/49) [@santiagorodriguez96]
|
|
21
52
|
|
|
22
53
|
## [v0.1.2](https://github.com/cedarcode/devise-webauthn/compare/v0.1.1...v0.1.2/) - 2025-12-03
|
|
23
54
|
|
|
24
55
|
### Fixed
|
|
25
56
|
|
|
26
|
-
- Fixed sign in with passkey for resources with name different from "User"
|
|
57
|
+
- Fixed sign in with passkey for resources with name different from "User". [#47](https://github.com/cedarcode/devise-webauthn/pull/47) [@joaquintomas2003], [@santiagorodriguez96]
|
|
27
58
|
|
|
28
59
|
## [v0.1.1](https://github.com/cedarcode/devise-webauthn/compare/v0.1.0...v0.1.1/) - 2025-11-13
|
|
29
60
|
|
|
30
61
|
### Changed
|
|
31
62
|
|
|
32
|
-
- Updated gemspec metadata.
|
|
63
|
+
- Updated gemspec metadata. [#43](https://github.com/cedarcode/devise-webauthn/pull/43) [@joaquintomas2003]
|
|
33
64
|
|
|
34
65
|
## [v0.1.0](https://github.com/cedarcode/devise-webauthn/compare/v0.0.0...v0.1.0/) - 2025-11-12
|
|
35
66
|
|
|
36
67
|
### Initial release
|
|
37
68
|
|
|
38
|
-
- Provides passkey authentication for apps using Devise.
|
|
69
|
+
- Provides passkey authentication for apps using Devise. [@joaquintomas2003], [@nicolastemciuc], [@RenzoMinelli], [@santiagorodriguez96]
|
|
70
|
+
|
|
71
|
+
[@RenzoMinelli]: https://github.com/RenzoMinelli
|
|
72
|
+
[@joaquintomas2003]: https://github.com/joaquintomas2003
|
|
73
|
+
[@nicolastemciuc]: https://github.com/nicolastemciuc
|
|
74
|
+
[@santiagorodriguez96]: https://github.com/santiagorodriguez96
|
data/Gemfile
CHANGED
|
@@ -7,9 +7,10 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
|
|
7
7
|
# Specify your gem's dependencies in devise-webauthn.gemspec
|
|
8
8
|
gemspec
|
|
9
9
|
|
|
10
|
-
gem "appraisal", "~> 2.5"
|
|
10
|
+
gem "appraisal", "~> 2.5", require: false
|
|
11
11
|
gem "capybara", "~> 3.40"
|
|
12
12
|
gem "combustion", "~> 1.3"
|
|
13
|
+
gem "devise", "~> 4.9"
|
|
13
14
|
gem "importmap-rails", "~> 2.2"
|
|
14
15
|
gem "propshaft", "~> 1.2"
|
|
15
16
|
gem "pry-byebug", "~> 3.11"
|
|
@@ -21,4 +22,3 @@ gem "rubocop-rails", "~> 2.32"
|
|
|
21
22
|
gem "rubocop-rspec", "~> 3.6"
|
|
22
23
|
gem "selenium-webdriver"
|
|
23
24
|
gem "sqlite3", "~> 2.7"
|
|
24
|
-
gem "stimulus-rails", "~> 1.3"
|
data/Gemfile.lock
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
devise-webauthn (0.
|
|
5
|
-
devise (
|
|
4
|
+
devise-webauthn (0.3.0)
|
|
5
|
+
devise (>= 4.9)
|
|
6
6
|
webauthn (~> 3.0)
|
|
7
7
|
|
|
8
8
|
GEM
|
|
@@ -303,8 +303,6 @@ GEM
|
|
|
303
303
|
websocket (~> 1.0)
|
|
304
304
|
sqlite3 (2.7.3)
|
|
305
305
|
mini_portile2 (~> 2.8.0)
|
|
306
|
-
stimulus-rails (1.3.4)
|
|
307
|
-
railties (>= 6.0.0)
|
|
308
306
|
stringio (3.1.7)
|
|
309
307
|
thor (1.4.0)
|
|
310
308
|
timeout (0.4.3)
|
|
@@ -345,6 +343,7 @@ DEPENDENCIES
|
|
|
345
343
|
appraisal (~> 2.5)
|
|
346
344
|
capybara (~> 3.40)
|
|
347
345
|
combustion (~> 1.3)
|
|
346
|
+
devise (~> 4.9)
|
|
348
347
|
devise-webauthn!
|
|
349
348
|
importmap-rails (~> 2.2)
|
|
350
349
|
propshaft (~> 1.2)
|
|
@@ -357,7 +356,6 @@ DEPENDENCIES
|
|
|
357
356
|
rubocop-rspec (~> 3.6)
|
|
358
357
|
selenium-webdriver
|
|
359
358
|
sqlite3 (~> 2.7)
|
|
360
|
-
stimulus-rails (~> 1.3)
|
|
361
359
|
|
|
362
360
|
BUNDLED WITH
|
|
363
361
|
2.7.1
|
data/README.md
CHANGED
|
@@ -6,9 +6,7 @@ Devise::Webauthn is a [Devise](https://github.com/heartcombo/devise) extension t
|
|
|
6
6
|
## Requirements
|
|
7
7
|
|
|
8
8
|
- **Ruby**: 2.7+
|
|
9
|
-
- **
|
|
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.
|
|
9
|
+
- **JavaScript**: This gem includes WebAuthn JavaScript as custom HTML elements. You'll need to import the JavaScript file in your application.
|
|
12
10
|
|
|
13
11
|
## Installation
|
|
14
12
|
|
|
@@ -31,7 +29,7 @@ Or install it yourself as:
|
|
|
31
29
|
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
30
|
Then, follow these steps to integrate Devise::Webauthn:
|
|
33
31
|
1. **Run Devise::Webauthn Generator:**
|
|
34
|
-
Run the generator to set up necessary configurations
|
|
32
|
+
Run the generator to set up necessary configurations and migrations:
|
|
35
33
|
```bash
|
|
36
34
|
$ bin/rails generate devise:webauthn:install
|
|
37
35
|
```
|
|
@@ -45,7 +43,7 @@ Then, follow these steps to integrate Devise::Webauthn:
|
|
|
45
43
|
- Create the WebAuthn initializer (`config/initializers/webauthn.rb`)
|
|
46
44
|
- Generate the `WebauthnCredential` model and migration
|
|
47
45
|
- Add `webauthn_id` field to your devise model (e.g., `User`)
|
|
48
|
-
-
|
|
46
|
+
- Configure JavaScript loading for your application (see [JavaScript Setup](#javascript-setup))
|
|
49
47
|
|
|
50
48
|
2. **Run Migrations:**
|
|
51
49
|
After running the generator, execute the migrations to update your database schema:
|
|
@@ -76,6 +74,29 @@ Then, follow these steps to integrate Devise::Webauthn:
|
|
|
76
74
|
end
|
|
77
75
|
```
|
|
78
76
|
|
|
77
|
+
5. **Include bundled WebAuthn JavaScript in your application:**
|
|
78
|
+
The install generator automatically configures JavaScript loading based on your setup:
|
|
79
|
+
|
|
80
|
+
**For importmap-rails:**
|
|
81
|
+
- Adds `pin "devise/webauthn", to: "devise/webauthn.js"` to `config/importmap.rb`
|
|
82
|
+
- Adds `import "devise/webauthn"` to `app/javascript/application.js`
|
|
83
|
+
|
|
84
|
+
**For node setups (esbuild, Bun, etc.):**
|
|
85
|
+
- Adds `<%= javascript_include_tag "devise/webauthn" %>` to your application layout
|
|
86
|
+
|
|
87
|
+
If the automatic setup doesn't work for your configuration, you can manually include the JavaScript:
|
|
88
|
+
```erb
|
|
89
|
+
<%= javascript_include_tag "devise/webauthn" %>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### Behavior
|
|
93
|
+
|
|
94
|
+
When the form is submitted:
|
|
95
|
+
1. The default form submission is prevented
|
|
96
|
+
2. The browser's WebAuthn prompt is triggered with the provided options
|
|
97
|
+
3. Upon successful authentication, the credential response is stored in the hidden input
|
|
98
|
+
4. The form is submitted with the credential data
|
|
99
|
+
|
|
79
100
|
## How It Works
|
|
80
101
|
|
|
81
102
|
### Passkey authentication
|
|
@@ -138,6 +159,90 @@ To add a passkeys creation form:
|
|
|
138
159
|
<% end %>
|
|
139
160
|
```
|
|
140
161
|
|
|
162
|
+
### Handling unsupported WebAuthn
|
|
163
|
+
|
|
164
|
+
The custom elements check for WebAuthn API support when they connect to the DOM. If the browser doesn't support WebAuthn, a `webauthn:unsupported` event is dispatched and the form submission handler is not attached.
|
|
165
|
+
|
|
166
|
+
```javascript
|
|
167
|
+
document.addEventListener('webauthn:unsupported', (event) => {
|
|
168
|
+
const { action } = event.detail; // 'create' or 'get'
|
|
169
|
+
|
|
170
|
+
// Hide the WebAuthn form and show a message
|
|
171
|
+
hideWebauthnFormWithMessage('Your browser does not support WebAuthn');
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Customizing Javascript Error Handling
|
|
176
|
+
|
|
177
|
+
By default, WebAuthn errors during registration or authentication are displayed using the browser's `alert()` dialog. You can customize this behavior by listening to the `webauthn:prompt:error` event.
|
|
178
|
+
|
|
179
|
+
#### Listening for WebAuthn Errors
|
|
180
|
+
|
|
181
|
+
The custom elements dispatch a `webauthn:prompt:error` event whenever an error occurs during the WebAuthn prompt interaction (registration or authentication). You can listen for this event and provide custom error handling:
|
|
182
|
+
|
|
183
|
+
```javascript
|
|
184
|
+
document.addEventListener('webauthn:prompt:error', (event) => {
|
|
185
|
+
event.preventDefault(); // Prevent the default alert
|
|
186
|
+
|
|
187
|
+
const { error, action } = event.detail;
|
|
188
|
+
|
|
189
|
+
// Your custom error handling
|
|
190
|
+
console.error(`WebAuthn ${action} failed:`, error);
|
|
191
|
+
showFlashMessage(error.message, 'error');
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
#### Event Details
|
|
196
|
+
|
|
197
|
+
The event includes the following information in `event.detail`:
|
|
198
|
+
- `error`: The error object thrown during the WebAuthn operation
|
|
199
|
+
- `action`: Either `"create"` (for registration) or `"get"` (for authentication)
|
|
200
|
+
|
|
201
|
+
#### Handling Specific Error Types
|
|
202
|
+
|
|
203
|
+
WebAuthn operations can fail for various reasons. Here are some common error types you might want to handle:
|
|
204
|
+
|
|
205
|
+
```javascript
|
|
206
|
+
document.addEventListener('webauthn:prompt:error', (event) => {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
|
|
209
|
+
const { error, action } = event.detail;
|
|
210
|
+
|
|
211
|
+
switch (error.name) {
|
|
212
|
+
case 'NotAllowedError':
|
|
213
|
+
// User cancelled the operation or timeout
|
|
214
|
+
showFlashMessage('Operation cancelled or timed out', 'warning');
|
|
215
|
+
break;
|
|
216
|
+
|
|
217
|
+
default:
|
|
218
|
+
// Generic error message
|
|
219
|
+
showFlashMessage(`Authentication error: ${error.message}`, 'error');
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### Different Handling for Registration vs Authentication
|
|
225
|
+
|
|
226
|
+
You can provide different error handling based on whether the error occurred during registration or authentication:
|
|
227
|
+
|
|
228
|
+
```javascript
|
|
229
|
+
document.addEventListener('webauthn:prompt:error', (event) => {
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
|
|
232
|
+
const { error, action } = event.detail;
|
|
233
|
+
|
|
234
|
+
if (action === 'create') {
|
|
235
|
+
// Handle registration errors
|
|
236
|
+
handleRegistrationError(error);
|
|
237
|
+
} else if (action === 'get') {
|
|
238
|
+
// Handle authentication errors
|
|
239
|
+
handleAuthenticationError(error);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Note:** If you don't call `event.preventDefault()`, the default `alert()` will still be shown.
|
|
245
|
+
|
|
141
246
|
### Customizing Controllers
|
|
142
247
|
Similar to [controllers customization on Devise](https://github.com/heartcombo/devise?tab=readme-ov-file#configuring-controllers), you can customize the Devise::Webauthn controllers.
|
|
143
248
|
|
|
@@ -155,6 +260,54 @@ devise_for :users, controllers: {
|
|
|
155
260
|
|
|
156
261
|
3. Change or extend the generated controllers as needed.
|
|
157
262
|
|
|
263
|
+
### Manually implementing WebAuthn forms
|
|
264
|
+
|
|
265
|
+
The gem provides two custom HTML elements for WebAuthn operations. While the [form helpers](#helper-methods) handle this automatically, you can use these elements directly for custom implementations.
|
|
266
|
+
|
|
267
|
+
#### `<webauthn-create>`
|
|
268
|
+
|
|
269
|
+
Used for registering new credentials (passkeys or security keys).
|
|
270
|
+
|
|
271
|
+
```html
|
|
272
|
+
<form action="/passkeys" method="post">
|
|
273
|
+
<webauthn-create data-options-json="<%= create_passkey_options(@user).to_json %>">
|
|
274
|
+
<input type="hidden" name="public_key_credential" data-webauthn-target="response">
|
|
275
|
+
<input type="text" name="name" placeholder="Passkey name">
|
|
276
|
+
<button type="submit">Create Passkey</button>
|
|
277
|
+
</webauthn-create>
|
|
278
|
+
</form>
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**Requirements:**
|
|
282
|
+
- Must be wrapped in a `<form>` element
|
|
283
|
+
- The form's action should point to the appropriate endpoint – you can use the provided url helpers:
|
|
284
|
+
- For creating passkeys: `passkeys_path(resource_name)`
|
|
285
|
+
- For creating 2FA security keys: `second_factor_webauthn_credentials_path(resource_name)`
|
|
286
|
+
- Requires a `data-options-json` attribute containing JSON-serialized WebAuthn creation options
|
|
287
|
+
- Must contain a hidden input with `data-webauthn-target="response"` to store the credential response
|
|
288
|
+
- 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
|
|
289
|
+
|
|
290
|
+
#### `<webauthn-get>`
|
|
291
|
+
|
|
292
|
+
Used for authenticating with existing credentials.
|
|
293
|
+
|
|
294
|
+
```html
|
|
295
|
+
<form action="/users/sign_in" method="post">
|
|
296
|
+
<webauthn-get data-options-json="<%= passkey_authentication_options.to_json %>">
|
|
297
|
+
<input type="hidden" name="public_key_credential" data-webauthn-target="response">
|
|
298
|
+
<button type="submit">Sign in with Passkey</button>
|
|
299
|
+
</webauthn-get>
|
|
300
|
+
</form>
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**Requirements:**
|
|
304
|
+
- Must be wrapped in a `<form>` element
|
|
305
|
+
- The form's action should point to the appropriate endpoint – you can use the provided url helpers:
|
|
306
|
+
- For passkey sign-in: `session_path(resource_name)`
|
|
307
|
+
- For 2FA with WebAuthn: `two_factor_authentication_path(resource_name)`
|
|
308
|
+
- Requires a `data-options-json` attribute containing JSON-serialized WebAuthn request options
|
|
309
|
+
- Must contain a hidden input with `data-webauthn-target="response"` to store the credential response
|
|
310
|
+
- 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
|
|
158
311
|
|
|
159
312
|
## Development
|
|
160
313
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
function isWebAuthnSupported() {
|
|
2
|
+
return !!(
|
|
3
|
+
navigator.credentials &&
|
|
4
|
+
navigator.credentials.create &&
|
|
5
|
+
navigator.credentials.get &&
|
|
6
|
+
window.PublicKeyCredential
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class WebauthnCreateElement extends HTMLElement {
|
|
11
|
+
connectedCallback() {
|
|
12
|
+
this.style.display = 'contents';
|
|
13
|
+
|
|
14
|
+
if (!isWebAuthnSupported()) {
|
|
15
|
+
this.handleWebauthnUnsupported();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
this.closest('form').addEventListener('submit', async (event) => {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const options = JSON.parse(this.getAttribute('data-options-json'));
|
|
24
|
+
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options);
|
|
25
|
+
const credential = await navigator.credentials.create({ publicKey });
|
|
26
|
+
|
|
27
|
+
this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyRegistrationCredentialWithGracefullyHandlingAuthenticatorIssues(credential);
|
|
28
|
+
|
|
29
|
+
this.closest('form').submit();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
this.handleError(error);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
handleError(error) {
|
|
37
|
+
const event = new CustomEvent('webauthn:prompt:error', {
|
|
38
|
+
detail: { error, action: 'create' },
|
|
39
|
+
bubbles: true,
|
|
40
|
+
cancelable: true
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// If no listener prevents default, show alert
|
|
44
|
+
if (this.dispatchEvent(event)) {
|
|
45
|
+
alert(error.message || error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
handleWebauthnUnsupported() {
|
|
50
|
+
this.dispatchEvent(new CustomEvent('webauthn:unsupported', {
|
|
51
|
+
detail: { action: 'create' },
|
|
52
|
+
bubbles: true
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Stringifies registration credentials gracefully handling malformed ones (e.g., due to issues with
|
|
57
|
+
// certain authenticators like 1Password).
|
|
58
|
+
// It first tries to stringify them normally, and if the credential cannot be stringified (because its
|
|
59
|
+
// malformed), it attempts a workaround to convert the malformed credential into a valid format. This
|
|
60
|
+
// workaround was introduced for 1Password and might fail for other authenticators.
|
|
61
|
+
//
|
|
62
|
+
// Authenticators that return a proper credential should not affected by this workaround!
|
|
63
|
+
async stringifyRegistrationCredentialWithGracefullyHandlingAuthenticatorIssues(credential) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.stringify(credential);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.warn("Authenticator returned a malformed credential, attempting to fix it. Error was:", e);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const response = credential.response;
|
|
71
|
+
const publicKey = response.getPublicKey ? await response.getPublicKey() : null;
|
|
72
|
+
|
|
73
|
+
return JSON.stringify({
|
|
74
|
+
type: credential.type,
|
|
75
|
+
id: credential.id,
|
|
76
|
+
rawId: credential.id,
|
|
77
|
+
authenticatorAttachment: credential.authenticatorAttachment,
|
|
78
|
+
clientExtensionResults: await credential.getClientExtensionResults(),
|
|
79
|
+
response: {
|
|
80
|
+
attestationObject: toBase64Url(response.attestationObject),
|
|
81
|
+
authenticatorData: toBase64Url(response.authenticatorData),
|
|
82
|
+
clientDataJSON: toBase64Url(response.clientDataJSON),
|
|
83
|
+
publicKey: toBase64Url(publicKey),
|
|
84
|
+
publicKeyAlgorithm: response.getPublicKeyAlgorithm(),
|
|
85
|
+
transports: response.getTransports(),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class WebauthnGetElement extends HTMLElement {
|
|
92
|
+
connectedCallback() {
|
|
93
|
+
this.style.display = 'contents';
|
|
94
|
+
|
|
95
|
+
if (!isWebAuthnSupported()) {
|
|
96
|
+
this.handleWebauthnUnsupported();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.closest('form').addEventListener('submit', async (event) => {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const options = JSON.parse(this.getAttribute('data-options-json'));
|
|
105
|
+
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
|
|
106
|
+
const credential = await navigator.credentials.get({ publicKey });
|
|
107
|
+
|
|
108
|
+
this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyAuthenticationCredentialWithGracefullyHandlingAuthenticatorIssues(credential);
|
|
109
|
+
|
|
110
|
+
this.closest('form').submit();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.handleError(error);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
handleError(error) {
|
|
118
|
+
const event = new CustomEvent('webauthn:prompt:error', {
|
|
119
|
+
detail: { error, action: 'get' },
|
|
120
|
+
bubbles: true,
|
|
121
|
+
cancelable: true
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// If no listener prevents default, show alert
|
|
125
|
+
if (this.dispatchEvent(event)) {
|
|
126
|
+
alert(error.message || error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
handleWebauthnUnsupported() {
|
|
131
|
+
this.dispatchEvent(new CustomEvent('webauthn:unsupported', {
|
|
132
|
+
detail: { action: 'get' },
|
|
133
|
+
bubbles: true
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Stringifies authentication credentials gracefully handling malformed ones (e.g., due to issues with
|
|
138
|
+
// certain authenticators like 1Password).
|
|
139
|
+
// It first tries to stringify them normally, and if the credential cannot be stringified (because its
|
|
140
|
+
// malformed), it attempts a workaround to convert the malformed credential into a valid format. This
|
|
141
|
+
// workaround was introduced for 1Password and might fail for other authenticators.
|
|
142
|
+
//
|
|
143
|
+
// Authenticators that return a proper credential should not affected by this workaround!
|
|
144
|
+
async stringifyAuthenticationCredentialWithGracefullyHandlingAuthenticatorIssues(credential) {
|
|
145
|
+
try {
|
|
146
|
+
return JSON.stringify(credential);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.warn("Authenticator returned a malformed credential, attempting to fix it. Error was:", e);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const response = credential.response;
|
|
152
|
+
|
|
153
|
+
return JSON.stringify({
|
|
154
|
+
type: credential.type,
|
|
155
|
+
id: credential.id,
|
|
156
|
+
rawId: credential.id,
|
|
157
|
+
authenticatorAttachment: credential.authenticatorAttachment,
|
|
158
|
+
clientExtensionResults: await credential.getClientExtensionResults(),
|
|
159
|
+
response: {
|
|
160
|
+
authenticatorData: toBase64Url(response.authenticatorData),
|
|
161
|
+
clientDataJSON: toBase64Url(response.clientDataJSON),
|
|
162
|
+
signature: toBase64Url(response.signature),
|
|
163
|
+
userHandle: response.userHandle ? toBase64Url(response.userHandle) : null,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toBase64Url(buffer) {
|
|
170
|
+
if (!buffer) return null;
|
|
171
|
+
|
|
172
|
+
const binary = String.fromCharCode(...new Uint8Array(buffer));
|
|
173
|
+
const base64 = btoa(binary);
|
|
174
|
+
|
|
175
|
+
return base64.replaceAll("+", "-").replaceAll("/", "_");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
customElements.define('webauthn-create', WebauthnCreateElement);
|
|
179
|
+
customElements.define('webauthn-get', WebauthnGetElement);
|
|
@@ -14,14 +14,24 @@ module Devise
|
|
|
14
14
|
else
|
|
15
15
|
set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
|
|
16
16
|
end
|
|
17
|
-
redirect_to
|
|
17
|
+
redirect_to after_create_path
|
|
18
18
|
rescue WebAuthn::Error
|
|
19
19
|
set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
|
|
20
|
-
redirect_to
|
|
20
|
+
redirect_to after_create_path
|
|
21
21
|
ensure
|
|
22
22
|
session.delete(:webauthn_challenge)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def update
|
|
26
|
+
if resource.second_factor_webauthn_credentials.find(params[:id]).update(authentication_factor: 0)
|
|
27
|
+
set_flash_message! :notice, :security_key_promoted
|
|
28
|
+
else
|
|
29
|
+
set_flash_message! :alert, :security_key_promotion_failed, scope: :"devise.failure"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
redirect_to after_update_path
|
|
33
|
+
end
|
|
34
|
+
|
|
25
35
|
def destroy
|
|
26
36
|
if resource.second_factor_webauthn_credentials.destroy(params[:id])
|
|
27
37
|
set_flash_message! :notice, :security_key_deleted
|
|
@@ -29,7 +39,7 @@ module Devise
|
|
|
29
39
|
set_flash_message! :alert, :security_key_deletion_failed, scope: :"devise.failure"
|
|
30
40
|
end
|
|
31
41
|
|
|
32
|
-
redirect_to
|
|
42
|
+
redirect_to after_destroy_path
|
|
33
43
|
end
|
|
34
44
|
|
|
35
45
|
private
|
|
@@ -52,9 +62,21 @@ module Devise
|
|
|
52
62
|
)
|
|
53
63
|
end
|
|
54
64
|
|
|
65
|
+
# The default url to be used after creating a second factor key. You can overwrite
|
|
66
|
+
# this method in your own SecondFactorWebauthnCredentialsController.
|
|
67
|
+
def after_create_path
|
|
68
|
+
new_second_factor_webauthn_credential_path(resource_name)
|
|
69
|
+
end
|
|
70
|
+
|
|
55
71
|
# The default url to be used after creating a second factor key. You can overwrite
|
|
56
72
|
# this method in your own SecondFactorWebauthnCredentialsController.
|
|
57
73
|
def after_update_path
|
|
74
|
+
request.referer || new_second_factor_webauthn_credential_path(resource_name)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The default url to be used after deleting a second factor key. You can overwrite
|
|
78
|
+
# this method in your own SecondFactorWebauthnCredentialsController.
|
|
79
|
+
def after_destroy_path
|
|
58
80
|
new_second_factor_webauthn_credential_path(resource_name)
|
|
59
81
|
end
|
|
60
82
|
end
|
data/config/locales/en.yml
CHANGED
|
@@ -6,11 +6,13 @@ en:
|
|
|
6
6
|
second_factor_webauthn_credentials:
|
|
7
7
|
security_key_created: "Security Key created successfully."
|
|
8
8
|
security_key_deleted: "Security Key deleted successfully."
|
|
9
|
+
security_key_promoted: "Security Key promoted to passkey successfully."
|
|
9
10
|
failure:
|
|
10
11
|
passkey_not_found: "Your passkey doesn't exist or is not valid."
|
|
11
12
|
passkey_verification_failed: "Passkey verification failed."
|
|
12
13
|
passkey_deletion_failed: "Passkey deletion failed."
|
|
13
14
|
security_key_deletion_failed: "Security Key deletion failed."
|
|
15
|
+
security_key_promotion_failed: "Security Key promotion to passkey failed."
|
|
14
16
|
sign_in_not_initiated: "Sign in was not initiated."
|
|
15
17
|
two_factor_required: "Two-factor authentication is required to sign in."
|
|
16
18
|
webauthn_credential_not_found: "Your WebAuthn credential doesn't exist or is not valid."
|
data/devise-webauthn.gemspec
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal", "~> 2.5", require: false
|
|
6
|
+
gem "capybara", "~> 3.39"
|
|
7
|
+
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "devise", "~> 5.0.0.rc"
|
|
9
|
+
gem "importmap-rails", "~> 2.0"
|
|
10
|
+
gem "propshaft", "~> 1.2"
|
|
11
|
+
gem "pry-byebug", "~> 3.10"
|
|
12
|
+
gem "puma", "~> 6.6"
|
|
13
|
+
gem "rails", ">= 7.1"
|
|
14
|
+
gem "rspec-rails", ">= 7.1"
|
|
15
|
+
gem "rubocop", "~> 1.79"
|
|
16
|
+
gem "rubocop-rails", "~> 2.32"
|
|
17
|
+
gem "rubocop-rspec", "~> 3.6"
|
|
18
|
+
gem "selenium-webdriver"
|
|
19
|
+
gem "sqlite3", ">= 1.6", "!= 1.7.0", "!= 1.7.1", "!= 1.7.2", "!= 1.7.3"
|
|
20
|
+
gem "stimulus-rails", "~> 1.3"
|
|
21
|
+
|
|
22
|
+
install_if -> { RUBY_VERSION < "3.0" } do
|
|
23
|
+
gem "rack", "~> 2.2"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
gemspec path: "../"
|
data/gemfiles/rails_7_1.gemfile
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
|
-
gem "appraisal", "~> 2.5"
|
|
5
|
+
gem "appraisal", "~> 2.5", require: false
|
|
6
6
|
gem "capybara", "~> 3.39"
|
|
7
7
|
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "devise", "~> 4.9"
|
|
8
9
|
gem "importmap-rails", "~> 2.0"
|
|
9
10
|
gem "propshaft", "~> 1.2"
|
|
10
11
|
gem "pry-byebug", "~> 3.10"
|
|
11
12
|
gem "puma", "~> 6.6"
|
|
12
|
-
gem "rails", "~> 7.1"
|
|
13
|
+
gem "rails", "~> 7.1.x"
|
|
13
14
|
gem "rspec-rails", "~> 7.1"
|
|
14
15
|
gem "rubocop", "~> 1.79"
|
|
15
16
|
gem "rubocop-rails", "~> 2.32"
|
data/gemfiles/rails_7_2.gemfile
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
|
-
gem "appraisal", "~> 2.5"
|
|
5
|
+
gem "appraisal", "~> 2.5", require: false
|
|
6
6
|
gem "capybara", "~> 3.40"
|
|
7
7
|
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "devise", "~> 4.9"
|
|
8
9
|
gem "importmap-rails", "~> 2.2"
|
|
9
10
|
gem "propshaft", "~> 1.2"
|
|
10
11
|
gem "pry-byebug", "~> 3.11"
|
|
11
12
|
gem "puma", "~> 6.6"
|
|
12
|
-
gem "rails", "~> 7.2"
|
|
13
|
+
gem "rails", "~> 7.2.x"
|
|
13
14
|
gem "rspec-rails", "~> 8.0"
|
|
14
15
|
gem "rubocop", "~> 1.79"
|
|
15
16
|
gem "rubocop-rails", "~> 2.32"
|
data/gemfiles/rails_8_0.gemfile
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
|
-
gem "appraisal", "~> 2.5"
|
|
5
|
+
gem "appraisal", "~> 2.5", require: false
|
|
6
6
|
gem "capybara", "~> 3.40"
|
|
7
7
|
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "devise", "~> 4.9"
|
|
8
9
|
gem "importmap-rails", "~> 2.2"
|
|
9
10
|
gem "propshaft", "~> 1.2"
|
|
10
11
|
gem "pry-byebug", "~> 3.11"
|
|
11
12
|
gem "puma", "~> 6.6"
|
|
12
|
-
gem "rails", "~> 8.0"
|
|
13
|
+
gem "rails", "~> 8.0.x"
|
|
13
14
|
gem "rspec-rails", "~> 8.0"
|
|
14
15
|
gem "rubocop", "~> 1.79"
|
|
15
16
|
gem "rubocop-rails", "~> 2.32"
|
data/gemfiles/rails_8_1.gemfile
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
|
-
gem "appraisal", "~> 2.5"
|
|
5
|
+
gem "appraisal", "~> 2.5", require: false
|
|
6
6
|
gem "capybara", "~> 3.40"
|
|
7
7
|
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "devise", "~> 4.9"
|
|
8
9
|
gem "importmap-rails", "~> 2.2"
|
|
9
10
|
gem "propshaft", "~> 1.2"
|
|
10
11
|
gem "pry-byebug", "~> 3.11"
|
|
11
12
|
gem "puma", "~> 6.6"
|
|
12
|
-
gem "rails", "~> 8.1"
|
|
13
|
+
gem "rails", "~> 8.1.x"
|
|
13
14
|
gem "rspec-rails", "~> 8.0"
|
|
14
15
|
gem "rubocop", "~> 1.79"
|
|
15
16
|
gem "rubocop-rails", "~> 2.32"
|
data/gemfiles/rails_edge.gemfile
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
|
-
gem "appraisal", "~> 2.5"
|
|
5
|
+
gem "appraisal", "~> 2.5", require: false
|
|
6
6
|
gem "capybara", "~> 3.40"
|
|
7
7
|
gem "combustion", "~> 1.3"
|
|
8
|
+
gem "devise", "~> 4.9"
|
|
8
9
|
gem "importmap-rails", "~> 2.2"
|
|
9
10
|
gem "propshaft", "~> 1.2"
|
|
10
11
|
gem "pry-byebug", "~> 3.11"
|
|
@@ -13,6 +13,7 @@ module Devise
|
|
|
13
13
|
if validate(resource){ hashed = true; resource.valid_password?(password) }
|
|
14
14
|
if second_factor_enabled?(resource)
|
|
15
15
|
session[:current_authentication_resource_id] = resource.id
|
|
16
|
+
session[:current_authentication_remember_me] = remember_me?
|
|
16
17
|
request.flash[:notice] = two_factor_required_message
|
|
17
18
|
request.commit_flash
|
|
18
19
|
redirect!(two_factor_authentication_path, {}, message: two_factor_required_message)
|
|
@@ -4,26 +4,32 @@ module Devise
|
|
|
4
4
|
module Strategies
|
|
5
5
|
class WebauthnTwoFactorAuthenticatable < Devise::Strategies::Base
|
|
6
6
|
def valid?
|
|
7
|
-
credential_param.present? &&
|
|
7
|
+
credential_param.present? &&
|
|
8
|
+
session[:current_authentication_resource_id].present? &&
|
|
9
|
+
session[:two_factor_authentication_challenge].present?
|
|
8
10
|
end
|
|
9
11
|
|
|
12
|
+
# rubocop:disable Metrics/AbcSize
|
|
10
13
|
def authenticate!
|
|
11
14
|
credential_from_params = WebAuthn::Credential.from_get(JSON.parse(credential_param))
|
|
12
|
-
|
|
15
|
+
resource = resource_class.find_by(id: session[:current_authentication_resource_id])
|
|
16
|
+
stored_credential = resource&.webauthn_credentials&.find_by(external_id: credential_from_params.id)
|
|
13
17
|
|
|
14
18
|
return fail!(:webauthn_credential_not_found) if stored_credential.blank?
|
|
15
19
|
|
|
16
20
|
verify_credential(credential_from_params, stored_credential)
|
|
17
21
|
|
|
18
|
-
resource =
|
|
22
|
+
resource.remember_me = session[:current_authentication_remember_me] if resource.respond_to?(:remember_me=)
|
|
19
23
|
success!(resource)
|
|
20
24
|
|
|
21
25
|
session.delete(:current_authentication_resource_id)
|
|
26
|
+
session.delete(:current_authentication_remember_me)
|
|
22
27
|
rescue WebAuthn::Error
|
|
23
28
|
fail!(:webauthn_credential_verification_failed)
|
|
24
29
|
ensure
|
|
25
30
|
session.delete(:two_factor_authentication_challenge)
|
|
26
31
|
end
|
|
32
|
+
# rubocop:enable Metrics/AbcSize
|
|
27
33
|
|
|
28
34
|
private
|
|
29
35
|
|
|
@@ -41,8 +47,8 @@ module Devise
|
|
|
41
47
|
stored_credential.update!(sign_count: credential_from_params.sign_count)
|
|
42
48
|
end
|
|
43
49
|
|
|
44
|
-
def
|
|
45
|
-
mapping.to
|
|
50
|
+
def resource_class
|
|
51
|
+
mapping.to
|
|
46
52
|
end
|
|
47
53
|
end
|
|
48
54
|
end
|
|
@@ -34,6 +34,12 @@ module Devise
|
|
|
34
34
|
initializer "devise.webauthn.url_helpers" do
|
|
35
35
|
Devise.include_helpers(Devise::Webauthn)
|
|
36
36
|
end
|
|
37
|
+
|
|
38
|
+
initializer "devise.webauthn.assets" do
|
|
39
|
+
if ::Rails.application.config.respond_to?(:assets)
|
|
40
|
+
::Rails.application.config.assets.precompile += %w[devise/webauthn.js]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
37
43
|
end
|
|
38
44
|
end
|
|
39
45
|
end
|
|
@@ -8,16 +8,12 @@ module Devise
|
|
|
8
8
|
form_with(
|
|
9
9
|
url: passkeys_path(resource),
|
|
10
10
|
method: :post,
|
|
11
|
-
class: form_classes
|
|
12
|
-
data: {
|
|
13
|
-
action: "webauthn-credentials#create:prevent",
|
|
14
|
-
controller: "webauthn-credentials",
|
|
15
|
-
webauthn_credentials_options_param: create_passkey_options(resource)
|
|
16
|
-
}
|
|
11
|
+
class: form_classes
|
|
17
12
|
) do |f|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
tag.webauthn_create(data: { options_json: create_passkey_options(resource) }) do
|
|
14
|
+
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
|
|
15
|
+
concat capture(f, &block)
|
|
16
|
+
end
|
|
21
17
|
end
|
|
22
18
|
end
|
|
23
19
|
|
|
@@ -25,16 +21,13 @@ module Devise
|
|
|
25
21
|
form_with(
|
|
26
22
|
url: session_path,
|
|
27
23
|
method: :post,
|
|
28
|
-
data: {
|
|
29
|
-
action: "webauthn-credentials#get:prevent",
|
|
30
|
-
controller: "webauthn-credentials",
|
|
31
|
-
webauthn_credentials_options_param: passkey_authentication_options
|
|
32
|
-
},
|
|
33
24
|
class: form_classes
|
|
34
25
|
) do |f|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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)
|
|
30
|
+
end
|
|
38
31
|
end
|
|
39
32
|
end
|
|
40
33
|
|
|
@@ -42,16 +35,12 @@ module Devise
|
|
|
42
35
|
form_with(
|
|
43
36
|
url: second_factor_webauthn_credentials_path(resource),
|
|
44
37
|
method: :post,
|
|
45
|
-
class: form_classes
|
|
46
|
-
data: {
|
|
47
|
-
action: "webauthn-credentials#create:prevent",
|
|
48
|
-
controller: "webauthn-credentials",
|
|
49
|
-
webauthn_credentials_options_param: create_security_key_options(resource)
|
|
50
|
-
}
|
|
38
|
+
class: form_classes
|
|
51
39
|
) do |f|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
40
|
+
tag.webauthn_create(data: { options_json: create_security_key_options(resource) }) do
|
|
41
|
+
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
|
|
42
|
+
concat capture(f, &block)
|
|
43
|
+
end
|
|
55
44
|
end
|
|
56
45
|
end
|
|
57
46
|
|
|
@@ -59,21 +48,15 @@ module Devise
|
|
|
59
48
|
form_with(
|
|
60
49
|
url: two_factor_authentication_path(resource),
|
|
61
50
|
method: :post,
|
|
62
|
-
data: {
|
|
63
|
-
action: "webauthn-credentials#get:prevent",
|
|
64
|
-
controller: "webauthn-credentials",
|
|
65
|
-
webauthn_credentials_options_param: security_key_authentication_options(resource)
|
|
66
|
-
},
|
|
67
51
|
class: form_classes
|
|
68
52
|
) do |f|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
53
|
+
tag.webauthn_get(data: { options_json: security_key_authentication_options(resource) }) do
|
|
54
|
+
concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" })
|
|
55
|
+
concat f.button(text, type: "submit", class: button_classes, &block)
|
|
56
|
+
end
|
|
72
57
|
end
|
|
73
58
|
end
|
|
74
59
|
|
|
75
|
-
private
|
|
76
|
-
|
|
77
60
|
def create_passkey_options(resource)
|
|
78
61
|
@create_passkey_options ||= begin
|
|
79
62
|
options = WebAuthn::Credential.options_for_create(
|
|
@@ -143,6 +126,8 @@ module Devise
|
|
|
143
126
|
end
|
|
144
127
|
end
|
|
145
128
|
|
|
129
|
+
private
|
|
130
|
+
|
|
146
131
|
def resource_human_palatable_identifier
|
|
147
132
|
authentication_keys = resource.class.authentication_keys
|
|
148
133
|
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
|
|
@@ -15,7 +15,7 @@ module ActionDispatch
|
|
|
15
15
|
controller: controllers[:two_factor_authentications]
|
|
16
16
|
|
|
17
17
|
resources :second_factor_webauthn_credentials,
|
|
18
|
-
only: %i[new create destroy],
|
|
18
|
+
only: %i[new create update destroy],
|
|
19
19
|
controller: controllers[:second_factor_webauthn_credentials]
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -12,17 +12,17 @@ module Devise
|
|
|
12
12
|
page.driver.browser.add_virtual_authenticator(options)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def add_passkey_to_authenticator(authenticator, resource)
|
|
16
|
-
add_credential_to_authenticator(authenticator, resource, passkey: true)
|
|
15
|
+
def add_passkey_to_authenticator(authenticator, resource, name: "My Passkey")
|
|
16
|
+
add_credential_to_authenticator(authenticator, resource, passkey: true, name: name)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def add_security_key_to_authenticator(authenticator, resource)
|
|
20
|
-
add_credential_to_authenticator(authenticator, resource, passkey: false)
|
|
19
|
+
def add_security_key_to_authenticator(authenticator, resource, name: "My Security Key")
|
|
20
|
+
add_credential_to_authenticator(authenticator, resource, passkey: false, name: name)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# rubocop:disable Metrics/AbcSize
|
|
24
24
|
# rubocop:disable Metrics/MethodLength
|
|
25
|
-
def add_credential_to_authenticator(authenticator, resource, passkey:)
|
|
25
|
+
def add_credential_to_authenticator(authenticator, resource, passkey:, name: "My Credential")
|
|
26
26
|
credential_id = SecureRandom.random_bytes(16)
|
|
27
27
|
encoded_credential_id = Base64.urlsafe_encode64(credential_id)
|
|
28
28
|
key = OpenSSL::PKey.generate_key("ED25519")
|
|
@@ -44,7 +44,7 @@ module Devise
|
|
|
44
44
|
authenticator.add_credential(credential_json)
|
|
45
45
|
|
|
46
46
|
resource.webauthn_credentials.create!(
|
|
47
|
-
name:
|
|
47
|
+
name: name,
|
|
48
48
|
external_id: Base64.urlsafe_encode64(credential_id, padding: false),
|
|
49
49
|
public_key: encoded_cose_public_key,
|
|
50
50
|
sign_count: 0,
|
|
@@ -26,8 +26,8 @@ module Devise
|
|
|
26
26
|
invoke "devise:webauthn:webauthn_id", [], resource_name: options[:resource_name]
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def
|
|
30
|
-
invoke "devise:webauthn:
|
|
29
|
+
def generate_javascript_configuration
|
|
30
|
+
invoke "devise:webauthn:javascript"
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def final_message
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Devise
|
|
6
|
+
module Webauthn
|
|
7
|
+
class JavascriptConfigurationGenerator < Rails::Generators::Base
|
|
8
|
+
hide!
|
|
9
|
+
namespace "devise:webauthn:javascript"
|
|
10
|
+
|
|
11
|
+
desc "Configure JavaScript loading for devise-webauthn"
|
|
12
|
+
|
|
13
|
+
def configure_javascript
|
|
14
|
+
if importmap?
|
|
15
|
+
setup_importmap
|
|
16
|
+
elsif using_node?
|
|
17
|
+
setup_node
|
|
18
|
+
else
|
|
19
|
+
say "Could not detect JavaScript setup. Please manually configure `devise/webauthn.js` loading.", :red
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def importmap?
|
|
26
|
+
File.exist?(File.join(destination_root, "config/importmap.rb"))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def using_node?
|
|
30
|
+
File.exist?(File.join(destination_root, "package.json"))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def setup_importmap
|
|
34
|
+
say "Detected importmap-rails setup", :green
|
|
35
|
+
|
|
36
|
+
append_to_file "config/importmap.rb", %(pin "devise/webauthn", to: "devise/webauthn.js"\n)
|
|
37
|
+
say "Added pin to config/importmap.rb", :green
|
|
38
|
+
|
|
39
|
+
if File.exist?(File.join(destination_root, "app/javascript/application.js"))
|
|
40
|
+
append_to_file "app/javascript/application.js", %(import "devise/webauthn"\n)
|
|
41
|
+
say "Added import to app/javascript/application.js", :green
|
|
42
|
+
else
|
|
43
|
+
say "Could not find app/javascript/application.js!", :red
|
|
44
|
+
say " Please add `import \"devise/webauthn\"` to your application.js file manually."
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def setup_node
|
|
49
|
+
say "Detected JavaScript bundler setup (Bun/Node)", :green
|
|
50
|
+
|
|
51
|
+
if File.exist?(File.join(destination_root, "app/views/layouts/application.html.erb"))
|
|
52
|
+
inject_into_file "app/views/layouts/application.html.erb",
|
|
53
|
+
%(\n <%= javascript_include_tag "devise/webauthn" %>),
|
|
54
|
+
before: "</head>"
|
|
55
|
+
say "Added javascript_include_tag to app/views/layouts/application.html.erb", :green
|
|
56
|
+
else
|
|
57
|
+
say "Could not find app/views/layouts/application.html.erb.", :red
|
|
58
|
+
say " Please add `<%= javascript_include_tag \"devise/webauthn\" %>` " \
|
|
59
|
+
"within the <head> tag in your custom layout."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -11,6 +11,11 @@ class <%= @scope_prefix %>SecondFactorWebauthnCredentialsController < Devise::Se
|
|
|
11
11
|
# super
|
|
12
12
|
# end
|
|
13
13
|
|
|
14
|
+
# PUT /resource/second_factor_webauthn_credentials/:id
|
|
15
|
+
# def update
|
|
16
|
+
# super
|
|
17
|
+
# end
|
|
18
|
+
|
|
14
19
|
# DELETE /resource/second_factor_webauthn_credentials/:id
|
|
15
20
|
# def destroy
|
|
16
21
|
# super
|
|
@@ -23,9 +28,21 @@ class <%= @scope_prefix %>SecondFactorWebauthnCredentialsController < Devise::Se
|
|
|
23
28
|
# super
|
|
24
29
|
# end
|
|
25
30
|
|
|
31
|
+
# The default url to be used after creating a second factor key. You can overwrite
|
|
32
|
+
# this method in your own SecondFactorWebauthnCredentialsController.
|
|
33
|
+
# def after_create_path
|
|
34
|
+
# super
|
|
35
|
+
# end
|
|
36
|
+
|
|
26
37
|
# The default url to be used after creating a second factor key. You can overwrite
|
|
27
38
|
# this method in your own SecondFactorWebauthnCredentialsController.
|
|
28
39
|
# def after_update_path
|
|
29
40
|
# super
|
|
30
41
|
# end
|
|
42
|
+
|
|
43
|
+
# The default url to be used after deleting a second factor key. You can overwrite
|
|
44
|
+
# this method in your own SecondFactorWebauthnCredentialsController.
|
|
45
|
+
# def after_destroy_path
|
|
46
|
+
# super
|
|
47
|
+
# end
|
|
31
48
|
end
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cedarcode
|
|
@@ -13,14 +13,14 @@ dependencies:
|
|
|
13
13
|
name: devise
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '4.9'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
|
-
- - "
|
|
23
|
+
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '4.9'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
@@ -58,6 +58,7 @@ files:
|
|
|
58
58
|
- LICENSE.txt
|
|
59
59
|
- README.md
|
|
60
60
|
- Rakefile
|
|
61
|
+
- app/assets/javascript/devise/webauthn.js
|
|
61
62
|
- app/controllers/devise/passkeys_controller.rb
|
|
62
63
|
- app/controllers/devise/second_factor_webauthn_credentials_controller.rb
|
|
63
64
|
- app/controllers/devise/two_factor_authentications_controller.rb
|
|
@@ -71,6 +72,7 @@ files:
|
|
|
71
72
|
- config.ru
|
|
72
73
|
- config/locales/en.yml
|
|
73
74
|
- devise-webauthn.gemspec
|
|
75
|
+
- gemfiles/devise_5_0.gemfile
|
|
74
76
|
- gemfiles/rails_7_1.gemfile
|
|
75
77
|
- gemfiles/rails_7_2.gemfile
|
|
76
78
|
- gemfiles/rails_8_0.gemfile
|
|
@@ -92,8 +94,7 @@ files:
|
|
|
92
94
|
- lib/generators/devise/webauthn/controllers_generator.rb
|
|
93
95
|
- lib/generators/devise/webauthn/install/install_generator.rb
|
|
94
96
|
- lib/generators/devise/webauthn/install/templates/webauthn.rb
|
|
95
|
-
- lib/generators/devise/webauthn/
|
|
96
|
-
- lib/generators/devise/webauthn/stimulus/templates/webauthn_credentials_controller.js
|
|
97
|
+
- lib/generators/devise/webauthn/javascript/javascript_configuration_generator.rb
|
|
97
98
|
- lib/generators/devise/webauthn/templates/controllers/README
|
|
98
99
|
- lib/generators/devise/webauthn/templates/controllers/passkeys_controller.rb.tt
|
|
99
100
|
- lib/generators/devise/webauthn/templates/controllers/second_factor_webauthn_credentials_controller.rb.tt
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rails/generators"
|
|
4
|
-
|
|
5
|
-
module Devise
|
|
6
|
-
module Webauthn
|
|
7
|
-
class StimulusGenerator < Rails::Generators::Base
|
|
8
|
-
hide!
|
|
9
|
-
source_root File.expand_path("templates", __dir__)
|
|
10
|
-
|
|
11
|
-
desc "Copy DeviseWebauthn Stimulus controller to your application"
|
|
12
|
-
|
|
13
|
-
def copy_stimulus_controller
|
|
14
|
-
copy_file "webauthn_credentials_controller.js",
|
|
15
|
-
"app/javascript/controllers/webauthn_credentials_controller.js"
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def show_instructions
|
|
19
|
-
say "✓ Stimulus controller setup complete!", :green
|
|
20
|
-
say "The webauthn_credentials controller has been installed and will be automatically registered by Stimulus."
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { Controller } from "@hotwired/stimulus"
|
|
2
|
-
|
|
3
|
-
export default class extends Controller {
|
|
4
|
-
static targets = ["credentialHiddenInput"]
|
|
5
|
-
|
|
6
|
-
async create({ params: { options } }) {
|
|
7
|
-
try {
|
|
8
|
-
const credentialOptions = PublicKeyCredential.parseCreationOptionsFromJSON(options);
|
|
9
|
-
const credential = await navigator.credentials.create({ publicKey: credentialOptions });
|
|
10
|
-
|
|
11
|
-
this.credentialHiddenInputTarget.value = JSON.stringify(credential);
|
|
12
|
-
|
|
13
|
-
this.element.submit();
|
|
14
|
-
} catch (error) {
|
|
15
|
-
alert(error.message || error);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async get({ params: { options } }) {
|
|
20
|
-
try {
|
|
21
|
-
const credentialOptions = PublicKeyCredential.parseRequestOptionsFromJSON(options);
|
|
22
|
-
const credential = await navigator.credentials.get({ publicKey: credentialOptions });
|
|
23
|
-
|
|
24
|
-
this.credentialHiddenInputTarget.value = JSON.stringify(credential);
|
|
25
|
-
|
|
26
|
-
this.element.submit();
|
|
27
|
-
} catch (error) {
|
|
28
|
-
alert(error.message || error);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|