Files
sure/test/controllers/passkey_sessions_controller_test.rb
T
Josh ca66346dc5 feat: add safe admin user removal (#3131)
* feat: add safe admin user removal

* fix: address user removal review findings

* fix: close remaining user removal review gaps

* fix: handle deleted users during session creation

* fix: fail closed when session creation fails

* fix: reject token issuance for inactive users
2026-08-22 21:54:32 +02:00

245 lines
8.3 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
test "rejects passkey authentication when session creation fails" do
PasskeySessionsController.any_instance.stubs(:create_session_for).returns(false)
post passkey_session_path, params: { credential: passkey_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
# 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