Skip to content

Commit 10cad17

Browse files
Merge pull request #125 from cedarcode/sr--backfill-webauthn-id
Fix ephemeral `webauthn_id` for existing users
2 parents 9e0300d + 9ba7eac commit 10cad17

6 files changed

Lines changed: 106 additions & 22 deletions

File tree

lib/devise/models/webauthn_credential_authenticatable.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ module WebauthnCredentialAuthenticatable
1212

1313
validates :webauthn_id, uniqueness: true, allow_blank: true
1414

15-
after_initialize do
15+
before_validation do
1616
self.webauthn_id ||= WebAuthn.generate_user_id
1717
end
1818
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
class AddWebauthnIdTo<%= user_table_name.camelize %> < ActiveRecord::Migration[<%= Rails.version.to_f %>]
4+
def up
5+
add_column :<%= user_table_name %>, :webauthn_id, :string
6+
add_index :<%= user_table_name %>, :webauthn_id, unique: true
7+
8+
# WARNING: The code below backfills webauthn_id for all existing records
9+
# one row at a time. For larger tables, consider removing it and running
10+
# the backfill separately (e.g., in a background job or maintenance task).
11+
#
12+
# Worth noting: PostgreSQL and MySQL support single-query backfills:
13+
#
14+
# PostgreSQL:
15+
# UPDATE <%= user_table_name %> SET webauthn_id = encode(gen_random_bytes(64), 'base64') WHERE webauthn_id IS NULL
16+
#
17+
# MySQL:
18+
# UPDATE <%= user_table_name %> SET webauthn_id = TO_BASE64(RANDOM_BYTES(64)) WHERE webauthn_id IS NULL
19+
#
20+
execute("SELECT id FROM <%= user_table_name %> WHERE webauthn_id IS NULL").each do |row|
21+
webauthn_id = WebAuthn.generate_user_id
22+
execute(ActiveRecord::Base.sanitize_sql_array(
23+
["UPDATE <%= user_table_name %> SET webauthn_id = ? WHERE id = ?", webauthn_id, row["id"]]
24+
))
25+
end
26+
# Note: if your application creates records using methods that skip
27+
# callbacks (e.g., insert_all), consider adding a database default
28+
# to ensure webauthn_id is always set. For example, in PostgreSQL:
29+
#
30+
# change_column_default :<%= user_table_name %>, :webauthn_id, from: nil, to: -> { "encode(gen_random_bytes(64), 'base64')" }
31+
#
32+
# For the same reason, you may want to add a NOT NULL constraint in a
33+
# separate migration after the backfill is complete:
34+
#
35+
# change_column_null :<%= user_table_name %>, :webauthn_id, false
36+
end
37+
38+
def down
39+
remove_column :<%= user_table_name %>, :webauthn_id
40+
end
41+
end

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

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

14+
source_root File.expand_path("templates", __dir__)
15+
1216
desc "Add webauthn_id field to User model"
1317
class_option :resource_name, type: :string, default: "user", desc: "The resource name for Devise (default: user)"
1418

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

2227
def show_instructions

spec/devise/models/passkey_authenticatable_spec.rb

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,30 @@
22

33
RSpec.describe Devise::Models::PasskeyAuthenticatable, type: :model do
44
describe "webauthn_id initialization" do
5-
it "generates a webauthn_id on initialize" do
6-
user = Account.new(email: "user@example.com", password: "password", password_confirmation: "password")
5+
it "generates a webauthn_id on create" do
6+
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password")
77
expect(user.webauthn_id).to be_present
88
end
99

10-
it "keeps existing webauthn_id" do
11-
user = Account.new(email: "user@example.com", password: "password", password_confirmation: "password",
12-
webauthn_id: "custom")
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
13+
end
14+
15+
it "keeps webauthn_id if created with one" do
16+
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password",
17+
webauthn_id: "custom")
1318
expect(user.webauthn_id).to eq("custom")
1419
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
25+
26+
user.update!(email: "updated@example.com")
27+
expect(user.webauthn_id).to be_present
28+
end
1529
end
1630

1731
describe "associations" do

spec/devise/models/webauthn_two_factor_authenticatable_spec.rb

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,30 @@
22

33
RSpec.describe Devise::Models::WebauthnTwoFactorAuthenticatable, type: :model do
44
describe "webauthn_id initialization" do
5-
it "generates a webauthn_id on initialize" do
6-
user = Account.new(email: "user@example.com", password: "password", password_confirmation: "password")
5+
it "generates a webauthn_id on create" do
6+
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password")
77
expect(user.webauthn_id).to be_present
88
end
99

10-
it "keeps existing webauthn_id" do
11-
user = Account.new(email: "user@example.com", password: "password", password_confirmation: "password",
12-
webauthn_id: "custom")
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
13+
end
14+
15+
it "keeps webauthn_id if created with one" do
16+
user = Account.create!(email: "user@example.com", password: "password", password_confirmation: "password",
17+
webauthn_id: "custom")
1318
expect(user.webauthn_id).to eq("custom")
1419
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
25+
26+
user.update!(email: "updated@example.com")
27+
expect(user.webauthn_id).to be_present
28+
end
1529
end
1630

1731
describe "associations" do

spec/generators/devise/webauthn/webauthn_id_generator_spec.rb

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

1010
before do
1111
prepare_destination
12-
allow(generator_instance).to receive(:invoke)
1312
invoke generator_instance
1413
end
1514

1615
context "when using default resource name" do
1716
let(:generator_instance) { generator }
1817

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"])
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
2230
end
2331
end
2432

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

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"])
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
3141
end
3242
end
3343
end

0 commit comments

Comments
 (0)