From 211fb1102dd208daee414cff1b8d71ea27ac5ebf Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 2 Apr 2026 11:26:29 +0530 Subject: [PATCH] chore: rotate oauth password if unconfirmed (#13878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user signs up with an email they don't own and sets a password, that password remains valid even after the real owner later signs in via OAuth. This means the original registrant — who never proved ownership of the email — retains working credentials on the account. This change closes that gap by rotating the password to a random value whenever an unconfirmed user completes an OAuth sign-in. The check (`oauth_user_needs_password_reset?`) is evaluated before `skip_confirmation!` runs, since confirmation would flip `confirmed_at` and mask the condition. If the user was unconfirmed, the stored password is replaced with a secure random string that satisfies the password policy. This applies to both the web and mobile OAuth callback paths, as well as the sign-up path where the password is rotated before the reset token is generated. Users who lose access to password-based login as a side effect can recover through the standard "Forgot password" flow at any time. Since they've already proven email ownership via OAuth, this is a low-friction recovery path --- .../omniauth_callbacks_controller.rb | 18 ++++++++++++++++++ .../omniauth_callbacks_controller_spec.rb | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index af759af54..2c8387142 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa private def sign_in_user + # Capture before skip_confirmation! sets confirmed_at, which would + # make oauth_user_needs_password_reset? return false and skip the + # password reset for persisted unconfirmed users. + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def sign_in_user_on_mobile + # See comment in sign_in_user for why this is captured before skip_confirmation! + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? create_account_for_user + set_random_password_if_oauth_user token = @resource.send(:set_reset_password_token) frontend_url = ENV.fetch('FRONTEND_URL', nil) redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" @@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) end + def oauth_user_needs_password_reset? + @resource.present? && (@resource.new_record? || !@resource.confirmed?) + end + + def set_random_password_if_oauth_user + # Password must satisfy secure_password requirements (uppercase, lowercase, number, special char) + @resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted? + end + def default_devise_mapping 'user' end diff --git a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb index 603458a01..35bae8e0b 100644 --- a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb @@ -164,5 +164,21 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do expect(response).to have_http_status(:ok) end end + + it 'resets password for an unconfirmed persisted user on OAuth login' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + user = create(:user, email: 'unconfirmed-oauth@example.com', skip_confirmation: false) + original_password_digest = user.encrypted_password + set_omniauth_config('unconfirmed-oauth@example.com') + + get '/omniauth/google_oauth2/callback' + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + follow_redirect! + + user.reload + expect(user).to be_confirmed + expect(user.encrypted_password).not_to eq(original_password_digest) + end + end end end