From 7dc3e25e007edc9d8ded6c53e4fbf69d4e903b39 Mon Sep 17 00:00:00 2001 From: rafaella-martino Date: Mon, 29 Sep 2025 13:25:38 -0300 Subject: [PATCH 1/4] feat: add webauthn credential field to know if it is discoverable --- ...0929161612_add_is_discoverable_to_webauthn_credentials.rb | 5 +++++ test/dummy/db/schema.rb | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 test/dummy/db/migrate/20250929161612_add_is_discoverable_to_webauthn_credentials.rb diff --git a/test/dummy/db/migrate/20250929161612_add_is_discoverable_to_webauthn_credentials.rb b/test/dummy/db/migrate/20250929161612_add_is_discoverable_to_webauthn_credentials.rb new file mode 100644 index 00000000..4aba26e8 --- /dev/null +++ b/test/dummy/db/migrate/20250929161612_add_is_discoverable_to_webauthn_credentials.rb @@ -0,0 +1,5 @@ +class AddIsDiscoverableToWebauthnCredentials < ActiveRecord::Migration[8.0] + def change + add_column :webauthn_credentials, :is_discoverable, :boolean + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index 8ab803e8..d0963eef 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_22_185347) do +ActiveRecord::Schema[8.0].define(version: 2025_09_29_161612) do create_table "sessions", force: :cascade do |t| t.integer "user_id", null: false t.string "ip_address" @@ -38,6 +38,7 @@ t.integer "authentication_factor", limit: 1, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "is_discoverable" t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" end From 6252bc1d48c08fe553f17ebe6a6e963412dc7ecd Mon Sep 17 00:00:00 2001 From: rafaella-martino Date: Mon, 29 Sep 2025 13:44:55 -0300 Subject: [PATCH 2/4] feat: consider discoverable field when saving a credential --- .../templates/app/controllers/passkeys_controller.rb | 8 +++++--- .../second_factor_webauthn_credentials_controller.rb | 8 +++++--- test/dummy/app/controllers/passkeys_controller.rb | 8 +++++--- .../second_factor_webauthn_credentials_controller.rb | 9 ++++++--- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb b/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb index e8d75978..e882d7d5 100644 --- a/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb +++ b/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb @@ -9,7 +9,8 @@ def create_options authenticator_selection: { resident_key: "required", user_verification: "required" - } + }, + extensions: { credProps: true } ) session[:current_registration] = { challenge: create_options.challenge } @@ -27,13 +28,14 @@ def create ) credential = Current.user.passkeys.find_or_initialize_by( - external_id: webauthn_credential.id + external_id: webauthn_credential.id, ) if credential.update( nickname: create_credential_params[:nickname], public_key: webauthn_credential.public_key, - sign_count: webauthn_credential.sign_count + sign_count: webauthn_credential.sign_count, + is_discoverable: webauthn_credential.client_extension_outputs.dig("credProps", "rk") ) redirect_to root_path, notice: "Security Key registered successfully" else diff --git a/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb b/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb index b64e0514..953fd75f 100644 --- a/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb +++ b/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb @@ -9,7 +9,8 @@ def create_options authenticator_selection: { resident_key: "discouraged", user_verification: "discouraged" - } + }, + extensions: { credProps: true } ) session[:current_registration] = { challenge: create_options.challenge } @@ -26,13 +27,14 @@ def create ) credential = Current.user.second_factor_webauthn_credentials.find_or_initialize_by( - external_id: webauthn_credential.id + external_id: webauthn_credential.id, ) if credential.update( nickname: create_credential_params[:nickname], public_key: webauthn_credential.public_key, - sign_count: webauthn_credential.sign_count + sign_count: webauthn_credential.sign_count, + is_discoverable: webauthn_credential.client_extension_outputs.dig("credProps", "rk") ) redirect_to root_path, notice: "Security Key registered successfully" else diff --git a/test/dummy/app/controllers/passkeys_controller.rb b/test/dummy/app/controllers/passkeys_controller.rb index e8d75978..e882d7d5 100644 --- a/test/dummy/app/controllers/passkeys_controller.rb +++ b/test/dummy/app/controllers/passkeys_controller.rb @@ -9,7 +9,8 @@ def create_options authenticator_selection: { resident_key: "required", user_verification: "required" - } + }, + extensions: { credProps: true } ) session[:current_registration] = { challenge: create_options.challenge } @@ -27,13 +28,14 @@ def create ) credential = Current.user.passkeys.find_or_initialize_by( - external_id: webauthn_credential.id + external_id: webauthn_credential.id, ) if credential.update( nickname: create_credential_params[:nickname], public_key: webauthn_credential.public_key, - sign_count: webauthn_credential.sign_count + sign_count: webauthn_credential.sign_count, + is_discoverable: webauthn_credential.client_extension_outputs.dig("credProps", "rk") ) redirect_to root_path, notice: "Security Key registered successfully" else diff --git a/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb b/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb index b64e0514..3440407b 100644 --- a/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb +++ b/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb @@ -9,7 +9,8 @@ def create_options authenticator_selection: { resident_key: "discouraged", user_verification: "discouraged" - } + }, + extensions: { credProps: true } ) session[:current_registration] = { challenge: create_options.challenge } @@ -26,13 +27,15 @@ def create ) credential = Current.user.second_factor_webauthn_credentials.find_or_initialize_by( - external_id: webauthn_credential.id + external_id: webauthn_credential.id, ) if credential.update( nickname: create_credential_params[:nickname], public_key: webauthn_credential.public_key, - sign_count: webauthn_credential.sign_count + sign_count: webauthn_credential.sign_count, + is_discoverable: webauthn_credential.client_extension_outputs.dig("credProps", "rk") + ) redirect_to root_path, notice: "Security Key registered successfully" else From da5ecf6c39d1320dcb9e07f48345239c3e989021 Mon Sep 17 00:00:00 2001 From: rafaella-martino Date: Mon, 29 Sep 2025 14:16:55 -0300 Subject: [PATCH 3/4] feat: set to false discoverable in case the credential extensions array is not present --- .../templates/app/controllers/passkeys_controller.rb | 5 ++++- .../second_factor_webauthn_credentials_controller.rb | 5 ++++- test/dummy/app/controllers/passkeys_controller.rb | 5 ++++- .../second_factor_webauthn_credentials_controller.rb | 6 ++++-- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb b/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb index e882d7d5..16b41e88 100644 --- a/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb +++ b/lib/generators/webauthn_authentication/templates/app/controllers/passkeys_controller.rb @@ -31,11 +31,14 @@ def create external_id: webauthn_credential.id, ) + credential_extensions = webauthn_credential.client_extension_outputs.is_a?(Hash) + discoverable = credential_extensions.is_a?(Hash) ? credential_extensions.dig("credProps", "rk") : false + if credential.update( nickname: create_credential_params[:nickname], public_key: webauthn_credential.public_key, sign_count: webauthn_credential.sign_count, - is_discoverable: webauthn_credential.client_extension_outputs.dig("credProps", "rk") + is_discoverable: discoverable ) redirect_to root_path, notice: "Security Key registered successfully" else diff --git a/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb b/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb index 953fd75f..9ee63313 100644 --- a/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb +++ b/lib/generators/webauthn_authentication/templates/app/controllers/second_factor_webauthn_credentials_controller.rb @@ -30,11 +30,14 @@ def create external_id: webauthn_credential.id, ) + credential_extensions = webauthn_credential.client_extension_outputs.is_a?(Hash) + discoverable = credential_extensions.is_a?(Hash) ? credential_extensions.dig("credProps", "rk") : false + if credential.update( nickname: create_credential_params[:nickname], public_key: webauthn_credential.public_key, sign_count: webauthn_credential.sign_count, - is_discoverable: webauthn_credential.client_extension_outputs.dig("credProps", "rk") + is_discoverable: discoverable ) redirect_to root_path, notice: "Security Key registered successfully" else diff --git a/test/dummy/app/controllers/passkeys_controller.rb b/test/dummy/app/controllers/passkeys_controller.rb index e882d7d5..16b41e88 100644 --- a/test/dummy/app/controllers/passkeys_controller.rb +++ b/test/dummy/app/controllers/passkeys_controller.rb @@ -31,11 +31,14 @@ def create external_id: webauthn_credential.id, ) + credential_extensions = webauthn_credential.client_extension_outputs.is_a?(Hash) + discoverable = credential_extensions.is_a?(Hash) ? credential_extensions.dig("credProps", "rk") : false + if credential.update( nickname: create_credential_params[:nickname], public_key: webauthn_credential.public_key, sign_count: webauthn_credential.sign_count, - is_discoverable: webauthn_credential.client_extension_outputs.dig("credProps", "rk") + is_discoverable: discoverable ) redirect_to root_path, notice: "Security Key registered successfully" else diff --git a/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb b/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb index 3440407b..9ee63313 100644 --- a/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb +++ b/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb @@ -30,12 +30,14 @@ def create external_id: webauthn_credential.id, ) + credential_extensions = webauthn_credential.client_extension_outputs.is_a?(Hash) + discoverable = credential_extensions.is_a?(Hash) ? credential_extensions.dig("credProps", "rk") : false + if credential.update( nickname: create_credential_params[:nickname], public_key: webauthn_credential.public_key, sign_count: webauthn_credential.sign_count, - is_discoverable: webauthn_credential.client_extension_outputs.dig("credProps", "rk") - + is_discoverable: discoverable ) redirect_to root_path, notice: "Security Key registered successfully" else From 56e166d99819d2d44f0b7676d0bb4cd4afadfde4 Mon Sep 17 00:00:00 2001 From: rafaella-martino Date: Mon, 29 Sep 2025 15:29:13 -0300 Subject: [PATCH 4/4] --wip-- [skip ci] --- ..._factor_webauthn_credentials_controller.rb | 23 +++++++++++++++++++ .../app/views/home/_credentials_list.html.erb | 1 + .../upgrade.html.erb | 18 +++++++++++++++ test/dummy/config/routes.rb | 2 ++ 4 files changed, 44 insertions(+) create mode 100644 test/dummy/app/views/second_factor_webauthn_credentials/upgrade.html.erb diff --git a/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb b/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb index 9ee63313..d6f28b75 100644 --- a/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb +++ b/test/dummy/app/controllers/second_factor_webauthn_credentials_controller.rb @@ -50,6 +50,29 @@ def create end end + def upgrade + webauthn_credential = WebAuthn::Credential.from_get(JSON.parse(session_params[:public_key_credential])) + + credential = user.webauthn_credentials.find_by(external_id: webauthn_credential.id) + unless credential + redirect_to root_path, alert: "Credential not recognized" + return + end + + begin + webauthn_credential.verify( + session[:current_authentication][:challenge] || session[:current_authentication]["challenge"], + public_key: credential.public_key, + sign_count: credential.sign_count + ) + + credential.update!(authenticator_factor: "first_factor") + redirect_to root_path + rescue WebAuthn::Error => e + redirect_to root_path, alert: "Verification failed: #{e.message}" + end + end + def destroy Current.user.second_factor_webauthn_credentials.destroy(params[:id]) diff --git a/test/dummy/app/views/home/_credentials_list.html.erb b/test/dummy/app/views/home/_credentials_list.html.erb index 228bbf53..4748b1e6 100644 --- a/test/dummy/app/views/home/_credentials_list.html.erb +++ b/test/dummy/app/views/home/_credentials_list.html.erb @@ -5,6 +5,7 @@ <%= credential.nickname %> <%= credential.external_id %> + <%=link_to "Upgrade" , second_factor_webauthn_credential_upgrade_path(credential), locals: {credential: credential} %> <%= link_to "Delete credential", delete_path.call(credential), data: { turbo_method: :delete } %> diff --git a/test/dummy/app/views/second_factor_webauthn_credentials/upgrade.html.erb b/test/dummy/app/views/second_factor_webauthn_credentials/upgrade.html.erb new file mode 100644 index 00000000..dd806a88 --- /dev/null +++ b/test/dummy/app/views/second_factor_webauthn_credentials/upgrade.html.erb @@ -0,0 +1,18 @@ +

Upgrade your security key to a passkey

+ +

+ You are about to upgrade your security key <%= @credential.nickname %> to a passkey. +

+<%= form_with( + url: second_factor_webauthn_credentials_upgrade, + data: { + controller: "webauthn-credentials", + action: "webauthn-credentials#get:prevent", + "webauthn-credentials-options-url-value": get_options_second_factor_webauthn_credentials_path, + }) do |form| %> + <%= form.hidden_field :public_key_credential, data: { "webauthn-credentials-target": "credentialHiddenInput" } %> + +
+ <%= form.submit "Upgrade to passkey", disabled: true, data: { "webauthn-credentials-target": "submitButton" } %> +
+<% end %> diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index b305e809..73184cd8 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -9,6 +9,8 @@ resources :second_factor_webauthn_credentials, only: [ :new, :create, :destroy ] do post :create_options, on: :collection + get :upgrade + post :upgrade end resource :second_factor_authentication, only: [ :new, :create ] do