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/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/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/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.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..61c666449 --- /dev/null +++ b/lib/clearance/passkey.rb @@ -0,0 +1,10 @@ +require "webauthn" + +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..4076b7195 --- /dev/null +++ b/lib/generators/clearance/passkeys/passkeys_generator.rb @@ -0,0 +1,79 @@ +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 + + def inject_passkeys_into_user_model + return unless File.exist?("app/models/user.rb") + + inject_into_file( + "app/models/user.rb", + " has_many :passkeys, class_name: \"Clearance::Passkey\", dependent: :destroy\n", + after: "include Clearance::User\n" + ) + end + + def display_readme_in_terminal + readme "README" + 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/README b/lib/generators/clearance/passkeys/templates/README new file mode 100644 index 000000000..3052e7f82 --- /dev/null +++ b/lib/generators/clearance/passkeys/templates/README @@ -0,0 +1,62 @@ +******************************************************************************* + +Next steps: + +1. Configure WebAuthn in an initializer: + + # config/initializers/webauthn.rb + WebAuthn.configure do |config| + 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: + + 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 + @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 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" + + // 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. + +******************************************************************************* 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/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/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/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/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..0a9c33354 --- /dev/null +++ b/spec/generators/clearance/passkeys/passkeys_generator_spec.rb @@ -0,0 +1,119 @@ +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 + + describe "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_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 + 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 + + 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 + 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