From a522711435fc12c2d52cf23995d1fa93f88b1021 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Wed, 10 Dec 2025 13:04:00 -0300 Subject: [PATCH 01/17] feat: migrate to use options_for_get --- app/controllers/devise/passkeys_controller.rb | 12 +++++++++ ..._factor_webauthn_credentials_controller.rb | 14 ++++++++++ .../webauthn/helpers/credentials_helper.rb | 27 ------------------- lib/devise/webauthn/routes.rb | 8 ++++-- lib/devise/webauthn/url_helpers.rb | 4 +-- 5 files changed, 34 insertions(+), 31 deletions(-) diff --git a/app/controllers/devise/passkeys_controller.rb b/app/controllers/devise/passkeys_controller.rb index ae67f5c3..78727d4a 100644 --- a/app/controllers/devise/passkeys_controller.rb +++ b/app/controllers/devise/passkeys_controller.rb @@ -32,6 +32,18 @@ def destroy redirect_to after_update_path end + def options_for_get + passkey_authentication_options = + WebAuthn::Credential.options_for_get( + user_verification: "required" + ) + + # Store challenge in session for later verification + session[:authentication_challenge] = passkey_authentication_options.challenge + + render json: passkey_authentication_options + end + private def authenticate_scope! diff --git a/app/controllers/devise/second_factor_webauthn_credentials_controller.rb b/app/controllers/devise/second_factor_webauthn_credentials_controller.rb index 64023c63..cdacf66f 100644 --- a/app/controllers/devise/second_factor_webauthn_credentials_controller.rb +++ b/app/controllers/devise/second_factor_webauthn_credentials_controller.rb @@ -3,6 +3,7 @@ module Devise class SecondFactorWebauthnCredentialsController < DeviseController before_action :authenticate_scope! + before_action :set_resource, only: :options_for_get def new; end @@ -42,6 +43,19 @@ def destroy redirect_to after_destroy_path end + def options_for_get + 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 authenticate_scope! diff --git a/lib/devise/webauthn/helpers/credentials_helper.rb b/lib/devise/webauthn/helpers/credentials_helper.rb index be5d4fa5..07ba0fb5 100644 --- a/lib/devise/webauthn/helpers/credentials_helper.rb +++ b/lib/devise/webauthn/helpers/credentials_helper.rb @@ -80,19 +80,6 @@ def create_passkey_options(resource) end end - def passkey_authentication_options - @passkey_authentication_options ||= begin - options = WebAuthn::Credential.options_for_get( - user_verification: "required" - ) - - # Store challenge in session for later verification - session[:authentication_challenge] = options.challenge - - options - end - end - def create_security_key_options(resource) @create_security_key_options ||= begin options = WebAuthn::Credential.options_for_create( @@ -114,20 +101,6 @@ def create_security_key_options(resource) end end - def security_key_authentication_options(resource) - @security_key_authentication_options ||= begin - 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] = options.challenge - - options - end - end - def resource_human_palatable_identifier authentication_keys = resource.class.authentication_keys authentication_keys = authentication_keys.keys if authentication_keys.is_a?(Hash) diff --git a/lib/devise/webauthn/routes.rb b/lib/devise/webauthn/routes.rb index 2a18ad49..fa4a3b1a 100644 --- a/lib/devise/webauthn/routes.rb +++ b/lib/devise/webauthn/routes.rb @@ -6,7 +6,9 @@ class Mapper protected def devise_passkey_authentication(_mapping, controllers) - resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] + resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] do + get :options_for_get, on: :collection + end end def devise_two_factor_authentication(_mapping, controllers) @@ -16,7 +18,9 @@ def devise_two_factor_authentication(_mapping, controllers) resources :second_factor_webauthn_credentials, only: %i[new create update destroy], - controller: controllers[:second_factor_webauthn_credentials] + controller: controllers[:second_factor_webauthn_credentials] do + get :options_for_get, on: :collection + end end end end diff --git a/lib/devise/webauthn/url_helpers.rb b/lib/devise/webauthn/url_helpers.rb index b0eb17b5..8e91f2eb 100644 --- a/lib/devise/webauthn/url_helpers.rb +++ b/lib/devise/webauthn/url_helpers.rb @@ -22,10 +22,10 @@ module Webauthn # module UrlHelpers { - passkeys: [nil], + passkeys: [nil, :options_for_get], passkey: [nil, :new], two_factor_authentication: [nil, :new], - second_factor_webauthn_credentials: [nil], + second_factor_webauthn_credentials: [nil, :options_for_get], second_factor_webauthn_credential: [nil, :new] }.each do |route, actions| %i[path url].each do |path_or_url| From 673ba4040866aae001320e3e943186f0bb175097 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Wed, 10 Dec 2025 13:04:52 -0300 Subject: [PATCH 02/17] feat: migrate to use options_for_create --- app/controllers/devise/passkeys_controller.rb | 29 ++++++++++- ..._factor_webauthn_credentials_controller.rb | 33 +++++++++++- .../webauthn/helpers/credentials_helper.rb | 51 ------------------- lib/devise/webauthn/routes.rb | 2 + lib/devise/webauthn/url_helpers.rb | 4 +- 5 files changed, 64 insertions(+), 55 deletions(-) diff --git a/app/controllers/devise/passkeys_controller.rb b/app/controllers/devise/passkeys_controller.rb index 78727d4a..4163c3b8 100644 --- a/app/controllers/devise/passkeys_controller.rb +++ b/app/controllers/devise/passkeys_controller.rb @@ -2,7 +2,7 @@ module Devise class PasskeysController < DeviseController - before_action :authenticate_scope! + before_action :authenticate_scope!, only: %i[new create destroy options_for_create] def new; end @@ -44,6 +44,26 @@ def options_for_get render json: passkey_authentication_options end + def options_for_create + create_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] = create_passkey_options.challenge + + render json: create_passkey_options + end + private def authenticate_scope! @@ -70,5 +90,12 @@ def verify_and_save_passkey(passkey_from_params) def after_update_path new_passkey_path(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/second_factor_webauthn_credentials_controller.rb b/app/controllers/devise/second_factor_webauthn_credentials_controller.rb index cdacf66f..2c8f4032 100644 --- a/app/controllers/devise/second_factor_webauthn_credentials_controller.rb +++ b/app/controllers/devise/second_factor_webauthn_credentials_controller.rb @@ -2,7 +2,7 @@ module Devise class SecondFactorWebauthnCredentialsController < DeviseController - before_action :authenticate_scope! + before_action :authenticate_scope!, only: %i[new create destroy options_for_create] before_action :set_resource, only: :options_for_get def new; end @@ -56,6 +56,26 @@ def options_for_get render json: security_key_authentication_options end + def options_for_create + 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! @@ -93,5 +113,16 @@ def after_update_path def after_destroy_path new_second_factor_webauthn_credential_path(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 + + def set_resource + @resource = resource_class.find(session[:current_authentication_resource_id]) + end end end diff --git a/lib/devise/webauthn/helpers/credentials_helper.rb b/lib/devise/webauthn/helpers/credentials_helper.rb index 07ba0fb5..bcbddff4 100644 --- a/lib/devise/webauthn/helpers/credentials_helper.rb +++ b/lib/devise/webauthn/helpers/credentials_helper.rb @@ -56,57 +56,6 @@ def login_with_security_key_button(text = nil, resource:, button_classes: nil, f end end end - - private - - def create_passkey_options(resource) - @create_passkey_options ||= begin - 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] = options.challenge - - options - end - end - - def create_security_key_options(resource) - @create_security_key_options ||= begin - 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] = options.challenge - - options - end - 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 end diff --git a/lib/devise/webauthn/routes.rb b/lib/devise/webauthn/routes.rb index fa4a3b1a..19ca8d3d 100644 --- a/lib/devise/webauthn/routes.rb +++ b/lib/devise/webauthn/routes.rb @@ -8,6 +8,7 @@ class Mapper def devise_passkey_authentication(_mapping, controllers) resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] do get :options_for_get, on: :collection + get :options_for_create, on: :collection end end @@ -20,6 +21,7 @@ def devise_two_factor_authentication(_mapping, controllers) only: %i[new create update destroy], controller: controllers[:second_factor_webauthn_credentials] do get :options_for_get, on: :collection + get :options_for_create, on: :collection end end end diff --git a/lib/devise/webauthn/url_helpers.rb b/lib/devise/webauthn/url_helpers.rb index 8e91f2eb..a965aa9d 100644 --- a/lib/devise/webauthn/url_helpers.rb +++ b/lib/devise/webauthn/url_helpers.rb @@ -22,10 +22,10 @@ module Webauthn # module UrlHelpers { - passkeys: [nil, :options_for_get], + passkeys: [nil, :options_for_get, :options_for_create], passkey: [nil, :new], two_factor_authentication: [nil, :new], - second_factor_webauthn_credentials: [nil, :options_for_get], + second_factor_webauthn_credentials: [nil, :options_for_get, :options_for_create], second_factor_webauthn_credential: [nil, :new] }.each do |route, actions| %i[path url].each do |path_or_url| From 6167f3bbf00227cccfd809e907e47221717b29e3 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Wed, 10 Dec 2025 13:06:03 -0300 Subject: [PATCH 03/17] fix: rubocop offenses --- lib/devise/webauthn/helpers/credentials_helper.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/devise/webauthn/helpers/credentials_helper.rb b/lib/devise/webauthn/helpers/credentials_helper.rb index bcbddff4..2d51df73 100644 --- a/lib/devise/webauthn/helpers/credentials_helper.rb +++ b/lib/devise/webauthn/helpers/credentials_helper.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -# rubocop:disable Metrics/ModuleLength module Devise module Webauthn module CredentialsHelper @@ -59,4 +58,3 @@ def login_with_security_key_button(text = nil, resource:, button_classes: nil, f end end end -# rubocop:enable Metrics/ModuleLength From 7c1b63c2149b5994a50a4120a7a1d7a3393e69f0 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Wed, 10 Dec 2025 13:29:11 -0300 Subject: [PATCH 04/17] test: call new endpoints to set the challenge in the session --- spec/requests/devise/passkeys_controller_spec.rb | 2 +- .../second_factor_webauthn_credentials_controller_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/requests/devise/passkeys_controller_spec.rb b/spec/requests/devise/passkeys_controller_spec.rb index be8ceef0..b7400c42 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 options_for_create_account_passkeys_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..cbee0869 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 options_for_create_account_second_factor_webauthn_credentials_path # To set the challenge in session end context "with valid parameters" do From c886e278170e3b7429c758904f49d012039149b1 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Wed, 10 Dec 2025 14:09:14 -0300 Subject: [PATCH 05/17] test: options_for_get and options_for_create endpoints --- .../devise/passkeys_controller_spec.rb | 58 +++++++++++++ ...or_webauthn_credentials_controller_spec.rb | 83 +++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/spec/requests/devise/passkeys_controller_spec.rb b/spec/requests/devise/passkeys_controller_spec.rb index b7400c42..5d09ea2e 100644 --- a/spec/requests/devise/passkeys_controller_spec.rb +++ b/spec/requests/devise/passkeys_controller_spec.rb @@ -118,4 +118,62 @@ end end end + + # rubocop:disable RSpec/MultipleExpectations + describe "GET #options_for_get" do + it "returns authentication options and stores the challenge in the session" do + get options_for_get_account_passkeys_path + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("application/json") + + body = response.parsed_body + expect(body).to include("challenge") + expect(session[:authentication_challenge]).to be_present + end + end + + describe "GET #options_for_create" do + context "when user is authenticated" do + before do + sign_in user, scope: :account + end + + it "returns passkey creation options and stores the challenge in the session" do + get options_for_create_account_passkeys_path + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("application/json") + + body = response.parsed_body + expect(body).to include("challenge") + expect(body.dig("user", "name")).to eq(user.email) + expect(session[:webauthn_challenge]).to be_present + end + + it "includes existing passkeys in the excludeCredentials list" do + user.passkeys.create!( + external_id: "existing-external-id", + name: "Existing Passkey", + public_key: "public-key", + sign_count: 0 + ) + + get options_for_create_account_passkeys_path + + body = response.parsed_body + exclude_credentials = body["excludeCredentials"] || [] + + expect(exclude_credentials.size).to eq(user.passkeys.count) + end + end + + context "when user is not authenticated" do + it "redirects to the sign-in page" do + get options_for_create_account_passkeys_path + expect(response).to redirect_to(new_account_session_path) + end + end + end + # rubocop:enable RSpec/MultipleExpectations end 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 cbee0869..8e7ece7f 100644 --- a/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb +++ b/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb @@ -157,4 +157,87 @@ end end end + + # rubocop:disable RSpec/MultipleExpectations + describe "GET #options_for_get" do + before do + user.webauthn_credentials.create!( + external_id: "second-factor-id", + name: "Second Factor Key", + public_key: "pk", + sign_count: 0 + ) + + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(described_class) + .to receive(:set_resource) do |instance| + instance.instance_variable_set(:@resource, user) + end + # rubocop:enable RSpec/AnyInstance + end + + it "returns authentication options and stores the challenge in the session" do + get options_for_get_account_second_factor_webauthn_credentials_path + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("application/json") + + body = response.parsed_body + expect(body).to include("challenge") + expect(body["userVerification"]).to eq("discouraged") + + expect(body["allowCredentials"].size).to eq(1) + + expect(session[:two_factor_authentication_challenge]).to be_present + end + end + + describe "GET #options_for_create" do + context "when user is authenticated" do + before do + sign_in user, scope: :account + end + + it "returns security key creation options and stores the challenge in the session" do + get options_for_create_account_second_factor_webauthn_credentials_path + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("application/json") + + body = response.parsed_body + expect(body).to include("challenge") + expect(body.dig("user", "name")).to eq(user.email) + + expect(body.dig("authenticatorSelection", "residentKey")).to eq("discouraged") + expect(body.dig("authenticatorSelection", "userVerification")).to eq("discouraged") + + expect(session[:webauthn_challenge]).to be_present + end + + it "includes existing first-factor credentials in the excludeCredentials list" do + user.webauthn_credentials.create!( + external_id: "existing-id", + name: "Existing Key", + public_key: "pk", + sign_count: 0 + ) + + get options_for_create_account_second_factor_webauthn_credentials_path + + body = response.parsed_body + exclude_credentials = body["excludeCredentials"] || [] + + expect(exclude_credentials.size).to eq(user.webauthn_credentials.count) + end + end + + context "when user is not authenticated" do + it "redirects to the sign-in page" do + get options_for_create_account_second_factor_webauthn_credentials_path + + expect(response).to redirect_to(new_account_session_path) + end + end + end + # rubocop:enable RSpec/MultipleExpectations end From 6a5d083267edb423d988b86b2861689a05980e28 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 11 Dec 2025 10:37:33 -0300 Subject: [PATCH 06/17] test: set authentication factors --- .../second_factor_webauthn_credentials_controller_spec.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 8e7ece7f..18903d58 100644 --- a/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb +++ b/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb @@ -165,7 +165,8 @@ external_id: "second-factor-id", name: "Second Factor Key", public_key: "pk", - sign_count: 0 + sign_count: 0, + authentication_factor: :second_factor ) # rubocop:disable RSpec/AnyInstance @@ -219,7 +220,8 @@ external_id: "existing-id", name: "Existing Key", public_key: "pk", - sign_count: 0 + sign_count: 0, + authentication_factor: :first_factor ) get options_for_create_account_second_factor_webauthn_credentials_path From d5dd6a603e7efb3fe4a44d003128146b87ae049a Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 15 Jan 2026 15:15:55 -0300 Subject: [PATCH 07/17] chore: fetch options endpoints from javascript --- app/assets/javascript/devise/webauthn.js | 8 ++++---- lib/devise/webauthn/helpers/credentials_helper.rb | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/assets/javascript/devise/webauthn.js b/app/assets/javascript/devise/webauthn.js index eae7592d..0adcee19 100644 --- a/app/assets/javascript/devise/webauthn.js +++ b/app/assets/javascript/devise/webauthn.js @@ -4,8 +4,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 = JSON.stringify(credential); @@ -37,8 +37,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 = JSON.stringify(credential); diff --git a/lib/devise/webauthn/helpers/credentials_helper.rb b/lib/devise/webauthn/helpers/credentials_helper.rb index 2d51df73..09990816 100644 --- a/lib/devise/webauthn/helpers/credentials_helper.rb +++ b/lib/devise/webauthn/helpers/credentials_helper.rb @@ -9,7 +9,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: options_for_create_passkeys_path(resource) }) do concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" }) concat capture(f, &block) end @@ -22,7 +22,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: options_for_get_passkeys_path(resource) }) do concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" }) concat f.button(text, type: "submit", class: button_classes, &block) @@ -36,7 +36,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: options_for_create_second_factor_webauthn_credentials_path(resource) } + ) do concat f.hidden_field(:public_key_credential, data: { webauthn_target: "response" }) concat capture(f, &block) end @@ -49,7 +51,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: options_for_get_second_factor_webauthn_credentials_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 From ebc2e8f5672a1cdefd60bd1e9c4b5a9f01243992 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 15 Jan 2026 16:58:16 -0300 Subject: [PATCH 08/17] fix: ensure an authenticated user when upgrading security key --- .../devise/second_factor_webauthn_credentials_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/devise/second_factor_webauthn_credentials_controller.rb b/app/controllers/devise/second_factor_webauthn_credentials_controller.rb index 2c8f4032..dc3e4e16 100644 --- a/app/controllers/devise/second_factor_webauthn_credentials_controller.rb +++ b/app/controllers/devise/second_factor_webauthn_credentials_controller.rb @@ -2,7 +2,7 @@ module Devise class SecondFactorWebauthnCredentialsController < DeviseController - before_action :authenticate_scope!, only: %i[new create destroy options_for_create] + before_action :authenticate_scope!, only: %i[new create update destroy options_for_create] before_action :set_resource, only: :options_for_get def new; end From 983d639f808be7769b4fbef648e01c39ea3d9b87 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Fri, 16 Jan 2026 11:25:48 -0300 Subject: [PATCH 09/17] style(rubocop): disable `Metrics/ModuleLength` cop --- lib/devise/webauthn/helpers/credentials_helper.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/devise/webauthn/helpers/credentials_helper.rb b/lib/devise/webauthn/helpers/credentials_helper.rb index 4753e323..52523219 100644 --- a/lib/devise/webauthn/helpers/credentials_helper.rb +++ b/lib/devise/webauthn/helpers/credentials_helper.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Metrics/ModuleLength module Devise module Webauthn module CredentialsHelper @@ -138,3 +139,4 @@ def resource_human_palatable_identifier end end end +# rubocop:enable Metrics/ModuleLength From 5e231a11779a306f7e327676efd5cc00c139a8bf Mon Sep 17 00:00:00 2001 From: Joaquin Tomas Date: Fri, 16 Jan 2026 11:22:08 -0300 Subject: [PATCH 10/17] test: assert current paths --- spec/system/manage_webauthn_credentials_spec.rb | 5 +++++ spec/system/sign_in_with_webauthn_spec.rb | 13 +++++++++++++ 2 files changed, 18 insertions(+) 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") From d94c938f54e35a94639acbb3955f4a8c3f631162 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Fri, 16 Jan 2026 17:10:26 -0300 Subject: [PATCH 11/17] feat: follow REST standard for getting passkey options --- ...sskey_authentication_options_controller.rb | 17 ++++++ ...passkey_registration_options_controller.rb | 41 +++++++++++++ app/controllers/devise/passkeys_controller.rb | 41 +------------ .../webauthn/helpers/credentials_helper.rb | 4 +- lib/devise/webauthn/routes.rb | 9 +-- lib/devise/webauthn/url_helpers.rb | 4 +- ...ey_authentication_options_controller.rb.tt | 8 +++ ...skey_registration_options_controller.rb.tt | 8 +++ ..._authentication_options_controller_spec.rb | 28 +++++++++ ...ey_registration_options_controller_spec.rb | 45 ++++++++++++++ .../devise/passkeys_controller_spec.rb | 60 +------------------ 11 files changed, 159 insertions(+), 106 deletions(-) create mode 100644 app/controllers/devise/passkey_authentication_options_controller.rb create mode 100644 app/controllers/devise/passkey_registration_options_controller.rb create mode 100644 lib/generators/devise/webauthn/templates/controllers/passkey_authentication_options_controller.rb.tt create mode 100644 lib/generators/devise/webauthn/templates/controllers/passkey_registration_options_controller.rb.tt create mode 100644 spec/requests/devise/passkey_authentication_options_controller_spec.rb create mode 100644 spec/requests/devise/passkey_registration_options_controller_spec.rb 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/passkeys_controller.rb b/app/controllers/devise/passkeys_controller.rb index 4163c3b8..ae67f5c3 100644 --- a/app/controllers/devise/passkeys_controller.rb +++ b/app/controllers/devise/passkeys_controller.rb @@ -2,7 +2,7 @@ module Devise class PasskeysController < DeviseController - before_action :authenticate_scope!, only: %i[new create destroy options_for_create] + before_action :authenticate_scope! def new; end @@ -32,38 +32,6 @@ def destroy redirect_to after_update_path end - def options_for_get - passkey_authentication_options = - WebAuthn::Credential.options_for_get( - user_verification: "required" - ) - - # Store challenge in session for later verification - session[:authentication_challenge] = passkey_authentication_options.challenge - - render json: passkey_authentication_options - end - - def options_for_create - create_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] = create_passkey_options.challenge - - render json: create_passkey_options - end - private def authenticate_scope! @@ -90,12 +58,5 @@ def verify_and_save_passkey(passkey_from_params) def after_update_path new_passkey_path(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 52523219..dce3c8a1 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_url: options_for_create_passkeys_path(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_url: options_for_get_passkeys_path(resource) }) 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) diff --git a/lib/devise/webauthn/routes.rb b/lib/devise/webauthn/routes.rb index 19ca8d3d..dd9a5f15 100644 --- a/lib/devise/webauthn/routes.rb +++ b/lib/devise/webauthn/routes.rb @@ -6,10 +6,11 @@ class Mapper protected def devise_passkey_authentication(_mapping, controllers) - resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] do - get :options_for_get, on: :collection - get :options_for_create, on: :collection - end + resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] + resources :passkey_authentication_options, only: %i[index], + controller: controllers[:passkey_authentication_options] + resources :passkey_registration_options, only: %i[index], + controller: controllers[:passkey_registration_options] end def devise_two_factor_authentication(_mapping, controllers) diff --git a/lib/devise/webauthn/url_helpers.rb b/lib/devise/webauthn/url_helpers.rb index a965aa9d..cd71164e 100644 --- a/lib/devise/webauthn/url_helpers.rb +++ b/lib/devise/webauthn/url_helpers.rb @@ -22,8 +22,10 @@ module Webauthn # module UrlHelpers { - passkeys: [nil, :options_for_get, :options_for_create], + passkeys: [nil], passkey: [nil, :new], + passkey_authentication_options: [nil], + passkey_registration_options: [nil], two_factor_authentication: [nil, :new], second_factor_webauthn_credentials: [nil, :options_for_get, :options_for_create], second_factor_webauthn_credential: [nil, :new] 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/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_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 5d09ea2e..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 options_for_create_account_passkeys_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 @@ -118,62 +118,4 @@ end end end - - # rubocop:disable RSpec/MultipleExpectations - describe "GET #options_for_get" do - it "returns authentication options and stores the challenge in the session" do - get options_for_get_account_passkeys_path - - expect(response).to have_http_status(:ok) - expect(response.media_type).to eq("application/json") - - body = response.parsed_body - expect(body).to include("challenge") - expect(session[:authentication_challenge]).to be_present - end - end - - describe "GET #options_for_create" do - context "when user is authenticated" do - before do - sign_in user, scope: :account - end - - it "returns passkey creation options and stores the challenge in the session" do - get options_for_create_account_passkeys_path - - expect(response).to have_http_status(:ok) - expect(response.media_type).to eq("application/json") - - body = response.parsed_body - expect(body).to include("challenge") - expect(body.dig("user", "name")).to eq(user.email) - expect(session[:webauthn_challenge]).to be_present - end - - it "includes existing passkeys in the excludeCredentials list" do - user.passkeys.create!( - external_id: "existing-external-id", - name: "Existing Passkey", - public_key: "public-key", - sign_count: 0 - ) - - get options_for_create_account_passkeys_path - - body = response.parsed_body - exclude_credentials = body["excludeCredentials"] || [] - - expect(exclude_credentials.size).to eq(user.passkeys.count) - end - end - - context "when user is not authenticated" do - it "redirects to the sign-in page" do - get options_for_create_account_passkeys_path - expect(response).to redirect_to(new_account_session_path) - end - end - end - # rubocop:enable RSpec/MultipleExpectations end From 5523100ca5ef720bc947a64f29a019662bbde215 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Mon, 19 Jan 2026 10:04:05 -0300 Subject: [PATCH 12/17] feat: follow REST standard for getting security key options --- ..._factor_webauthn_credentials_controller.rb | 47 +--------- ...y_key_authentication_options_controller.rb | 26 ++++++ ...ity_key_registration_options_controller.rb | 41 +++++++++ .../webauthn/helpers/credentials_helper.rb | 4 +- lib/devise/webauthn/routes.rb | 9 +- lib/devise/webauthn/url_helpers.rb | 6 +- ...ey_authentication_options_controller.rb.tt | 8 ++ ..._key_registration_options_controller.rb.tt | 8 ++ ...or_webauthn_credentials_controller_spec.rb | 87 +------------------ ..._authentication_options_controller_spec.rb | 36 ++++++++ ...ey_registration_options_controller_spec.rb | 43 +++++++++ 11 files changed, 175 insertions(+), 140 deletions(-) create mode 100644 app/controllers/devise/security_key_authentication_options_controller.rb create mode 100644 app/controllers/devise/security_key_registration_options_controller.rb create mode 100644 lib/generators/devise/webauthn/templates/controllers/security_key_authentication_options_controller.rb.tt create mode 100644 lib/generators/devise/webauthn/templates/controllers/security_key_registration_options_controller.rb.tt create mode 100644 spec/requests/devise/security_key_authentication_options_controller_spec.rb create mode 100644 spec/requests/devise/security_key_registration_options_controller_spec.rb diff --git a/app/controllers/devise/second_factor_webauthn_credentials_controller.rb b/app/controllers/devise/second_factor_webauthn_credentials_controller.rb index dc3e4e16..64023c63 100644 --- a/app/controllers/devise/second_factor_webauthn_credentials_controller.rb +++ b/app/controllers/devise/second_factor_webauthn_credentials_controller.rb @@ -2,8 +2,7 @@ module Devise class SecondFactorWebauthnCredentialsController < DeviseController - before_action :authenticate_scope!, only: %i[new create update destroy options_for_create] - before_action :set_resource, only: :options_for_get + before_action :authenticate_scope! def new; end @@ -43,39 +42,6 @@ def destroy redirect_to after_destroy_path end - def options_for_get - 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 - - def options_for_create - 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! @@ -113,16 +79,5 @@ def after_update_path def after_destroy_path new_second_factor_webauthn_credential_path(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 - - def set_resource - @resource = resource_class.find(session[:current_authentication_resource_id]) - 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 dce3c8a1..2173263a 100644 --- a/lib/devise/webauthn/helpers/credentials_helper.rb +++ b/lib/devise/webauthn/helpers/credentials_helper.rb @@ -38,7 +38,7 @@ def security_key_creation_form_for(resource, form_classes: nil, &block) class: form_classes ) do |f| tag.webauthn_create( - data: { options_url: options_for_create_second_factor_webauthn_credentials_path(resource) } + 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) @@ -52,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_url: options_for_get_second_factor_webauthn_credentials_path(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 dd9a5f15..075664d3 100644 --- a/lib/devise/webauthn/routes.rb +++ b/lib/devise/webauthn/routes.rb @@ -20,10 +20,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] do - get :options_for_get, on: :collection - get :options_for_create, on: :collection - end + 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 cd71164e..d0894b74 100644 --- a/lib/devise/webauthn/url_helpers.rb +++ b/lib/devise/webauthn/url_helpers.rb @@ -27,8 +27,10 @@ module UrlHelpers passkey_authentication_options: [nil], passkey_registration_options: [nil], two_factor_authentication: [nil, :new], - second_factor_webauthn_credentials: [nil, :options_for_get, :options_for_create], - second_factor_webauthn_credential: [nil, :new] + second_factor_webauthn_credentials: [nil], + 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/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/requests/devise/second_factor_webauthn_credentials_controller_spec.rb b/spec/requests/devise/second_factor_webauthn_credentials_controller_spec.rb index 18903d58..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 options_for_create_account_second_factor_webauthn_credentials_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 @@ -157,89 +157,4 @@ end end end - - # rubocop:disable RSpec/MultipleExpectations - describe "GET #options_for_get" do - before do - user.webauthn_credentials.create!( - external_id: "second-factor-id", - name: "Second Factor Key", - public_key: "pk", - sign_count: 0, - authentication_factor: :second_factor - ) - - # rubocop:disable RSpec/AnyInstance - allow_any_instance_of(described_class) - .to receive(:set_resource) do |instance| - instance.instance_variable_set(:@resource, user) - end - # rubocop:enable RSpec/AnyInstance - end - - it "returns authentication options and stores the challenge in the session" do - get options_for_get_account_second_factor_webauthn_credentials_path - - expect(response).to have_http_status(:ok) - expect(response.media_type).to eq("application/json") - - body = response.parsed_body - expect(body).to include("challenge") - expect(body["userVerification"]).to eq("discouraged") - - expect(body["allowCredentials"].size).to eq(1) - - expect(session[:two_factor_authentication_challenge]).to be_present - end - end - - describe "GET #options_for_create" do - context "when user is authenticated" do - before do - sign_in user, scope: :account - end - - it "returns security key creation options and stores the challenge in the session" do - get options_for_create_account_second_factor_webauthn_credentials_path - - expect(response).to have_http_status(:ok) - expect(response.media_type).to eq("application/json") - - body = response.parsed_body - expect(body).to include("challenge") - expect(body.dig("user", "name")).to eq(user.email) - - expect(body.dig("authenticatorSelection", "residentKey")).to eq("discouraged") - expect(body.dig("authenticatorSelection", "userVerification")).to eq("discouraged") - - expect(session[:webauthn_challenge]).to be_present - end - - it "includes existing first-factor credentials in the excludeCredentials list" do - user.webauthn_credentials.create!( - external_id: "existing-id", - name: "Existing Key", - public_key: "pk", - sign_count: 0, - authentication_factor: :first_factor - ) - - get options_for_create_account_second_factor_webauthn_credentials_path - - body = response.parsed_body - exclude_credentials = body["excludeCredentials"] || [] - - expect(exclude_credentials.size).to eq(user.webauthn_credentials.count) - end - end - - context "when user is not authenticated" do - it "redirects to the sign-in page" do - get options_for_create_account_second_factor_webauthn_credentials_path - - expect(response).to redirect_to(new_account_session_path) - end - end - end - # rubocop:enable RSpec/MultipleExpectations end 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 From ceccb3e583592fc5b7340a6dc4747a2397df8a6f Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Tue, 20 Jan 2026 15:03:51 -0300 Subject: [PATCH 13/17] chore: scope options controllers under passkey and security_key --- .../authentication_options_controller.rb | 19 ++++++++ .../registration_options_controller.rb | 43 +++++++++++++++++++ ...sskey_authentication_options_controller.rb | 17 -------- ...passkey_registration_options_controller.rb | 41 ------------------ .../authentication_options_controller.rb | 28 ++++++++++++ .../registration_options_controller.rb | 43 +++++++++++++++++++ ...y_key_authentication_options_controller.rb | 26 ----------- ...ity_key_registration_options_controller.rb | 41 ------------------ lib/devise/webauthn/routes.rb | 20 +++++---- .../authentication_options_controller.rb.tt} | 2 +- .../registration_options_controller.rb.tt} | 2 +- .../authentication_options_controller.rb.tt | 8 ++++ .../registration_options_controller.rb.tt} | 2 +- ...ey_authentication_options_controller.rb.tt | 8 ---- ...authentication_options_controller_spec.rb} | 2 +- .../registration_options_controller_spec.rb} | 2 +- ...authentication_options_controller_spec.rb} | 2 +- .../registration_options_controller_spec.rb} | 2 +- 18 files changed, 160 insertions(+), 148 deletions(-) create mode 100644 app/controllers/devise/passkey/authentication_options_controller.rb create mode 100644 app/controllers/devise/passkey/registration_options_controller.rb delete mode 100644 app/controllers/devise/passkey_authentication_options_controller.rb delete mode 100644 app/controllers/devise/passkey_registration_options_controller.rb create mode 100644 app/controllers/devise/security_key/authentication_options_controller.rb create mode 100644 app/controllers/devise/security_key/registration_options_controller.rb delete mode 100644 app/controllers/devise/security_key_authentication_options_controller.rb delete mode 100644 app/controllers/devise/security_key_registration_options_controller.rb rename lib/generators/devise/webauthn/templates/controllers/{passkey_authentication_options_controller.rb.tt => passkey/authentication_options_controller.rb.tt} (50%) rename lib/generators/devise/webauthn/templates/controllers/{passkey_registration_options_controller.rb.tt => passkey/registration_options_controller.rb.tt} (50%) create mode 100644 lib/generators/devise/webauthn/templates/controllers/security_key/authentication_options_controller.rb.tt rename lib/generators/devise/webauthn/templates/controllers/{security_key_registration_options_controller.rb.tt => security_key/registration_options_controller.rb.tt} (50%) delete mode 100644 lib/generators/devise/webauthn/templates/controllers/security_key_authentication_options_controller.rb.tt rename spec/requests/devise/{passkey_authentication_options_controller_spec.rb => passkey/authentication_options_controller_spec.rb} (90%) rename spec/requests/devise/{passkey_registration_options_controller_spec.rb => passkey/registration_options_controller_spec.rb} (94%) rename spec/requests/devise/{security_key_authentication_options_controller_spec.rb => security_key/authentication_options_controller_spec.rb} (90%) rename spec/requests/devise/{security_key_registration_options_controller_spec.rb => security_key/registration_options_controller_spec.rb} (93%) 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..8b95b927 --- /dev/null +++ b/app/controllers/devise/passkey/authentication_options_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Devise + module Passkey + class AuthenticationOptionsController < 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 +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..5f3d67f8 --- /dev/null +++ b/app/controllers/devise/passkey/registration_options_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Devise + module Passkey + class RegistrationOptionsController < 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 +end diff --git a/app/controllers/devise/passkey_authentication_options_controller.rb b/app/controllers/devise/passkey_authentication_options_controller.rb deleted file mode 100644 index 1747ff10..00000000 --- a/app/controllers/devise/passkey_authentication_options_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index f1a7aa7f..00000000 --- a/app/controllers/devise/passkey_registration_options_controller.rb +++ /dev/null @@ -1,41 +0,0 @@ -# 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..27514e5b --- /dev/null +++ b/app/controllers/devise/security_key/authentication_options_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Devise + module SecurityKey + class AuthenticationOptionsController < 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 +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..e8538887 --- /dev/null +++ b/app/controllers/devise/security_key/registration_options_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Devise + module SecurityKey + class RegistrationOptionsController < 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 +end diff --git a/app/controllers/devise/security_key_authentication_options_controller.rb b/app/controllers/devise/security_key_authentication_options_controller.rb deleted file mode 100644 index fe24415e..00000000 --- a/app/controllers/devise/security_key_authentication_options_controller.rb +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index 3d13405a..00000000 --- a/app/controllers/devise/security_key_registration_options_controller.rb +++ /dev/null @@ -1,41 +0,0 @@ -# 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/routes.rb b/lib/devise/webauthn/routes.rb index 075664d3..d614c771 100644 --- a/lib/devise/webauthn/routes.rb +++ b/lib/devise/webauthn/routes.rb @@ -7,10 +7,11 @@ class Mapper def devise_passkey_authentication(_mapping, controllers) resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] - resources :passkey_authentication_options, only: %i[index], - controller: controllers[:passkey_authentication_options] - resources :passkey_registration_options, only: %i[index], - controller: controllers[:passkey_registration_options] + + resource :passkey do + resources :authentication_options, only: :index, controller: controllers[:"passkey/authentication_options"] + resources :registration_options, only: :index, controller: controllers[:"passkey/registration_options"] + end end def devise_two_factor_authentication(_mapping, controllers) @@ -21,10 +22,13 @@ 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] + + resource :security_key do + resources :authentication_options, only: %i[index], + controller: controllers[:"security_key/authentication_options"] + resources :registration_options, only: %i[index], + controller: controllers[:"security_key/registration_options"] + end end end end 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 similarity index 50% rename from lib/generators/devise/webauthn/templates/controllers/passkey_authentication_options_controller.rb.tt rename to lib/generators/devise/webauthn/templates/controllers/passkey/authentication_options_controller.rb.tt index 313a52dc..c56605cc 100644 --- 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -class <%= @scope_prefix %>PasskeyAuthenticationOptionsController < Devise::PasskeyAuthenticationOptionsController +class <%= @scope_prefix %>Passkey::AuthenticationOptionsController < Devise::PasskeyAuthenticationOptionsController # GET /resource/passkey_authentication_options # def index # super 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 similarity index 50% rename from lib/generators/devise/webauthn/templates/controllers/passkey_registration_options_controller.rb.tt rename to lib/generators/devise/webauthn/templates/controllers/passkey/registration_options_controller.rb.tt index 0db021c8..36ac1af2 100644 --- 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -class <%= @scope_prefix %>PasskeyRegistrationOptionsController < Devise::PasskeyRegistrationOptionsController +class <%= @scope_prefix %>Passkey::RegistrationOptionsController < Devise::PasskeyRegistrationOptionsController # GET /resource/passkey_registration_options # def index # super 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..df6f0744 --- /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 %>SecurityKey::AuthenticationOptionsController < 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 similarity index 50% rename from lib/generators/devise/webauthn/templates/controllers/security_key_registration_options_controller.rb.tt rename to lib/generators/devise/webauthn/templates/controllers/security_key/registration_options_controller.rb.tt index 0397f605..33019293 100644 --- 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -class <%= @scope_prefix %>SecurityKeyRegistrationOptionsController < Devise::SecurityKeyRegistrationOptionsController +class <%= @scope_prefix %>SecurityKey::RegistrationOptionsController < Devise::SecurityKeyRegistrationOptionsController # GET /resource/securiy_key_registration_options # def index # super 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 deleted file mode 100644 index dcc908d5..00000000 --- a/lib/generators/devise/webauthn/templates/controllers/security_key_authentication_options_controller.rb.tt +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class <%= @scope_prefix %>SecurityKeyAuthenticationOptionsController < Devise::SecurityKeyAuthenticationOptionsController - # GET /resource/security_key_authentication_options - # def index - # super - # end -end diff --git a/spec/requests/devise/passkey_authentication_options_controller_spec.rb b/spec/requests/devise/passkey/authentication_options_controller_spec.rb similarity index 90% rename from spec/requests/devise/passkey_authentication_options_controller_spec.rb rename to spec/requests/devise/passkey/authentication_options_controller_spec.rb index 689d30b8..88f5c2e7 100644 --- a/spec/requests/devise/passkey_authentication_options_controller_spec.rb +++ b/spec/requests/devise/passkey/authentication_options_controller_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Devise::PasskeyAuthenticationOptionsController, type: :request do +RSpec.describe Devise::Passkey::AuthenticationOptionsController, 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 diff --git a/spec/requests/devise/passkey_registration_options_controller_spec.rb b/spec/requests/devise/passkey/registration_options_controller_spec.rb similarity index 94% rename from spec/requests/devise/passkey_registration_options_controller_spec.rb rename to spec/requests/devise/passkey/registration_options_controller_spec.rb index dc0cf94e..ac0ddc6c 100644 --- a/spec/requests/devise/passkey_registration_options_controller_spec.rb +++ b/spec/requests/devise/passkey/registration_options_controller_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Devise::PasskeyRegistrationOptionsController, type: :request do +RSpec.describe Devise::Passkey::RegistrationOptionsController, type: :request do let(:user) { Account.create!(email: "test@example.com", password: "password123") } describe "GET #index" 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 similarity index 90% rename from spec/requests/devise/security_key_authentication_options_controller_spec.rb rename to spec/requests/devise/security_key/authentication_options_controller_spec.rb index c0420d12..40a301bf 100644 --- a/spec/requests/devise/security_key_authentication_options_controller_spec.rb +++ b/spec/requests/devise/security_key/authentication_options_controller_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Devise::SecurityKeyAuthenticationOptionsController, type: :request do +RSpec.describe Devise::SecurityKey::AuthenticationOptionsController, type: :request do let(:user) { Account.create!(email: "test@example.com", password: "password123") } describe "GET #index" do diff --git a/spec/requests/devise/security_key_registration_options_controller_spec.rb b/spec/requests/devise/security_key/registration_options_controller_spec.rb similarity index 93% rename from spec/requests/devise/security_key_registration_options_controller_spec.rb rename to spec/requests/devise/security_key/registration_options_controller_spec.rb index f1ad88e9..eb04166f 100644 --- a/spec/requests/devise/security_key_registration_options_controller_spec.rb +++ b/spec/requests/devise/security_key/registration_options_controller_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Devise::SecurityKeyRegistrationOptionsController, type: :request do +RSpec.describe Devise::SecurityKey::RegistrationOptionsController, type: :request do let(:user) { Account.create!(email: "test@example.com", password: "password123") } describe "GET #index" do From be02455182f360783492f2430bcba877c1bb2736 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Tue, 20 Jan 2026 15:13:34 -0300 Subject: [PATCH 14/17] test: set the options in the session --- spec/requests/devise/passkey_authentication_spec.rb | 4 ++++ spec/requests/devise/two_factor_authentication_spec.rb | 5 +++++ 2 files changed, 9 insertions(+) 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/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 From 8c5ede684b6d890919084e77722948cf66b66613 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Tue, 20 Jan 2026 16:38:35 -0300 Subject: [PATCH 15/17] chore: allow generating new controllers --- lib/generators/devise/webauthn/controllers_generator.rb | 4 ++++ .../devise/webauthn/controllers_generator_spec.rb | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/lib/generators/devise/webauthn/controllers_generator.rb b/lib/generators/devise/webauthn/controllers_generator.rb index ac271767..d5257533 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/spec/generators/devise/webauthn/controllers_generator_spec.rb b/spec/generators/devise/webauthn/controllers_generator_spec.rb index e3d61b9a..5bfb93e1 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/authenticator_options_controller.rb" + assert_no_file "app/controllers/passkey/registration_options_controller.rb" + assert_no_file "app/controllers/security_key/authenticator_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::Passkey::AuthenticationOptionsController/ + assert_file "app/controllers/users/passkey/registration_options_controller.rb", /Users::Passkey::RegistrationOptionsController/ + assert_file "app/controllers/users/security_key/authentication_options_controller.rb", /Users::SecurityKey::AuthenticationOptionsController/ + assert_file "app/controllers/users/security_key/registration_options_controller.rb", /Users::SecurityKey::RegistrationOptionsController/ end end end From 81d4261673fa445448430945860a99896e73d543 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 22 Jan 2026 11:25:35 -0300 Subject: [PATCH 16/17] chore: revert scoping controllers --- .../authentication_options_controller.rb | 19 -------- .../registration_options_controller.rb | 43 ------------------- ...sskey_authentication_options_controller.rb | 17 ++++++++ ...passkey_registration_options_controller.rb | 41 ++++++++++++++++++ .../authentication_options_controller.rb | 28 ------------ .../registration_options_controller.rb | 43 ------------------- ...y_key_authentication_options_controller.rb | 26 +++++++++++ ...ity_key_registration_options_controller.rb | 41 ++++++++++++++++++ lib/devise/webauthn/routes.rb | 17 +++----- .../devise/webauthn/controllers_generator.rb | 8 ++-- ...y_authentication_options_controller.rb.tt} | 2 +- ...key_registration_options_controller.rb.tt} | 2 +- .../authentication_options_controller.rb.tt | 8 ---- .../registration_options_controller.rb.tt | 8 ---- ...ey_authentication_options_controller.rb.tt | 8 ++++ ..._key_registration_options_controller.rb.tt | 8 ++++ .../webauthn/controllers_generator_spec.rb | 16 +++---- ...authentication_options_controller_spec.rb} | 2 +- ...y_registration_options_controller_spec.rb} | 2 +- ...authentication_options_controller_spec.rb} | 2 +- ...y_registration_options_controller_spec.rb} | 2 +- 21 files changed, 166 insertions(+), 177 deletions(-) delete mode 100644 app/controllers/devise/passkey/authentication_options_controller.rb delete mode 100644 app/controllers/devise/passkey/registration_options_controller.rb create mode 100644 app/controllers/devise/passkey_authentication_options_controller.rb create mode 100644 app/controllers/devise/passkey_registration_options_controller.rb delete mode 100644 app/controllers/devise/security_key/authentication_options_controller.rb delete mode 100644 app/controllers/devise/security_key/registration_options_controller.rb create mode 100644 app/controllers/devise/security_key_authentication_options_controller.rb create mode 100644 app/controllers/devise/security_key_registration_options_controller.rb rename lib/generators/devise/webauthn/templates/controllers/{passkey/authentication_options_controller.rb.tt => passkey_authentication_options_controller.rb.tt} (50%) rename lib/generators/devise/webauthn/templates/controllers/{passkey/registration_options_controller.rb.tt => passkey_registration_options_controller.rb.tt} (50%) delete mode 100644 lib/generators/devise/webauthn/templates/controllers/security_key/authentication_options_controller.rb.tt delete mode 100644 lib/generators/devise/webauthn/templates/controllers/security_key/registration_options_controller.rb.tt create mode 100644 lib/generators/devise/webauthn/templates/controllers/security_key_authentication_options_controller.rb.tt create mode 100644 lib/generators/devise/webauthn/templates/controllers/security_key_registration_options_controller.rb.tt rename spec/requests/devise/{passkey/authentication_options_controller_spec.rb => passkey_authentication_options_controller_spec.rb} (90%) rename spec/requests/devise/{passkey/registration_options_controller_spec.rb => passkey_registration_options_controller_spec.rb} (94%) rename spec/requests/devise/{security_key/authentication_options_controller_spec.rb => security_key_authentication_options_controller_spec.rb} (90%) rename spec/requests/devise/{security_key/registration_options_controller_spec.rb => security_key_registration_options_controller_spec.rb} (93%) diff --git a/app/controllers/devise/passkey/authentication_options_controller.rb b/app/controllers/devise/passkey/authentication_options_controller.rb deleted file mode 100644 index 8b95b927..00000000 --- a/app/controllers/devise/passkey/authentication_options_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Devise - module Passkey - class AuthenticationOptionsController < 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 -end diff --git a/app/controllers/devise/passkey/registration_options_controller.rb b/app/controllers/devise/passkey/registration_options_controller.rb deleted file mode 100644 index 5f3d67f8..00000000 --- a/app/controllers/devise/passkey/registration_options_controller.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module Devise - module Passkey - class RegistrationOptionsController < 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 -end 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 deleted file mode 100644 index 27514e5b..00000000 --- a/app/controllers/devise/security_key/authentication_options_controller.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Devise - module SecurityKey - class AuthenticationOptionsController < 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 -end diff --git a/app/controllers/devise/security_key/registration_options_controller.rb b/app/controllers/devise/security_key/registration_options_controller.rb deleted file mode 100644 index e8538887..00000000 --- a/app/controllers/devise/security_key/registration_options_controller.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module Devise - module SecurityKey - class RegistrationOptionsController < 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 -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/routes.rb b/lib/devise/webauthn/routes.rb index d614c771..bbfad070 100644 --- a/lib/devise/webauthn/routes.rb +++ b/lib/devise/webauthn/routes.rb @@ -8,10 +8,9 @@ class Mapper def devise_passkey_authentication(_mapping, controllers) resources :passkeys, only: %i[new create destroy], controller: controllers[:passkeys] - resource :passkey do - resources :authentication_options, only: :index, controller: controllers[:"passkey/authentication_options"] - resources :registration_options, only: :index, controller: controllers[:"passkey/registration_options"] - end + 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) @@ -23,12 +22,10 @@ def devise_two_factor_authentication(_mapping, controllers) only: %i[new create update destroy], controller: controllers[:second_factor_webauthn_credentials] - resource :security_key do - resources :authentication_options, only: %i[index], - controller: controllers[:"security_key/authentication_options"] - resources :registration_options, only: %i[index], - controller: controllers[:"security_key/registration_options"] - end + 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/generators/devise/webauthn/controllers_generator.rb b/lib/generators/devise/webauthn/controllers_generator.rb index d5257533..8c31e45a 100644 --- a/lib/generators/devise/webauthn/controllers_generator.rb +++ b/lib/generators/devise/webauthn/controllers_generator.rb @@ -9,10 +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 + 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 similarity index 50% rename from lib/generators/devise/webauthn/templates/controllers/passkey/authentication_options_controller.rb.tt rename to lib/generators/devise/webauthn/templates/controllers/passkey_authentication_options_controller.rb.tt index 6cf281cc..313a52dc 100644 --- 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -class <%= @scope_prefix %>Passkey::AuthenticationOptionsController < Devise::Passkey::AuthenticationOptionsController +class <%= @scope_prefix %>PasskeyAuthenticationOptionsController < Devise::PasskeyAuthenticationOptionsController # GET /resource/passkey_authentication_options # def index # super 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 similarity index 50% rename from lib/generators/devise/webauthn/templates/controllers/passkey/registration_options_controller.rb.tt rename to lib/generators/devise/webauthn/templates/controllers/passkey_registration_options_controller.rb.tt index c29b46aa..0db021c8 100644 --- 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -class <%= @scope_prefix %>Passkey::RegistrationOptionsController < Devise::Passkey::RegistrationOptionsController +class <%= @scope_prefix %>PasskeyRegistrationOptionsController < Devise::PasskeyRegistrationOptionsController # GET /resource/passkey_registration_options # def index # super 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 deleted file mode 100644 index 392b6fdb..00000000 --- a/lib/generators/devise/webauthn/templates/controllers/security_key/authentication_options_controller.rb.tt +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class <%= @scope_prefix %>SecurityKey::AuthenticationOptionsController < Devise::SecurityKey::AuthenticationOptionsController - # 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 deleted file mode 100644 index 834ca5fd..00000000 --- a/lib/generators/devise/webauthn/templates/controllers/security_key/registration_options_controller.rb.tt +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class <%= @scope_prefix %>SecurityKey::RegistrationOptionsController < Devise::SecurityKey::RegistrationOptionsController - # GET /resource/securiy_key_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 5bfb93e1..99116398 100644 --- a/spec/generators/devise/webauthn/controllers_generator_spec.rb +++ b/spec/generators/devise/webauthn/controllers_generator_spec.rb @@ -20,10 +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/authenticator_options_controller.rb" - assert_no_file "app/controllers/passkey/registration_options_controller.rb" - assert_no_file "app/controllers/security_key/authenticator_options_controller.rb" - assert_no_file "app/controllers/security_key/registration_options_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 @@ -36,10 +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::Passkey::AuthenticationOptionsController/ - assert_file "app/controllers/users/passkey/registration_options_controller.rb", /Users::Passkey::RegistrationOptionsController/ - assert_file "app/controllers/users/security_key/authentication_options_controller.rb", /Users::SecurityKey::AuthenticationOptionsController/ - assert_file "app/controllers/users/security_key/registration_options_controller.rb", /Users::SecurityKey::RegistrationOptionsController/ + 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 similarity index 90% rename from spec/requests/devise/passkey/authentication_options_controller_spec.rb rename to spec/requests/devise/passkey_authentication_options_controller_spec.rb index 88f5c2e7..689d30b8 100644 --- a/spec/requests/devise/passkey/authentication_options_controller_spec.rb +++ b/spec/requests/devise/passkey_authentication_options_controller_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Devise::Passkey::AuthenticationOptionsController, type: :request do +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 diff --git a/spec/requests/devise/passkey/registration_options_controller_spec.rb b/spec/requests/devise/passkey_registration_options_controller_spec.rb similarity index 94% rename from spec/requests/devise/passkey/registration_options_controller_spec.rb rename to spec/requests/devise/passkey_registration_options_controller_spec.rb index ac0ddc6c..dc0cf94e 100644 --- a/spec/requests/devise/passkey/registration_options_controller_spec.rb +++ b/spec/requests/devise/passkey_registration_options_controller_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Devise::Passkey::RegistrationOptionsController, type: :request do +RSpec.describe Devise::PasskeyRegistrationOptionsController, type: :request do let(:user) { Account.create!(email: "test@example.com", password: "password123") } describe "GET #index" 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 similarity index 90% rename from spec/requests/devise/security_key/authentication_options_controller_spec.rb rename to spec/requests/devise/security_key_authentication_options_controller_spec.rb index 40a301bf..c0420d12 100644 --- a/spec/requests/devise/security_key/authentication_options_controller_spec.rb +++ b/spec/requests/devise/security_key_authentication_options_controller_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Devise::SecurityKey::AuthenticationOptionsController, type: :request do +RSpec.describe Devise::SecurityKeyAuthenticationOptionsController, type: :request do let(:user) { Account.create!(email: "test@example.com", password: "password123") } describe "GET #index" do diff --git a/spec/requests/devise/security_key/registration_options_controller_spec.rb b/spec/requests/devise/security_key_registration_options_controller_spec.rb similarity index 93% rename from spec/requests/devise/security_key/registration_options_controller_spec.rb rename to spec/requests/devise/security_key_registration_options_controller_spec.rb index eb04166f..f1ad88e9 100644 --- a/spec/requests/devise/security_key/registration_options_controller_spec.rb +++ b/spec/requests/devise/security_key_registration_options_controller_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Devise::SecurityKey::RegistrationOptionsController, type: :request do +RSpec.describe Devise::SecurityKeyRegistrationOptionsController, type: :request do let(:user) { Account.create!(email: "test@example.com", password: "password123") } describe "GET #index" do From d0ee47d74f7e8c4bb9a3489dd84d01ba99dbb2b0 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc Date: Thu, 22 Jan 2026 13:28:17 -0300 Subject: [PATCH 17/17] docs: update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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