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

@@ -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=

View File

@@ -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 =

View File

@@ -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
# ---------------------------------------------------------------------------------

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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");
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -3,6 +3,29 @@
header_description t(".description")
%>
<% if @user&.webauthn_enabled? %>
<div class="space-y-3 mt-4 md:mt-0" data-controller="webauthn-authentication"
data-webauthn-authentication-options-url-value="<%= webauthn_options_mfa_path %>"
data-webauthn-authentication-verify-url-value="<%= verify_webauthn_mfa_path %>"
data-webauthn-authentication-unsupported-message-value="<%= t(".webauthn_unsupported") %>"
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" }
) %>
<p class="text-sm text-destructive" role="alert" aria-live="assertive" aria-atomic="true" aria-hidden="true" hidden data-webauthn-authentication-target="error"></p>
<div class="flex items-center gap-3">
<div class="border-t border-primary flex-1"></div>
<span class="text-xs text-secondary"><%= t(".or") %></span>
<div class="border-t border-primary flex-1"></div>
</div>
</div>
<% 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,

View File

@@ -45,6 +45,82 @@
</div>
<% end %>
<% if Current.user.otp_required? %>
<%= settings_section title: t(".webauthn_title"), subtitle: t(".webauthn_description") do %>
<div class="space-y-4">
<% if @webauthn_credentials.any? %>
<div class="space-y-2">
<% @webauthn_credentials.each do |credential| %>
<div class="flex items-center justify-between bg-container p-4 shadow-border-xs rounded-lg">
<div class="flex items-center gap-3">
<div class="w-9 h-9 shrink-0 bg-surface rounded-full flex items-center justify-center">
<%= icon "fingerprint", class: "w-5 h-5 text-secondary" %>
</div>
<div>
<p class="font-medium text-primary"><%= credential.nickname %></p>
<p class="text-sm text-secondary">
<%= t(".webauthn_added", date: l(credential.created_at.to_date)) %>
<% if credential.last_used_at.present? %>
<span><%= t(".webauthn_last_used", time_ago: time_ago_in_words(credential.last_used_at)) %></span>
<% end %>
</p>
</div>
</div>
<%= 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
)
) %>
</div>
<% end %>
</div>
<% else %>
<div class="bg-surface-inset rounded-lg p-4">
<p class="text-sm text-secondary"><%= t(".webauthn_empty") %></p>
</div>
<% 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" } %>
<p class="text-sm text-destructive" role="alert" aria-live="assertive" aria-atomic="true" aria-hidden="true" hidden data-webauthn-registration-target="error"></p>
<div>
<%= render DS::Button.new(
text: t(".webauthn_add"),
variant: "secondary",
icon: "fingerprint",
type: "submit"
) %>
</div>
<% end %>
</div>
<% 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? %>

View File

@@ -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|

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

18
db/schema.rb generated
View File

@@ -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

View File

@@ -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:

27
docs/hosting/webauthn.md Normal file
View File

@@ -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.

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

View File

@@ -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