From 79b93bed77d028f631a71b9a3be3f491d758367d Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 10 Sep 2025 20:02:27 +0530 Subject: [PATCH] feat: SAML authentication controllers [CW-2958] (#12319) --- Gemfile | 2 + Gemfile.lock | 10 +- .../omniauth_callbacks_controller.rb | 8 +- .../devise_overrides/passwords_controller.rb | 2 + app/models/user.rb | 2 +- config/application.rb | 4 + config/initializers/omniauth.rb | 4 + config/locales/en.yml | 3 + enterprise/app/builders/saml_user_builder.rb | 101 +++++++++ .../omniauth_callbacks_controller.rb | 64 ++++++ .../devise_overrides/passwords_controller.rb | 15 ++ .../devise_overrides/sessions_controller.rb | 14 ++ .../app/helpers/saml_authentication_helper.rb | 12 + .../config/initializers/omniauth_saml.rb | 43 ++++ .../builders/saml_user_builder_spec.rb | 214 ++++++++++++++++++ .../omniauth_callbacks_controller_spec.rb | 61 +++++ .../passwords_controller_spec.rb | 36 +++ .../session_controller_spec.rb | 85 +++++-- 18 files changed, 653 insertions(+), 27 deletions(-) create mode 100644 enterprise/app/builders/saml_user_builder.rb create mode 100644 enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb create mode 100644 enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb create mode 100644 enterprise/app/helpers/saml_authentication_helper.rb create mode 100644 enterprise/config/initializers/omniauth_saml.rb create mode 100644 spec/enterprise/builders/saml_user_builder_spec.rb create mode 100644 spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb create mode 100644 spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb diff --git a/Gemfile b/Gemfile index 03d0b62b9..927a853a0 100644 --- a/Gemfile +++ b/Gemfile @@ -81,6 +81,7 @@ gem 'devise_token_auth', '>= 1.2.3' # authorization gem 'jwt' gem 'pundit' + # super admin gem 'administrate', '>= 0.20.1' gem 'administrate-field-active_storage', '>= 1.0.3' @@ -171,6 +172,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1' # need for google auth gem 'omniauth', '>= 2.1.2' +gem 'omniauth-saml' gem 'omniauth-google-oauth2', '>= 1.1.3' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' diff --git a/Gemfile.lock b/Gemfile.lock index e8bccf54e..2cce9f322 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -589,8 +589,9 @@ GEM oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.2) + omniauth (2.1.3) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection omniauth-google-oauth2 (1.1.3) @@ -604,6 +605,9 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) + omniauth-saml (2.2.4) + omniauth (~> 2.1) + ruby-saml (~> 1.18) opensearch-ruby (3.4.0) faraday (>= 1.0, < 3) multi_json (>= 1.0) @@ -773,6 +777,9 @@ GEM faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) + ruby-saml (1.18.1) + nokogiri (>= 1.13.10) + rexml ruby-vips (2.1.4) ffi (~> 1.12) ruby2_keywords (0.0.5) @@ -1047,6 +1054,7 @@ DEPENDENCIES omniauth-google-oauth2 (>= 1.1.3) omniauth-oauth2 omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) + omniauth-saml opensearch-ruby pg pg_search diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index db312e94f..fd3dba87c 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -47,10 +47,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName - # find the user with their email instead of UID and token - @resource = resource_class.where( - email: auth_hash['info']['email'] - ).first + email = auth_hash.dig('info', 'email') + @resource = resource_class.from_email(email) end def validate_signup_email_is_business_domain? @@ -75,3 +73,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa 'user' end end + +DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController') diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb index 17dd32086..00976c3cd 100644 --- a/app/controllers/devise_overrides/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController }, status: status end end + +DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController') diff --git a/app/models/user.rb b/app/models/user.rb index d1907362a..e0440a67c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -58,7 +58,7 @@ class User < ApplicationRecord :validatable, :confirmable, :password_has_required_content, - :omniauthable, omniauth_providers: [:google_oauth2] + :omniauthable, omniauth_providers: [:google_oauth2, :saml] # TODO: remove in a future version once online status is moved to account users # remove the column availability from users diff --git a/config/application.rb b/config/application.rb index 3eca267f0..7fd1b94ba 100644 --- a/config/application.rb +++ b/config/application.rb @@ -47,6 +47,10 @@ module Chatwoot # Add enterprise views to the view paths config.paths['app/views'].unshift('enterprise/app/views') + # Load enterprise initializers alongside standard initializers + enterprise_initializers = Rails.root.join('enterprise/config/initializers') + Dir[enterprise_initializers.join('**/*.rb')].each { |f| require f } if enterprise_initializers.exist? + # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers # -- all .rb files in that directory are automatically loaded after loading diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index d92d9b040..54aa6ded8 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -1,3 +1,7 @@ +# OmniAuth configuration +# Sets the full host URL for callbacks and proper redirect handling +OmniAuth.config.full_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + Rails.application.config.middleware.use OmniAuth::Builder do provider :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), { provider_ignores_state: true diff --git a/config/locales/en.yml b/config/locales/en.yml index c9614d3a6..68f0b72fd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -39,6 +39,9 @@ en: messages: reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions. reset_password_failure: Uh ho! We could not find any user with the specified email. + reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator. + login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider. + saml_not_available: SAML authentication is not available in this installation. inbox_deletetion_response: Your inbox deletion request will be processed in some time. errors: diff --git a/enterprise/app/builders/saml_user_builder.rb b/enterprise/app/builders/saml_user_builder.rb new file mode 100644 index 000000000..bd0059d07 --- /dev/null +++ b/enterprise/app/builders/saml_user_builder.rb @@ -0,0 +1,101 @@ +class SamlUserBuilder + def initialize(auth_hash, account_id) + @auth_hash = auth_hash + @account_id = account_id + @saml_settings = AccountSamlSettings.find_by(account_id: account_id) + end + + def perform + @user = find_or_create_user + add_user_to_account if @user.persisted? + @user + end + + private + + def find_or_create_user + user = User.from_email(auth_attribute('email')) + + if user + convert_existing_user_to_saml(user) + return user + end + + create_user + end + + def convert_existing_user_to_saml(user) + return if user.provider == 'saml' + + user.update!(provider: 'saml') + end + + def create_user + full_name = [auth_attribute('first_name'), auth_attribute('last_name')].compact.join(' ') + fallback_name = auth_attribute('name') || auth_attribute('email').split('@').first + + User.create( + email: auth_attribute('email'), + name: (full_name.presence || fallback_name), + display_name: auth_attribute('first_name'), + provider: 'saml', + uid: uid, + password: SecureRandom.hex(32), + confirmed_at: Time.current + ) + end + + def add_user_to_account + account = Account.find_by(id: @account_id) + return unless account + + # Create account_user if not exists + account_user = AccountUser.find_or_create_by( + user: @user, + account: account + ) + + # Set default role as agent if not set + account_user.update(role: 'agent') if account_user.role.blank? + + # Handle role mappings if configured + apply_role_mappings(account_user, account) + end + + def apply_role_mappings(account_user, account) + matching_mapping = find_matching_role_mapping(account) + return unless matching_mapping + + if matching_mapping['role'] + account_user.update(role: matching_mapping['role']) + elsif matching_mapping['custom_role_id'] + account_user.update(custom_role_id: matching_mapping['custom_role_id']) + end + end + + def find_matching_role_mapping(_account) + return if @saml_settings&.role_mappings.blank? + + saml_groups.each do |group| + mapping = @saml_settings.role_mappings[group] + return mapping if mapping.present? + end + nil + end + + def auth_attribute(key, fallback = nil) + @auth_hash.dig('info', key) || fallback + end + + def uid + @auth_hash['uid'] + end + + def saml_groups + # Groups can come from different attributes depending on IdP + @auth_hash.dig('extra', 'raw_info', 'groups') || + @auth_hash.dig('extra', 'raw_info', 'Group') || + @auth_hash.dig('extra', 'raw_info', 'memberOf') || + [] + end +end diff --git a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb new file mode 100644 index 000000000..973f26650 --- /dev/null +++ b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb @@ -0,0 +1,64 @@ +module Enterprise::DeviseOverrides::OmniauthCallbacksController + def saml + # Call parent's omniauth_success which handles the auth + omniauth_success + end + + def redirect_callbacks + # derive target redirect route from 'resource_class' param, which was set + # before authentication. + devise_mapping = get_devise_mapping + redirect_route = get_redirect_route(devise_mapping) + + # preserve omniauth info for success route. ignore 'extra' in twitter + # auth response to avoid CookieOverflow. + session['dta.omniauth.auth'] = request.env['omniauth.auth'].except('extra') + session['dta.omniauth.params'] = request.env['omniauth.params'] + + # For SAML, use 303 See Other to convert POST to GET and preserve session + if params[:provider] == 'saml' + redirect_to redirect_route, { status: 303 }.merge(redirect_options) + else + super + end + end + + def omniauth_success + case auth_hash&.dig('provider') + when 'saml' + handle_saml_auth + else + super + end + end + + private + + def handle_saml_auth + account_id = extract_saml_account_id + return redirect_to login_page_url(error: 'saml-not-enabled') unless saml_enabled_for_account?(account_id) + + @resource = SamlUserBuilder.new(auth_hash, account_id).perform + + if @resource.persisted? + sign_in_user + else + redirect_to login_page_url(error: 'saml-authentication-failed') + end + end + + def extract_saml_account_id + params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id') + end + + def saml_enabled_for_account?(account_id) + return false if account_id.blank? + + account = Account.find_by(id: account_id) + + return false if account.nil? + return false unless account.feature_enabled?('saml') + + AccountSamlSettings.find_by(account_id: account_id).present? + end +end diff --git a/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb new file mode 100644 index 000000000..bd4daee2a --- /dev/null +++ b/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb @@ -0,0 +1,15 @@ +module Enterprise::DeviseOverrides::PasswordsController + include SamlAuthenticationHelper + + def create + if saml_user_attempting_password_auth?(params[:email]) + render json: { + success: false, + errors: [I18n.t('messages.reset_password_saml_user')] + }, status: :forbidden + return + end + + super + end +end diff --git a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb index e11e3fff9..ea456bdd1 100644 --- a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb +++ b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb @@ -1,4 +1,18 @@ module Enterprise::DeviseOverrides::SessionsController + include SamlAuthenticationHelper + + def create + if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token]) + render json: { + success: false, + errors: [I18n.t('messages.login_saml_user')] + }, status: :unauthorized + return + end + + super + end + def render_create_success create_audit_event('sign_in') super diff --git a/enterprise/app/helpers/saml_authentication_helper.rb b/enterprise/app/helpers/saml_authentication_helper.rb new file mode 100644 index 000000000..9adcb22c3 --- /dev/null +++ b/enterprise/app/helpers/saml_authentication_helper.rb @@ -0,0 +1,12 @@ +module SamlAuthenticationHelper + def saml_user_attempting_password_auth?(email, sso_auth_token: nil) + return false if email.blank? + + user = User.from_email(email) + return false unless user&.provider == 'saml' + + return false if sso_auth_token.present? && user.valid_sso_auth_token?(sso_auth_token) + + true + end +end diff --git a/enterprise/config/initializers/omniauth_saml.rb b/enterprise/config/initializers/omniauth_saml.rb new file mode 100644 index 000000000..f73e3a109 --- /dev/null +++ b/enterprise/config/initializers/omniauth_saml.rb @@ -0,0 +1,43 @@ +# Enterprise Edition SAML SSO Provider +# This initializer adds SAML authentication support for Enterprise customers + +# SAML setup proc for multi-tenant configuration +SAML_SETUP_PROC = proc do |env| + request = ActionDispatch::Request.new(env) + + # Extract account_id from various sources + account_id = request.params['account_id'] || + request.session[:saml_account_id] || + env['omniauth.params']&.dig('account_id') + + if account_id + # Store in session and omniauth params for callback + request.session[:saml_account_id] = account_id + env['omniauth.params'] ||= {} + env['omniauth.params']['account_id'] = account_id + + # Find SAML settings for this account + settings = AccountSamlSettings.find_by(account_id: account_id) + + if settings + # Configure the strategy options dynamically + env['omniauth.strategy'].options[:assertion_consumer_service_url] = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}" + env['omniauth.strategy'].options[:sp_entity_id] = settings.sp_entity_id + env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id + env['omniauth.strategy'].options[:idp_sso_service_url] = settings.sso_url + env['omniauth.strategy'].options[:idp_cert] = settings.certificate + env['omniauth.strategy'].options[:name_identifier_format] = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + else + # Set a dummy certificate to avoid the error + env['omniauth.strategy'].options[:idp_cert] = 'DUMMY' + end + else + # Set a dummy certificate to avoid the error + env['omniauth.strategy'].options[:idp_cert] = 'DUMMY' + end +end + +Rails.application.config.middleware.use OmniAuth::Builder do + # SAML provider with setup phase for multi-tenant configuration + provider :saml, setup: SAML_SETUP_PROC +end diff --git a/spec/enterprise/builders/saml_user_builder_spec.rb b/spec/enterprise/builders/saml_user_builder_spec.rb new file mode 100644 index 000000000..63fd65fd3 --- /dev/null +++ b/spec/enterprise/builders/saml_user_builder_spec.rb @@ -0,0 +1,214 @@ +require 'rails_helper' + +RSpec.describe SamlUserBuilder do + let(:email) { 'saml.user@example.com' } + let(:auth_hash) do + { + 'provider' => 'saml', + 'uid' => 'saml-uid-123', + 'info' => { + 'email' => email, + 'name' => 'SAML User', + 'first_name' => 'SAML', + 'last_name' => 'User' + }, + 'extra' => { + 'raw_info' => { + 'groups' => %w[Administrators Users] + } + } + } + end + let(:account) { create(:account) } + let(:builder) { described_class.new(auth_hash, account.id) } + + describe '#perform' do + context 'when user does not exist' do + it 'creates a new user' do + expect { builder.perform }.to change(User, :count).by(1) + end + + it 'creates user with correct attributes' do + user = builder.perform + + expect(user.email).to eq(email) + expect(user.name).to eq('SAML User') + expect(user.display_name).to eq('SAML') + expect(user.provider).to eq('saml') + expect(user.uid).to eq(email) # User model sets uid to email in before_validation callback + expect(user.confirmed_at).to be_present + end + + it 'creates user with a random password' do + user = builder.perform + expect(user.encrypted_password).to be_present + end + + it 'adds user to the account' do + user = builder.perform + expect(user.accounts).to include(account) + end + + it 'sets default role as agent' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + expect(account_user.role).to eq('agent') + end + + context 'when name is not provided' do + let(:auth_hash) do + { + 'provider' => 'saml', + 'uid' => 'saml-uid-123', + 'info' => { + 'email' => email + } + } + end + + it 'derives name from email' do + user = builder.perform + expect(user.name).to eq('saml.user') + end + end + end + + context 'when user already exists' do + let!(:existing_user) { create(:user, email: email) } + + it 'does not create a new user' do + expect { builder.perform }.not_to change(User, :count) + end + + it 'returns the existing user' do + user = builder.perform + expect(user).to eq(existing_user) + end + + it 'adds existing user to the account if not already added' do + user = builder.perform + expect(user.accounts).to include(account) + end + + it 'converts existing user to SAML' do + expect(existing_user.provider).not_to eq('saml') + + builder.perform + + expect(existing_user.reload.provider).to eq('saml') + end + + it 'does not change provider if user is already SAML' do + existing_user.update!(provider: 'saml') + + expect { builder.perform }.not_to(change { existing_user.reload.provider }) + end + + it 'does not duplicate account association' do + existing_user.account_users.create!(account: account, role: 'agent') + + expect { builder.perform }.not_to change(AccountUser, :count) + end + end + + context 'with role mappings' do + let(:saml_settings) do + create(:account_saml_settings, + account: account, + role_mappings: { + 'Administrators' => { 'role' => 'administrator' }, + 'Agents' => { 'role' => 'agent' } + }) + end + + before { saml_settings } + + it 'applies administrator role based on SAML groups' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + expect(account_user.role).to eq('administrator') + end + + context 'with custom role mapping' do + let!(:custom_role) { create(:custom_role, account: account) } + let(:saml_settings) do + create(:account_saml_settings, + account: account, + role_mappings: { + 'Administrators' => { 'custom_role_id' => custom_role.id } + }) + end + + before { saml_settings } + + it 'applies custom role based on SAML groups' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + expect(account_user.custom_role_id).to eq(custom_role.id) + end + end + + context 'when user is not in any mapped groups' do + let(:auth_hash) do + { + 'provider' => 'saml', + 'uid' => 'saml-uid-123', + 'info' => { + 'email' => email, + 'name' => 'SAML User' + }, + 'extra' => { + 'raw_info' => { + 'groups' => ['UnmappedGroup'] + } + } + } + end + + it 'keeps default agent role' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + expect(account_user.role).to eq('agent') + end + end + end + + context 'with different group attribute names' do + let(:auth_hash) do + { + 'provider' => 'saml', + 'uid' => 'saml-uid-123', + 'info' => { + 'email' => email, + 'name' => 'SAML User' + }, + 'extra' => { + 'raw_info' => { + 'memberOf' => ['CN=Administrators,OU=Groups,DC=example,DC=com'] + } + } + } + end + + it 'reads groups from memberOf attribute' do + builder_instance = described_class.new(auth_hash, account_id: account.id) + allow(builder_instance).to receive(:saml_groups).and_return(['CN=Administrators,OU=Groups,DC=example,DC=com']) + user = builder_instance.perform + expect(user).to be_persisted + end + end + + context 'when there are errors' do + it 'returns unsaved user object when user creation fails' do + allow(User).to receive(:create).and_return(User.new(email: email)) + user = builder.perform + expect(user.persisted?).to be false + end + + it 'does not create account association for failed user' do + allow(User).to receive(:create).and_return(User.new(email: email)) + expect { builder.perform }.not_to change(AccountUser, :count) + end + end + end +end diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb new file mode 100644 index 000000000..af67e3009 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do + let!(:account) { create(:account) } + let(:saml_settings) { create(:account_saml_settings, account: account) } + + def set_saml_config(email = 'test@example.com') + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:saml] = OmniAuth::AuthHash.new( + provider: 'saml', + uid: '123545', + info: { + name: 'Test User', + email: email + } + ) + end + + before do + allow(ChatwootApp).to receive(:enterprise?).and_return(true) + account.enable_features!('saml') + saml_settings + end + + describe '#saml callback' do + it 'creates new user and logs them in' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + set_saml_config('new_user@example.com') + + get "/omniauth/saml/callback?account_id=#{account.id}" + + # expect a 302 redirect to auth/saml/callback + expect(response).to redirect_to('http://www.example.com/auth/saml/callback') + follow_redirect! + + # expect redirect to login with SSO token + expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) + + # verify user was created + user = User.from_email('new_user@example.com') + expect(user).to be_present + expect(user.provider).to eq('saml') + end + end + + it 'logs in existing user' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + create(:user, email: 'existing@example.com', account: account) + set_saml_config('existing@example.com') + + get "/omniauth/saml/callback?account_id=#{account.id}" + + # expect a 302 redirect to auth/saml/callback + expect(response).to redirect_to('http://www.example.com/auth/saml/callback') + follow_redirect! + + expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) + end + end + end +end diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb new file mode 100644 index 000000000..23b498be8 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise Passwords Controller', type: :request do + let!(:account) { create(:account) } + + describe 'POST /auth/password' do + context 'with SAML user email' do + let!(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) } + + it 'prevents password reset and returns forbidden with custom error message' do + params = { email: saml_user.email, redirect_url: 'http://test.host' } + + post user_password_path, params: params, as: :json + + expect(response).to have_http_status(:forbidden) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(false) + expect(json_response['errors']).to include(I18n.t('messages.reset_password_saml_user')) + end + end + + context 'with non-SAML user email' do + let!(:regular_user) { create(:user, email: 'regular@example.com', provider: 'email', account: account) } + + it 'allows password reset for non-SAML users' do + params = { email: regular_user.email, redirect_url: 'http://test.host' } + + post user_password_path, params: params, as: :json + + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response['message']).to be_present + end + end + end +end diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb index 89f8794ee..08c69c840 100644 --- a/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb @@ -5,32 +5,75 @@ RSpec.describe 'Enterprise Audit API', type: :request do let!(:user) { create(:user, password: 'Password1!', account: account) } describe 'POST /sign_in' do - it 'creates a sign_in audit event wwith valid credentials' do - params = { email: user.email, password: 'Password1!' } + context 'with SAML user attempting password login' do + let(:saml_settings) { create(:account_saml_settings, account: account) } + let(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) } - expect do - post new_user_session_url, - params: params, - as: :json - end.to change(Enterprise::AuditLog, :count).by(1) + before do + saml_settings + saml_user + end - expect(response).to have_http_status(:success) - expect(response.body).to include(user.email) + it 'prevents login and returns SAML authentication error' do + params = { email: saml_user.email, password: 'Password1!' } - # Check if the sign_in event is created - user.reload - expect(user.audits.last.action).to eq('sign_in') - expect(user.audits.last.associated_id).to eq(account.id) - expect(user.audits.last.associated_type).to eq('Account') + post new_user_session_url, params: params, as: :json + + expect(response).to have_http_status(:unauthorized) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(false) + expect(json_response['errors']).to include(I18n.t('messages.login_saml_user')) + end + + it 'allows login with valid SSO token' do + valid_token = saml_user.generate_sso_auth_token + params = { email: saml_user.email, sso_auth_token: valid_token, password: 'Password1!' } + + expect do + post new_user_session_url, params: params, as: :json + end.to change(Enterprise::AuditLog, :count).by(1) + + expect(response).to have_http_status(:success) + expect(response.body).to include(saml_user.email) + end end - it 'will not create a sign_in audit event with invalid credentials' do - params = { email: user.email, password: 'invalid' } - expect do - post new_user_session_url, - params: params, - as: :json - end.not_to change(Enterprise::AuditLog, :count) + context 'with regular user credentials' do + it 'creates a sign_in audit event wwith valid credentials' do + params = { email: user.email, password: 'Password1!' } + + expect do + post new_user_session_url, + params: params, + as: :json + end.to change(Enterprise::AuditLog, :count).by(1) + + expect(response).to have_http_status(:success) + expect(response.body).to include(user.email) + + # Check if the sign_in event is created + user.reload + expect(user.audits.last.action).to eq('sign_in') + expect(user.audits.last.associated_id).to eq(account.id) + expect(user.audits.last.associated_type).to eq('Account') + end + + it 'will not create a sign_in audit event with invalid credentials' do + params = { email: user.email, password: 'invalid' } + expect do + post new_user_session_url, + params: params, + as: :json + end.not_to change(Enterprise::AuditLog, :count) + end + end + + context 'with blank email' do + it 'skips SAML check and processes normally' do + params = { email: '', password: 'Password1!' } + post new_user_session_url, params: params, as: :json + expect(response).to have_http_status(:unauthorized) + end end end