CVE-2024-21654 (published 2024-01-12T21:15Z)

Rubygems.org is the Ruby community's gem hosting service. Rubygems.org users with MFA enabled would normally be protected from account takeover in the case of email account takeover. However, a workaround on the forgotten password form allows an attacker to bypass the MFA requirement and takeover the account. This vulnerability has been patched in commit 0b3272a.

CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-287
cwe_name: Improper Authentication
description: When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
extended_description: 
            
			
         
         

Fix information:
repo_url: https://github.com/rubygems/rubygems.org
commit_details: repo_url: https://github.com/rubygems/rubygems.org
author: Martin Emde
author_date: 2023-12-22 11:53:04-08:00
msg: Protect forgotten password changes from MFA bypass.

It was previously possible to submit the password update form directly
with only the confirmation token, bypassing MFA protections.
repository: repo_url: https://github.com/rubygems/rubygems.org
repo_name: rubygems/rubygems.org
description: The Ruby community's gem hosting service.
repo_language: Ruby
owner: rubygems
file_changes: filename: adoptions_controller.rb
change_type: ModificationType.MODIFY
diff: @@ -3,7 +3,7 @@ class AdoptionsController < ApplicationController
 
   before_action :find_rubygem
   before_action :verify_ownership_requestable
-  before_action :redirect_to_verify, if: -> { current_user_is_owner? && !password_session_active? }
+  before_action :redirect_to_verify, if: -> { current_user_is_owner? && !verified_session_active? }
 
   def index
     @ownership_call     = @rubygem.ownership_call

programming_language: Ruby
filename: application_controller.rb
change_type: ModificationType.MODIFY
diff: @@ -153,10 +153,6 @@ class ApplicationController < ActionController::Base
     response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
   end
 
-  def password_session_active?
-    session[:verification] && session[:verification] > Time.current && session.fetch(:verified_user, "") == current_user.id
-  end
-
   def set_error_context_user
     return unless current_user
 

programming_language: Ruby
filename: session_verifiable.rb
change_type: ModificationType.MODIFY
diff: @@ -6,7 +6,7 @@ module SessionVerifiable
       before_action :redirect_to_signin, **opts, unless: :signed_in?
       before_action :redirect_to_new_mfa, **opts, if: :mfa_required_not_yet_enabled?
       before_action :redirect_to_settings_strong_mfa_required, **opts, if: :mfa_required_weak_level_enabled?
-      before_action :redirect_to_verify, **opts, unless: :password_session_active?
+      before_action :redirect_to_verify, **opts, unless: :verified_session_active?
     end
   end
 
@@ -25,5 +25,16 @@ module SessionVerifiable
       session[:redirect_uri] = verify_session_redirect_path
       redirect_to verify_session_path
     end
+
+    def session_verified
+      session[:verified_user] = current_user.id
+      session[:verification] = Gemcutter::PASSWORD_VERIFICATION_EXPIRY.from_now
+    end
+
+    def verified_session_active?
+      session[:verification] &&
+        session[:verification] > Time.current &&
+        session.fetch(:verified_user, "") == current_user.id
+    end
   end
 end

programming_language: Ruby
filename: passwords_controller.rb
change_type: ModificationType.MODIFY
diff: @@ -1,10 +1,17 @@
 class PasswordsController < Clearance::PasswordsController
   include MfaExpiryMethods
   include WebauthnVerifiable
+  include SessionVerifiable
 
   before_action :validate_confirmation_token, only: %i[edit otp_edit webauthn_edit]
   after_action :delete_mfa_expiry_session, only: %i[otp_edit webauthn_edit]
 
+  # By default, clearance expects the token to be submitted with the password update.
+  # We already invalidated the token when the user became verified by token(+mfa).
+  skip_before_action :ensure_existing_user, only: %i[update]
+  # Instead of the token, we now require the user to have been verified recently.
+  verify_session_before only: %i[update]
+
   def edit
     if @user.mfa_enabled?
       @otp_verification_url = otp_edit_user_password_url(@user, token: @user.confirmation_token)
@@ -14,17 +21,16 @@ class PasswordsController < Clearance::PasswordsController
 
       render template: "multifactor_auths/prompt"
     else
+      # When user doesn't have mfa, a valid token is a full "magic link" sign in.
+      verified_sign_in
       render template: "passwords/edit"
     end
   end
 
   def update
-    @user = find_user_for_update
-
-    if @user.update_password password_from_password_reset_params
-      @user.reset_api_key! if reset_params[:reset_api_key] == "true"
-      @user.api_keys.expire_all! if reset_params[:reset_api_keys] == "true"
-      sign_in @user
+    if current_user.update_password password_from_password_reset_params
+      current_user.reset_api_key! if reset_params[:reset_api_key] == "true"
+      current_user.api_keys.expire_all! if reset_params[:reset_api_keys] == "true"
       redirect_to url_after_update
       session[:password_reset_token] = nil
     else
@@ -35,6 +41,8 @@ class PasswordsController < Clearance::PasswordsController
 
   def otp_edit
     if otp_edit_conditions_met?
+      # When the user identified by the email token submits adequate totp, they are logged in
+      verified_sign_in
       render template: "passwords/edit"
     elsif !session_active?
       login_failure(t("multifactor_auths.session_expired"))
@@ -51,11 +59,20 @@ class PasswordsController < Clearance::PasswordsController
 
     return login_failure(@webauthn_error) unless webauthn_credential_verified?
 
+    # When the user identified by the email token submits verified webauthn, they are logged in
+    verified_sign_in
     render template: "passwords/edit"
   end
 
   private
 
+  def verified_sign_in
+    sign_in @user
+    session_verified
+    @user.update!(confirmation_token: nil)
+    StatsD.increment "login.success"
+  end
+
   def url_after_update
     dashboard_path
   end
@@ -81,4 +98,9 @@ class PasswordsController < Clearance::PasswordsController
     flash.now.alert = message
     render template: "multifactor_auths/prompt", status: :unauthorized
   end
+
+  def redirect_to_verify
+    session[:redirect_uri] = verify_session_redirect_path
+    redirect_to verify_session_path, alert: t("verification_expired")
+  end
 end

programming_language: Ruby
filename: sessions_controller.rb
change_type: ModificationType.MODIFY
diff: @@ -1,6 +1,7 @@
 class SessionsController < Clearance::SessionsController
   include MfaExpiryMethods
   include WebauthnVerifiable
+  include SessionVerifiable
 
   before_action :redirect_to_signin, unless: :signed_in?, only: %i[verify webauthn_authenticate authenticate]
   before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, only: %i[verify webauthn_authenticate authenticate]
@@ -93,8 +94,7 @@ class SessionsController < Clearance::SessionsController
   private
 
   def mark_verified
-    session[:verified_user] = current_user.id
-    session[:verification] = Gemcutter::PASSWORD_VERIFICATION_EXPIRY.from_now
+    session_verified
     redirect_to session.delete(:redirect_uri) || root_path
   end
 

programming_language: Ruby
filename: edit.html.erb
change_type: ModificationType.MODIFY
diff: @@ -1,9 +1,9 @@
 <% @title = t('.title') %>
 
 <%= form_for(:password_reset,
-             :url => user_password_path(@user, :token => @user.confirmation_token),
+             :url => user_password_path(current_user),
              :html => { :method => :put }) do |form| %>
-  <%= error_messages_for @user %>
+  <%= error_messages_for current_user %>
   <div class="password_field">
     <%= form.label :password, "Password", :class => 'form__label' %>
     <%= form.password_field :password, autocomplete: 'new-password', class: 'form__input' %>

programming_language: Ruby
filename: de.yml
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ de:
   credentials_required:
   edit: Bearbeiten
   failure_when_forbidden:
+  verification_expired:
   feed_latest: RubyGems.org | Neueste Gems
   feed_subscribed: RubyGems.org | Abonnierte Gems
   footer_about_html:

programming_language: Markdown
filename: en.yml
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ en:
   credentials_required: Credentials required
   edit: Edit
   failure_when_forbidden: Please double check the URL or try submitting it again.
+  verification_expired: The verification has expired. Please verify again.
   feed_latest: RubyGems.org | Latest Gems
   feed_subscribed: RubyGems.org | Subscribed Gems
   footer_about_html:

programming_language: Ruby
filename: es.yml
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ es:
   credentials_required: Credenciales requeridas
   edit: Editar
   failure_when_forbidden: Por favor verifica la URL o inténtalo nuevamente.
+  verification_expired:
   feed_latest: RubyGems.org | Gemas más recientes
   feed_subscribed: RubyGems.org | Suscripciones a gemas
   footer_about_html: RubyGems.org es el servicio de alojamiento de Gemas de la comunidad

programming_language: Markdown
filename: fr.yml
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ fr:
   credentials_required:
   edit: Modification
   failure_when_forbidden: Veuillez vérifier l'URL ou réessayer.
+  verification_expired:
   feed_latest: RubyGems.org | Derniers Gems
   feed_subscribed: RubyGems.org | Gems abonnés
   footer_about_html: RubyGems.org est le service d&rsquo;hébergement de la communauté

programming_language: Markdown
filename: ja.yml
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ ja:
   credentials_required: 認証情報が必要です
   edit: 編集
   failure_when_forbidden: URLを見返すか、再度送信してみてください。
+  verification_expired:
   feed_latest: RubyGems.org | 最新のgemの一覧
   feed_subscribed: RubyGems.org | 購読したgemの一覧
   footer_about_html: RubyGems.orgはRubyコミュニティのgemのホスティングサービスです。すぐに<a href="%{publish_docs}">gemを公開</a>して<a

programming_language: Markdown
filename: nl.yml
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ nl:
   credentials_required:
   edit: Wijzig
   failure_when_forbidden: Controleer het webadres, en probeer het opnieuw.
+  verification_expired:
   feed_latest: RubyGems.org | Nieuwste Gems
   feed_subscribed: RubyGems.org | Geabonneerde Gems
   footer_about_html: RubyGems.org is de gem hosting service van de Ruby community.

programming_language: Markdown
filename: pt-BR.yml
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ pt-BR:
   credentials_required:
   edit: Editar
   failure_when_forbidden: Por favor, confira a URL ou tente submetê-la novamente.
+  verification_expired:
   feed_latest: RubyGems.org | Últimas Gems
   feed_subscribed: RubyGems.org | Gems do seu Feed
   footer_about_html: RubyGems.org é o serviço de hospedagem de gems da comunidade

programming_language: Markdown
filename: zh-CN.yml
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ zh-CN:
   credentials_required: 需要凭证
   edit: 编辑
   failure_when_forbidden: 请再次确认 URL 或尝试重新提交
+  verification_expired:
   feed_latest: RubyGems.org | 最新的 Gem
   feed_subscribed: RubyGems.org | 订阅的 Gem
   footer_about_html: RubyGems.org 是 Ruby 社区的 Gem 托管服务。 立即 <a href="%{publish_docs}">发布您的

programming_language: Markdown
filename: zh-TW.yml
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ zh-TW:
   credentials_required:
   edit: 編輯
   failure_when_forbidden: 請確認 URL 或再次提交
+  verification_expired:
   feed_latest: RubyGems.org | 最新 Gems
   feed_subscribed: RubyGems.org | 訂閱 Gems
   footer_about_html: RubyGems.org 是 Ruby 社群的 Gem 套件管理服務，讓你能立即地發佈及安裝你的 Gem 套件，並且利用

programming_language: Python
filename: passwords_controller_test.rb
change_type: ModificationType.MODIFY
diff: @@ -35,6 +35,14 @@ class PasswordsControllerTest < ActionController::TestCase
 
       should respond_with :success
 
+      should "sign in the user" do
+        assert_predicate @controller.request.env[:clearance], :signed_in?
+      end
+
+      should "invalidate the confirmation_token" do
+        assert_nil @user.reload.confirmation_token
+      end
+
       should "display edit form" do
         page.assert_text("Reset password")
         page.assert_selector("input[type=password][autocomplete=new-password]")
@@ -49,6 +57,10 @@ class PasswordsControllerTest < ActionController::TestCase
 
       should redirect_to("the home page") { root_path }
 
+      should "not sign in the user" do
+        refute_predicate @controller.request.env[:clearance], :signed_in?
+      end
+
       should "warn about invalid url" do
         assert_equal "Please double check the URL or try submitting it again.", flash[:alert]
       end
@@ -62,6 +74,10 @@ class PasswordsControllerTest < ActionController::TestCase
 
       should respond_with :success
 
+      should "not sign in the user" do
+        refute_predicate @controller.request.env[:clearance], :signed_in?
+      end
+
       should "display otp form" do
         assert page.has_content?("Multi-factor authentication")
         assert page.has_content?("OTP code")
@@ -80,6 +96,10 @@ class PasswordsControllerTest < ActionController::TestCase
 
       should respond_with :success
 
+      should "not sign in the user" do
+        refute_predicate @controller.request.env[:clearance], :signed_in?
+      end
+
       should "display webauthn prompt" do
         assert page.has_button?("Authenticate with security device")
       end
@@ -97,6 +117,10 @@ class PasswordsControllerTest < ActionController::TestCase
 
       should respond_with :success
 
+      should "not sign in the user" do
+        refute_predicate @controller.request.env[:clearance], :signed_in?
+      end
+
       should "display webauthn prompt" do
         assert page.has_button?("Authenticate with security device")
       end
@@ -115,6 +139,10 @@ class PasswordsControllerTest < ActionController::TestCase
 
       should respond_with :success
 
+      should "not sign in the user" do
+        refute_predicate @controller.request.env[:clearance], :signed_in?
+      end
+
       should "display webauthn prompt" do
         assert page.has_button?("Authenticate with security device")
       end
@@ -142,9 +170,18 @@ class PasswordsControllerTest < ActionController::TestCase
 
         should respond_with :success
 
+        should "sign in the user" do
+          assert_predicate @controller.request.env[:clearance], :signed_in?
+        end
+
+        should "invalidate the confirmation_token" do
+          assert_nil @user.reload.confirmation_token
+        end
+
         should "display edit form" do
           page.assert_text("Reset password")
         end
+
         should "clear mfa_expires_at" do
           assert_nil @controller.session[:mfa_expires_at]
         end
@@ -158,6 +195,10 @@ class PasswordsControllerTest < ActionController::TestCase
 
         should respond_with :unauthorized
 
+        should "not sign in the user" do
+          refute_predicate @controller.request.env[:clearance], :signed_in?
+        end
+
         should "alert about otp being incorrect" do
           assert_equal "Your OTP code is incorrect.", flash[:alert]
         end
@@ -222,6 +263,14 @@ class PasswordsControllerTest < ActionController::TestCase
 
       should respond_with :success
 
+      should "sign in the user" do
+        assert_predicate @controller.request.env[:clearance], :signed_in?
+      end
+
+      should "invalidate the confirmation_token" do
+        assert_nil @user.reload.confirmation_token
+      end
+
       should "display edit form" do
         page.assert_text("Reset password")
       end
@@ -238,6 +287,10 @@ class PasswordsControllerTest < ActionController::TestCase
 
       should respond_with :unauthorized
 
+      should "not sign in the user" do
+        refute_predicate @controller.request.env[:clearance], :signed_in?
+      end
+
       should "set flash notice" do
         assert_equal "Credentials required", flash[:alert]
       end
@@ -266,9 +319,14 @@ class PasswordsControllerTest < ActionController::TestCase
 
       should respond_with :unauthorized
 
+      should "not sign in the user" do
+        refute_predicate @controller.request.env[:clearance], :signed_in?
+      end
+
       should "set flash notice" do
         assert_equal "WebAuthn::ChallengeVerificationError", flash[:alert]
       end
+
       should "still have the webauthn form url" do
         assert_not_nil page.find(".js-webauthn-session--form")[:action]
       end
@@ -322,16 +380,16 @@ class PasswordsControllerTest < ActionController::TestCase
       @old_encrypted_password = @user.encrypted_password
     end
 
-    context "with reset_api_key and invalid password" do
+    context "when not signed in" do
       setup do
         put :update, params: {
           user_id: @user.id,
           token: @user.confirmation_token,
-          password_reset: { reset_api_key: "true", password: "pass" }
+          password_reset: { reset_api_key: "true", reset_api_keys: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
         }
       end
 
-      should respond_with :success
+      should redirect_to("the sign in page") { sign_in_path }
 
       should "not change api_key" do
         assert_equal(@user.reload.api_key, @api_key)
@@ -339,89 +397,145 @@ class PasswordsControllerTest < ActionController::TestCase
       should "not change password" do
         assert_equal(@user.reload.encrypted_password, @old_encrypted_password)
       end
+      should "not sign in the user" do
+        refute_predicate @controller.request.env[:clearance], :signed_in?
+      end
     end
 
-    context "without reset_api_key and valid password" do
+    context "when signed in" do
       setup do
-        put :update, params: {
-          user_id: @user.id,
-          token: @user.confirmation_token,
-          password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD }
-        }
+        sign_in_as @user
+        session[:verification] = 10.minutes.from_now
+        session[:verified_user] = @user.id
       end
 
-      should respond_with :found
-
-      should "not change api_key" do
-        assert_equal(@user.reload.api_key, @api_key)
+      teardown do
+        session[:verification] = nil
+        session[:verified_user] = nil
       end
-      should "change password" do
-        refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
-      end
-    end
 
-    context "with reset_api_key false and valid password" do
-      setup do
-        put :update, params: {
-          user_id: @user.id,
-          token: @user.confirmation_token,
-          password_reset: { reset_api_key: "false", password: PasswordHelpers::SECURE_TEST_PASSWORD }
-        }
-      end
+      context "with invalid password" do
+        setup do
+          put :update, params: {
+            user_id: @user.id,
+            token: @user.confirmation_token,
+            password_reset: { reset_api_key: "true", password: "pass" }
+          }
+        end
 
-      should respond_with :found
+        should respond_with :success
 
-      should "not change api_key" do
-        assert_equal(@user.reload.api_key, @api_key)
-      end
-      should "change password" do
-        refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+        should "not change api_key" do
+          assert_equal(@user.reload.api_key, @api_key)
+        end
+        should "not change password" do
+          assert_equal(@user.reload.encrypted_password, @old_encrypted_password)
+        end
       end
-    end
 
-    context "with reset_api_key and valid password" do
-      setup do
-        put :update, params: {
-          user_id: @user.id,
-          token: @user.confirmation_token,
-          password_reset: { reset_api_key: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
-        }
-      end
+      context "with a valid password" do
+        context "when verification has expired" do
+          setup do
+            travel 16.minutes do
+              put :update, params: {
+                user_id: @user.id,
+                token: @user.confirmation_token,
+                password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD }
+              }
+            end
+          end
 
-      should respond_with :found
+          should set_flash[:alert]
+          should redirect_to("the verification page") { verify_session_path }
 
-      should "change api_key" do
-        refute_equal(@user.reload.api_key, @api_key)
-      end
-      should "change password" do
-        refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
-      end
-      should "not delete new api key" do
-        refute_predicate @new_api_key.reload, :destroyed?
-        refute_empty @user.reload.api_keys
-      end
-    end
+          should "not sign the user out" do
+            assert_predicate @controller.request.env[:clearance], :signed_in?
+          end
+        end
 
-    context "with reset_api_key and reset_api_keys and valid password" do
-      setup do
-        put :update, params: {
-          user_id: @user.id,
-          token: @user.confirmation_token,
-          password_reset: { reset_api_key: "true", reset_api_keys: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
-        }
-      end
+        context "without reset_api_key" do
+          setup do
+            put :update, params: {
+              user_id: @user.id,
+              token: @user.confirmation_token,
+              password_reset: { password: PasswordHelpers::SECURE_TEST_PASSWORD }
+            }
+          end
 
-      should respond_with :found
+          should respond_with :found
 
-      should "change api_key" do
-        refute_equal(@user.reload.api_key, @api_key)
-      end
-      should "change password" do
-        refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
-      end
-      should "expire new api key" do
-        assert_empty @user.reload.api_keys.unexpired
-        refute_empty @user.reload.api_keys.expired
+          should "not change api_key" do
+            assert_equal(@user.reload.api_key, @api_key)
+          end
+          should "change password" do
+            refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+          end
+        end
+
+        context "with reset_api_key false" do
+          setup do
+            put :update, params: {
+              user_id: @user.id,
+              token: @user.confirmation_token,
+              password_reset: { reset_api_key: "false", password: PasswordHelpers::SECURE_TEST_PASSWORD }
+            }
+          end
+
+          should respond_with :found
+
+          should "not change api_key" do
+            assert_equal(@user.reload.api_key, @api_key)
+          end
+          should "change password" do
+            refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+          end
+        end
+
+        context "with reset_api_key" do
+          setup do
+            put :update, params: {
+              user_id: @user.id,
+              token: @user.confirmation_token,
+              password_reset: { reset_api_key: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
+            }
+          end
+
+          should respond_with :found
+
+          should "change api_key" do
+            refute_equal(@user.reload.api_key, @api_key)
+          end
+          should "change password" do
+            refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+          end
+          should "not delete new api key" do
+            refute_predicate @new_api_key.reload, :destroyed?
+            refute_empty @user.reload.api_keys
+          end
+        end
+
+        context "with reset_api_key and reset_api_keys" do
+          setup do
+            put :update, params: {
+              user_id: @user.id,
+              token: @user.confirmation_token,
+              password_reset: { reset_api_key: "true", reset_api_keys: "true", password: PasswordHelpers::SECURE_TEST_PASSWORD }
+            }
+          end
+
+          should respond_with :found
+
+          should "change api_key" do
+            refute_equal(@user.reload.api_key, @api_key)
+          end
+          should "change password" do
+            refute_equal(@user.reload.encrypted_password, @old_encrypted_password)
+          end
+          should "expire new api key" do
+            assert_empty @user.reload.api_keys.unexpired
+            refute_empty @user.reload.api_keys.expired
+          end
+        end
       end
     end
   end

programming_language: Ruby
filename: password_reset_test.rb
change_type: ModificationType.MODIFY
diff: @@ -55,13 +55,36 @@ class PasswordResetTest < SystemTest
 
     visit password_reset_link
 
+    assert page.has_content?("Sign out")
+
     fill_in "Password", with: ""
     click_button "Save this password"
 
     assert page.has_content? "Password can't be blank."
-    assert page.has_content? "Sign in"
+    assert page.has_content? "Reset password"
+
+    # try again
+    fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
+    click_button "Save this password"
+
+    assert @user.reload.authenticated? PasswordHelpers::SECURE_TEST_PASSWORD
+  end
+
+  test "resetting a password but waiting too long after token auth" do
+    forgot_password_with @user.email
+
+    visit password_reset_link
+
+    fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
+
+    travel 16.minutes do
+      click_button "Save this password"
+
+      assert page.has_content? "verification has expired. Please verify again."
+    end
   end
 
+
   test "resetting a password when signed in" do
     visit sign_in_path
 
@@ -78,6 +101,8 @@ class PasswordResetTest < SystemTest
 
     visit password_reset_link
 
+    assert page.has_content?("Sign out")
+
     fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
     click_button "Save this password"
 
@@ -90,13 +115,15 @@ class PasswordResetTest < SystemTest
 
     visit password_reset_link
 
+    refute page.has_content?("Sign out")
+
     fill_in "otp", with: ROTP::TOTP.new(@user.totp_seed).now
     click_button "Authenticate"
 
+    assert page.has_content?("Sign out")
+
     fill_in "Password", with: PasswordHelpers::SECURE_TEST_PASSWORD
     click_button "Save this password"
-
-    assert page.has_content?("Sign out")
   end
 
   test "resetting a password when mfa is enabled but mfa session is expired" do
@@ -141,6 +168,7 @@ class PasswordResetTest < SystemTest
 
     visit password_reset_link
 
+    refute page.has_content? "Sign out"
     assert page.has_content? "Multi-factor authentication"
     assert page.has_content? "Security Device"
     assert page.has_content? "Recovery code"

programming_language: Ruby