Files
sure/app/controllers/settings/webauthn_credentials_controller.rb
T
Guillem Arias Fauste 344cf091e1 feat(auth): sign in with a passkey, without a password (#2911)
* feat(auth): sign in with a passkey, without a password

Passkeys could only ever replace the TOTP code: registration required 2FA
to already be on, and the WebAuthn ceremony was reachable only after
User.authenticate_by had succeeded. A registered passkey can now complete
sign-in on its own, from the login page.

The ceremony requests userVerification: "required", so the authenticator
has to confirm the person as well as the device. That makes a lone passkey
two independent factors, the same bar as the password plus TOTP flow it
replaces, which is why this path deliberately skips the TOTP step. A
credential that can only prove presence is rejected here and still works
as a second factor.

Sign-in is usernameless: no email is submitted, because the browser
returns the account handle with the assertion. Nothing on this path can be
probed to learn whether an account exists. Registration now asks for a
discoverable credential with residentKey: "preferred" so the key is
offered by the picker, while authenticators without a free resident-key
slot still register as a second factor.

Where conditional mediation is available, saved passkeys appear in the
email field's autofill menu; everywhere else the button covers it. The
automatic challenge request that conditional mediation makes on every page
load gets its own looser Rack::Attack budget, so ordinary page views can
no longer exhaust the limit that protects the MFA endpoints.

Set AUTH_PASSKEY_LOGIN_ENABLED=false to keep passkeys as a second factor
only. Passkey sign-in follows the same policy as local login, so it stays
closed to regular users when AUTH_LOCAL_LOGIN_ENABLED is false.

* refactor(auth): group the passkey button with the other sign-in methods

It sat directly under the password fields, so the forgot-password link
split it from the identical SSO buttons. It is an alternative to the
credential form rather than part of it.

* fix(auth): close the passkey challenge races and document the upgrade

Three review passes converged on the conditional-mediation flow. The
AbortController was created after `isConditionalMediationAvailable()`
resolved, so a button click or a Turbo disconnect landing in that window
found nothing to abort: the conditional task carried on, re-minted the
challenge, and the assertion the user was about to produce verified
against a challenge the server had already replaced.

It is created before the first await now, and held in a local, because
`abortConditionalMediation()` nulls the field. Checking that one signal
after each await covers both triggers, so no separate connected flag is
needed.

The same symptom had a second cause nobody flagged: `authenticate()` was
not re-entrant. A double-click minted a fresh challenge under an open
authenticator prompt and rejected a perfectly valid passkey, with no race
window at all — and it was live on the MFA step-up too, which shares the
method.

The conditional catch was silent for every failure, including a rejected
assertion the user had deliberately chosen from the autofill menu.
Splitting the try draws the line where it belongs: silence before the
user has been asked anything, feedback once they have picked a passkey.
Filtering on `error.name` cannot draw it, since `fetchOptions` and
`verifyCredential` both raise a plain Error.

Also documents the upgrade: passwordless is on by default and applies to
already-registered credentials, so a passkey added purely as a second
factor can now sign its owner in alone. Nothing in the schema marks a
credential discoverable — the authenticator decides — and the opt-out is
instance-wide.

The invitation test is a guard, not coverage for this change. The pending
token lives in the Rack session and `complete_sign_in` reads it right
after creating the session, so a `reset_session` dropped in between
strands the invitee in their own family, silently and with every existing
test still green.

* fix(auth): cancel the in-flight conditional options request

Aborting the conditional flow did not cancel its options request, because
`fetchOptions` never received the signal. A click landing while that POST
was in flight left it to finish, and its response could apply last.

The challenge rides in the session cookie, so "the server wrote it" only
counts if the Set-Cookie reaches the browser. Threading the signal means
an aborted request's response is discarded, which closes the window
without needing the server to hold two challenges open.

Also drops the absolute claim about which existing credentials gain
passwordless sign-in. `residentKey: "preferred"` is a request an
authenticator may decline, and nothing records what it decided, so the
honest statement is that password managers and platform authenticators
generally store discoverable credentials rather than always.
2026-08-12 20:35:13 +02:00

90 lines
3.1 KiB
Ruby

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),
# `resident_key: "preferred"` asks for a discoverable credential so the
# key can also be used for passwordless sign-in. "preferred" rather than
# "required" so authenticators without free resident-key slots (older
# security keys) can still register as a second factor. User verification
# stays "preferred" here for the same reason and is enforced as
# "required" on the passwordless sign-in ceremony instead.
authenticator_selection: { resident_key: "preferred", 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