Skip to content

Commit da54288

Browse files
Merge pull request #148 from cedarcode/sr--generate-webauthn-id-lazily
Generate `webauthn_id` lazily when registration options are requested
2 parents cbb000e + 0bc96e9 commit da54288

14 files changed

Lines changed: 96 additions & 107 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Changed
66

77
- 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. [@santiagorodriguez96]
8+
- 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. [@santiagorodriguez96]
89

910
## [v0.4.0](https://github.com/cedarcode/devise-webauthn/compare/v0.3.1...v0.4.0/) - 2026-04-06
1011

‎app/controllers/devise/passkey_registration_options_controller.rb‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class PasskeyRegistrationOptionsController < DeviseController
55
skip_forgery_protection
66

77
before_action :authenticate_scope!
8+
before_action :ensure_webauthn_id!
89

910
def create
1011
passkey_options =
@@ -33,6 +34,10 @@ def authenticate_scope!
3334
self.resource = send(:"current_#{resource_name}")
3435
end
3536

37+
def ensure_webauthn_id!
38+
resource.ensure_webauthn_id!
39+
end
40+
3641
def resource_human_palatable_identifier
3742
authentication_keys = resource.class.authentication_keys
3843
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)

‎app/controllers/devise/security_key_registration_options_controller.rb‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class SecurityKeyRegistrationOptionsController < DeviseController
55
skip_forgery_protection
66

77
before_action :authenticate_scope!
8+
before_action :ensure_webauthn_id!
89

910
def create
1011
create_security_key_options =
@@ -33,6 +34,10 @@ def authenticate_scope!
3334
self.resource = send(:"current_#{resource_name}")
3435
end
3536

37+
def ensure_webauthn_id!
38+
resource.ensure_webauthn_id!
39+
end
40+
3641
def resource_human_palatable_identifier
3742
authentication_keys = resource.class.authentication_keys
3843
authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash)

‎lib/devise/models/webauthn_credential_authenticatable.rb‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ module WebauthnCredentialAuthenticatable
1111
has_many :webauthn_credentials, dependent: :destroy
1212

1313
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)
1421

15-
before_validation do
16-
self.webauthn_id ||= WebAuthn.generate_user_id
17-
end
22+
webauthn_id
1823
end
1924
end
2025
end

‎lib/devise/webauthn/test/authenticator_helpers.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def add_credential_to_authenticator(authenticator, resource, passkey:, name: "My
3939
"privateKey" => encoded_private_key,
4040
"signCount" => 0
4141
}
42-
credential_json["userHandle"] = resource.webauthn_id if passkey
42+
credential_json["userHandle"] = resource.ensure_webauthn_id! if passkey
4343

4444
authenticator.add_credential(credential_json)
4545

‎lib/generators/devise/webauthn/webauthn_id/templates/add_webauthn_id.rb.erb‎

Lines changed: 0 additions & 41 deletions
This file was deleted.

‎lib/generators/devise/webauthn/webauthn_id/webauthn_id_generator.rb‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,17 @@
66
module Devise
77
module Webauthn
88
class WebauthnIdGenerator < Rails::Generators::Base
9-
include Rails::Generators::Migration
10-
119
hide!
1210
namespace "devise:webauthn:webauthn_id"
1311

14-
source_root File.expand_path("templates", __dir__)
15-
1612
desc "Add webauthn_id field to User model"
1713
class_option :resource_name, type: :string, default: "user", desc: "The resource name for Devise (default: user)"
1814

19-
def self.next_migration_number(dirname)
20-
ActiveRecord::Generators::Base.next_migration_number(dirname)
21-
end
22-
2315
def generate_migration
24-
migration_template "add_webauthn_id.rb.erb", "db/migrate/add_webauthn_id_to_#{user_table_name}.rb"
16+
invoke "active_record:migration", [
17+
"add_webauthn_id_to_#{user_table_name}",
18+
"webauthn_id:string:uniq"
19+
]
2520
end
2621

2722
def show_instructions

‎spec/devise/models/passkey_authenticatable_spec.rb‎

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,27 @@
11
# frozen_string_literal: true
22

33
RSpec.describe Devise::Models::PasskeyAuthenticatable, type: :model do
4-
describe "webauthn_id initialization" do
5-
it "generates a webauthn_id on create" do
4+
describe "#ensure_webauthn_id!" do
5+
it "does not generate a webauthn_id on create" do
66
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password")
7-
expect(user.webauthn_id).to be_present
7+
expect(user.webauthn_id).to be_nil
88
end
99

10-
it "does not generate a webauthn_id on initialize" do
11-
user = Account.new(email: "user@example.com", password: "password", password_confirmation: "password")
12-
expect(user.webauthn_id).to be_nil
10+
it "generates and persists a webauthn_id when missing" do
11+
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password")
12+
13+
webauthn_id = user.ensure_webauthn_id!
14+
15+
expect(webauthn_id).to be_present
16+
expect(user.reload.webauthn_id).to eq(webauthn_id)
1317
end
1418

15-
it "keeps webauthn_id if created with one" do
19+
it "does not replace an existing webauthn_id" do
1620
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password",
1721
webauthn_id: "custom")
18-
expect(user.webauthn_id).to eq("custom")
19-
end
20-
21-
it "generates a webauthn_id on update if missing" do
22-
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password")
23-
user.update_column(:webauthn_id, nil) # rubocop:disable Rails/SkipsModelValidations
24-
user.reload
2522

26-
user.update!(email: "updated@example.com")
27-
expect(user.webauthn_id).to be_present
23+
expect(user.ensure_webauthn_id!).to eq("custom")
24+
expect(user.reload.webauthn_id).to eq("custom")
2825
end
2926
end
3027

@@ -43,7 +40,8 @@
4340

4441
describe "validations" do
4542
it "validates uniqueness of webauthn_id" do
46-
existing = Account.create!(email: "existing@example.com", password: "password", password_confirmation: "password")
43+
existing = Account.create!(email: "existing@example.com", password: "password",
44+
password_confirmation: "password", webauthn_id: WebAuthn.generate_user_id)
4745
user = Account.new(email: "new@example.com", webauthn_id: existing.webauthn_id)
4846
expect(user).not_to be_valid
4947
expect(user.errors[:webauthn_id]).to include("has already been taken")

‎spec/devise/models/webauthn_two_factor_authenticatable_spec.rb‎

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,27 @@
11
# frozen_string_literal: true
22

33
RSpec.describe Devise::Models::WebauthnTwoFactorAuthenticatable, type: :model do
4-
describe "webauthn_id initialization" do
5-
it "generates a webauthn_id on create" do
4+
describe "#ensure_webauthn_id!" do
5+
it "does not generate a webauthn_id on create" do
66
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password")
7-
expect(user.webauthn_id).to be_present
7+
expect(user.webauthn_id).to be_nil
88
end
99

10-
it "does not generate a webauthn_id on initialize" do
11-
user = Account.new(email: "user@example.com", password: "password", password_confirmation: "password")
12-
expect(user.webauthn_id).to be_nil
10+
it "generates and persists a webauthn_id when missing" do
11+
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password")
12+
13+
webauthn_id = user.ensure_webauthn_id!
14+
15+
expect(webauthn_id).to be_present
16+
expect(user.reload.webauthn_id).to eq(webauthn_id)
1317
end
1418

15-
it "keeps webauthn_id if created with one" do
19+
it "does not replace an existing webauthn_id" do
1620
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password",
1721
webauthn_id: "custom")
18-
expect(user.webauthn_id).to eq("custom")
19-
end
20-
21-
it "generates a webauthn_id on update if missing" do
22-
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password")
23-
user.update_column(:webauthn_id, nil) # rubocop:disable Rails/SkipsModelValidations
24-
user.reload
2522

26-
user.update!(email: "updated@example.com")
27-
expect(user.webauthn_id).to be_present
23+
expect(user.ensure_webauthn_id!).to eq("custom")
24+
expect(user.reload.webauthn_id).to eq("custom")
2825
end
2926
end
3027

@@ -66,7 +63,8 @@
6663

6764
describe "validations" do
6865
it "validates uniqueness of webauthn_id" do
69-
existing = Account.create!(email: "existing@example.com", password: "password", password_confirmation: "password")
66+
existing = Account.create!(email: "existing@example.com", password: "password",
67+
password_confirmation: "password", webauthn_id: WebAuthn.generate_user_id)
7068
user = Account.new(email: "new@example.com", webauthn_id: existing.webauthn_id)
7169
expect(user).not_to be_valid
7270
expect(user.errors[:webauthn_id]).to include("has already been taken")

‎spec/generators/devise/webauthn/webauthn_id_generator_spec.rb‎

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,35 +9,25 @@
99

1010
before do
1111
prepare_destination
12+
allow(generator_instance).to receive(:invoke)
1213
invoke generator_instance
1314
end
1415

1516
context "when using default resource name" do
1617
let(:generator_instance) { generator }
1718

18-
it "creates a migration that adds webauthn_id to users" do
19-
assert_migration "db/migrate/add_webauthn_id_to_users.rb" do |migration|
20-
assert_match(/add_column :users, :webauthn_id, :string/, migration)
21-
assert_match(/add_index :users, :webauthn_id, unique: true/, migration)
22-
end
23-
end
24-
25-
it "creates a migration that backfills existing records" do
26-
assert_migration "db/migrate/add_webauthn_id_to_users.rb" do |migration|
27-
assert_match(/SELECT id FROM users WHERE webauthn_id IS NULL/, migration)
28-
assert_match(/WebAuthn\.generate_user_id/, migration)
29-
end
19+
it "invokes the active_record:migration generator with correct arguments" do
20+
expect(generator).to have_received(:invoke).with("active_record:migration",
21+
["add_webauthn_id_to_users", "webauthn_id:string:uniq"])
3022
end
3123
end
3224

3325
context "when using a custom resource name" do
3426
let(:generator_instance) { generator([destination_root], ["--resource_name=admin"]) }
3527

36-
it "creates a migration that adds webauthn_id to admins" do
37-
assert_migration "db/migrate/add_webauthn_id_to_admins.rb" do |migration|
38-
assert_match(/add_column :admins, :webauthn_id, :string/, migration)
39-
assert_match(/add_index :admins, :webauthn_id, unique: true/, migration)
40-
end
28+
it "invokes the active_record:migration generator with correct arguments" do
29+
expect(generator).to have_received(:invoke).with("active_record:migration",
30+
["add_webauthn_id_to_admins", "webauthn_id:string:uniq"])
4131
end
4232
end
4333
end

0 commit comments

Comments
 (0)