mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 13:21:17 +00:00
* 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.
235 lines
7.8 KiB
Ruby
235 lines
7.8 KiB
Ruby
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
|