From fea2ce73906ce30e01f6431119af166469271187 Mon Sep 17 00:00:00 2001 From: Valeria Graffeo Date: Thu, 2 Apr 2026 18:57:46 +0200 Subject: [PATCH 1/5] Add Clearance::Passkey model and passkeys generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces opt-in passkey (WebAuthn) support to Clearance via a new generator and model. Add the `webauthn ~> 3.0` gem dependency. Add `Clearance::Passkey` (lib/clearance/passkey.rb), an ActiveRecord model with: - `belongs_to :user, class_name: "::User", optional: false` - presence validations on `label`, `external_id`, and `public_key` - uniqueness validation on `external_id` Add the `clearance:passkeys` generator (lib/generators/clearance/passkeys/) which produces two migrations: - `add_webauthn_id_to_users` — adds a unique `webauthn_id:string` column to the users table (skipped if column already exists) - `create_passkeys` — creates the passkeys table with `user_id`, `label`, `external_id`, `public_key`, `sign_count` (not null, default 0), timestamps, and a unique index on `external_id` (skipped if table already exists) Wire up the dummy app with the corresponding migrations, an updated schema, and a `Passkey < Clearance::Passkey` model. Add a `:passkey` FactoryBot factory and specs covering the generator and the model (belongs_to, validations, db columns/indexes, and uniqueness behaviour including after destroy). --- Gemfile.lock | 26 ++++++ clearance.gemspec | 1 + lib/clearance.rb | 1 + lib/clearance/passkey.rb | 8 ++ .../clearance/passkeys/passkeys_generator.rb | 65 +++++++++++++++ .../migrate/add_webauthn_id_to_users.rb.erb | 6 ++ .../db/migrate/create_passkeys.rb.erb | 14 ++++ spec/dummy/app/models/passkey.rb | 2 + ...20110111224544_add_webauthn_id_to_users.rb | 6 ++ .../migrate/20110111224545_create_passkeys.rb | 14 ++++ spec/dummy/db/schema.rb | 15 +++- spec/factories/passkeys.rb | 9 +++ .../passkeys/passkeys_generator_spec.rb | 79 +++++++++++++++++++ spec/models/clearance/passkey_spec.rb | 28 +++++++ 14 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 lib/clearance/passkey.rb create mode 100644 lib/generators/clearance/passkeys/passkeys_generator.rb create mode 100644 lib/generators/clearance/passkeys/templates/db/migrate/add_webauthn_id_to_users.rb.erb create mode 100644 lib/generators/clearance/passkeys/templates/db/migrate/create_passkeys.rb.erb create mode 100644 spec/dummy/app/models/passkey.rb create mode 100644 spec/dummy/db/migrate/20110111224544_add_webauthn_id_to_users.rb create mode 100644 spec/dummy/db/migrate/20110111224545_create_passkeys.rb create mode 100644 spec/factories/passkeys.rb create mode 100644 spec/generators/clearance/passkeys/passkeys_generator_spec.rb create mode 100644 spec/models/clearance/passkey_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 7481eda43..0eff82729 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,6 +9,7 @@ PATH bcrypt (>= 3.1.1) email_validator (~> 2.0) railties (>= 5.0) + webauthn (~> 3.0) GEM remote: https://rubygems.org/ @@ -64,6 +65,7 @@ GEM activesupport (>= 3.0) railties (>= 3.0) rspec-rails (>= 2.2) + android_key_attestation (0.3.0) appraisal (2.5.0) bundler rake @@ -83,6 +85,7 @@ GEM parser (>= 2.4) smart_properties bigdecimal (4.1.1) + bindata (2.5.1) builder (3.3.0) capybara (3.40.0) addressable @@ -93,9 +96,13 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cbor (0.5.10.2) coderay (1.1.3) concurrent-ruby (1.3.6) connection_pool (3.0.2) + cose (1.3.1) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) crass (1.0.6) database_cleaner (2.1.0) database_cleaner-active_record (>= 2, < 3) @@ -138,6 +145,8 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.19.3) + jwt (3.1.2) + base64 language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -168,6 +177,9 @@ GEM racc (~> 1.4) nokogiri (1.19.2-x86_64-linux-gnu) racc (~> 1.4) + openssl (4.0.1) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) parallel (1.28.0) parser (3.3.11.1) ast (~> 2.4.1) @@ -258,6 +270,8 @@ GEM rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (1.13.0) + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) securerandom (0.4.1) shoulda-matchers (7.0.1) activesupport (>= 7.1) @@ -280,6 +294,10 @@ GEM thor (1.5.0) timecop (0.9.11) timeout (0.4.3) + tpm-key_attestation (0.14.1) + bindata (~> 2.4) + openssl (> 2.0) + openssl-signature_algorithm (~> 1.0) tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -288,6 +306,14 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) + webauthn (3.4.3) + android_key_attestation (~> 0.3.0) + bindata (~> 2.4) + cbor (~> 0.5.9) + cose (~> 1.1) + openssl (>= 2.2) + safety_net_attestation (~> 0.5.0) + tpm-key_attestation (~> 0.14.0) xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.7.5) diff --git a/clearance.gemspec b/clearance.gemspec index 08a7a3493..6d9623c14 100644 --- a/clearance.gemspec +++ b/clearance.gemspec @@ -3,6 +3,7 @@ require "clearance/version" Gem::Specification.new do |s| s.add_dependency "bcrypt", ">= 3.1.1" + s.add_dependency "webauthn", "~> 3.0" s.add_dependency "argon2", "~> 2.0", ">= 2.0.2" s.add_dependency "email_validator", "~> 2.0" s.add_dependency "railties", ">= 5.0" diff --git a/lib/clearance.rb b/lib/clearance.rb index 02841e7c2..0787a19cf 100644 --- a/lib/clearance.rb +++ b/lib/clearance.rb @@ -5,6 +5,7 @@ require "clearance/back_door" require "clearance/controller" require "clearance/user" +require "clearance/passkey" require "clearance/password_strategies" require "clearance/constraints" require "clearance/engine" diff --git a/lib/clearance/passkey.rb b/lib/clearance/passkey.rb new file mode 100644 index 000000000..408f5c0c9 --- /dev/null +++ b/lib/clearance/passkey.rb @@ -0,0 +1,8 @@ +module Clearance + class Passkey < ActiveRecord::Base + belongs_to :user, class_name: "::User", optional: false + + validates :label, :external_id, :public_key, presence: true + validates :external_id, uniqueness: true + end +end diff --git a/lib/generators/clearance/passkeys/passkeys_generator.rb b/lib/generators/clearance/passkeys/passkeys_generator.rb new file mode 100644 index 000000000..79d709777 --- /dev/null +++ b/lib/generators/clearance/passkeys/passkeys_generator.rb @@ -0,0 +1,65 @@ +require "rails/generators/base" +require "rails/generators/active_record" + +module Clearance + module Generators + class PasskeysGenerator < Rails::Generators::Base + include Rails::Generators::Migration + + source_root File.expand_path("../templates", __FILE__) + + # Required by Rails::Generators::Migration to produce timestamped filenames. + def self.next_migration_number(dir) + ActiveRecord::Generators::Base.next_migration_number(dir) + end + + def create_migrations + copy_migration("add_webauthn_id_to_users") unless webauthn_id_column_exists? + copy_migration("create_passkeys") unless passkeys_table_exists? + end + + private + + def copy_migration(migration_name) + unless migration_exists?(migration_name) + migration_template( + "db/migrate/#{migration_name}.rb.erb", + "db/migrate/#{migration_name}.rb", + migration_version: migration_version + ) + end + end + + def webauthn_id_column_exists? + users_table_exists? && + connection.columns(:users).map(&:name).include?("webauthn_id") + end + + def users_table_exists? + connection.data_source_exists?(:users) + end + + def passkeys_table_exists? + connection.data_source_exists?(:passkeys) + end + + def migration_exists?(name) + existing_migrations.include?(name) + end + + def existing_migrations + @existing_migrations ||= Dir.glob("db/migrate/*.rb").map do |file| + file.sub(%r{^.*(db/migrate/)(?:\d+_)?}, "").chomp(".rb") + end + end + + def migration_version + "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" + end + + def connection + ActiveRecord::Base.connection + end + end + end +end diff --git a/lib/generators/clearance/passkeys/templates/db/migrate/add_webauthn_id_to_users.rb.erb b/lib/generators/clearance/passkeys/templates/db/migrate/add_webauthn_id_to_users.rb.erb new file mode 100644 index 000000000..b47f69acf --- /dev/null +++ b/lib/generators/clearance/passkeys/templates/db/migrate/add_webauthn_id_to_users.rb.erb @@ -0,0 +1,6 @@ +class AddWebauthnIdToUsers < ActiveRecord::Migration<%= migration_version %> + def change + add_column :users, :webauthn_id, :string + add_index :users, :webauthn_id, unique: true + end +end diff --git a/lib/generators/clearance/passkeys/templates/db/migrate/create_passkeys.rb.erb b/lib/generators/clearance/passkeys/templates/db/migrate/create_passkeys.rb.erb new file mode 100644 index 000000000..afe1c6bc6 --- /dev/null +++ b/lib/generators/clearance/passkeys/templates/db/migrate/create_passkeys.rb.erb @@ -0,0 +1,14 @@ +class CreatePasskeys < ActiveRecord::Migration<%= migration_version %> + def change + create_table :passkeys do |t| + t.references :user, null: false, foreign_key: true + t.string :label, null: false + t.string :external_id, null: false + t.string :public_key, null: false + t.integer :sign_count, null: false, default: 0 + t.timestamps + end + + add_index :passkeys, :external_id, unique: true + end +end diff --git a/spec/dummy/app/models/passkey.rb b/spec/dummy/app/models/passkey.rb new file mode 100644 index 000000000..8683d2d77 --- /dev/null +++ b/spec/dummy/app/models/passkey.rb @@ -0,0 +1,2 @@ +class Passkey < Clearance::Passkey +end diff --git a/spec/dummy/db/migrate/20110111224544_add_webauthn_id_to_users.rb b/spec/dummy/db/migrate/20110111224544_add_webauthn_id_to_users.rb new file mode 100644 index 000000000..4538b5573 --- /dev/null +++ b/spec/dummy/db/migrate/20110111224544_add_webauthn_id_to_users.rb @@ -0,0 +1,6 @@ +class AddWebauthnIdToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :webauthn_id, :string + add_index :users, :webauthn_id, unique: true + end +end diff --git a/spec/dummy/db/migrate/20110111224545_create_passkeys.rb b/spec/dummy/db/migrate/20110111224545_create_passkeys.rb new file mode 100644 index 000000000..389b139dc --- /dev/null +++ b/spec/dummy/db/migrate/20110111224545_create_passkeys.rb @@ -0,0 +1,14 @@ +class CreatePasskeys < ActiveRecord::Migration[7.1] + def change + create_table :passkeys do |t| + t.references :user, null: false, foreign_key: true + t.string :label, null: false + t.string :external_id, null: false + t.string :public_key, null: false + t.integer :sign_count, null: false, default: 0 + t.timestamps + end + + add_index :passkeys, :external_id, unique: true + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index cad6f33ef..8d15f08ce 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2011_01_11_224543) do +ActiveRecord::Schema.define(version: 2011_01_11_224545) do create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -18,8 +18,21 @@ t.string "encrypted_password", limit: 128, null: false t.string "confirmation_token", limit: 128 t.string "remember_token", limit: 128, null: false + t.string "webauthn_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email" t.index ["remember_token"], name: "index_users_on_remember_token", unique: true + t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true + end + + create_table "passkeys", force: :cascade do |t| + t.references "user", null: false, foreign_key: true + t.string "label", null: false + t.string "external_id", null: false + t.string "public_key", null: false + t.integer "sign_count", null: false, default: 0 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "index_passkeys_on_external_id", unique: true end end diff --git a/spec/factories/passkeys.rb b/spec/factories/passkeys.rb new file mode 100644 index 000000000..28bde076e --- /dev/null +++ b/spec/factories/passkeys.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :passkey, class: "Clearance::Passkey" do + user + label { "My Key" } + sequence(:external_id) { |n| "external_id_#{n}" } + public_key { "public_key_data" } + sign_count { 0 } + end +end diff --git a/spec/generators/clearance/passkeys/passkeys_generator_spec.rb b/spec/generators/clearance/passkeys/passkeys_generator_spec.rb new file mode 100644 index 000000000..d9e40834c --- /dev/null +++ b/spec/generators/clearance/passkeys/passkeys_generator_spec.rb @@ -0,0 +1,79 @@ +require "spec_helper" +require "generators/clearance/passkeys/passkeys_generator" + +describe Clearance::Generators::PasskeysGenerator, :generator do + describe "add_webauthn_id_to_users migration" do + it "is created when users table has no webauthn_id column" do + stub_columns_for_users(without: "webauthn_id") + stub_passkeys_table_absent + + run_generator + migration = migration_file("db/migrate/add_webauthn_id_to_users.rb") + + expect(migration).to exist + expect(migration).to have_correct_syntax + expect(migration).to contain("add_column :users, :webauthn_id, :string") + expect(migration).to contain("add_index :users, :webauthn_id, unique: true") + end + + it "is not created when webauthn_id column already exists" do + stub_columns_for_users(with: "webauthn_id") + stub_passkeys_table_absent + + run_generator + migration = migration_file("db/migrate/add_webauthn_id_to_users.rb") + + expect(migration).not_to exist + end + end + + describe "create_passkeys migration" do + it "is created when passkeys table does not exist" do + stub_columns_for_users(without: "webauthn_id") + stub_passkeys_table_absent + + run_generator + migration = migration_file("db/migrate/create_passkeys.rb") + + expect(migration).to exist + expect(migration).to have_correct_syntax + expect(migration).to contain("create_table :passkeys") + expect(migration).to contain("t.references :user, null: false, foreign_key: true") + expect(migration).to contain("t.string :label, null: false") + expect(migration).to contain("t.string :external_id, null: false") + expect(migration).to contain("t.string :public_key, null: false") + expect(migration).to contain("t.integer :sign_count, null: false, default: 0") + expect(migration).to contain("add_index :passkeys, :external_id, unique: true") + end + + it "is not created when passkeys table already exists" do + stub_columns_for_users(without: "webauthn_id") + stub_passkeys_table_present + + run_generator + migration = migration_file("db/migrate/create_passkeys.rb") + + expect(migration).not_to exist + end + end + + def stub_columns_for_users(without: nil, with: nil) + column = Struct.new(:name) + columns = with ? [column.new(with)] : [] + + allow(ActiveRecord::Base.connection) + .to receive(:data_source_exists?).with(:users).and_return(true) + allow(ActiveRecord::Base.connection) + .to receive(:columns).with(:users).and_return(columns) + end + + def stub_passkeys_table_absent + allow(ActiveRecord::Base.connection) + .to receive(:data_source_exists?).with(:passkeys).and_return(false) + end + + def stub_passkeys_table_present + allow(ActiveRecord::Base.connection) + .to receive(:data_source_exists?).with(:passkeys).and_return(true) + end +end diff --git a/spec/models/clearance/passkey_spec.rb b/spec/models/clearance/passkey_spec.rb new file mode 100644 index 000000000..39a726f94 --- /dev/null +++ b/spec/models/clearance/passkey_spec.rb @@ -0,0 +1,28 @@ +require "spec_helper" + +describe Clearance::Passkey do + it { is_expected.to belong_to(:user) } + it { is_expected.to validate_presence_of(:label) } + it { is_expected.to validate_presence_of(:external_id) } + it { is_expected.to validate_presence_of(:public_key) } + it { is_expected.to have_db_index(:external_id).unique(true) } + it { is_expected.to have_db_column(:sign_count).with_options(null: false, default: 0) } + + it "rejects a duplicate external_id" do + user = create(:user) + create(:passkey, user: user, external_id: "cred_abc") + duplicate = build(:passkey, user: user, external_id: "cred_abc") + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:external_id]).not_to be_empty + end + + it "allows the same external_id to be used after the original is destroyed" do + user = create(:user) + passkey = create(:passkey, user: user, external_id: "cred_abc") + passkey.destroy + new_passkey = build(:passkey, user: user, external_id: "cred_abc") + + expect(new_passkey).to be_valid + end +end From 2c7a2cdbc1d3bb2649b93ed83c86f85c258cf72d Mon Sep 17 00:00:00 2001 From: Valeria Graffeo Date: Thu, 23 Apr 2026 13:06:58 +0200 Subject: [PATCH 2/5] Add passkey registration and authentication controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change, the clearance:passkeys generator set up the necessary database structure — a passkeys table and a webauthn_id column on users — but there was no controller layer. There was no way for a user to register a passkey or sign in with one. The goal was to wire up both WebAuthn ceremonies using the webauthn gem's credential API, following the same patterns as Clearance's existing session and password controllers. PasskeysController handles registration: GET /passkeys/new generates WebAuthn credential creation options and stores the challenge in the session; POST /passkeys verifies the browser's response and persists the new passkey against the signed-in user. The webauthn_id on the user is set lazily on first registration. PasskeyAuthenticationsController handles sign-in: GET /passkey_authentication/new returns assertion options, and POST /passkey_authentication verifies the credential, updates the stored sign count, and delegates to Clearance's sign_in so the full guard stack still runs. Both controllers render JSON, since the WebAuthn API is JavaScript-driven. Routes for both are added inside the existing routes_enabled? block. The generator was also updated to inject has_many :passkeys into the host app's User model, and to print a README after running with instructions for configuring the webauthn gem. That configuration (origin and rp_name) is deliberately left to the host app via WebAuthn.configure rather than routing it through Clearance.configuration — the webauthn gem already provides a good home for those settings and wrapping them would add abstraction without benefit. --- .../passkey_authentications_controller.rb | 34 +++++++++++ .../clearance/passkeys_controller.rb | 30 ++++++++++ config/routes.rb | 8 +++ lib/clearance/passkey.rb | 2 + .../clearance/passkeys/passkeys_generator.rb | 14 +++++ .../clearance/passkeys/templates/README | 31 ++++++++++ ...passkey_authentications_controller_spec.rb | 57 ++++++++++++++++++ .../clearance/passkeys_controller_spec.rb | 60 +++++++++++++++++++ spec/dummy/app/models/user.rb | 2 + .../passkeys/passkeys_generator_spec.rb | 27 +++++++++ 10 files changed, 265 insertions(+) create mode 100644 app/controllers/clearance/passkey_authentications_controller.rb create mode 100644 app/controllers/clearance/passkeys_controller.rb create mode 100644 lib/generators/clearance/passkeys/templates/README create mode 100644 spec/controllers/clearance/passkey_authentications_controller_spec.rb create mode 100644 spec/controllers/clearance/passkeys_controller_spec.rb diff --git a/app/controllers/clearance/passkey_authentications_controller.rb b/app/controllers/clearance/passkey_authentications_controller.rb new file mode 100644 index 000000000..cf64ad26d --- /dev/null +++ b/app/controllers/clearance/passkey_authentications_controller.rb @@ -0,0 +1,34 @@ +class Clearance::PasskeyAuthenticationsController < Clearance::BaseController + skip_before_action :require_login, raise: false + + def new + options = WebAuthn::Credential.options_for_get + session[:passkey_authentication_challenge] = options.challenge + + render json: options + end + + def create + credential = WebAuthn::Credential.from_get(params) + passkey = Clearance::Passkey.find_by!(external_id: credential.id) + + credential.verify( + session.delete(:passkey_authentication_challenge), + public_key: passkey.public_key, + sign_count: passkey.sign_count + ) + passkey.update!(sign_count: credential.sign_count) + + sign_in(passkey.user) do |status| + if status.success? + render json: {redirect_to: Clearance.configuration.redirect_url} + else + render json: {error: status.failure_message}, status: :unauthorized + end + end + rescue ActiveRecord::RecordNotFound + render json: {error: "Passkey not found"}, status: :unauthorized + rescue WebAuthn::Error => e + render json: {error: e.message}, status: :unprocessable_content + end +end diff --git a/app/controllers/clearance/passkeys_controller.rb b/app/controllers/clearance/passkeys_controller.rb new file mode 100644 index 000000000..9122a0924 --- /dev/null +++ b/app/controllers/clearance/passkeys_controller.rb @@ -0,0 +1,30 @@ +class Clearance::PasskeysController < Clearance::BaseController + before_action :require_login + + def new + current_user.update_column(:webauthn_id, WebAuthn.generate_user_id) unless current_user.webauthn_id? + + options = WebAuthn::Credential.options_for_create( + user: {id: current_user.webauthn_id, name: current_user.email} + ) + session[:passkey_creation_challenge] = options.challenge + + render json: options + end + + def create + credential = WebAuthn::Credential.from_create(params) + credential.verify(session.delete(:passkey_creation_challenge)) + + current_user.passkeys.create!( + label: params[:label], + external_id: credential.id, + public_key: credential.public_key, + sign_count: credential.sign_count + ) + + head :created + rescue WebAuthn::Error => e + render json: {error: e.message}, status: :unprocessable_content + end +end diff --git a/config/routes.rb b/config/routes.rb index ab2d6d759..137f942d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,5 +24,13 @@ if Clearance.configuration.allow_sign_up? get "/sign_up" => "clearance/users#new", :as => "sign_up" end + + resources :passkeys, + controller: "clearance/passkeys", + only: [:new, :create] + + resource :passkey_authentication, + controller: "clearance/passkey_authentications", + only: [:new, :create] end end diff --git a/lib/clearance/passkey.rb b/lib/clearance/passkey.rb index 408f5c0c9..61c666449 100644 --- a/lib/clearance/passkey.rb +++ b/lib/clearance/passkey.rb @@ -1,3 +1,5 @@ +require "webauthn" + module Clearance class Passkey < ActiveRecord::Base belongs_to :user, class_name: "::User", optional: false diff --git a/lib/generators/clearance/passkeys/passkeys_generator.rb b/lib/generators/clearance/passkeys/passkeys_generator.rb index 79d709777..8bffab98a 100644 --- a/lib/generators/clearance/passkeys/passkeys_generator.rb +++ b/lib/generators/clearance/passkeys/passkeys_generator.rb @@ -18,6 +18,20 @@ def create_migrations copy_migration("create_passkeys") unless passkeys_table_exists? end + def inject_passkeys_into_user_model + return unless File.exist?("app/models/user.rb") + + inject_into_class( + "app/models/user.rb", + "User", + " has_many :passkeys, class_name: \"Clearance::Passkey\", dependent: :destroy\n" + ) + end + + def display_readme_in_terminal + readme "README" + end + private def copy_migration(migration_name) diff --git a/lib/generators/clearance/passkeys/templates/README b/lib/generators/clearance/passkeys/templates/README new file mode 100644 index 000000000..1a728e58d --- /dev/null +++ b/lib/generators/clearance/passkeys/templates/README @@ -0,0 +1,31 @@ +******************************************************************************* + +Next steps: + +1. Configure WebAuthn in an initializer: + + # config/initializers/webauthn.rb + WebAuthn.configure do |config| + config.origin = "https://yourapp.example.com" + config.rp_name = "Your Application Name" + end + + In development, use your local server URL (e.g. "http://localhost:3000"). + In production, use your app's full HTTPS URL. + +2. Migrate: + + Run `rails db:migrate` to add passkey database changes. + +3. Handle credential encoding on the frontend: + + The WebAuthn browser API returns ArrayBuffers, not plain strings. Use + a library like @github/webauthn-json to handle Base64url encoding + between the browser's native credential objects and the JSON your + server expects: + + npm install @github/webauthn-json + + See https://github.com/github/webauthn-json for usage. + +******************************************************************************* diff --git a/spec/controllers/clearance/passkey_authentications_controller_spec.rb b/spec/controllers/clearance/passkey_authentications_controller_spec.rb new file mode 100644 index 000000000..fa60690e7 --- /dev/null +++ b/spec/controllers/clearance/passkey_authentications_controller_spec.rb @@ -0,0 +1,57 @@ +require "spec_helper" + +describe Clearance::PasskeyAuthenticationsController do + it { should be_a Clearance::BaseController } + + describe "on GET to #new" do + it "stores the challenge in session and returns authentication options as JSON" do + options = double(challenge: "the_challenge", to_json: '{"challenge":"the_challenge"}') + allow(WebAuthn::Credential).to receive(:options_for_get).and_return(options) + + get :new + + expect(response).to have_http_status(:ok) + expect(session[:passkey_authentication_challenge]).to eq("the_challenge") + end + end + + describe "on POST to #create" do + it "signs in the user and returns redirect URL when credential is valid" do + user = create(:user) + passkey = create(:passkey, user: user) + session[:passkey_authentication_challenge] = "the_challenge" + credential = double(id: passkey.external_id, sign_count: 1) + allow(WebAuthn::Credential).to receive(:from_get).and_return(credential) + allow(credential).to receive(:verify) + + post :create + + expect(response).to have_http_status(:ok) + parsed = JSON.parse(response.body) + expect(parsed["redirect_to"]).to eq(Clearance.configuration.redirect_url) + end + + it "returns unauthorized when the passkey is not found" do + session[:passkey_authentication_challenge] = "the_challenge" + credential = double(id: "nonexistent_id") + allow(WebAuthn::Credential).to receive(:from_get).and_return(credential) + + post :create + + expect(response).to have_http_status(:unauthorized) + end + + it "returns unprocessable_content when credential verification fails" do + user = create(:user) + passkey = create(:passkey, user: user) + session[:passkey_authentication_challenge] = "the_challenge" + credential = double(id: passkey.external_id) + allow(WebAuthn::Credential).to receive(:from_get).and_return(credential) + allow(credential).to receive(:verify).and_raise(WebAuthn::Error, "bad credential") + + post :create + + expect(response).to have_http_status(:unprocessable_content) + end + end +end diff --git a/spec/controllers/clearance/passkeys_controller_spec.rb b/spec/controllers/clearance/passkeys_controller_spec.rb new file mode 100644 index 000000000..a3bfd1e13 --- /dev/null +++ b/spec/controllers/clearance/passkeys_controller_spec.rb @@ -0,0 +1,60 @@ +require "spec_helper" + +describe Clearance::PasskeysController do + it { should be_a Clearance::BaseController } + + describe "on GET to #new" do + it "redirects unauthenticated requests to sign in" do + get :new + + expect(response).to redirect_to(sign_in_url) + end + + it "stores the challenge in session and returns creation options as JSON" do + user = create(:user) + sign_in_as(user) + options = double(challenge: "the_challenge", to_json: '{"challenge":"the_challenge"}') + allow(WebAuthn::Credential).to receive(:options_for_create).and_return(options) + + get :new + + expect(response).to have_http_status(:ok) + expect(session[:passkey_creation_challenge]).to eq("the_challenge") + end + end + + describe "on POST to #create" do + it "redirects unauthenticated requests to sign in" do + post :create, params: {label: "My Key"} + + expect(response).to redirect_to(sign_in_url) + end + + it "creates a passkey and responds with 201 when credential is valid" do + user = create(:user) + sign_in_as(user) + session[:passkey_creation_challenge] = "the_challenge" + credential = double(id: "cred_id", public_key: "pub_key", sign_count: 0) + allow(WebAuthn::Credential).to receive(:from_create).and_return(credential) + allow(credential).to receive(:verify) + + post :create, params: {label: "My Key"} + + expect(response).to have_http_status(:created) + expect(user.passkeys.count).to eq(1) + end + + it "returns unprocessable_content when credential verification fails" do + user = create(:user) + sign_in_as(user) + session[:passkey_creation_challenge] = "the_challenge" + credential = double + allow(WebAuthn::Credential).to receive(:from_create).and_return(credential) + allow(credential).to receive(:verify).and_raise(WebAuthn::Error, "bad credential") + + post :create, params: {label: "My Key"} + + expect(response).to have_http_status(:unprocessable_content) + end + end +end diff --git a/spec/dummy/app/models/user.rb b/spec/dummy/app/models/user.rb index 6d077a175..a8e9892f9 100644 --- a/spec/dummy/app/models/user.rb +++ b/spec/dummy/app/models/user.rb @@ -1,3 +1,5 @@ class User < ActiveRecord::Base include Clearance::User + + has_many :passkeys, class_name: "Clearance::Passkey", dependent: :destroy end diff --git a/spec/generators/clearance/passkeys/passkeys_generator_spec.rb b/spec/generators/clearance/passkeys/passkeys_generator_spec.rb index d9e40834c..3711c4f4d 100644 --- a/spec/generators/clearance/passkeys/passkeys_generator_spec.rb +++ b/spec/generators/clearance/passkeys/passkeys_generator_spec.rb @@ -72,6 +72,33 @@ def stub_passkeys_table_absent .to receive(:data_source_exists?).with(:passkeys).and_return(false) end + describe "user model" do + it "injects has_many :passkeys into an existing user model" do + stub_columns_for_users(without: "webauthn_id") + stub_passkeys_table_absent + provide_existing_user_class + + run_generator + user_model = file("app/models/user.rb") + + expect(user_model).to exist + expect(user_model).to have_correct_syntax + expect(user_model).to contain( + 'has_many :passkeys, class_name: "Clearance::Passkey", dependent: :destroy' + ) + end + + it "does not create the user model if it does not exist" do + stub_columns_for_users(without: "webauthn_id") + stub_passkeys_table_absent + + run_generator + user_model = file("app/models/user.rb") + + expect(user_model).not_to exist + end + end + def stub_passkeys_table_present allow(ActiveRecord::Base.connection) .to receive(:data_source_exists?).with(:passkeys).and_return(true) From cb2cb36f4146b8940e80a0323f28fb9a3f360ee9 Mon Sep 17 00:00:00 2001 From: Valeria Graffeo Date: Thu, 23 Apr 2026 15:04:43 +0200 Subject: [PATCH 3/5] Fix has_many injection order in passkeys generator inject_into_class inserts at the top of the class body, which placed has_many :passkeys before include Clearance::User. Switched to inject_into_file with after: "include Clearance::User\n" so the association is always declared after the module inclusion. Also tightened the spec to assert ordering rather than just presence. --- .../clearance/passkeys/passkeys_generator.rb | 6 +++--- .../passkeys/passkeys_generator_spec.rb | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/generators/clearance/passkeys/passkeys_generator.rb b/lib/generators/clearance/passkeys/passkeys_generator.rb index 8bffab98a..4076b7195 100644 --- a/lib/generators/clearance/passkeys/passkeys_generator.rb +++ b/lib/generators/clearance/passkeys/passkeys_generator.rb @@ -21,10 +21,10 @@ def create_migrations def inject_passkeys_into_user_model return unless File.exist?("app/models/user.rb") - inject_into_class( + inject_into_file( "app/models/user.rb", - "User", - " has_many :passkeys, class_name: \"Clearance::Passkey\", dependent: :destroy\n" + " has_many :passkeys, class_name: \"Clearance::Passkey\", dependent: :destroy\n", + after: "include Clearance::User\n" ) end diff --git a/spec/generators/clearance/passkeys/passkeys_generator_spec.rb b/spec/generators/clearance/passkeys/passkeys_generator_spec.rb index 3711c4f4d..0a9c33354 100644 --- a/spec/generators/clearance/passkeys/passkeys_generator_spec.rb +++ b/spec/generators/clearance/passkeys/passkeys_generator_spec.rb @@ -73,19 +73,22 @@ def stub_passkeys_table_absent end describe "user model" do - it "injects has_many :passkeys into an existing user model" do + it "injects has_many :passkeys after include Clearance::User" do stub_columns_for_users(without: "webauthn_id") stub_passkeys_table_absent - provide_existing_user_class + provide_user_class_with_clearance run_generator user_model = file("app/models/user.rb") + content = user_model.read expect(user_model).to exist expect(user_model).to have_correct_syntax expect(user_model).to contain( 'has_many :passkeys, class_name: "Clearance::Passkey", dependent: :destroy' ) + expect(content.index("include Clearance::User")).to be < + content.index("has_many :passkeys") end it "does not create the user model if it does not exist" do @@ -97,6 +100,16 @@ def stub_passkeys_table_absent expect(user_model).not_to exist end + + def provide_user_class_with_clearance + FileUtils.mkdir_p(File.join(destination_root, "app/models")) + File.write( + File.join(destination_root, "app/models/user.rb"), + "class User < ApplicationRecord\n include Clearance::User\nend\n" + ) + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with("app/models/user.rb").and_return(true) + end end def stub_passkeys_table_present From 3759cd289532f3f1c9802f3eed836de01e45fc4b Mon Sep 17 00:00:00 2001 From: Valeria Graffeo Date: Thu, 23 Apr 2026 15:04:50 +0200 Subject: [PATCH 4/5] Improve passkeys generator README Replace deprecated config.origin= with config.allowed_origins=, and expand the frontend setup instructions to show the importmap install path and working JS snippets for both the registration and authentication flows. --- .../clearance/passkeys/templates/README | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/lib/generators/clearance/passkeys/templates/README b/lib/generators/clearance/passkeys/templates/README index 1a728e58d..b060ff3b0 100644 --- a/lib/generators/clearance/passkeys/templates/README +++ b/lib/generators/clearance/passkeys/templates/README @@ -6,12 +6,13 @@ Next steps: # config/initializers/webauthn.rb WebAuthn.configure do |config| - config.origin = "https://yourapp.example.com" + config.allowed_origins = ["https://yourapp.example.com"] config.rp_name = "Your Application Name" end In development, use your local server URL (e.g. "http://localhost:3000"). In production, use your app's full HTTPS URL. + allowed_origins takes an array, so you can list multiple origins if needed. 2. Migrate: @@ -20,12 +21,38 @@ Next steps: 3. Handle credential encoding on the frontend: The WebAuthn browser API returns ArrayBuffers, not plain strings. Use - a library like @github/webauthn-json to handle Base64url encoding - between the browser's native credential objects and the JSON your - server expects: - - npm install @github/webauthn-json - - See https://github.com/github/webauthn-json for usage. + @github/webauthn-json to handle Base64url encoding between the + browser's native credential objects and the JSON your server expects. + + Install it using your app's JavaScript setup. For example, with + importmaps (Rails default): + + ./bin/importmap pin @github/webauthn-json + + Then in your JavaScript: + + import { create, get } from "@github/webauthn-json" + + // Registration (user must be signed in) + const options = await fetch("/passkeys/new").then(r => r.json()) + const credential = await create({ publicKey: options }) + await fetch("/passkeys", { + method: "POST", + headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, + body: JSON.stringify({ ...credential, label: "My device" }) + }) + + // Authentication + const options = await fetch("/passkey_authentication/new").then(r => r.json()) + const credential = await get({ publicKey: options }) + const result = await fetch("/passkey_authentication", { + method: "POST", + headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, + body: JSON.stringify(credential) + }).then(r => r.json()) + window.location = result.redirect_to + + See https://github.com/github/webauthn-json for other installation + options. ******************************************************************************* From e77489d07dc595cc483a0d14828fbddb817ba795 Mon Sep 17 00:00:00 2001 From: Valeria Graffeo Date: Thu, 23 Apr 2026 15:15:06 +0200 Subject: [PATCH 5/5] Document importmap pin step in passkeys generator README Add the missing instruction to pin the app's passkeys JS file in config/importmap.rb, without which the module specifier cannot be resolved and the WebAuthn flows silently fail. --- lib/generators/clearance/passkeys/templates/README | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/generators/clearance/passkeys/templates/README b/lib/generators/clearance/passkeys/templates/README index b060ff3b0..3052e7f82 100644 --- a/lib/generators/clearance/passkeys/templates/README +++ b/lib/generators/clearance/passkeys/templates/README @@ -29,7 +29,11 @@ Next steps: ./bin/importmap pin @github/webauthn-json - Then in your JavaScript: + Then pin your own passkeys JS file in config/importmap.rb: + + pin "passkeys", to: "passkeys.js" + + Then in your JavaScript (app/javascript/passkeys.js): import { create, get } from "@github/webauthn-json"