From 911aa34ba9cd6f75cae31be80ce0a9dc17d67928 Mon Sep 17 00:00:00 2001
From: ghost <49853598+JSONbored@users.noreply.github.com>
Date: Sun, 3 May 2026 14:13:28 -0600
Subject: [PATCH] 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
---
.env.example | 6 +
.env.local.example | 5 +
.env.test.example | 4 +
Gemfile | 1 +
Gemfile.lock | 23 +++
.../concerns/webauthn_relying_party.rb | 31 ++++
app/controllers/mfa_controller.rb | 72 +++++++-
.../settings/securities_controller.rb | 1 +
.../webauthn_credentials_controller.rb | 83 +++++++++
.../webauthn_authentication_controller.js | 62 +++++++
.../controllers/webauthn_controller.js | 39 ++++
.../webauthn_registration_controller.js | 67 +++++++
app/javascript/utils/webauthn.js | 85 +++++++++
app/models/user.rb | 28 ++-
app/models/webauthn_credential.rb | 15 ++
app/views/mfa/verify.html.erb | 23 +++
app/views/settings/securities/show.html.erb | 76 ++++++++
config/initializers/rack_attack.rb | 8 +
config/initializers/webauthn.rb | 34 ++++
config/locales/views/mfa/en.yml | 7 +
.../locales/views/settings/securities/en.yml | 22 +++
config/routes.rb | 5 +
...60501142000_create_webauthn_credentials.rb | 21 +++
db/schema.rb | 18 ++
docs/hosting/docker.md | 11 ++
docs/hosting/webauthn.md | 27 +++
test/controllers/mfa_controller_test.rb | 151 ++++++++++++++++
.../webauthn_credentials_controller_test.rb | 168 ++++++++++++++++++
test/models/user_test.rb | 34 ++++
29 files changed, 1117 insertions(+), 10 deletions(-)
create mode 100644 app/controllers/concerns/webauthn_relying_party.rb
create mode 100644 app/controllers/settings/webauthn_credentials_controller.rb
create mode 100644 app/javascript/controllers/webauthn_authentication_controller.js
create mode 100644 app/javascript/controllers/webauthn_controller.js
create mode 100644 app/javascript/controllers/webauthn_registration_controller.js
create mode 100644 app/javascript/utils/webauthn.js
create mode 100644 app/models/webauthn_credential.rb
create mode 100644 config/initializers/webauthn.rb
create mode 100644 db/migrate/20260501142000_create_webauthn_credentials.rb
create mode 100644 docs/hosting/webauthn.md
create mode 100644 test/controllers/settings/webauthn_credentials_controller_test.rb
diff --git a/.env.example b/.env.example
index 64d0f4810..e0bc454b1 100644
--- a/.env.example
+++ b/.env.example
@@ -115,6 +115,12 @@ REDIS_URL=redis://localhost:6379/1
# This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places.
APP_DOMAIN=
+# WebAuthn / passkey MFA configuration
+# RP ID is usually the registrable domain (example.com), not a full URL.
+# Allowed origins are full HTTPS origins where users access Sure.
+WEBAUTHN_RP_ID=
+WEBAUTHN_ALLOWED_ORIGINS=
+
# OpenID Connect configuration
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
diff --git a/.env.local.example b/.env.local.example
index e03ce605f..cbe138774 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -55,6 +55,11 @@ OIDC_CLIENT_SECRET=
OIDC_ISSUER=
OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback
+# WebAuthn / passkey MFA development defaults
+# RP ID must match the domain where credentials are registered.
+WEBAUTHN_RP_ID=localhost
+WEBAUTHN_ALLOWED_ORIGINS=http://localhost:3000
+
# Langfuse config
LANGFUSE_PUBLIC_KEY =
LANGFUSE_SECRET_KEY =
diff --git a/.env.test.example b/.env.test.example
index 9b0fbb62b..02d7fea24 100644
--- a/.env.test.example
+++ b/.env.test.example
@@ -26,6 +26,10 @@ OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback
+# WebAuthn / passkey MFA test defaults
+WEBAUTHN_RP_ID=www.example.com
+WEBAUTHN_ALLOWED_ORIGINS=http://www.example.com
+
# ================
# Data Providers
# ---------------------------------------------------------------------------------
diff --git a/Gemfile b/Gemfile
index a49de166f..19fdf8a17 100644
--- a/Gemfile
+++ b/Gemfile
@@ -82,6 +82,7 @@ gem "snaptrade", "~> 2.0"
gem "httparty"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 3.0"
+gem "webauthn", "~> 3.4"
gem "activerecord-import"
gem "rubyzip", "~> 2.3"
gem "pdf-reader", "~> 2.12"
diff --git a/Gemfile.lock b/Gemfile.lock
index e4210d504..2b3ab3c5f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -86,6 +86,7 @@ GEM
after_commit_everywhere (1.6.0)
activerecord (>= 4.2)
activesupport
+ android_key_attestation (0.3.0)
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
@@ -135,6 +136,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
+ cbor (0.5.10.2)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
@@ -142,6 +144,9 @@ GEM
climate_control (1.2.0)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
+ cose (1.3.1)
+ cbor (~> 0.5.9)
+ openssl-signature_algorithm (~> 1.0)
countries (8.0.3)
unaccent (~> 0.3)
crack (1.0.0)
@@ -482,6 +487,9 @@ GEM
tzinfo
validate_url
webfinger (~> 2.0)
+ openssl (4.0.1)
+ openssl-signature_algorithm (1.3.0)
+ openssl (> 2.0)
os (1.1.4)
ostruct (0.6.2)
pagy (9.3.5)
@@ -694,6 +702,8 @@ GEM
logger
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
+ safety_net_attestation (0.5.0)
+ jwt (>= 2.0, < 4.0)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
@@ -772,6 +782,10 @@ GEM
unicode-display_width (>= 1.1.1, < 4)
thor (1.4.0)
timeout (0.6.1)
+ tpm-key_attestation (0.14.1)
+ bindata (~> 2.4)
+ openssl (> 2.0)
+ openssl-signature_algorithm (~> 1.0)
trailblazer-option (0.1.2)
tsort (0.2.0)
ttfunk (1.8.0)
@@ -805,6 +819,14 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
+ webauthn (3.4.3)
+ android_key_attestation (~> 0.3.0)
+ bindata (~> 2.4)
+ cbor (~> 0.5.9)
+ cose (~> 1.1)
+ openssl (>= 2.2)
+ safety_net_attestation (~> 0.5.0)
+ tpm-key_attestation (~> 0.14.0)
webfinger (2.1.3)
activesupport
faraday (~> 2.0)
@@ -929,6 +951,7 @@ DEPENDENCIES
vernier
view_component
web-console
+ webauthn (~> 3.4)
webmock
RUBY VERSION
diff --git a/app/controllers/concerns/webauthn_relying_party.rb b/app/controllers/concerns/webauthn_relying_party.rb
new file mode 100644
index 000000000..a6dfb7905
--- /dev/null
+++ b/app/controllers/concerns/webauthn_relying_party.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module WebauthnRelyingParty
+ extend ActiveSupport::Concern
+
+ private
+ def webauthn_relying_party
+ webauthn_config = Rails.application.config.x.webauthn
+
+ WebAuthn::RelyingParty.new(
+ name: "Sure",
+ id: webauthn_config.rp_id,
+ allowed_origins: webauthn_config.allowed_origins,
+ # Accept consumer passkeys/security keys without attesting device vendor
+ # identity; this keeps MFA registration broad for self-hosted users.
+ verify_attestation_statement: false
+ )
+ end
+
+ def webauthn_credential_payload
+ payload = params.require(:credential)
+ payload = JSON.parse(payload) if payload.is_a?(String)
+
+ payload = payload.to_unsafe_h if payload.respond_to?(:to_unsafe_h)
+ raise ActionController::BadRequest, "credential must be an object" unless payload.is_a?(Hash)
+
+ payload
+ rescue JSON::ParserError, TypeError, ArgumentError
+ raise ActionController::BadRequest, "invalid credential payload"
+ end
+end
diff --git a/app/controllers/mfa_controller.rb b/app/controllers/mfa_controller.rb
index 987170a9c..568d0f103 100644
--- a/app/controllers/mfa_controller.rb
+++ b/app/controllers/mfa_controller.rb
@@ -1,6 +1,8 @@
class MfaController < ApplicationController
+ include WebauthnRelyingParty
+
layout :determine_layout
- skip_authentication only: [ :verify, :verify_code ]
+ skip_authentication only: [ :verify, :verify_code, :webauthn_options, :verify_webauthn ]
def new
redirect_to root_path if Current.user.otp_required?
@@ -30,9 +32,7 @@ class MfaController < ApplicationController
@user = User.find_by(id: session[:mfa_user_id])
if @user&.verify_otp?(params[:code])
- session.delete(:mfa_user_id)
- @session = create_session_for(@user)
- flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(@user)
+ complete_mfa_sign_in(@user)
redirect_to root_path
else
flash.now[:alert] = t(".invalid_code")
@@ -40,6 +40,60 @@ class MfaController < ApplicationController
end
end
+ def webauthn_options
+ @user = User.find_by(id: session[:mfa_user_id])
+
+ unless @user&.webauthn_enabled?
+ return render json: { error: t(".unavailable") }, status: :unprocessable_entity
+ end
+
+ options = webauthn_relying_party.options_for_authentication(
+ allow: @user.webauthn_credentials.pluck(:credential_id),
+ user_verification: "preferred"
+ )
+ session[:webauthn_authentication_challenge] = options.challenge
+
+ render json: options
+ end
+
+ def verify_webauthn
+ @user = User.find_by(id: session[:mfa_user_id])
+ challenge = session.delete(:webauthn_authentication_challenge)
+
+ unless @user&.webauthn_enabled? && challenge.present?
+ return render json: { error: t(".invalid_credential") }, status: :unprocessable_entity
+ end
+
+ credential = WebAuthn::Credential.from_get(
+ webauthn_credential_payload,
+ relying_party: webauthn_relying_party
+ )
+ stored_credential = @user.webauthn_credentials.find_by(credential_id: credential.id)
+
+ unless stored_credential
+ return render json: { error: t(".invalid_credential") }, status: :unprocessable_entity
+ end
+
+ stored_credential.with_lock do
+ credential.verify(
+ challenge,
+ public_key: stored_credential.public_key,
+ sign_count: stored_credential.sign_count,
+ user_presence: true
+ )
+
+ stored_credential.update!(
+ sign_count: credential.sign_count,
+ last_used_at: Time.current
+ )
+ end
+ complete_mfa_sign_in(@user)
+
+ render json: { redirect_url: root_path }
+ rescue WebAuthn::Error, ActionController::BadRequest, ActionController::ParameterMissing
+ render json: { error: t(".invalid_credential") }, status: :unprocessable_entity
+ end
+
def disable
Current.user.disable_mfa!
redirect_to settings_security_path, notice: t(".success")
@@ -48,10 +102,18 @@ class MfaController < ApplicationController
private
def determine_layout
- if action_name.in?(%w[verify verify_code])
+ if action_name.in?(%w[webauthn_options verify_webauthn])
+ false
+ elsif action_name.in?(%w[verify verify_code])
"auth"
else
"settings"
end
end
+
+ def complete_mfa_sign_in(user)
+ session.delete(:mfa_user_id)
+ @session = create_session_for(user)
+ flash[:notice] = t("invitations.accept_choice.joined_household") if accept_pending_invitation_for(user)
+ end
end
diff --git a/app/controllers/settings/securities_controller.rb b/app/controllers/settings/securities_controller.rb
index fd6791994..e04c27e01 100644
--- a/app/controllers/settings/securities_controller.rb
+++ b/app/controllers/settings/securities_controller.rb
@@ -7,5 +7,6 @@ class Settings::SecuritiesController < ApplicationController
[ "Security", nil ]
]
@oidc_identities = Current.user.oidc_identities.order(:provider)
+ @webauthn_credentials = Current.user.webauthn_credentials.order(created_at: :asc)
end
end
diff --git a/app/controllers/settings/webauthn_credentials_controller.rb b/app/controllers/settings/webauthn_credentials_controller.rb
new file mode 100644
index 000000000..943ec75ea
--- /dev/null
+++ b/app/controllers/settings/webauthn_credentials_controller.rb
@@ -0,0 +1,83 @@
+class Settings::WebauthnCredentialsController < ApplicationController
+ include WebauthnRelyingParty
+
+ layout "settings"
+
+ before_action :ensure_mfa_enabled
+
+ def options
+ Current.user.ensure_webauthn_id!
+
+ registration_options = webauthn_relying_party.options_for_registration(
+ user: {
+ id: Current.user.webauthn_id,
+ name: Current.user.email,
+ display_name: Current.user.display_name
+ },
+ exclude: Current.user.webauthn_credentials.pluck(:credential_id),
+ authenticator_selection: { user_verification: "preferred" },
+ attestation: "none"
+ )
+
+ session[:webauthn_registration_challenge] = registration_options.challenge
+
+ render json: registration_options
+ end
+
+ def create
+ challenge = session.delete(:webauthn_registration_challenge)
+
+ unless challenge.present?
+ return render json: { error: t("webauthn_credentials.failure") }, status: :unprocessable_entity
+ end
+
+ credential = webauthn_relying_party.verify_registration(
+ webauthn_credential_payload,
+ challenge,
+ user_presence: true
+ )
+
+ Current.user.webauthn_credentials.create!(
+ nickname: webauthn_credential_name,
+ credential_id: credential.id,
+ public_key: credential.public_key,
+ sign_count: credential.sign_count,
+ transports: webauthn_credential_transports
+ )
+
+ render json: { redirect_url: settings_security_path }
+ rescue WebAuthn::Error, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique, ActionController::BadRequest, ActionController::ParameterMissing
+ render json: { error: t("webauthn_credentials.failure") }, status: :unprocessable_entity
+ end
+
+ def destroy
+ Current.user.webauthn_credentials.find(params[:id]).destroy!
+ redirect_to settings_security_path, notice: t("webauthn_credentials.success")
+ end
+
+ private
+ def ensure_mfa_enabled
+ return if Current.user.otp_required?
+
+ respond_to do |format|
+ format.html { redirect_to settings_security_path, alert: t("webauthn_credentials.mfa_required") }
+ format.json { render json: { error: t("webauthn_credentials.mfa_required") }, status: :forbidden }
+ end
+ end
+
+ def webauthn_credential_name
+ webauthn_credential_params[:nickname]
+ end
+
+ def webauthn_credential_transports
+ Array(credential_response_params.dig(:response, :transports)).compact_blank
+ end
+
+ def webauthn_credential_params
+ params.fetch(:webauthn_credential, ActionController::Parameters.new).permit(:nickname)
+ end
+
+ def credential_response_params
+ params.fetch(:credential, ActionController::Parameters.new).permit(response: [ transports: [] ])
+ end
+end
diff --git a/app/javascript/controllers/webauthn_authentication_controller.js b/app/javascript/controllers/webauthn_authentication_controller.js
new file mode 100644
index 000000000..d4c141307
--- /dev/null
+++ b/app/javascript/controllers/webauthn_authentication_controller.js
@@ -0,0 +1,62 @@
+import WebauthnController from "controllers/webauthn_controller";
+import {
+ prepareCredentialRequestOptions,
+ serializePublicKeyCredential,
+} from "utils/webauthn";
+
+export default class extends WebauthnController {
+ static targets = ["error"];
+ static values = {
+ optionsUrl: String,
+ verifyUrl: String,
+ unsupportedMessage: String,
+ errorFallback: String,
+ };
+
+ async authenticate(event) {
+ event.preventDefault();
+ this.clearError();
+
+ if (!window.PublicKeyCredential) {
+ this.showError(this.unsupportedMessageValue);
+ return;
+ }
+
+ try {
+ const options = await this.fetchOptions();
+ const credential = await navigator.credentials.get({
+ publicKey: prepareCredentialRequestOptions(options),
+ });
+
+ await this.verifyCredential(serializePublicKeyCredential(credential));
+ } catch (error) {
+ this.showError(error.message);
+ }
+ }
+
+ async fetchOptions() {
+ const response = await fetch(this.optionsUrlValue, {
+ method: "POST",
+ headers: this.headers,
+ credentials: "same-origin",
+ });
+
+ if (!response.ok) throw new Error(await this.errorMessage(response));
+
+ return response.json();
+ }
+
+ async verifyCredential(credential) {
+ const response = await fetch(this.verifyUrlValue, {
+ method: "POST",
+ headers: this.headers,
+ credentials: "same-origin",
+ body: JSON.stringify({ credential }),
+ });
+
+ if (!response.ok) throw new Error(await this.errorMessage(response));
+
+ const result = await response.json();
+ window.location.href = result.redirect_url;
+ }
+}
diff --git a/app/javascript/controllers/webauthn_controller.js b/app/javascript/controllers/webauthn_controller.js
new file mode 100644
index 000000000..f421451ec
--- /dev/null
+++ b/app/javascript/controllers/webauthn_controller.js
@@ -0,0 +1,39 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ get headers() {
+ return {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")
+ ?.content,
+ };
+ }
+
+ async errorMessage(response) {
+ try {
+ const result = await response.clone().json();
+ if (result.error) return result.error;
+ } catch (_error) {
+ return this.errorFallbackValue;
+ }
+
+ return this.errorFallbackValue;
+ }
+
+ showError(message) {
+ if (this.hasErrorTarget) {
+ this.errorTarget.textContent = message;
+ this.errorTarget.hidden = false;
+ this.errorTarget.setAttribute("aria-hidden", "false");
+ }
+ }
+
+ clearError() {
+ if (this.hasErrorTarget) {
+ this.errorTarget.textContent = "";
+ this.errorTarget.hidden = true;
+ this.errorTarget.setAttribute("aria-hidden", "true");
+ }
+ }
+}
diff --git a/app/javascript/controllers/webauthn_registration_controller.js b/app/javascript/controllers/webauthn_registration_controller.js
new file mode 100644
index 000000000..2e0325a6f
--- /dev/null
+++ b/app/javascript/controllers/webauthn_registration_controller.js
@@ -0,0 +1,67 @@
+import WebauthnController from "controllers/webauthn_controller";
+import {
+ prepareCredentialCreationOptions,
+ serializePublicKeyCredential,
+} from "utils/webauthn";
+
+export default class extends WebauthnController {
+ static targets = ["error", "nickname"];
+ static values = {
+ optionsUrl: String,
+ createUrl: String,
+ unsupportedMessage: String,
+ errorFallback: String,
+ };
+
+ async register(event) {
+ event.preventDefault();
+ this.clearError();
+
+ if (!window.PublicKeyCredential) {
+ this.showError(this.unsupportedMessageValue);
+ return;
+ }
+
+ try {
+ const options = await this.fetchOptions();
+ const credential = await navigator.credentials.create({
+ publicKey: prepareCredentialCreationOptions(options),
+ });
+
+ await this.createCredential(serializePublicKeyCredential(credential));
+ } catch (error) {
+ this.showError(error.message);
+ }
+ }
+
+ async fetchOptions() {
+ const response = await fetch(this.optionsUrlValue, {
+ method: "POST",
+ headers: this.headers,
+ credentials: "same-origin",
+ });
+
+ if (!response.ok) throw new Error(await this.errorMessage(response));
+
+ return response.json();
+ }
+
+ async createCredential(credential) {
+ const response = await fetch(this.createUrlValue, {
+ method: "POST",
+ headers: this.headers,
+ credentials: "same-origin",
+ body: JSON.stringify({
+ credential,
+ webauthn_credential: {
+ nickname: this.hasNicknameTarget ? this.nicknameTarget.value : "",
+ },
+ }),
+ });
+
+ if (!response.ok) throw new Error(await this.errorMessage(response));
+
+ const result = await response.json();
+ window.location.href = result.redirect_url;
+ }
+}
diff --git a/app/javascript/utils/webauthn.js b/app/javascript/utils/webauthn.js
new file mode 100644
index 000000000..f7799e64d
--- /dev/null
+++ b/app/javascript/utils/webauthn.js
@@ -0,0 +1,85 @@
+function bufferToBase64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ const binary = String.fromCharCode(...bytes);
+
+ return btoa(binary)
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/g, "");
+}
+
+function base64urlToBuffer(value) {
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
+ const padded = base64.padEnd(
+ base64.length + ((4 - (base64.length % 4)) % 4),
+ "=",
+ );
+ const binary = atob(padded);
+ const bytes = new Uint8Array(binary.length);
+
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+
+ return bytes.buffer;
+}
+
+export function prepareCredentialCreationOptions(options) {
+ options.challenge = base64urlToBuffer(options.challenge);
+ options.user.id = base64urlToBuffer(options.user.id);
+ options.excludeCredentials = (options.excludeCredentials || []).map(
+ (credential) => ({
+ ...credential,
+ id: base64urlToBuffer(credential.id),
+ }),
+ );
+
+ return options;
+}
+
+export function prepareCredentialRequestOptions(options) {
+ options.challenge = base64urlToBuffer(options.challenge);
+ options.allowCredentials = (options.allowCredentials || []).map(
+ (credential) => ({
+ ...credential,
+ id: base64urlToBuffer(credential.id),
+ }),
+ );
+
+ return options;
+}
+
+export function serializePublicKeyCredential(credential) {
+ const serialized = {
+ id: credential.id,
+ rawId: bufferToBase64url(credential.rawId),
+ type: credential.type,
+ authenticatorAttachment: credential.authenticatorAttachment,
+ clientExtensionResults: credential.getClientExtensionResults(),
+ };
+
+ if (credential.response.attestationObject) {
+ serialized.response = {
+ attestationObject: bufferToBase64url(
+ credential.response.attestationObject,
+ ),
+ clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
+ transports: credential.response.getTransports
+ ? credential.response.getTransports()
+ : [],
+ };
+ } else {
+ serialized.response = {
+ authenticatorData: bufferToBase64url(
+ credential.response.authenticatorData,
+ ),
+ clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
+ signature: bufferToBase64url(credential.response.signature),
+ userHandle: credential.response.userHandle
+ ? bufferToBase64url(credential.response.userHandle)
+ : null,
+ };
+ }
+
+ return serialized;
+}
diff --git a/app/models/user.rb b/app/models/user.rb
index ade5ee4cb..ee51c97d1 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -28,6 +28,7 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy
has_many :chats, dependent: :destroy
has_many :api_keys, dependent: :destroy
+ has_many :webauthn_credentials, dependent: :destroy
has_many :mobile_devices, dependent: :destroy
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
@@ -227,11 +228,14 @@ class User < ApplicationRecord
end
def disable_mfa!
- update!(
- otp_secret: nil,
- otp_required: false,
- otp_backup_codes: []
- )
+ transaction do
+ update!(
+ otp_secret: nil,
+ otp_required: false,
+ otp_backup_codes: []
+ )
+ webauthn_credentials.destroy_all
+ end
end
def verify_otp?(code)
@@ -245,6 +249,20 @@ class User < ApplicationRecord
totp.provisioning_uri(email)
end
+ def ensure_webauthn_id!
+ return webauthn_id if webauthn_id.present?
+
+ with_lock do
+ update!(webauthn_id: WebAuthn.generate_user_id) unless webauthn_id.present?
+ end
+
+ webauthn_id
+ end
+
+ def webauthn_enabled?
+ otp_required? && webauthn_credentials.exists?
+ end
+
def onboarded?
onboarded_at.present?
end
diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb
new file mode 100644
index 000000000..da7ebefe3
--- /dev/null
+++ b/app/models/webauthn_credential.rb
@@ -0,0 +1,15 @@
+class WebauthnCredential < ApplicationRecord
+ belongs_to :user
+
+ before_validation :set_default_nickname
+
+ validates :nickname, presence: true, length: { maximum: 80 }
+ validates :credential_id, presence: true, uniqueness: true
+ validates :public_key, presence: true
+ validates :sign_count, numericality: { greater_than_or_equal_to: 0, only_integer: true }
+
+ private
+ def set_default_nickname
+ self.nickname = nickname.to_s.strip.presence || I18n.t("webauthn_credentials.default_name")
+ end
+end
diff --git a/app/views/mfa/verify.html.erb b/app/views/mfa/verify.html.erb
index 3652c1832..bfb772345 100644
--- a/app/views/mfa/verify.html.erb
+++ b/app/views/mfa/verify.html.erb
@@ -3,6 +3,29 @@
header_description t(".description")
%>
+<% if @user&.webauthn_enabled? %>
+
"
+ data-webauthn-authentication-error-fallback-value="<%= t("mfa.verify_webauthn.invalid_credential") %>">
+ <%= render DS::Button.new(
+ text: t(".webauthn_button"),
+ variant: "secondary",
+ icon: "fingerprint",
+ full_width: true,
+ type: "button",
+ data: { action: "webauthn-authentication#authenticate" }
+ ) %>
+
+
+
+<% end %>
+
<%= styled_form_with url: verify_mfa_path, method: :post, class: "space-y-4 mt-4 md:mt-0", data: { turbo: false } do |form| %>
<%= form.text_field :code,
required: true,
diff --git a/app/views/settings/securities/show.html.erb b/app/views/settings/securities/show.html.erb
index b7866c225..f740c6545 100644
--- a/app/views/settings/securities/show.html.erb
+++ b/app/views/settings/securities/show.html.erb
@@ -45,6 +45,82 @@
<% end %>
+<% if Current.user.otp_required? %>
+ <%= settings_section title: t(".webauthn_title"), subtitle: t(".webauthn_description") do %>
+
+ <% if @webauthn_credentials.any? %>
+
+ <% @webauthn_credentials.each do |credential| %>
+
+
+
+ <%= icon "fingerprint", class: "w-5 h-5 text-secondary" %>
+
+
+
<%= credential.nickname %>
+
+ <%= t(".webauthn_added", date: l(credential.created_at.to_date)) %>
+ <% if credential.last_used_at.present? %>
+ <%= t(".webauthn_last_used", time_ago: time_ago_in_words(credential.last_used_at)) %>
+ <% end %>
+
+
+
+
+ <%= render DS::Button.new(
+ text: t(".webauthn_remove"),
+ variant: "outline_destructive",
+ size: "sm",
+ href: settings_webauthn_credential_path(credential),
+ method: :delete,
+ confirm: CustomConfirm.new(
+ title: t(".webauthn_remove_confirm"),
+ body: t(".webauthn_remove_confirm_body"),
+ btn_text: t(".webauthn_remove"),
+ destructive: true
+ )
+ ) %>
+
+ <% end %>
+
+ <% else %>
+
+
<%= t(".webauthn_empty") %>
+
+ <% end %>
+
+ <%= styled_form_with scope: :webauthn_credential,
+ url: settings_webauthn_credentials_path,
+ method: :post,
+ class: "space-y-3",
+ data: {
+ controller: "webauthn-registration",
+ action: "submit->webauthn-registration#register",
+ webauthn_registration_options_url_value: options_settings_webauthn_credentials_path,
+ webauthn_registration_create_url_value: settings_webauthn_credentials_path,
+ webauthn_registration_unsupported_message_value: t(".webauthn_unsupported"),
+ webauthn_registration_error_fallback_value: t("webauthn_credentials.failure")
+ } do |form| %>
+ <%= form.text_field :nickname,
+ placeholder: t(".webauthn_name_placeholder"),
+ label: t(".webauthn_name_label"),
+ data: { webauthn_registration_target: "nickname" } %>
+
+
+
+
+ <%= render DS::Button.new(
+ text: t(".webauthn_add"),
+ variant: "secondary",
+ icon: "fingerprint",
+ type: "submit"
+ ) %>
+
+ <% end %>
+
+ <% end %>
+<% end %>
+
<% if @oidc_identities.any? || AuthConfig.sso_providers.any? %>
<%= settings_section title: t(".sso_title"), subtitle: t(".sso_subtitle") do %>
<% if @oidc_identities.any? %>
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index 4a16a69bb..8918e6f76 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -10,6 +10,14 @@ class Rack::Attack
request.ip if request.path == "/oauth/token"
end
+ # Throttle unauthenticated WebAuthn MFA ceremonies similarly to sign-in
+ # endpoints; registration remains behind normal application authentication.
+ throttle("mfa/webauthn", limit: 10, period: 1.minute) do |request|
+ if request.post? && request.path.in?(%w[/mfa/webauthn_options /mfa/verify_webauthn])
+ request.ip
+ end
+ end
+
# Throttle admin endpoints to prevent brute-force attacks
# More restrictive than general API limits since admin access is sensitive
throttle("admin/ip", limit: 10, period: 1.minute) do |request|
diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb
new file mode 100644
index 000000000..dd51232df
--- /dev/null
+++ b/config/initializers/webauthn.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+Rails.application.configure do
+ config.x.webauthn = ActiveSupport::OrderedOptions.new
+
+ credentials_config = Rails.application.credentials.webauthn || {}
+ credential_rp_id = credentials_config[:rp_id] || credentials_config["rp_id"]
+ credential_origins = credentials_config[:allowed_origins] || credentials_config["allowed_origins"]
+
+ configured_rp_id = ENV["WEBAUTHN_RP_ID"].presence || credential_rp_id.presence || ENV["APP_DOMAIN"].presence
+ default_rp_id = Rails.env.test? ? "www.example.com" : "localhost"
+
+ rp_id = configured_rp_id.presence || default_rp_id
+ rp_id = rp_id.to_s.strip.sub(%r{\Ahttps?://}, "").split("/").first.to_s.split(":").first
+
+ configured_origins = ENV["WEBAUTHN_ALLOWED_ORIGINS"].presence || credential_origins
+ allowed_origins = Array(configured_origins)
+ .flat_map { |origin| origin.to_s.split(",") }
+ .map { |origin| origin.strip.chomp("/") }
+ .reject(&:blank?)
+
+ if allowed_origins.blank?
+ allowed_origins = if Rails.env.test?
+ [ "http://www.example.com" ]
+ elsif rp_id == "localhost"
+ [ "http://localhost:3000" ]
+ else
+ [ "https://#{rp_id}" ]
+ end
+ end
+
+ config.x.webauthn.rp_id = rp_id
+ config.x.webauthn.allowed_origins = allowed_origins
+end
diff --git a/config/locales/views/mfa/en.yml b/config/locales/views/mfa/en.yml
index 786f52594..7ac8e0c19 100644
--- a/config/locales/views/mfa/en.yml
+++ b/config/locales/views/mfa/en.yml
@@ -31,8 +31,15 @@ en:
verify_title: 2. Enter Verification Code
verify:
description: Enter the code from your authenticator app to continue
+ or: or
page_title: Verify Two-Factor Authentication
title: Two-Factor Authentication
verify_button: Verify
+ webauthn_button: Use passkey or security key
+ webauthn_unsupported: This browser does not support passkeys or security keys.
verify_code:
invalid_code: Invalid authentication code. Please try again.
+ verify_webauthn:
+ invalid_credential: Could not verify that passkey or security key. Please try again.
+ webauthn_options:
+ unavailable: No passkeys or security keys are available for this account.
diff --git a/config/locales/views/settings/securities/en.yml b/config/locales/views/settings/securities/en.yml
index af439522c..453df4ccd 100644
--- a/config/locales/views/settings/securities/en.yml
+++ b/config/locales/views/settings/securities/en.yml
@@ -10,3 +10,25 @@ en:
mfa_description: Add an extra layer of security to your account by requiring
a code from your authenticator app when signing in
mfa_title: Two-Factor Authentication
+ webauthn_add: Add passkey or security key
+ webauthn_added: Added %{date}
+ webauthn_description: Use a passkey, Touch ID, Windows Hello, or a hardware
+ security key as a second factor when signing in.
+ webauthn_empty: No passkeys or security keys are registered yet.
+ webauthn_last_used: Last used %{time_ago} ago
+ webauthn_name_label: Key name
+ webauthn_name_placeholder: MacBook Touch ID, YubiKey, etc.
+ webauthn_remove: Remove
+ webauthn_remove_confirm: Are you sure you want to remove this passkey or
+ security key?
+ webauthn_remove_confirm_body: You will need to register this passkey or
+ security key again before it can be used for sign-in verification.
+ webauthn_title: Passkeys and security keys
+ webauthn_unsupported: This browser does not support passkeys or security
+ keys.
+ webauthn_credentials:
+ default_name: Security key
+ failure: Could not save that passkey or security key. Please try again.
+ mfa_required: Enable two-factor authentication before adding a passkey or security
+ key.
+ success: Passkey or security key removed.
diff --git a/config/routes.rb b/config/routes.rb
index 2cb17b5c0..4a5e11443 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -118,6 +118,8 @@ Rails.application.routes.draw do
resource :mfa, controller: "mfa", only: [ :new, :create ] do
get :verify
post :verify, to: "mfa#verify_code"
+ post :webauthn_options
+ post :verify_webauthn
delete :disable
end
@@ -195,6 +197,9 @@ Rails.application.routes.draw do
end
resource :payment, only: :show
resource :security, only: :show
+ resources :webauthn_credentials, only: %i[create destroy] do
+ post :options, on: :collection
+ end
resources :sso_identities, only: :destroy
resource :api_key, only: [ :show, :new, :create, :destroy ]
resource :ai_prompts, only: :show
diff --git a/db/migrate/20260501142000_create_webauthn_credentials.rb b/db/migrate/20260501142000_create_webauthn_credentials.rb
new file mode 100644
index 000000000..fdd53900e
--- /dev/null
+++ b/db/migrate/20260501142000_create_webauthn_credentials.rb
@@ -0,0 +1,21 @@
+class CreateWebauthnCredentials < ActiveRecord::Migration[7.2]
+ def change
+ add_column :users, :webauthn_id, :string
+ add_index :users, :webauthn_id, unique: true, where: "webauthn_id IS NOT NULL"
+
+ create_table :webauthn_credentials, id: :uuid do |t|
+ t.references :user, null: false, foreign_key: true, type: :uuid
+ t.string :nickname, null: false
+ t.string :credential_id, null: false
+ t.text :public_key, null: false
+ t.bigint :sign_count, null: false, default: 0
+ t.string :transports, array: true, null: false, default: []
+ t.datetime :last_used_at
+
+ t.timestamps
+ end
+
+ add_index :webauthn_credentials, :credential_id, unique: true
+ add_check_constraint :webauthn_credentials, "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index ca892aab8..3289baace 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1605,6 +1605,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_02_120000) do
t.string "locale"
t.string "ui_layout"
t.uuid "default_account_id"
+ t.string "webauthn_id"
t.index ["default_account_id"], name: "index_users_on_default_account_id"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id"
@@ -1612,6 +1613,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_02_120000) do
t.index ["locale"], name: "index_users_on_locale"
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)"
t.index ["preferences"], name: "index_users_on_preferences", using: :gin
+ t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true, where: "(webauthn_id IS NOT NULL)"
end
create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1633,6 +1635,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_02_120000) do
t.string "subtype"
end
+ create_table "webauthn_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "user_id", null: false
+ t.string "nickname", null: false
+ t.string "credential_id", null: false
+ t.text "public_key", null: false
+ t.bigint "sign_count", default: 0, null: false
+ t.string "transports", default: [], null: false, array: true
+ t.datetime "last_used_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
+ t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true
+ t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
+ end
+
add_foreign_key "account_providers", "accounts", on_delete: :cascade
add_foreign_key "account_shares", "accounts"
add_foreign_key "account_shares", "users"
@@ -1728,4 +1745,5 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_02_120000) do
add_foreign_key "users", "accounts", column: "default_account_id", on_delete: :nullify
add_foreign_key "users", "chats", column: "last_viewed_chat_id"
add_foreign_key "users", "families"
+ add_foreign_key "webauthn_credentials", "users"
end
diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md
index 96ec28065..afd462752 100644
--- a/docs/hosting/docker.md
+++ b/docs/hosting/docker.md
@@ -111,6 +111,17 @@ and change it to `true`
RAILS_ASSUME_SSL: "true"
```
+#### WebAuthn MFA (passkeys and security keys)
+
+If you enable passkeys, Touch ID, Windows Hello, or hardware security keys as MFA credentials, pin the WebAuthn relying party settings in your `.env` file:
+
+```txt
+WEBAUTHN_RP_ID="example.com"
+WEBAUTHN_ALLOWED_ORIGINS="https://sure.example.com"
+```
+
+`WEBAUTHN_RP_ID` should usually be your registrable domain, not a full URL. See [WebAuthn MFA Configuration](webauthn.md) before changing hostnames or reverse proxy settings for an instance with registered passkeys.
+
#### Binding to IPv6 (optional)
By default Sure listens on `0.0.0.0:3000` (IPv4 wildcard) inside the container and Docker publishes the port on the host's IPv4 interface only. If you want the app reachable over IPv6 as well, two things need to change:
diff --git a/docs/hosting/webauthn.md b/docs/hosting/webauthn.md
new file mode 100644
index 000000000..2e78ceb59
--- /dev/null
+++ b/docs/hosting/webauthn.md
@@ -0,0 +1,27 @@
+# WebAuthn MFA Configuration
+
+Sure supports passkeys, Touch ID, Windows Hello, and hardware security keys as MFA credentials. WebAuthn credentials are bound to the relying party ID used when they are registered, so production deployments should pin these values explicitly instead of deriving them from incoming request headers.
+
+Set these environment variables for self-hosted deployments:
+
+```bash
+WEBAUTHN_RP_ID=example.com
+WEBAUTHN_ALLOWED_ORIGINS=https://sure.example.com
+```
+
+`WEBAUTHN_RP_ID` is usually the registrable domain, such as `example.com`, not a full URL and not a hostname with a port. This lets credentials work across subdomains when the browser permits it.
+
+`WEBAUTHN_ALLOWED_ORIGINS` is a comma-separated list of full origins where users access Sure, including scheme and host. Examples:
+
+```bash
+WEBAUTHN_ALLOWED_ORIGINS=https://sure.example.com,https://app.example.com
+```
+
+For local development, use:
+
+```bash
+WEBAUTHN_RP_ID=localhost
+WEBAUTHN_ALLOWED_ORIGINS=http://localhost:3000
+```
+
+Changing `WEBAUTHN_RP_ID` after users register credentials can make existing passkeys and security keys unavailable. Keep the value stable across reverse proxy, domain, and hostname changes.
diff --git a/test/controllers/mfa_controller_test.rb b/test/controllers/mfa_controller_test.rb
index b653df41a..f9b558afe 100644
--- a/test/controllers/mfa_controller_test.rb
+++ b/test/controllers/mfa_controller_test.rb
@@ -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
diff --git a/test/controllers/settings/webauthn_credentials_controller_test.rb b/test/controllers/settings/webauthn_credentials_controller_test.rb
new file mode 100644
index 000000000..d2fc8c69f
--- /dev/null
+++ b/test/controllers/settings/webauthn_credentials_controller_test.rb
@@ -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
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 00696fce6..8a44d57f0 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -102,11 +102,45 @@ class UserTest < ActiveSupport::TestCase
user = users(:family_member)
user.setup_mfa!
user.enable_mfa!
+ user.webauthn_credentials.create!(
+ nickname: "YubiKey",
+ credential_id: "credential-id",
+ public_key: "public-key"
+ )
+
user.disable_mfa!
assert_nil user.otp_secret
assert_not user.otp_required?
assert_empty user.otp_backup_codes
+ assert_empty user.webauthn_credentials
+ end
+
+ test "ensure_webauthn_id! generates a stable credential user handle" do
+ user = users(:family_member)
+ assert_nil user.webauthn_id
+
+ webauthn_id = user.ensure_webauthn_id!
+
+ assert webauthn_id.present?
+ assert_equal webauthn_id, user.reload.ensure_webauthn_id!
+ end
+
+ test "webauthn_enabled? requires MFA and at least one credential" do
+ user = users(:family_member)
+ assert_not user.webauthn_enabled?
+
+ user.setup_mfa!
+ user.enable_mfa!
+ assert_not user.webauthn_enabled?
+
+ user.webauthn_credentials.create!(
+ nickname: "Touch ID",
+ credential_id: "touch-id-credential",
+ public_key: "public-key"
+ )
+
+ assert user.webauthn_enabled?
end
test "verify_otp? validates TOTP codes" do