diff --git a/.env.example b/.env.example
index c84d0e414..b3cd1cb2c 100644
--- a/.env.example
+++ b/.env.example
@@ -123,11 +123,15 @@ 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
+# WebAuthn / passkey configuration
# RP ID is usually the registrable domain (example.com), not a full URL.
-# Allowed origins are full HTTPS origins where users access Sure.
+# Allowed origins is a comma-separated list of the full origins where users
+# access Sure (scheme included), e.g. https://sure.example.com,https://app.example.com.
WEBAUTHN_RP_ID=
WEBAUTHN_ALLOWED_ORIGINS=
+# Set to false to keep passkeys as a second factor only, instead of also
+# allowing passwordless sign-in. See docs/hosting/webauthn.md.
+# AUTH_PASSKEY_LOGIN_ENABLED=true
# OpenID Connect configuration
OIDC_CLIENT_ID=
diff --git a/app/controllers/passkey_sessions_controller.rb b/app/controllers/passkey_sessions_controller.rb
new file mode 100644
index 000000000..72dacaf2b
--- /dev/null
+++ b/app/controllers/passkey_sessions_controller.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+# Passwordless sign-in with a discoverable passkey ("usernameless" WebAuthn).
+#
+# The browser resolves which credential to use, so no email is submitted and
+# nothing here can be probed for account enumeration. User verification is
+# REQUIRED at assertion time, which makes a lone passkey two factors on its
+# own (possession + biometric/PIN) — that is why this path deliberately skips
+# the TOTP step in MfaController.
+class PasskeySessionsController < ApplicationController
+ include WebauthnRelyingParty
+
+ skip_authentication only: %i[options create]
+
+ def options
+ return head :forbidden unless AuthConfig.passkey_login_enabled?
+
+ request_options = webauthn_relying_party.options_for_authentication(
+ user_verification: "required"
+ )
+
+ session[:passkey_login_challenge] = request_options.challenge
+
+ render json: request_options
+ end
+
+ def create
+ return head :forbidden unless AuthConfig.passkey_login_enabled?
+
+ challenge = session.delete(:passkey_login_challenge)
+ return render_invalid if challenge.blank?
+
+ credential = WebAuthn::Credential.from_get(
+ webauthn_credential_payload,
+ relying_party: webauthn_relying_party
+ )
+
+ user = user_for(credential)
+ return render_invalid unless user&.active?
+ return render_invalid unless AuthConfig.local_login_allowed_for?(user)
+
+ # Scoped to the user so an assertion can never pair one account's user
+ # handle with another account's credential.
+ stored_credential = user.webauthn_credentials.find_by(credential_id: credential.id)
+ return render_invalid unless stored_credential
+
+ stored_credential.with_lock do
+ credential.verify(
+ challenge,
+ public_key: stored_credential.public_key,
+ sign_count: stored_credential.sign_count,
+ user_presence: true,
+ user_verification: true
+ )
+
+ stored_credential.update!(
+ sign_count: credential.sign_count,
+ last_used_at: Time.current
+ )
+ end
+
+ complete_sign_in(user)
+
+ render json: { redirect_url: root_path }
+ rescue WebAuthn::Error, ActionController::BadRequest, ActionController::ParameterMissing
+ render_invalid
+ end
+
+ private
+ def user_for(credential)
+ # `presence` matters: `find_by(webauthn_id: nil)` would match every user
+ # who never registered a credential.
+ handle = credential.user_handle.presence
+ return nil if handle.blank?
+
+ User.find_by(webauthn_id: handle)
+ end
+
+ def complete_sign_in(user)
+ # Drop any half-finished password + TOTP attempt from this browser.
+ 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
+
+ def render_invalid
+ render json: { error: t("passkey_sessions.invalid_credential") }, status: :unprocessable_entity
+ end
+end
diff --git a/app/controllers/settings/webauthn_credentials_controller.rb b/app/controllers/settings/webauthn_credentials_controller.rb
index 943ec75ea..cfcadf315 100644
--- a/app/controllers/settings/webauthn_credentials_controller.rb
+++ b/app/controllers/settings/webauthn_credentials_controller.rb
@@ -15,7 +15,13 @@ class Settings::WebauthnCredentialsController < ApplicationController
display_name: Current.user.display_name
},
exclude: Current.user.webauthn_credentials.pluck(:credential_id),
- authenticator_selection: { user_verification: "preferred" },
+ # `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"
)
diff --git a/app/javascript/controllers/webauthn_authentication_controller.js b/app/javascript/controllers/webauthn_authentication_controller.js
index d4c141307..19430feac 100644
--- a/app/javascript/controllers/webauthn_authentication_controller.js
+++ b/app/javascript/controllers/webauthn_authentication_controller.js
@@ -11,8 +11,20 @@ export default class extends WebauthnController {
verifyUrl: String,
unsupportedMessage: String,
errorFallback: String,
+ // Opt in to browser autofill ("conditional mediation"): passkeys are
+ // offered from the username field instead of behind a button click. Only
+ // passwordless sign-in enables this; the MFA step-up does not.
+ conditional: Boolean,
};
+ connect() {
+ if (this.conditionalValue) this.#startConditionalMediation();
+ }
+
+ disconnect() {
+ this.abortConditionalMediation();
+ }
+
async authenticate(event) {
event.preventDefault();
this.clearError();
@@ -22,6 +34,18 @@ export default class extends WebauthnController {
return;
}
+ // A second click would mint a fresh challenge while the first
+ // authenticator prompt is still open, so the assertion the user is about
+ // to produce would verify against a challenge the server has replaced.
+ if (this.authenticating) return;
+ this.authenticating = true;
+
+ // A pending conditional request holds the challenge minted on connect.
+ // Fetching options below replaces it server-side, so the stale request has
+ // to go first or its assertion would verify against a challenge that no
+ // longer exists.
+ this.abortConditionalMediation();
+
try {
const options = await this.fetchOptions();
const credential = await navigator.credentials.get({
@@ -31,14 +55,65 @@ export default class extends WebauthnController {
await this.verifyCredential(serializePublicKeyCredential(credential));
} catch (error) {
this.showError(error.message);
+ } finally {
+ this.authenticating = false;
}
}
- async fetchOptions() {
+ async #startConditionalMediation() {
+ // Created before the first await so a button click or a Turbo disconnect
+ // in the meantime has something to abort. Held in a local because
+ // abortConditionalMediation() nulls the field.
+ const controller = new AbortController();
+ this.abortController = controller;
+
+ const available =
+ await window.PublicKeyCredential?.isConditionalMediationAvailable?.();
+ if (!available || controller.signal.aborted) return;
+
+ let credential;
+
+ try {
+ const options = await this.fetchOptions(controller.signal);
+ if (controller.signal.aborted) return;
+
+ credential = await navigator.credentials.get({
+ publicKey: prepareCredentialRequestOptions(options),
+ mediation: "conditional",
+ signal: controller.signal,
+ });
+ } catch (_error) {
+ // Nothing has been asked of the user yet: aborted, dismissed, or the
+ // background options request failed. Surfacing that would paint an error
+ // on a login page nobody has touched.
+ return;
+ }
+
+ if (controller.signal.aborted || !credential) return;
+
+ try {
+ await this.verifyCredential(serializePublicKeyCredential(credential));
+ } catch (error) {
+ // The user did pick a passkey from the autofill menu, so a rejection
+ // here has to be visible.
+ this.showError(error.message);
+ }
+ }
+
+ abortConditionalMediation() {
+ this.abortController?.abort();
+ this.abortController = null;
+ }
+
+ // Takes a signal so the conditional flow's request can be cancelled. The
+ // challenge rides in the session cookie, so a response whose Set-Cookie never
+ // lands cannot overwrite the challenge a manual click just minted.
+ async fetchOptions(signal) {
const response = await fetch(this.optionsUrlValue, {
method: "POST",
headers: this.headers,
credentials: "same-origin",
+ signal,
});
if (!response.ok) throw new Error(await this.errorMessage(response));
diff --git a/app/models/auth_config.rb b/app/models/auth_config.rb
index 7b4c9c7a4..44fb74b59 100644
--- a/app/models/auth_config.rb
+++ b/app/models/auth_config.rb
@@ -12,6 +12,13 @@ class AuthConfig
!!Rails.configuration.x.auth.local_admin_override_enabled
end
+ # Whether a registered passkey may be used to sign in on its own, skipping
+ # both the password and the TOTP step. Defaults to true when unconfigured.
+ def passkey_login_enabled?
+ value = Rails.configuration.x.auth.passkey_login_enabled
+ value.nil? ? true : !!value
+ end
+
# When the local login form should be visible on the login page.
# - true when local login is enabled for everyone
# - true when admin override is enabled (super-admin only backend guard)
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
index 774e73f3e..ea6da081f 100644
--- a/app/views/sessions/new.html.erb
+++ b/app/views/sessions/new.html.erb
@@ -16,12 +16,20 @@
<% end %>
+<% passkey_login_enabled = AuthConfig.passkey_login_enabled? %>
+
<% if AuthConfig.local_login_form_visible? %>
<%= styled_form_with url: sessions_path, class: "space-y-4", data: { turbo: false } do |form| %>
+ <%#
+ The "webauthn" autocomplete token is what lets browsers offer a saved
+ passkey from this field's autofill menu (conditional mediation). It is
+ only added when passwordless sign-in is enabled, so the menu never
+ advertises a path the server would reject.
+ %>
<%= form.email_field :email,
label: t(".email"),
autofocus: false,
- autocomplete: "email",
+ autocomplete: passkey_login_enabled ? "username webauthn" : "email",
required: "required",
placeholder: t(".email_placeholder"),
value: @email %>
@@ -46,6 +54,38 @@
<%= link_to t(".forgot_password"), new_password_reset_path, class: "font-medium text-sm text-primary hover:underline transition" %>
<% end %>
+
+ <%#
+ Last thing in the local-login branch, so it renders directly above the SSO
+ buttons: a passkey is an alternative to the credential form, not part of it.
+
+ It stays inside this branch on purpose. PasskeySessionsController#create
+ gates on AuthConfig.local_login_allowed_for?, so in SSO-only mode (local
+ login off, no admin override) every assertion is rejected. Hoisting this
+ out would render a button that always fails, and mount a Stimulus
+ controller that mints an unredeemable challenge on every page view.
+ %>
+ <% if passkey_login_enabled %>
+
"
+ data-webauthn-authentication-error-fallback-value="<%= t("passkey_sessions.invalid_credential") %>">
+ <%= render DS::Button.new(
+ text: t(".passkey_button"),
+ variant: :outline,
+ size: :md,
+ full_width: true,
+ icon: "fingerprint",
+ type: "button",
+ class: "gap-2",
+ data: { action: "webauthn-authentication#authenticate" }
+ ) %>
+
+
+ <% end %>
<% end %>
<% providers = AuthConfig.sso_providers %>
diff --git a/config/auth.yml b/config/auth.yml
index 162364ea6..58b5f96c3 100644
--- a/config/auth.yml
+++ b/config/auth.yml
@@ -8,6 +8,13 @@ default: &default
# local login as an emergency override. Regular users remain SSO-only.
admin_override_enabled: <%= ENV.fetch("AUTH_LOCAL_ADMIN_OVERRIDE_ENABLED", "false") == "true" %>
+ passkey_login:
+ # When true, users with a registered passkey can sign in without a password
+ # (and without the TOTP step). User verification is always required for this
+ # path, so the passkey alone is two factors. Set to false to keep passkeys
+ # as a second factor only.
+ enabled: <%= ENV.fetch("AUTH_PASSKEY_LOGIN_ENABLED", "true") == "true" %>
+
jit:
# Controls behavior when a user signs in via SSO and no OIDC identity exists.
# - "create_and_link" (default): create a new user + family when no match exists
diff --git a/config/initializers/auth.rb b/config/initializers/auth.rb
index c77999e49..03fdbc1f6 100644
--- a/config/initializers/auth.rb
+++ b/config/initializers/auth.rb
@@ -14,6 +14,8 @@ auth_config = raw_auth_config.deep_symbolize_keys
Rails.configuration.x.auth.local_login_enabled = auth_config.dig(:local_login, :enabled)
Rails.configuration.x.auth.local_admin_override_enabled = auth_config.dig(:local_login, :admin_override_enabled)
+Rails.configuration.x.auth.passkey_login_enabled = auth_config.dig(:passkey_login, :enabled)
+
Rails.configuration.x.auth.jit_mode = auth_config.dig(:jit, :mode) || "create_and_link"
raw_domains = auth_config.dig(:jit, :allowed_oidc_domains).to_s
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index 1b2eb680a..ddca1da95 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -14,14 +14,29 @@ class Rack::Attack
request.ip if request.post? && request.path == "/register"
end
- # Throttle unauthenticated WebAuthn MFA ceremonies similarly to sign-in
+ # Throttle unauthenticated WebAuthn ceremonies similarly to sign-in
# endpoints; registration remains behind normal application authentication.
+ # Covers both the MFA step-up and passwordless passkey sign-in.
throttle("mfa/webauthn", limit: 10, period: 1.minute) do |request|
- if request.post? && request.path.in?(%w[/mfa/webauthn_options /mfa/verify_webauthn])
+ if request.post? && request.path.in?(%w[
+ /mfa/webauthn_options
+ /mfa/verify_webauthn
+ /sessions/passkey
+ ])
request.ip
end
end
+ # The passwordless challenge endpoint gets its own, looser budget: browsers
+ # that support conditional mediation call it automatically on every login
+ # page load, so sharing the limit above would let ordinary page views lock a
+ # shared NAT out of MFA. Minting a challenge touches no credential and
+ # reveals nothing, and the assertion it produces is still verified under the
+ # stricter limit.
+ throttle("passkey/options", limit: 30, period: 1.minute) do |request|
+ request.ip if request.post? && request.path == "/sessions/passkey_options"
+ 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/locales/views/passkey_sessions/en.yml b/config/locales/views/passkey_sessions/en.yml
new file mode 100644
index 000000000..ff3009cf2
--- /dev/null
+++ b/config/locales/views/passkey_sessions/en.yml
@@ -0,0 +1,5 @@
+---
+en:
+ passkey_sessions:
+ invalid_credential: Could not sign in with that passkey. Please try again or
+ use your password.
diff --git a/config/locales/views/sessions/en.yml b/config/locales/views/sessions/en.yml
index bf331ca45..5ea089018 100644
--- a/config/locales/views/sessions/en.yml
+++ b/config/locales/views/sessions/en.yml
@@ -30,6 +30,8 @@ en:
oidc: Sign in with OpenID Connect
google_auth_connect: Sign in with Google
local_login_admin_only: Local login is restricted to administrators.
+ passkey_button: Sign in with a passkey
+ passkey_unsupported: This browser does not support passkeys.
no_auth_methods_enabled: No authentication methods are currently enabled. Please contact an administrator.
demo_banner_title: "Demo Mode Active"
demo_banner_message: "This is a demonstration environment. Login credentials have been pre-filled for your convenience. Please do not enter real or sensitive information."
diff --git a/config/routes.rb b/config/routes.rb
index 7ca540b07..f6ffcd6ba 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -277,6 +277,10 @@ Rails.application.routes.draw do
resource :registration, only: %i[new create]
resources :sessions, only: %i[index new create destroy]
+ # Passwordless sign-in with a discoverable passkey. Unauthenticated by design;
+ # rate limited alongside the MFA WebAuthn endpoints in Rack::Attack.
+ post "/sessions/passkey_options", to: "passkey_sessions#options", as: :passkey_session_options
+ post "/sessions/passkey", to: "passkey_sessions#create", as: :passkey_session
# Desktop app SSO: opens the flow in the system browser (so passkeys/WebAuthn
# work), then hands a single-use, PKCE-bound code back via the sure:// scheme
# which the desktop webview exchanges for a normal web session.
diff --git a/docs/hosting/webauthn.md b/docs/hosting/webauthn.md
index 2e78ceb59..db1f1d889 100644
--- a/docs/hosting/webauthn.md
+++ b/docs/hosting/webauthn.md
@@ -1,6 +1,6 @@
-# WebAuthn MFA Configuration
+# WebAuthn 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.
+Sure supports passkeys, Touch ID, Windows Hello, and hardware security keys, both as a second factor and for passwordless sign-in. 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:
@@ -25,3 +25,56 @@ 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.
+
+## Passwordless sign-in
+
+A registered passkey can sign a user in directly from the login page, without a password and without the TOTP step. This is enabled by default:
+
+```bash
+AUTH_PASSKEY_LOGIN_ENABLED=true
+```
+
+Set it to `false` to keep passkeys as a second factor only.
+
+### Why skipping the password is not a downgrade
+
+The passwordless ceremony always requests `userVerification: "required"`, so the authenticator must confirm the person as well as the device — a biometric, a device PIN, or a security key PIN. That makes a lone passkey two independent factors (possession plus inherence or knowledge), which is the same bar as the password plus TOTP flow it replaces. An authenticator that can only confirm presence, such as a bare touch, is rejected on this path and still works as a second factor.
+
+Sign-in is "usernameless": no email is submitted, because the browser returns the account handle along with the assertion. Nothing on this path can be probed to discover whether an account exists.
+
+### Requirements
+
+- The passkey must be **discoverable** (also called a resident key). Registration asks for one with `residentKey: "preferred"`. Password managers and platform authenticators — Proton Pass, iCloud Keychain, 1Password, Bitwarden, Windows Hello — store discoverable credentials by default. A hardware security key with no free resident-key slots still registers, but only as a second factor, and will not appear in the passkey picker.
+- Users must enable two-factor authentication before they can register a passkey. Disabling 2FA removes every registered passkey, which also removes passwordless sign-in for that user.
+- Passkey sign-in follows the same policy as local login. When `AUTH_LOCAL_LOGIN_ENABLED=false`, only super admins with `AUTH_LOCAL_ADMIN_OVERRIDE_ENABLED=true` may use it.
+
+### Upgrading an instance that already has passkeys
+
+Passwordless sign-in is on by default, and it applies to passkeys that were
+registered before this feature existed. A passkey a user added purely as a
+second factor can, after the upgrade, sign that user in on its own.
+
+This applies to a credential only if the authenticator that holds it made it
+discoverable. Registration asks with `residentKey: "preferred"`, which an
+authenticator is free to decline, and nothing in the database records what it
+decided — so Sure cannot tell you in advance which existing credentials are
+affected. In practice password managers and platform authenticators (Proton
+Pass, iCloud Keychain, 1Password, Bitwarden, Windows Hello) store discoverable
+credentials by default, so theirs generally are; a credential an authenticator
+stored non-discoverably, such as one on a hardware key with no free resident-key
+slot, stays second-factor only. Nothing needs to be re-registered either way.
+
+The opt-out is instance-wide. There is no per-user or per-credential setting: to
+keep passkeys as a second factor for everyone, set
+`AUTH_PASSKEY_LOGIN_ENABLED=false` before upgrading. A single user can only opt
+out by removing the credential.
+
+This is not a reduction in security — the passwordless ceremony requires user
+verification, so the passkey alone is still two factors, as described above. It
+is a change in what an already-registered credential can do, and users who chose
+a passkey specifically as a *second* factor have not consented to it signing
+them in alone.
+
+### Browser autofill
+
+When the browser supports conditional mediation, saved passkeys are offered from the email field's autofill menu, before any button is clicked. Browsers without it fall back to the "Sign in with a passkey" button, which works the same way.
diff --git a/test/controllers/passkey_sessions_controller_test.rb b/test/controllers/passkey_sessions_controller_test.rb
new file mode 100644
index 000000000..f38787822
--- /dev/null
+++ b/test/controllers/passkey_sessions_controller_test.rb
@@ -0,0 +1,234 @@
+require "test_helper"
+require "webauthn/fake_client"
+
+class PasskeySessionsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = users(:family_admin)
+ @user.webauthn_credentials.destroy_all
+ sign_in @user
+ @user.setup_mfa!
+ @user.enable_mfa!
+ @client = register_webauthn_credential
+ @stored_credential = @user.webauthn_credentials.reload.first
+ sign_out
+ end
+
+ test "signs in with a discoverable passkey, skipping password and TOTP" do
+ assertion = passkey_assertion
+
+ post passkey_session_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
+
+ # The pending invitation lives in the Rack session, and complete_sign_in reads
+ # it immediately after creating the session. Anything that clears the session
+ # in between — a reset_session added to "fix" session fixation, say — drops the
+ # invitee into their own family with no error and no failing test.
+ test "accepts a pending invitation stored before the passkey sign-in" do
+ invitation = Invitation.create!(
+ email: @user.email,
+ role: "member",
+ family: @user.family,
+ inviter: @user
+ )
+
+ get new_session_path(invitation: invitation.token)
+ assert_response :success
+
+ post passkey_session_path, params: { credential: passkey_assertion }, as: :json
+
+ assert_response :success
+ assert invitation.reload.accepted_at.present?, "invitation was not accepted during passkey sign-in"
+ assert_equal "member", @user.reload.role
+ end
+
+ test "rejects an assertion without user verification" do
+ assertion = passkey_assertion(user_verified: false)
+
+ post passkey_session_path, params: { credential: assertion }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_equal I18n.t("passkey_sessions.invalid_credential"), JSON.parse(response.body).fetch("error")
+ assert_not Session.exists?(user_id: @user.id)
+ end
+
+ test "rejects an unknown user handle" do
+ assertion = passkey_assertion(user_handle: WebAuthn.generate_user_id)
+
+ post passkey_session_path, params: { credential: assertion }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_not Session.exists?(user_id: @user.id)
+ end
+
+ # A blank handle must not fall through to `find_by(webauthn_id: nil)`, which
+ # would match every user who never registered a credential.
+ test "rejects a blank user handle" do
+ other_user = users(:family_member)
+ assert_nil other_user.webauthn_id
+
+ assertion = passkey_assertion
+ assertion["response"]["userHandle"] = nil
+
+ post passkey_session_path, params: { credential: assertion }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_empty Session.where(user_id: [ @user.id, other_user.id ])
+ end
+
+ test "rejects a credential that belongs to a different user than the handle" do
+ other_user = users(:family_member)
+ other_user.ensure_webauthn_id!
+
+ assertion = passkey_assertion(user_handle: other_user.reload.webauthn_id)
+
+ post passkey_session_path, params: { credential: assertion }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_empty Session.where(user_id: [ @user.id, other_user.id ])
+ end
+
+ test "rejects a deactivated user" do
+ @user.update_column(:active, false)
+ assertion = passkey_assertion
+
+ post passkey_session_path, params: { credential: assertion }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_not Session.exists?(user_id: @user.id)
+ end
+
+ test "rejects a replayed assertion" do
+ assertion = passkey_assertion
+
+ post passkey_session_path, params: { credential: assertion }, as: :json
+ assert_response :success
+
+ Session.where(user_id: @user.id).destroy_all
+
+ post passkey_session_path, params: { credential: assertion }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_not Session.exists?(user_id: @user.id)
+ end
+
+ test "rejects an assertion with no challenge in the session" do
+ assertion = passkey_assertion
+
+ reset!
+
+ post passkey_session_path, params: { credential: assertion }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_not Session.exists?(user_id: @user.id)
+ end
+
+ test "rejects malformed credential payloads" do
+ post passkey_session_options_path, as: :json
+ assert_response :success
+
+ post passkey_session_path, params: { credential: "not-json" }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_not Session.exists?(user_id: @user.id)
+ end
+
+ test "rejects users who are not allowed to use local login" do
+ AuthConfig.stubs(:local_login_allowed_for?).returns(false)
+ assertion = passkey_assertion
+
+ post passkey_session_path, params: { credential: assertion }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_not Session.exists?(user_id: @user.id)
+ end
+
+ test "both endpoints are unavailable when passkey login is disabled" do
+ AuthConfig.stubs(:passkey_login_enabled?).returns(false)
+
+ post passkey_session_options_path, as: :json
+ assert_response :forbidden
+
+ post passkey_session_path, params: { credential: {} }, as: :json
+ assert_response :forbidden
+ assert_not Session.exists?(user_id: @user.id)
+ end
+
+ test "requests a discoverable credential with user verification required" do
+ post passkey_session_options_path, as: :json
+
+ assert_response :success
+ options = JSON.parse(response.body)
+ assert_equal "www.example.com", options.fetch("rpId")
+ assert_equal "required", options.fetch("userVerification")
+ assert_empty options.fetch("allowCredentials")
+ end
+
+ test "options use the configured relying party id" do
+ with_webauthn_config(rp_id: "example.test", allowed_origins: [ "https://app.example.test" ]) do
+ post passkey_session_options_path, as: :json
+
+ assert_response :success
+ assert_equal "example.test", JSON.parse(response.body).fetch("rpId")
+ end
+ end
+
+ private
+ # Runs a full options -> get -> assertion cycle against the passwordless
+ # endpoint, so each assertion is bound to a freshly minted challenge.
+ def passkey_assertion(user_verified: true, user_handle: nil)
+ post passkey_session_options_path, as: :json
+ assert_response :success
+ options = JSON.parse(response.body)
+
+ @client.get(
+ challenge: options.fetch("challenge"),
+ rp_id: "www.example.com",
+ user_verified: user_verified,
+ user_handle: raw_user_handle(user_handle || @user.reload.webauthn_id)
+ )
+ end
+
+ # FakeClient encodes whatever it is handed, but `webauthn_id` is already a
+ # base64url string, so it has to be decoded back to raw bytes first.
+ def raw_user_handle(webauthn_id)
+ WebAuthn.standard_encoder.decode(webauthn_id)
+ end
+
+ 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 sign_out
+ @user.sessions.each { |session| delete session_path(session) }
+ 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/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb
index 712343de1..56291a645 100644
--- a/test/controllers/sessions_controller_test.rb
+++ b/test/controllers/sessions_controller_test.rb
@@ -38,6 +38,31 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
+ test "login page offers passkey sign-in" do
+ AuthConfig.stubs(:passkey_login_enabled?).returns(true)
+
+ get new_session_url
+
+ assert_response :success
+ assert_select "button", text: I18n.t("sessions.new.passkey_button")
+ assert_select "[data-webauthn-authentication-conditional-value='true']"
+ assert_select "[data-webauthn-authentication-unsupported-message-value=?]", I18n.t("sessions.new.passkey_unsupported")
+ assert_select "[data-webauthn-authentication-error-fallback-value=?]", I18n.t("passkey_sessions.invalid_credential")
+ # Browsers only surface passkeys from autofill when the field carries the
+ # "webauthn" token.
+ assert_select "input[type=email][autocomplete='username webauthn']"
+ end
+
+ test "login page hides passkey sign-in when disabled" do
+ AuthConfig.stubs(:passkey_login_enabled?).returns(false)
+
+ get new_session_url
+
+ assert_response :success
+ assert_select "button", text: I18n.t("sessions.new.passkey_button"), count: 0
+ assert_select "input[type=email][autocomplete='email']"
+ end
+
test "can sign in" do
sign_in @user
assert_redirected_to root_url
diff --git a/test/controllers/settings/webauthn_credentials_controller_test.rb b/test/controllers/settings/webauthn_credentials_controller_test.rb
index d2fc8c69f..444aceb05 100644
--- a/test/controllers/settings/webauthn_credentials_controller_test.rb
+++ b/test/controllers/settings/webauthn_credentials_controller_test.rb
@@ -20,6 +20,13 @@ class Settings::WebauthnCredentialsControllerTest < ActionDispatch::IntegrationT
assert_equal I18n.t("webauthn_credentials.mfa_required"), JSON.parse(response.body).fetch("error")
end
+ test "asks for a discoverable credential so it can be used for passwordless sign-in" do
+ options = registration_options
+
+ assert_equal "preferred", options.dig("authenticatorSelection", "residentKey")
+ assert_equal "preferred", options.dig("authenticatorSelection", "userVerification")
+ 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")