devise-webauthn 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8272be7a84a98c8568d2149ef3e0e73dbabae206fa2947ebbcf44cdd5a427126
4
- data.tar.gz: 3597cedab2a236c6c897fcf49e29145c7d924943b3e3128caf6dcf644461e31e
3
+ metadata.gz: 8083a823cce7edf858475a062ea009193c3a86cf9b4a821db1dd2715430580d4
4
+ data.tar.gz: df00de569113a642c13f87cc4f6c93836acb5c5df1ef1cecfe492b01247a17aa
5
5
  SHA512:
6
- metadata.gz: '0564994b5a7091635d5927618339b23d15e538eb5c8a1c8f3ac2f8261013cd06b3c203bbbb8c4b5662161d96866cfdad4d87adcfbebfaf9155d96948df1ca50e'
7
- data.tar.gz: 8ba40676150aa81956b1a78b5216e05947b883bf044c39e22012c21033a86daaa85d5fa2b95fadc697aaa3760a08f6dd5b19c4d82ee445989d5d9748ac4b7cfa
6
+ metadata.gz: ce764390d121fc68bd1732d655700ccae382ced384a24f350256f2bb633910d0038972637a8e9f4f5e512d0e13030c53851f7288733fb233e8d86fe00ab5c8b8
7
+ data.tar.gz: d2072d245253437be40005c81b9879f22f0a7c0fa3e0e3cd39b9b03f812aa05cdcd7404cbd4123afac473785364472f3e025dbdf025ae3f74af117854dcbca38
@@ -0,0 +1 @@
1
+ github: [cedarcode]
@@ -0,0 +1,28 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ release:
9
+ name: Publish to Rubygems
10
+
11
+ runs-on: ubuntu-latest
12
+
13
+ environment: release
14
+
15
+ permissions:
16
+ contents: write
17
+ id-token: write
18
+
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+
22
+ - name: Setup Ruby
23
+ uses: ruby/setup-ruby@v1
24
+ with:
25
+ bundler-cache: true
26
+
27
+ - name: Publish to RubyGems
28
+ uses: rubygems/release-gem@v1
@@ -32,6 +32,7 @@ jobs:
32
32
  - 2.7
33
33
  gemfile:
34
34
  - rails_edge
35
+ - rails_8_1
35
36
  - rails_8_0
36
37
  - rails_7_2
37
38
  - rails_7_1
@@ -39,11 +40,15 @@ jobs:
39
40
  exclude:
40
41
  - ruby: 3.1
41
42
  gemfile: rails_edge
43
+ - ruby: 3.1
44
+ gemfile: rails_8_1
42
45
  - ruby: 3.1
43
46
  gemfile: rails_8_0
44
47
 
45
48
  - ruby: 3.0
46
49
  gemfile: rails_edge
50
+ - ruby: 3.0
51
+ gemfile: rails_8_1
47
52
  - ruby: 3.0
48
53
  gemfile: rails_8_0
49
54
  - ruby: 3.0
@@ -51,6 +56,8 @@ jobs:
51
56
 
52
57
  - ruby: 2.7
53
58
  gemfile: rails_edge
59
+ - ruby: 2.7
60
+ gemfile: rails_8_1
54
61
  - ruby: 2.7
55
62
  gemfile: rails_8_0
56
63
  - ruby: 2.7
data/.rubocop.yml CHANGED
@@ -9,6 +9,7 @@ AllCops:
9
9
  - "spec/internal/**/*"
10
10
  - "vendor/**/*"
11
11
  - "gemfiles/**/*"
12
+ - "lib/devise/strategies/database_authenticatable.rb"
12
13
 
13
14
  Style/Documentation:
14
15
  Enabled: false
data/Appraisals CHANGED
@@ -4,6 +4,10 @@ appraise "rails-edge" do
4
4
  gem "rails", github: "rails/rails", branch: "main"
5
5
  end
6
6
 
7
+ appraise "rails-8_1" do
8
+ gem "rails", "~> 8.1"
9
+ end
10
+
7
11
  appraise "rails-8_0" do
8
12
  gem "rails", "~> 8.0"
9
13
  end
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## [v0.2.1](https://github.com/cedarcode/devise-webauthn/compare/v0.2.0...v0.2.1/) - 2025-12-10
6
+
7
+ - Add form helpers for security key registration and 2FA authentication.
8
+ - Fix incorrect call to `resource_name` instead of using passed `resource` param in `login_with_security_key_button` helper.
9
+ - Fix `NoMethodError` when calling `second_factor_enabled?` on resources without 2FA.
10
+ - Avoid assuming `email` as the authentication key of the resource in form helpers.
11
+
12
+ ## [v0.2.0](https://github.com/cedarcode/devise-webauthn/compare/v0.1.2...v0.2.0/) - 2025-12-03
13
+
14
+ - Add new `webauthn_two_factor_authenticatable` module for enabling 2FA using WebAuthn credentials.
15
+
3
16
  ## [v0.1.2](https://github.com/cedarcode/devise-webauthn/compare/v0.1.1...v0.1.2/) - 2025-12-03
4
17
 
5
18
  ### Fixed
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- devise-webauthn (0.1.2)
4
+ devise-webauthn (0.2.1)
5
5
  devise (~> 4.9)
6
6
  webauthn (~> 3.0)
7
7
 
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Devise::Webauthn
2
2
  [![Gem Version](https://badge.fury.io/rb/devise-webauthn.svg)](https://badge.fury.io/rb/devise-webauthn)
3
3
 
4
- Devise::Webauthn is a [Devise](https://github.com/heartcombo/devise) extension that adds [WebAuthn](https://www.w3.org/TR/2025/WD-webauthn-3-20250127/) support to your Rails application, allowing users to authenticate with [passkeys](https://www.w3.org/TR/2025/WD-webauthn-3-20250127/#passkey).
4
+ Devise::Webauthn is a [Devise](https://github.com/heartcombo/devise) extension that adds [WebAuthn](https://www.w3.org/TR/2025/WD-webauthn-3-20250127/) support to your Rails application, allowing users to authenticate with [passkeys](https://www.w3.org/TR/2025/WD-webauthn-3-20250127/#passkey) and use [security keys](https://www.w3.org/TR/webauthn-3/#server-side-credential) for two factor authentication.
5
5
 
6
6
  ## Requirements
7
7
 
@@ -54,11 +54,12 @@ Then, follow these steps to integrate Devise::Webauthn:
54
54
  ```
55
55
 
56
56
  3. **Update Your Devise Model:**
57
- Add `:passkey_authenticatable` to your Devise model (e.g., `User`):
57
+ Add `:passkey_authenticatable` to your Devise model (e.g., `User`) for passkeys authentication and `:webauthn_two_factor_authenticatable` for WebAuthn-based 2FA if desired. For example:
58
58
  ```ruby
59
59
  class User < ApplicationRecord
60
60
  devise :database_authenticatable, :registerable,
61
- :recoverable, :rememberable, :validatable, :passkey_authenticatable
61
+ :recoverable, :rememberable, :validatable,
62
+ :passkey_authenticatable, :webauthn_two_factor_authenticatable
62
63
  end
63
64
  ```
64
65
 
@@ -77,10 +78,12 @@ Then, follow these steps to integrate Devise::Webauthn:
77
78
 
78
79
  ## How It Works
79
80
 
80
- ### Adding Passkeys
81
+ ### Passkey authentication
82
+
83
+ #### Adding Passkeys
81
84
  Signed-in users can add passkeys by visiting `/users/passkeys/new`.
82
85
 
83
- ### Sign In with Passkeys
86
+ #### Sign In with Passkeys
84
87
  When a user visits `/users/sign_in` they can choose to authenticate using a passkey. The authentication flow is handled by `PasskeysAuthenticatable` strategy.
85
88
 
86
89
  The WebAuthn passkey sign-in flow works as follows:
@@ -89,6 +92,22 @@ The WebAuthn passkey sign-in flow works as follows:
89
92
  3. User selects a passkey and verifies with their [authenticator](https://www.w3.org/TR/webauthn-3/#webauthn-authenticator).
90
93
  4. The server verifies the response and signs in the user.
91
94
 
95
+ ### Two-Factor Authentication (2FA) with WebAuthn
96
+
97
+ #### Adding Security Keys for 2FA
98
+ Signed-in users can add security keys by visiting `/users/second_factor_webauthn_credentials/new`.
99
+
100
+ #### 2FA Sign In with Security Keys
101
+ When a user that has 2FA enabled (i.e., has registered passkeys or security keys) visits `/users/sign_in`, after entering their primary credentials (e.g., email and password), they will be prompted to complete the second factor authentication using WebAuthn. The authentication flow is handled by `WebauthnTwoFactorAuthenticatable` strategy.
102
+
103
+ The two factor authentication flow with WebAuthn works as follows:
104
+ 1. User enters their primary credentials (e.g., email and password) and submits the form.
105
+ 2. If the user has 2FA enabled, they are redirected to a second factor authentication page.
106
+ 3. User clicks "Use security key", starting a WebAuthn authentication ceremony.
107
+ 4. Browser shows available credentials (which can be both passkeys and security keys).
108
+ 5. User selects a credential and verifies with their [authenticator](https://www.w3.org/TR/webauthn-3/#webauthn-authenticator).
109
+ 6. The server verifies the response and signs in the user.
110
+
92
111
  ## Customization
93
112
 
94
113
  ### Customizing Views
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ class SecondFactorWebauthnCredentialsController < DeviseController
5
+ before_action :authenticate_scope!
6
+
7
+ def new; end
8
+
9
+ def create
10
+ security_key_from_params = WebAuthn::Credential.from_create(JSON.parse(params[:public_key_credential]))
11
+
12
+ if verify_and_save_security_key(security_key_from_params)
13
+ set_flash_message! :notice, :security_key_created
14
+ else
15
+ set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
16
+ end
17
+ redirect_to after_update_path
18
+ rescue WebAuthn::Error
19
+ set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
20
+ redirect_to after_update_path
21
+ ensure
22
+ session.delete(:webauthn_challenge)
23
+ end
24
+
25
+ def destroy
26
+ resource.second_factor_webauthn_credentials.destroy(params[:id])
27
+ redirect_to after_update_path
28
+ end
29
+
30
+ private
31
+
32
+ def authenticate_scope!
33
+ send(:"authenticate_#{resource_name}!", force: true)
34
+ self.resource = send(:"current_#{resource_name}")
35
+ end
36
+
37
+ def verify_and_save_security_key(security_key_from_params)
38
+ security_key_from_params.verify(
39
+ session[:webauthn_challenge]
40
+ )
41
+
42
+ resource.second_factor_webauthn_credentials.create(
43
+ external_id: security_key_from_params.id,
44
+ name: params[:name],
45
+ public_key: security_key_from_params.public_key,
46
+ sign_count: security_key_from_params.sign_count
47
+ )
48
+ end
49
+
50
+ # The default url to be used after creating a second factor key. You can overwrite
51
+ # this method in your own SecondFactorWebauthnCredentialsController.
52
+ def after_update_path
53
+ new_second_factor_webauthn_credential_path(resource_name)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ class TwoFactorAuthenticationsController < DeviseController
5
+ prepend_before_action :set_resource, only: :new
6
+ prepend_before_action :ensure_sign_in_initiated
7
+ prepend_before_action :require_no_authentication
8
+
9
+ def new; end
10
+
11
+ def create
12
+ self.resource = warden.authenticate!(auth_options)
13
+ set_flash_message! :notice, :signed_in, scope: :"devise.sessions"
14
+ sign_in(resource_name, resource)
15
+ yield resource if block_given?
16
+ respond_with resource, location: after_sign_in_path_for(resource)
17
+ end
18
+
19
+ protected
20
+
21
+ def auth_options
22
+ { scope: resource_name, recall: "#{controller_path}#new", locale: I18n.locale }
23
+ end
24
+
25
+ private
26
+
27
+ def ensure_sign_in_initiated
28
+ return if session[:current_authentication_resource_id].present?
29
+
30
+ set_flash_message! :alert, :sign_in_not_initiated, scope: :"devise.failure"
31
+ redirect_to new_session_path(resource_name)
32
+ end
33
+
34
+ def set_resource
35
+ @resource = resource_class.find(session[:current_authentication_resource_id])
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ <%= security_key_creation_form_for(resource) do |form| %>
2
+ <%= form.label :name, 'Security Key name' %>
3
+ <%= form.text_field :name, required: true %>
4
+ <%= form.submit 'Create Security Key' %>
5
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= login_with_security_key_button('Use security key', resource: @resource) %>
@@ -2,7 +2,12 @@ en:
2
2
  devise:
3
3
  passkeys:
4
4
  passkey_created: "Passkey created successfully."
5
- passkey_creation_failed: "Passkey creation failed."
5
+ second_factor_webauthn_credentials:
6
+ security_key_created: "Security Key created successfully."
6
7
  failure:
7
8
  passkey_not_found: "Your passkey doesn't exist or is not valid."
8
9
  passkey_verification_failed: "Passkey verification failed."
10
+ sign_in_not_initiated: "Sign in was not initiated."
11
+ two_factor_required: "Two-factor authentication is required to sign in."
12
+ webauthn_credential_not_found: "Your WebAuthn credential doesn't exist or is not valid."
13
+ webauthn_credential_verification_failed: "Webauthn credential verification failed."
@@ -0,0 +1,21 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal", "~> 2.5"
6
+ gem "capybara", "~> 3.40"
7
+ gem "combustion", "~> 1.3"
8
+ gem "importmap-rails", "~> 2.2"
9
+ gem "propshaft", "~> 1.2"
10
+ gem "pry-byebug", "~> 3.11"
11
+ gem "puma", "~> 6.6"
12
+ gem "rails", "~> 8.1"
13
+ gem "rspec-rails", "~> 8.0"
14
+ gem "rubocop", "~> 1.79"
15
+ gem "rubocop-rails", "~> 2.32"
16
+ gem "rubocop-rspec", "~> 3.6"
17
+ gem "selenium-webdriver"
18
+ gem "sqlite3", "~> 2.7"
19
+ gem "stimulus-rails", "~> 1.3"
20
+
21
+ gemspec path: "../"
@@ -1,21 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/concern"
4
+ require "devise/models/webauthn_credential_authenticatable"
4
5
  require "devise/strategies/passkey_authenticatable"
5
6
 
6
7
  module Devise
7
8
  module Models
8
9
  module PasskeyAuthenticatable
9
10
  extend ActiveSupport::Concern
11
+ include WebauthnCredentialAuthenticatable
10
12
 
11
13
  included do
12
- has_many :passkeys, dependent: :destroy, class_name: "WebauthnCredential"
13
-
14
- validates :webauthn_id, uniqueness: true, allow_blank: true
15
-
16
- after_initialize do
17
- self.webauthn_id ||= WebAuthn.generate_user_id
18
- end
14
+ has_many :passkeys, -> { passkey }, class_name: "WebauthnCredential"
19
15
  end
20
16
  end
21
17
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Devise
6
+ module Models
7
+ module WebauthnCredentialAuthenticatable
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ has_many :webauthn_credentials, dependent: :destroy
12
+
13
+ validates :webauthn_id, uniqueness: true, allow_blank: true
14
+
15
+ after_initialize do
16
+ self.webauthn_id ||= WebAuthn.generate_user_id
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "devise/models/webauthn_credential_authenticatable"
5
+ require "devise/strategies/webauthn_two_factor_authenticatable"
6
+
7
+ module Devise
8
+ module Models
9
+ module WebauthnTwoFactorAuthenticatable
10
+ extend ActiveSupport::Concern
11
+ include WebauthnCredentialAuthenticatable
12
+
13
+ included do
14
+ has_many :second_factor_webauthn_credentials, -> { second_factor }, class_name: "WebauthnCredential"
15
+ end
16
+
17
+ def second_factor_enabled?
18
+ webauthn_credentials.any?
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'devise/strategies/authenticatable'
4
+
5
+ module Devise
6
+ module Strategies
7
+ # Default strategy for signing in a user, based on their email and password in the database.
8
+ class DatabaseAuthenticatable < Authenticatable
9
+ def authenticate!
10
+ resource = password.present? && mapping.to.find_for_database_authentication(authentication_hash)
11
+ hashed = false
12
+
13
+ if validate(resource){ hashed = true; resource.valid_password?(password) }
14
+ if second_factor_enabled?(resource)
15
+ session[:current_authentication_resource_id] = resource.id
16
+ request.flash[:notice] = two_factor_required_message
17
+ request.commit_flash
18
+ redirect!(two_factor_authentication_path, {}, message: two_factor_required_message)
19
+ else
20
+ remember_me(resource)
21
+ resource.after_database_authentication
22
+ success!(resource)
23
+ end
24
+ end
25
+
26
+ # In paranoid mode, hash the password even when a resource doesn't exist for the given authentication key.
27
+ # This is necessary to prevent enumeration attacks - e.g. the request is faster when a resource doesn't
28
+ # exist in the database if the password hashing algorithm is not called.
29
+ mapping.to.new.password = password if !hashed && Devise.paranoid
30
+ unless resource
31
+ Devise.paranoid ? fail(:invalid) : fail(:not_found_in_database)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def two_factor_authentication_path
38
+ Rails.application.routes.url_helpers.send(:"new_#{scope}_two_factor_authentication_path")
39
+ end
40
+
41
+ def two_factor_required_message
42
+ I18n.t(:"#{scope}.two_factor_required", resource_name: scope, scope: "devise.failure", default: :two_factor_required)
43
+ end
44
+
45
+ def second_factor_enabled?(resource)
46
+ resource.respond_to?(:second_factor_enabled?) && resource.second_factor_enabled?
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ Warden::Strategies.add(:database_authenticatable, Devise::Strategies::DatabaseAuthenticatable)
@@ -9,7 +9,7 @@ module Devise
9
9
 
10
10
  def authenticate!
11
11
  passkey_from_params = WebAuthn::Credential.from_get(JSON.parse(passkey_param))
12
- stored_passkey = WebauthnCredential.find_by(external_id: passkey_from_params.id)
12
+ stored_passkey = WebauthnCredential.passkey.find_by(external_id: passkey_from_params.id)
13
13
 
14
14
  return fail!(:passkey_not_found) if stored_passkey.blank?
15
15
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Strategies
5
+ class WebauthnTwoFactorAuthenticatable < Devise::Strategies::Base
6
+ def valid?
7
+ credential_param.present? && session[:two_factor_authentication_challenge].present?
8
+ end
9
+
10
+ def authenticate!
11
+ credential_from_params = WebAuthn::Credential.from_get(JSON.parse(credential_param))
12
+ stored_credential = WebauthnCredential.find_by(external_id: credential_from_params.id)
13
+
14
+ return fail!(:webauthn_credential_not_found) if stored_credential.blank?
15
+
16
+ verify_credential(credential_from_params, stored_credential)
17
+
18
+ resource = stored_credential.public_send(resource_name)
19
+ success!(resource)
20
+
21
+ session.delete(:current_authentication_resource_id)
22
+ rescue WebAuthn::Error
23
+ fail!(:webauthn_credential_verification_failed)
24
+ ensure
25
+ session.delete(:two_factor_authentication_challenge)
26
+ end
27
+
28
+ private
29
+
30
+ def credential_param
31
+ params[:public_key_credential]
32
+ end
33
+
34
+ def verify_credential(credential_from_params, stored_credential)
35
+ credential_from_params.verify(
36
+ session[:two_factor_authentication_challenge],
37
+ public_key: stored_credential.public_key,
38
+ sign_count: stored_credential.sign_count
39
+ )
40
+
41
+ stored_credential.update!(sign_count: credential_from_params.sign_count)
42
+ end
43
+
44
+ def resource_name
45
+ mapping.to.name.underscore
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ Warden::Strategies.add(:webauthn_two_factor_authenticatable, Devise::Strategies::WebauthnTwoFactorAuthenticatable)
@@ -14,6 +14,15 @@ module Devise
14
14
  route: { passkey_authentication: [] }
15
15
  }
16
16
  )
17
+
18
+ Devise.add_module(
19
+ :webauthn_two_factor_authenticatable,
20
+ {
21
+ model: "devise/models/webauthn_two_factor_authenticatable",
22
+ strategy: true,
23
+ route: { two_factor_authentication: [] }
24
+ }
25
+ )
17
26
  end
18
27
 
19
28
  initializer "devise.webauthn.helpers" do
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/ModuleLength
3
4
  module Devise
4
5
  module Webauthn
5
6
  module CredentialsHelper
@@ -27,7 +28,41 @@ module Devise
27
28
  data: {
28
29
  action: "webauthn-credentials#get:prevent",
29
30
  controller: "webauthn-credentials",
30
- webauthn_credentials_options_param: webauthn_authentication_options
31
+ webauthn_credentials_options_param: passkey_authentication_options
32
+ },
33
+ class: form_classes
34
+ ) do |f|
35
+ concat f.hidden_field(:public_key_credential,
36
+ data: { "webauthn-credentials-target": "credentialHiddenInput" })
37
+ concat f.button(text, type: "submit", class: button_classes, &block)
38
+ end
39
+ end
40
+
41
+ def security_key_creation_form_for(resource, form_classes: nil, &block)
42
+ form_with(
43
+ url: second_factor_webauthn_credentials_path(resource),
44
+ 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
+ }
51
+ ) do |f|
52
+ concat f.hidden_field(:public_key_credential,
53
+ data: { "webauthn-credentials-target": "credentialHiddenInput" })
54
+ concat capture(f, &block)
55
+ end
56
+ end
57
+
58
+ def login_with_security_key_button(text = nil, resource:, button_classes: nil, form_classes: nil, &block)
59
+ form_with(
60
+ url: two_factor_authentication_path(resource),
61
+ method: :post,
62
+ data: {
63
+ action: "webauthn-credentials#get:prevent",
64
+ controller: "webauthn-credentials",
65
+ webauthn_credentials_options_param: security_key_authentication_options(resource)
31
66
  },
32
67
  class: form_classes
33
68
  ) do |f|
@@ -44,7 +79,7 @@ module Devise
44
79
  options = WebAuthn::Credential.options_for_create(
45
80
  user: {
46
81
  id: resource.webauthn_id,
47
- name: resource.email
82
+ name: resource_human_palatable_identifier
48
83
  },
49
84
  exclude: resource.passkeys.pluck(:external_id),
50
85
  authenticator_selection: {
@@ -60,8 +95,8 @@ module Devise
60
95
  end
61
96
  end
62
97
 
63
- def webauthn_authentication_options
64
- @webauthn_authentication_options ||= begin
98
+ def passkey_authentication_options
99
+ @passkey_authentication_options ||= begin
65
100
  options = WebAuthn::Credential.options_for_get(
66
101
  user_verification: "required"
67
102
  )
@@ -72,6 +107,49 @@ module Devise
72
107
  options
73
108
  end
74
109
  end
110
+
111
+ def create_security_key_options(resource)
112
+ @create_security_key_options ||= begin
113
+ options = WebAuthn::Credential.options_for_create(
114
+ user: {
115
+ id: resource.webauthn_id,
116
+ name: resource_human_palatable_identifier
117
+ },
118
+ exclude: resource.webauthn_credentials.pluck(:external_id),
119
+ authenticator_selection: {
120
+ resident_key: "discouraged",
121
+ user_verification: "discouraged"
122
+ }
123
+ )
124
+
125
+ # Store challenge in session for later verification
126
+ session[:webauthn_challenge] = options.challenge
127
+
128
+ options
129
+ end
130
+ end
131
+
132
+ def security_key_authentication_options(resource)
133
+ @security_key_authentication_options ||= begin
134
+ options = WebAuthn::Credential.options_for_get(
135
+ allow: resource.webauthn_credentials.pluck(:external_id),
136
+ user_verification: "discouraged"
137
+ )
138
+
139
+ # Store challenge in session for later verification
140
+ session[:two_factor_authentication_challenge] = options.challenge
141
+
142
+ options
143
+ end
144
+ end
145
+
146
+ def resource_human_palatable_identifier
147
+ authentication_keys = resource.class.authentication_keys
148
+ authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)
149
+
150
+ authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first
151
+ end
75
152
  end
76
153
  end
77
154
  end
155
+ # rubocop:enable Metrics/ModuleLength
@@ -8,6 +8,16 @@ module ActionDispatch
8
8
  def devise_passkey_authentication(_mapping, controllers)
9
9
  resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys]
10
10
  end
11
+
12
+ def devise_two_factor_authentication(_mapping, controllers)
13
+ resource :two_factor_authentication,
14
+ only: %i[new create],
15
+ controller: controllers[:two_factor_authentications]
16
+
17
+ resources :second_factor_webauthn_credentials,
18
+ only: %i[new create destroy],
19
+ controller: controllers[:second_factor_webauthn_credentials]
20
+ end
11
21
  end
12
22
  end
13
23
  end
@@ -23,7 +23,10 @@ module Devise
23
23
  module UrlHelpers
24
24
  {
25
25
  passkeys: [nil],
26
- passkey: [nil, :new]
26
+ passkey: [nil, :new],
27
+ two_factor_authentication: [nil, :new],
28
+ second_factor_webauthn_credentials: [nil],
29
+ second_factor_webauthn_credential: [nil, :new]
27
30
  }.each do |route, actions|
28
31
  %i[path url].each do |path_or_url|
29
32
  actions.each do |action|
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Devise
4
4
  module Webauthn
5
- VERSION = "0.1.2"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
@@ -19,7 +19,8 @@ module Devise
19
19
  "name:string",
20
20
  "public_key:text",
21
21
  "sign_count:integer{8}",
22
- "#{user_model_name}:references"
22
+ "#{user_model_name}:references",
23
+ "authentication_factor:integer{1}"
23
24
  ]
24
25
  end
25
26
 
@@ -28,6 +29,10 @@ module Devise
28
29
  <<~RUBY.indent(2)
29
30
  validates :external_id, :public_key, :name, :sign_count, presence: true
30
31
  validates :external_id, uniqueness: true
32
+
33
+ enum :authentication_factor, { first_factor: 0, second_factor: 1 }
34
+
35
+ scope :passkey, -> { first_factor }
31
36
  RUBY
32
37
  end
33
38
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: devise-webauthn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedarcode
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-12-03 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: devise
@@ -38,13 +37,14 @@ dependencies:
38
37
  - - "~>"
39
38
  - !ruby/object:Gem::Version
40
39
  version: '3.0'
41
- description:
42
40
  email:
43
41
  - webauthn@cedarcode.com
44
42
  executables: []
45
43
  extensions: []
46
44
  extra_rdoc_files: []
47
45
  files:
46
+ - ".github/FUNDING.yml"
47
+ - ".github/workflows/release.yml"
48
48
  - ".github/workflows/ruby.yml"
49
49
  - ".gitignore"
50
50
  - ".rspec"
@@ -58,8 +58,12 @@ files:
58
58
  - README.md
59
59
  - Rakefile
60
60
  - app/controllers/devise/passkeys_controller.rb
61
+ - app/controllers/devise/second_factor_webauthn_credentials_controller.rb
62
+ - app/controllers/devise/two_factor_authentications_controller.rb
61
63
  - app/views/devise/passkeys/new.html.erb
64
+ - app/views/devise/second_factor_webauthn_credentials/new.html.erb
62
65
  - app/views/devise/sessions/new.html.erb
66
+ - app/views/devise/two_factor_authentications/new.html.erb
63
67
  - bin/console
64
68
  - bin/rails
65
69
  - bin/setup
@@ -69,9 +73,14 @@ files:
69
73
  - gemfiles/rails_7_1.gemfile
70
74
  - gemfiles/rails_7_2.gemfile
71
75
  - gemfiles/rails_8_0.gemfile
76
+ - gemfiles/rails_8_1.gemfile
72
77
  - gemfiles/rails_edge.gemfile
73
78
  - lib/devise/models/passkey_authenticatable.rb
79
+ - lib/devise/models/webauthn_credential_authenticatable.rb
80
+ - lib/devise/models/webauthn_two_factor_authenticatable.rb
81
+ - lib/devise/strategies/database_authenticatable.rb
74
82
  - lib/devise/strategies/passkey_authenticatable.rb
83
+ - lib/devise/strategies/webauthn_two_factor_authenticatable.rb
75
84
  - lib/devise/webauthn.rb
76
85
  - lib/devise/webauthn/engine.rb
77
86
  - lib/devise/webauthn/helpers/credentials_helper.rb
@@ -97,7 +106,6 @@ metadata:
97
106
  source_code_uri: https://github.com/cedarcode/devise-webauthn
98
107
  changelog_uri: https://github.com/cedarcode/devise-webauthn/blob/master/CHANGELOG.md
99
108
  rubygems_mfa_required: 'true'
100
- post_install_message:
101
109
  rdoc_options: []
102
110
  require_paths:
103
111
  - lib
@@ -112,8 +120,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
120
  - !ruby/object:Gem::Version
113
121
  version: '0'
114
122
  requirements: []
115
- rubygems_version: 3.2.1
116
- signing_key:
123
+ rubygems_version: 3.6.7
117
124
  specification_version: 4
118
125
  summary: Devise extension to support WebAuthn.
119
126
  test_files: []