feat(auth): add WebAuthn MFA credentials (#1628)

* feat(auth): add WebAuthn MFA credentials

* fix(auth): harden WebAuthn MFA review paths

* fix(auth): polish WebAuthn error handling

* fix(auth): handle duplicate WebAuthn credential races

* fix(auth): permit WebAuthn credential params

* fix(auth): trim WebAuthn registration controller cleanup

* fix(auth): tighten WebAuthn MFA handling

* fix(auth): pin WebAuthn relying party config
This commit is contained in:
ghost
2026-05-03 14:13:28 -06:00
committed by GitHub
parent faf31b9c91
commit 911aa34ba9
29 changed files with 1117 additions and 10 deletions

View File

@@ -1,4 +1,5 @@
require "test_helper"
require "webauthn/fake_client"
class MfaControllerTest < ActionDispatch::IntegrationTest
setup do
@@ -64,6 +65,21 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
assert_select "form[action=?]", verify_mfa_path
end
test "verify shows WebAuthn option when credentials are registered" do
@user.setup_mfa!
@user.enable_mfa!
register_webauthn_credential
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
get verify_mfa_path
assert_response :success
assert_select "button", text: I18n.t("mfa.verify.webauthn_button")
assert_select "[data-webauthn-authentication-error-fallback-value=?]", I18n.t("mfa.verify_webauthn.invalid_credential")
assert_select "p[data-webauthn-authentication-target='error'][aria-live='assertive'][aria-atomic='true'][aria-hidden='true']"
end
test "verify_code authenticates with valid TOTP" do
@user.setup_mfa!
@user.enable_mfa!
@@ -105,9 +121,114 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
assert_not Session.exists?(user_id: @user.id)
end
test "webauthn_options require a pending MFA session" do
post webauthn_options_mfa_path, as: :json
assert_response :unprocessable_entity
end
test "verify_webauthn authenticates with a registered credential" do
@user.setup_mfa!
@user.enable_mfa!
client = register_webauthn_credential
stored_credential = @user.webauthn_credentials.first
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
post webauthn_options_mfa_path, as: :json
assert_response :success
options = JSON.parse(response.body)
assertion = client.get(
challenge: options.fetch("challenge"),
rp_id: "www.example.com",
allow_credentials: [ stored_credential.credential_id ]
)
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
assert_response :success
assert_equal root_path, JSON.parse(response.body).fetch("redirect_url")
assert Session.exists?(user_id: @user.id)
assert stored_credential.reload.last_used_at.present?
assert_operator stored_credential.sign_count, :>, 0
end
test "verify_webauthn authenticates with configured relying party id" do
with_webauthn_config(rp_id: "example.test", allowed_origins: [ "https://app.example.test" ]) do
@user.setup_mfa!
@user.enable_mfa!
client = register_webauthn_credential(origin: "https://app.example.test", rp_id: "example.test")
stored_credential = @user.webauthn_credentials.first
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
post webauthn_options_mfa_path, as: :json
assert_response :success
options = JSON.parse(response.body)
assert_equal "example.test", options.fetch("rpId")
assertion = client.get(
challenge: options.fetch("challenge"),
rp_id: "example.test",
allow_credentials: [ stored_credential.credential_id ]
)
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
assert_response :success
assert_equal root_path, JSON.parse(response.body).fetch("redirect_url")
end
end
test "verify_webauthn rejects invalid credentials" do
@user.setup_mfa!
@user.enable_mfa!
client = register_webauthn_credential
stored_credential = @user.webauthn_credentials.first
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
post webauthn_options_mfa_path, as: :json
options = JSON.parse(response.body)
assertion = client.get(
challenge: options.fetch("challenge"),
rp_id: "www.example.com",
allow_credentials: [ stored_credential.credential_id ]
)
assertion["id"] = "invalid"
post verify_webauthn_mfa_path, params: { credential: assertion }, as: :json
assert_response :unprocessable_entity
assert_not Session.exists?(user_id: @user.id)
end
test "verify_webauthn rejects malformed credential payloads" do
@user.setup_mfa!
@user.enable_mfa!
register_webauthn_credential
sign_out
post sessions_path, params: { email: @user.email, password: user_password_test }
post webauthn_options_mfa_path, as: :json
assert_response :success
post verify_webauthn_mfa_path, params: { credential: [] }, as: :json
assert_response :unprocessable_entity
assert_equal I18n.t("mfa.verify_webauthn.invalid_credential"), JSON.parse(response.body).fetch("error")
assert_not Session.exists?(user_id: @user.id)
end
test "disable removes MFA" do
@user.setup_mfa!
@user.enable_mfa!
@user.webauthn_credentials.create!(
nickname: "YubiKey",
credential_id: "disable-mfa-credential",
public_key: "public-key"
)
delete disable_mfa_path
@@ -115,5 +236,35 @@ class MfaControllerTest < ActionDispatch::IntegrationTest
assert_not @user.reload.otp_required?
assert_nil @user.otp_secret
assert_empty @user.otp_backup_codes
assert_empty @user.webauthn_credentials
end
private
def register_webauthn_credential(origin: "http://www.example.com", rp_id: "www.example.com")
client = WebAuthn::FakeClient.new(origin)
post options_settings_webauthn_credentials_path, as: :json
options = JSON.parse(response.body)
credential = client.create(challenge: options.fetch("challenge"), rp_id: rp_id)
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "MacBook Touch ID" },
credential: credential
}, as: :json
assert_response :success
client
end
def with_webauthn_config(rp_id:, allowed_origins:)
config = Rails.application.config.x.webauthn
previous_rp_id = config.rp_id
previous_allowed_origins = config.allowed_origins
config.rp_id = rp_id
config.allowed_origins = allowed_origins
yield
ensure
config.rp_id = previous_rp_id
config.allowed_origins = previous_allowed_origins
end
end

View File

@@ -0,0 +1,168 @@
require "test_helper"
require "webauthn/fake_client"
class Settings::WebauthnCredentialsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@user.webauthn_credentials.destroy_all
sign_in @user
@user.setup_mfa!
@user.enable_mfa!
@client = WebAuthn::FakeClient.new("http://www.example.com")
end
test "options require enabled MFA" do
@user.disable_mfa!
post options_settings_webauthn_credentials_path, as: :json
assert_response :forbidden
assert_equal I18n.t("webauthn_credentials.mfa_required"), JSON.parse(response.body).fetch("error")
end
test "creates a credential from a verified registration challenge" do
options = registration_options
credential = @client.create(challenge: options.fetch("challenge"), rp_id: "www.example.com")
assert_difference -> { @user.webauthn_credentials.count }, 1 do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "MacBook Touch ID" },
credential: credential
}, as: :json
end
assert_response :success
assert_equal settings_security_path, JSON.parse(response.body).fetch("redirect_url")
stored_credential = @user.webauthn_credentials.reload.last
assert_equal "MacBook Touch ID", stored_credential.nickname
assert_equal credential.fetch("id"), stored_credential.credential_id
assert_includes stored_credential.transports, "internal"
assert @user.reload.webauthn_id.present?
end
test "uses configured relying party id and allowed origin" do
with_webauthn_config(rp_id: "example.test", allowed_origins: [ "https://app.example.test" ]) do
client = WebAuthn::FakeClient.new("https://app.example.test")
options = registration_options
assert_equal "example.test", options.dig("rp", "id")
credential = client.create(challenge: options.fetch("challenge"), rp_id: "example.test")
assert_difference -> { @user.webauthn_credentials.count }, 1 do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "Configured origin key" },
credential: credential
}, as: :json
end
assert_response :success
end
end
test "rejects a credential when registration challenge has already been used" do
options = registration_options
credential = @client.create(challenge: options.fetch("challenge"), rp_id: "www.example.com")
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "MacBook Touch ID" },
credential: credential
}, as: :json
assert_response :success
assert_no_difference -> { @user.webauthn_credentials.count } do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "Replay" },
credential: credential
}, as: :json
end
assert_response :unprocessable_entity
end
test "rejects malformed credential payloads" do
registration_options
assert_no_difference -> { @user.webauthn_credentials.count } do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "Malformed" },
credential: []
}, as: :json
end
assert_response :unprocessable_entity
assert_equal I18n.t("webauthn_credentials.failure"), JSON.parse(response.body).fetch("error")
end
test "rejects database-level duplicate credential races" do
registration_options
@user.webauthn_credentials.create!(
nickname: "Existing security key",
credential_id: "duplicate-credential-id",
public_key: "public-key"
)
verified_credential = Struct.new(:id, :public_key, :sign_count).new("duplicate-credential-id", "new-public-key", 0)
relying_party = mock("webauthn_relying_party")
relying_party.expects(:verify_registration).returns(verified_credential)
Settings::WebauthnCredentialsController.any_instance.stubs(:webauthn_relying_party).returns(relying_party)
assert_no_difference -> { @user.webauthn_credentials.count } do
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "Duplicate security key" },
credential: { id: "duplicate-credential-id", response: {} }
}, as: :json
end
assert_response :unprocessable_entity
assert_equal I18n.t("webauthn_credentials.failure"), JSON.parse(response.body).fetch("error")
end
test "uses localized default credential nickname" do
options = registration_options
credential = @client.create(challenge: options.fetch("challenge"), rp_id: "www.example.com")
post settings_webauthn_credentials_path, params: {
webauthn_credential: { nickname: "" },
credential: credential
}, as: :json
assert_response :success
assert_equal I18n.t("webauthn_credentials.default_name"), @user.webauthn_credentials.reload.last.nickname
end
test "destroys a credential owned by the current user" do
credential = @user.webauthn_credentials.create!(
nickname: "YubiKey",
credential_id: "credential-to-delete",
public_key: "public-key"
)
assert_difference -> { @user.webauthn_credentials.count }, -1 do
delete settings_webauthn_credential_path(credential)
end
assert_redirected_to settings_security_path
end
private
def registration_options
post options_settings_webauthn_credentials_path, as: :json
assert_response :success
JSON.parse(response.body)
end
def with_webauthn_config(rp_id:, allowed_origins:)
config = Rails.application.config.x.webauthn
previous_rp_id = config.rp_id
previous_allowed_origins = config.allowed_origins
config.rp_id = rp_id
config.allowed_origins = allowed_origins
yield
ensure
config.rp_id = previous_rp_id
config.allowed_origins = previous_allowed_origins
end
end