diff --git a/CHANGELOG.md b/CHANGELOG.md index bd3a9fe8..f4116822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- Options for getting or creating passkeys and security keys are now served by dedicated Rails controllers and retrieved via JavaScript fetch requests. [#73](https://github.com/cedarcode/devise-webauthn/pull/73) [@nicolastemciuc] + ## [v0.3.0](https://github.com/cedarcode/devise-webauthn/compare/v0.2.2...v0.3.0/) - 2026-01-16 ### Added diff --git a/app/assets/javascript/devise/webauthn.js b/app/assets/javascript/devise/webauthn.js index b61bf0a6..7639a31d 100644 --- a/app/assets/javascript/devise/webauthn.js +++ b/app/assets/javascript/devise/webauthn.js @@ -20,8 +20,8 @@ export class WebauthnCreateElement extends HTMLElement { event.preventDefault(); try { - const options = JSON.parse(this.getAttribute('data-options-json')); - const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options); + const response = await fetch(this.getAttribute('data-options-url')); + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(await response.json()); const credential = await navigator.credentials.create({ publicKey }); this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyRegistrationCredentialWithGracefullyHandlingAuthenticatorIssues(credential); @@ -101,8 +101,8 @@ export class WebauthnGetElement extends HTMLElement { event.preventDefault(); try { - const options = JSON.parse(this.getAttribute('data-options-json')); - const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options); + const response = await fetch(this.getAttribute('data-options-url')); + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(await response.json()); const credential = await navigator.credentials.get({ publicKey }); this.querySelector('[data-webauthn-target="response"]').value = await this.stringifyAuthenticationCredentialWithGracefullyHandlingAuthenticatorIssues(credential); diff --git a/app/controllers/devise/passkey_authentication_options_controller.rb b/app/controllers/devise/passkey_authentication_options_controller.rb new file mode 100644 index 00000000..1747ff10 --- /dev/null +++ b/app/controllers/devise/passkey_authentication_options_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Devise + class PasskeyAuthenticationOptionsController < DeviseController + def index + passkey_options = + WebAuthn::Credential.options_for_get( + user_verification: "required" + ) + + # Store challenge in session for later verification + session[:authentication_challenge] = passkey_options.challenge + + render json: passkey_options + end + end +end diff --git a/app/controllers/devise/passkey_registration_options_controller.rb b/app/controllers/devise/passkey_registration_options_controller.rb new file mode 100644 index 00000000..f1a7aa7f --- /dev/null +++ b/app/controllers/devise/passkey_registration_options_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Devise + class PasskeyRegistrationOptionsController < DeviseController + before_action :authenticate_scope! + + def index + passkey_options = + WebAuthn::Credential.options_for_create( + user: { + id: resource.webauthn_id, + name: resource_human_palatable_identifier + }, + exclude: resource.passkeys.pluck(:external_id), + authenticator_selection: { + resident_key: "required", + user_verification: "required" + } + ) + + # Store challenge in session for later verification + session[:webauthn_challenge] = passkey_options.challenge + + render json: passkey_options + end + + private + + def authenticate_scope! + send(:"authenticate_#{resource_name}!", force: true) + self.resource = send(:"current_#{resource_name}") + end + + def resource_human_palatable_identifier + authentication_keys = resource.class.authentication_keys + authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash) + + authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first + end + end +end diff --git a/app/controllers/devise/security_key_authentication_options_controller.rb b/app/controllers/devise/security_key_authentication_options_controller.rb new file mode 100644 index 00000000..fe24415e --- /dev/null +++ b/app/controllers/devise/security_key_authentication_options_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Devise + class SecurityKeyAuthenticationOptionsController < DeviseController + before_action :set_resource + + def index + security_key_authentication_options = + WebAuthn::Credential.options_for_get( + allow: @resource.webauthn_credentials.pluck(:external_id), + user_verification: "discouraged" + ) + + # Store challenge in session for later verification + session[:two_factor_authentication_challenge] = security_key_authentication_options.challenge + + render json: security_key_authentication_options + end + + private + + def set_resource + @resource = resource_class.find(session[:current_authentication_resource_id]) + end + end +end diff --git a/app/controllers/devise/security_key_registration_options_controller.rb b/app/controllers/devise/security_key_registration_options_controller.rb new file mode 100644 index 00000000..3d13405a --- /dev/null +++ b/app/controllers/devise/security_key_registration_options_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Devise + class SecurityKeyRegistrationOptionsController < DeviseController + before_action :authenticate_scope! + + def index + create_security_key_options = + WebAuthn::Credential.options_for_create( + user: { + id: resource.webauthn_id, + name: resource_human_palatable_identifier + }, + exclude: resource.webauthn_credentials.pluck(:external_id), + authenticator_selection: { + resident_key: "discouraged", + user_verification: "discouraged" + } + ) + + # Store challenge in session for later verification + session[:webauthn_challenge] = create_security_key_options.challenge + + render json: create_security_key_options + end + + private + + def authenticate_scope! + send(:"authenticate_#{resource_name}!", force: true) + self.resource = send(:"current_#{resource_name}") + end + + def resource_human_palatable_identifier + authentication_keys = resource.class.authentication_keys + authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash) + + authentication_keys.filter_map { |authentication_key| resource.public_send(authentication_key) }.first + end + end +end diff --git a/lib/devise/webauthn/helpers/credentials_helper.rb b/lib/devise/webauthn/helpers/credentials_helper.rb index 7dc87633..2173263a 100644 --- a/lib/devise/webauthn/helpers/credentials_helper.rb +++ b/lib/devise/webauthn/helpers/credentials_helper.rb @@ -10,7 +10,7 @@ def passkey_creation_form_for(resource, form_classes: nil, &block) method: :post, class: form_classes ) do |f| - tag.webauthn_create(data: { options_json: create_passkey_options(resource) }) do + tag.webauthn_create(data: { options_url: passkey_registration_options_path(resource) }) do concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" }) concat capture(f, &block) end @@ -23,7 +23,7 @@ def login_with_passkey_button(text = nil, session_path:, button_classes: nil, fo method: :post, class: form_classes ) do |f| - tag.webauthn_get(data: { options_json: passkey_authentication_options }) do + tag.webauthn_get(data: { options_url: passkey_authentication_options_path(resource) }) do concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" }) concat f.button(text, type: "submit", class: button_classes, &block) @@ -37,7 +37,9 @@ def security_key_creation_form_for(resource, form_classes: nil, &block) method: :post, class: form_classes ) do |f| - tag.webauthn_create(data: { options_json: create_security_key_options(resource) }) do + tag.webauthn_create( + data: { options_url: security_key_registration_options_path(resource) } + ) do concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" }) concat capture(f, &block) end @@ -50,7 +52,7 @@ def login_with_security_key_button(text = nil, resource:, button_classes: nil, f method: :post, class: form_classes ) do |f| - tag.webauthn_get(data: { options_json: security_key_authentication_options(resource) }) do + tag.webauthn_get(data: { options_url: security_key_authentication_options_path(resource) }) do concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" }) concat f.button(text, type: "submit", class: button_classes, &block) end diff --git a/lib/devise/webauthn/routes.rb b/lib/devise/webauthn/routes.rb index 2a18ad49..bbfad070 100644 --- a/lib/devise/webauthn/routes.rb +++ b/lib/devise/webauthn/routes.rb @@ -7,6 +7,10 @@ class Mapper def devise_passkey_authentication(_mapping, controllers) resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] + + resources :passkey_authentication_options, only: :index, + controller: controllers[:passkey_authentication_options] + resources :passkey_registration_options, only: :index, controller: controllers[:passkey_registration_options] end def devise_two_factor_authentication(_mapping, controllers) @@ -17,6 +21,11 @@ def devise_two_factor_authentication(_mapping, controllers) resources :second_factor_webauthn_credentials, only: %i[new create update destroy], controller: controllers[:second_factor_webauthn_credentials] + + resources :security_key_authentication_options, only: %i[index], + controller: controllers[:security_key_authentication_options] + resources :security_key_registration_options, only: %i[index], + controller: controllers[:security_key_registration_options] end end end diff --git a/lib/devise/webauthn/url_helpers.rb b/lib/devise/webauthn/url_helpers.rb index b0eb17b5..d0894b74 100644 --- a/lib/devise/webauthn/url_helpers.rb +++ b/lib/devise/webauthn/url_helpers.rb @@ -24,9 +24,13 @@ module UrlHelpers { passkeys: [nil], passkey: [nil, :new], + passkey_authentication_options: [nil], + passkey_registration_options: [nil], two_factor_authentication: [nil, :new], second_factor_webauthn_credentials: [nil], - second_factor_webauthn_credential: [nil, :new] + second_factor_webauthn_credential: [nil, :new], + security_key_authentication_options: [nil], + security_key_registration_options: [nil] }.each do |route, actions| %i[path url].each do |path_or_url| actions.each do |action| diff --git a/lib/generators/devise/webauthn/controllers_generator.rb b/lib/generators/devise/webauthn/controllers_generator.rb index ac271767..8c31e45a 100644 --- a/lib/generators/devise/webauthn/controllers_generator.rb +++ b/lib/generators/devise/webauthn/controllers_generator.rb @@ -9,6 +9,10 @@ class ControllersGenerator < Rails::Generators::Base passkeys second_factor_webauthn_credentials two_factor_authentications + passkey_authentication_options + passkey_registration_options + security_key_authentication_options + security_key_registration_options ].freeze desc "Create inherited Devise::Webauthn controllers in your app/controllers folder." diff --git a/lib/generators/devise/webauthn/templates/controllers/passkey_authentication_options_controller.rb.tt b/lib/generators/devise/webauthn/templates/controllers/passkey_authentication_options_controller.rb.tt new file mode 100644 index 00000000..313a52dc --- /dev/null +++ b/lib/generators/devise/webauthn/templates/controllers/passkey_authentication_options_controller.rb.tt @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class <%= @scope_prefix %>PasskeyAuthenticationOptionsController < Devise::PasskeyAuthenticationOptionsController + # GET /resource/passkey_authentication_options + # def index + # super + # end +end diff --git a/lib/generators/devise/webauthn/templates/controllers/passkey_registration_options_controller.rb.tt b/lib/generators/devise/webauthn/templates/controllers/passkey_registration_options_controller.rb.tt new file mode 100644 index 00000000..0db021c8 --- /dev/null +++ b/lib/generators/devise/webauthn/templates/controllers/passkey_registration_options_controller.rb.tt @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class <%= @scope_prefix %>PasskeyRegistrationOptionsController < Devise::PasskeyRegistrationOptionsController + # GET /resource/passkey_registration_options + # def index + # super + # end +end diff --git a/lib/generators/devise/webauthn/templates/controllers/security_key_authentication_options_controller.rb.tt b/lib/generators/devise/webauthn/templates/controllers/security_key_authentication_options_controller.rb.tt new file mode 100644 index 00000000..dcc908d5 --- /dev/null +++ b/lib/generators/devise/webauthn/templates/controllers/security_key_authentication_options_controller.rb.tt @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class <%= @scope_prefix %>SecurityKeyAuthenticationOptionsController < Devise::SecurityKeyAuthenticationOptionsController + # GET /resource/security_key_authentication_options + # def index + # super + # end +end diff --git a/lib/generators/devise/webauthn/templates/controllers/security_key_registration_options_controller.rb.tt b/lib/generators/devise/webauthn/templates/controllers/security_key_registration_options_controller.rb.tt new file mode 100644 index 00000000..0397f605 --- /dev/null +++ b/lib/generators/devise/webauthn/templates/controllers/security_key_registration_options_controller.rb.tt @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class <%= @scope_prefix %>SecurityKeyRegistrationOptionsController < Devise::SecurityKeyRegistrationOptionsController + # GET /resource/securiy_key_registration_options + # def index + # super + # end +end diff --git a/spec/generators/devise/webauthn/controllers_generator_spec.rb b/spec/generators/devise/webauthn/controllers_generator_spec.rb index e3d61b9a..99116398 100644 --- a/spec/generators/devise/webauthn/controllers_generator_spec.rb +++ b/spec/generators/devise/webauthn/controllers_generator_spec.rb @@ -20,6 +20,10 @@ assert_no_file "app/controllers/passkeys_controller.rb" assert_no_file "app/controllers/second_factor_webauthn_credentials_controller.rb" assert_no_file "app/controllers/two_factor_authentications_controller.rb" + assert_no_file "app/controllers/passkey_authentication_options_controller.rb" + assert_no_file "app/controllers/passkey_registration_options_controller.rb" + assert_no_file "app/controllers/security_key_authentication_options_controller.rb" + assert_no_file "app/controllers/security_key_registration_options_controller.rb" end end @@ -32,6 +36,10 @@ assert_file "app/controllers/users/passkeys_controller.rb", /Users::PasskeysController/ assert_file "app/controllers/users/second_factor_webauthn_credentials_controller.rb", /Users::SecondFactorWebauthnCredentialsController/ assert_file "app/controllers/users/two_factor_authentications_controller.rb", /Users::TwoFactorAuthenticationsController/ + assert_file "app/controllers/users/passkey_authentication_options_controller.rb", /Users::PasskeyAuthenticationOptionsController/ + assert_file "app/controllers/users/passkey_registration_options_controller.rb", /Users::PasskeyRegistrationOptionsController/ + assert_file "app/controllers/users/security_key_authentication_options_controller.rb", /Users::SecurityKeyAuthenticationOptionsController/ + assert_file "app/controllers/users/security_key_registration_options_controller.rb", /Users::SecurityKeyRegistrationOptionsController/ end end end diff --git a/spec/requests/devise/passkey_authentication_options_controller_spec.rb b/spec/requests/devise/passkey_authentication_options_controller_spec.rb new file mode 100644 index 00000000..689d30b8 --- /dev/null +++ b/spec/requests/devise/passkey_authentication_options_controller_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Devise::PasskeyAuthenticationOptionsController, type: :request do + describe "GET #index" do + it "stores the challenge in session and returns it as json" do + get account_passkey_authentication_options_path + + expect(response).to have_http_status(:ok) + + json = response.parsed_body + expect(json["challenge"]).to be_present + expect(session[:authentication_challenge]).to eq(json["challenge"]) + end + + it "generates a new challenge on each request" do + get account_passkey_authentication_options_path + first_challenge = session[:authentication_challenge] + + get account_passkey_authentication_options_path + second_challenge = session[:authentication_challenge] + + expect(first_challenge).to be_present + expect(second_challenge).not_to eq(first_challenge) + end + end +end diff --git a/spec/requests/devise/passkey_authentication_spec.rb b/spec/requests/devise/passkey_authentication_spec.rb index 03fba375..c7fdd14d 100644 --- a/spec/requests/devise/passkey_authentication_spec.rb +++ b/spec/requests/devise/passkey_authentication_spec.rb @@ -33,6 +33,10 @@ def generate_assertion(fake_client, challenge:, credential:) describe "sign-in with passkeys" do let!(:passkey) { create_passkey_for(user, client) } + before do + get account_passkey_authentication_options_path # To set the challenge in session + end + it "completes authentication with valid credential" do get new_account_session_path diff --git a/spec/requests/devise/passkey_registration_options_controller_spec.rb b/spec/requests/devise/passkey_registration_options_controller_spec.rb new file mode 100644 index 00000000..dc0cf94e --- /dev/null +++ b/spec/requests/devise/passkey_registration_options_controller_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Devise::PasskeyRegistrationOptionsController, type: :request do + let(:user) { Account.create!(email: "test@example.com", password: "password123") } + + describe "GET #index" do + context "when user is not authenticated" do + it "redirects to the sign-in page" do + get account_passkey_registration_options_path + expect(response).to redirect_to(new_account_session_path) + end + end + + context "when user is authenticated" do + before do + sign_in user, scope: :account + end + + it "returns webauthn create options as json and stores the challenge in session" do + get account_passkey_registration_options_path + + expect(response).to have_http_status(:ok) + + json = response.parsed_body + expect(json["challenge"]).to be_present + + expect(session[:webauthn_challenge]).to eq(json["challenge"]) + end + + it "generates a new challenge on each request" do + get account_passkey_registration_options_path + first_challenge = session[:webauthn_challenge] + + get account_passkey_registration_options_path + second_challenge = session[:webauthn_challenge] + + expect(first_challenge).to be_present + expect(second_challenge).to be_present + expect(second_challenge).not_to eq(first_challenge) + end + end + end +end diff --git a/spec/requests/devise/passkeys_controller_spec.rb b/spec/requests/devise/passkeys_controller_spec.rb index be8ceef0..bbb6f50f 100644 --- a/spec/requests/devise/passkeys_controller_spec.rb +++ b/spec/requests/devise/passkeys_controller_spec.rb @@ -31,7 +31,7 @@ context "when user is authenticated" do before do sign_in user, scope: :account - get new_account_passkey_path # To set the challenge in session + get account_passkey_registration_options_path # To set the challenge in session end context "with valid parameters" do diff --git a/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb b/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb index d8853af4..1ea20354 100644 --- a/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb +++ b/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb @@ -41,7 +41,7 @@ context "when user is authenticated" do before do sign_in user - get new_account_second_factor_webauthn_credential_path # To set the challenge in session + get account_security_key_registration_options_path # To set the challenge in session end context "with valid parameters" do diff --git a/spec/requests/devise/security_key_authentication_options_controller_spec.rb b/spec/requests/devise/security_key_authentication_options_controller_spec.rb new file mode 100644 index 00000000..c0420d12 --- /dev/null +++ b/spec/requests/devise/security_key_authentication_options_controller_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Devise::SecurityKeyAuthenticationOptionsController, type: :request do + let(:user) { Account.create!(email: "test@example.com", password: "password123") } + + describe "GET #index" do + before do + user.passkeys.create!( + external_id: "external-id", + name: "My Passkey", + public_key: "public-key", + sign_count: 0 + ) + + post account_session_path, params: { + account: { + email: user.email, + password: "password123" + } + } + end + + it "returns authentication options and stores the challenge in the session" do + get account_security_key_authentication_options_path + + expect(response).to have_http_status(:ok) + + body = response.parsed_body + expect(body).to include("challenge") + + expect(session[:two_factor_authentication_challenge]).to eq(body["challenge"]) + end + end +end diff --git a/spec/requests/devise/security_key_registration_options_controller_spec.rb b/spec/requests/devise/security_key_registration_options_controller_spec.rb new file mode 100644 index 00000000..f1ad88e9 --- /dev/null +++ b/spec/requests/devise/security_key_registration_options_controller_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Devise::SecurityKeyRegistrationOptionsController, type: :request do + let(:user) { Account.create!(email: "test@example.com", password: "password123") } + + describe "GET #index" do + context "when user is not authenticated" do + it "redirects to the sign-in page" do + get account_security_key_registration_options_path + expect(response).to redirect_to(new_account_session_path) + end + end + + context "when user is authenticated" do + before do + sign_in user, scope: :account + end + + it "stores the challenge in session and returns it as json" do + get account_security_key_registration_options_path + + expect(response).to have_http_status(:ok) + + json = response.parsed_body + expect(json["challenge"]).to be_present + expect(session[:webauthn_challenge]).to eq(json["challenge"]) + end + + it "generates a new challenge on each request" do + get account_security_key_registration_options_path + first_challenge = session[:webauthn_challenge] + + get account_security_key_registration_options_path + second_challenge = session[:webauthn_challenge] + + expect(first_challenge).to be_present + expect(second_challenge).not_to eq(first_challenge) + end + end + end +end diff --git a/spec/requests/devise/two_factor_authentication_spec.rb b/spec/requests/devise/two_factor_authentication_spec.rb index 63d0e7c3..5d12bf2b 100644 --- a/spec/requests/devise/two_factor_authentication_spec.rb +++ b/spec/requests/devise/two_factor_authentication_spec.rb @@ -44,6 +44,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to eq(I18n.t("devise.failure.two_factor_required")) + get account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil @@ -83,6 +84,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) + get account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil @@ -112,6 +114,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) + get account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil @@ -142,6 +145,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) + get account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil @@ -169,6 +173,7 @@ def generate_assertion(fake_client, challenge:, credential:) expect(response).to have_http_status(:ok) expect(flash[:notice]).to include(I18n.t("devise.failure.two_factor_required")) + get account_security_key_authentication_options_path # To set the challenge in session expect(session[:current_authentication_resource_id]).to eq(user.id) expect(session[:two_factor_authentication_challenge]).not_to be_nil diff --git a/spec/system/manage_webauthn_credentials_spec.rb b/spec/system/manage_webauthn_credentials_spec.rb index c5bdf90b..29c6521b 100644 --- a/spec/system/manage_webauthn_credentials_spec.rb +++ b/spec/system/manage_webauthn_credentials_spec.rb @@ -26,6 +26,7 @@ fill_in "Passkey name", with: "My Passkey" click_button "Create Passkey" + expect(page).to have_current_path(new_account_passkey_path) expect(page).to have_content("Passkey created successfully.") end end @@ -42,6 +43,7 @@ fill_in "Passkey name", with: "My Passkey" click_button "Create Passkey" + expect(page).to have_current_path(new_account_passkey_path) expect(page).to have_content("Passkey verification failed.") end end @@ -55,6 +57,7 @@ fill_in "Security Key name", with: "My Security Key" click_button "Create Security Key" + expect(page).to have_current_path(new_account_second_factor_webauthn_credential_path) expect(page).to have_content("Security Key created successfully.") end end @@ -71,6 +74,7 @@ fill_in "Security Key name", with: "My Security Key" click_button "Create Security Key" + expect(page).to have_current_path(new_account_second_factor_webauthn_credential_path) expect(page).to have_content("Webauthn credential verification failed.") end end @@ -93,6 +97,7 @@ click_button "Upgrade to Passkey" end + expect(page).to have_current_path(root_path) expect(page).to have_content("Security Key promoted to passkey successfully.") within passkeys_section do diff --git a/spec/system/sign_in_with_webauthn_spec.rb b/spec/system/sign_in_with_webauthn_spec.rb index 962d34ab..8468926a 100644 --- a/spec/system/sign_in_with_webauthn_spec.rb +++ b/spec/system/sign_in_with_webauthn_spec.rb @@ -14,6 +14,7 @@ authenticator.remove! end + # rubocop:disable RSpec/MultipleExpectations describe "sign in using passkeys" do before do add_passkey_to_authenticator(authenticator, user) @@ -23,6 +24,7 @@ visit new_account_session_path click_button "Log in with passkeys" + expect(page).to have_current_path(root_path) expect(page).to have_content("Signed in successfully.") end @@ -34,14 +36,18 @@ click_button "Log in" + expect(page).to have_current_path(new_account_two_factor_authentication_path) expect(page).to have_content("Two-factor authentication is required to sign in.") click_button "Use security key" + expect(page).to have_current_path(root_path) expect(page).to have_content("Signed in successfully.") end end + # rubocop:enable RSpec/MultipleExpectations + # rubocop:disable RSpec/MultipleExpectations describe "sign in with security keys as second factor" do before do add_security_key_to_authenticator(authenticator, user) @@ -55,10 +61,12 @@ click_button "Log in" + expect(page).to have_current_path(new_account_two_factor_authentication_path) expect(page).to have_content("Two-factor authentication is required to sign in.") click_button "Use security key" + expect(page).to have_current_path(root_path) expect(page).to have_content("Signed in successfully.") expect(remember_cookie).to be_nil end @@ -77,10 +85,12 @@ click_button "Log in" + expect(page).to have_current_path(new_account_two_factor_authentication_path) expect(page).to have_content("Two-factor authentication is required to sign in.") click_button "Use security key" + expect(page).to have_current_path(new_account_two_factor_authentication_path) expect(page).to have_content("Webauthn credential verification failed.") expect(page).to have_button("Use security key") end @@ -96,15 +106,18 @@ click_button "Log in" + expect(page).to have_current_path(new_account_two_factor_authentication_path) expect(page).to have_content("Two-factor authentication is required to sign in.") click_button "Use security key" + expect(page).to have_current_path(root_path) expect(page).to have_content("Signed in successfully.") expect(remember_cookie).to be_present end end end + # rubocop:enable RSpec/MultipleExpectations def remember_cookie page.driver.browser.manage.cookie_named("remember_account_token")