diff --git a/app/javascript/dashboard/i18n/locale/en/login.json b/app/javascript/dashboard/i18n/locale/en/login.json index ec5658db2..864c76359 100644 --- a/app/javascript/dashboard/i18n/locale/en/login.json +++ b/app/javascript/dashboard/i18n/locale/en/login.json @@ -22,6 +22,20 @@ }, "FORGOT_PASSWORD": "Forgot your password?", "CREATE_NEW_ACCOUNT": "Create a new account", - "SUBMIT": "Login" + "SUBMIT": "Login", + "SAML": { + "LABEL": "Log in via SSO", + "TITLE": "Initiate Single Sign-on (SSO)", + "SUBTITLE": "Enter your work email to access your organization", + "BACK_TO_LOGIN": "Login via Password", + "WORK_EMAIL": { + "LABEL": "Work Email", + "PLACEHOLDER": "Enter your work email" + }, + "SUBMIT": "Continue with SSO", + "API": { + "ERROR_MESSAGE": "SSO authentication failed" + } + } } } diff --git a/app/javascript/shared/store/globalConfig.js b/app/javascript/shared/store/globalConfig.js index 608a31ec1..8abeba123 100644 --- a/app/javascript/shared/store/globalConfig.js +++ b/app/javascript/shared/store/globalConfig.js @@ -1,3 +1,5 @@ +import { parseBoolean } from '@chatwoot/utils'; + const { API_CHANNEL_NAME: apiChannelName, API_CHANNEL_THUMBNAIL: apiChannelThumbnail, @@ -15,6 +17,7 @@ const { LOGO: logo, LOGO_DARK: logoDark, PRIVACY_URL: privacyURL, + IS_ENTERPRISE: isEnterprise, TERMS_URL: termsURL, WIDGET_BRAND_URL: widgetBrandURL, DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate, @@ -30,8 +33,8 @@ const state = { chatwootInboxToken, deploymentEnv, createNewAccountFromDashboard, - directUploadsEnabled: directUploadsEnabled === 'true', - disableUserProfileUpdate: disableUserProfileUpdate === 'true', + directUploadsEnabled: parseBoolean(directUploadsEnabled), + disableUserProfileUpdate: parseBoolean(disableUserProfileUpdate), displayManifest, gitSha, hCaptchaSiteKey, @@ -42,6 +45,7 @@ const state = { privacyURL, termsURL, widgetBrandURL, + isEnterprise: parseBoolean(isEnterprise), }; export const getters = { diff --git a/app/javascript/v3/components/Form/Input.vue b/app/javascript/v3/components/Form/Input.vue index 674b1a59a..4a0b0dc63 100644 --- a/app/javascript/v3/components/Form/Input.vue +++ b/app/javascript/v3/components/Form/Input.vue @@ -55,6 +55,7 @@ const model = defineModel({ +
+ + {{ $t('LOGIN.SAML.LABEL') }} + +
diff --git a/app/javascript/v3/views/login/Saml.vue b/app/javascript/v3/views/login/Saml.vue new file mode 100644 index 000000000..fc4d75494 --- /dev/null +++ b/app/javascript/v3/views/login/Saml.vue @@ -0,0 +1,102 @@ + + + diff --git a/app/javascript/v3/views/routes.js b/app/javascript/v3/views/routes.js index e1f14c78e..6b975e09b 100644 --- a/app/javascript/v3/views/routes.js +++ b/app/javascript/v3/views/routes.js @@ -1,6 +1,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper'; import Login from './login/Index.vue'; +import SamlLogin from './login/Saml.vue'; import Signup from './auth/signup/Index.vue'; import ResetPassword from './auth/reset/password/Index.vue'; import Confirmation from './auth/confirmation/Index.vue'; @@ -20,6 +21,12 @@ export default [ authError: route.query.error, }), }, + { + path: frontendURL('login/sso'), + name: 'sso_login', + component: SamlLogin, + meta: { requireEnterprise: true }, + }, { path: frontendURL('auth/signup'), name: 'auth_signup', diff --git a/config/locales/en.yml b/config/locales/en.yml index 0d869c75f..ca3a9e950 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -36,6 +36,10 @@ en: success: 'Channel reauthorized successfully' not_required: 'Reauthorization is not required for this inbox' invalid_channel: 'Invalid channel type for reauthorization' + auth: + saml: + invalid_email: 'Please enter a valid email address' + authentication_failed: 'Authentication failed. Please check your credentials and try again.' 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. diff --git a/config/routes.rb b/config/routes.rb index 244bd67bf..6a484b380 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -325,6 +325,9 @@ Rails.application.routes.draw do resources :webhooks, only: [:create] end + # Frontend API endpoint to trigger SAML authentication flow + post 'auth/saml_login', to: 'auth#saml_login' + resource :profile, only: [:show, :update] do delete :avatar, on: :collection member do diff --git a/enterprise/app/controllers/api/v1/auth_controller.rb b/enterprise/app/controllers/api/v1/auth_controller.rb new file mode 100644 index 000000000..a8eb7ad9d --- /dev/null +++ b/enterprise/app/controllers/api/v1/auth_controller.rb @@ -0,0 +1,49 @@ +class Api::V1::AuthController < Api::BaseController + skip_before_action :authenticate_user!, only: [:saml_login] + before_action :find_user_and_account, only: [:saml_login] + + def saml_login + return if @account.nil? + + saml_initiation_url = "/auth/saml?account_id=#{@account.id}" + redirect_to saml_initiation_url, status: :temporary_redirect + end + + private + + def find_user_and_account + return unless validate_email_presence + + find_saml_enabled_account + end + + def validate_email_presence + @email = params[:email]&.downcase&.strip + return true if @email.present? + + render json: { error: I18n.t('auth.saml.invalid_email') }, status: :bad_request + false + end + + def find_saml_enabled_account + user = User.from_email(@email) + return render_saml_error unless user + + account_user = find_account_with_saml(user) + return render_saml_error unless account_user + + @account = account_user.account + end + + def find_account_with_saml(user) + user.account_users + .joins(account: :saml_settings) + .where.not(saml_settings: { sso_url: [nil, ''] }) + .where.not(saml_settings: { certificate: [nil, ''] }) + .find { |account_user| account_user.account.feature_enabled?('saml') } + end + + def render_saml_error + render json: { error: I18n.t('auth.saml.authentication_failed') }, status: :unauthorized + end +end diff --git a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb new file mode 100644 index 000000000..30367ab17 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1::Auth', type: :request do + let(:account) { create(:account) } + let(:user) { create(:user, email: 'user@example.com') } + + before do + account.enable_features('saml') + account.save! + end + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'POST /api/v1/auth/saml_login' do + context 'when email is blank' do + it 'returns bad request' do + post '/api/v1/auth/saml_login', params: { email: '' } + + expect(response).to have_http_status(:bad_request) + end + end + + context 'when email is nil' do + it 'returns bad request' do + post '/api/v1/auth/saml_login', params: {} + + expect(response).to have_http_status(:bad_request) + end + end + + context 'when user does not exist' do + it 'returns unauthorized with generic message' do + post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when user exists but has no SAML enabled accounts' do + before do + create(:account_user, user: user, account: account) + end + + it 'returns unauthorized' do + post '/api/v1/auth/saml_login', params: { email: user.email } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when user has account without SAML feature enabled' do + let(:saml_settings) { create(:account_saml_settings, account: account) } + + before do + saml_settings + create(:account_user, user: user, account: account) + account.disable_features('saml') + account.save! + end + + it 'returns unauthorized' do + post '/api/v1/auth/saml_login', params: { email: user.email } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when user has valid SAML configuration' do + let(:saml_settings) do + create(:account_saml_settings, account: account) + end + + before do + saml_settings + create(:account_user, user: user, account: account) + end + + it 'redirects to SAML initiation URL' do + post '/api/v1/auth/saml_login', params: { email: user.email } + + expect(response).to have_http_status(:temporary_redirect) + expect(response.location).to include("/auth/saml?account_id=#{account.id}") + end + + it 'handles email case insensitivity' do + post '/api/v1/auth/saml_login', params: { email: user.email.upcase } + + expect(response).to have_http_status(:temporary_redirect) + expect(response.location).to include("/auth/saml?account_id=#{account.id}") + end + + it 'strips whitespace from email' do + post '/api/v1/auth/saml_login', params: { email: " #{user.email} " } + + expect(response).to have_http_status(:temporary_redirect) + expect(response.location).to include("/auth/saml?account_id=#{account.id}") + end + end + + context 'when user has multiple accounts with SAML' do + let(:account2) { create(:account) } + let(:saml_settings1) do + create(:account_saml_settings, account: account) + end + let(:saml_settings2) do + create(:account_saml_settings, account: account2) + end + + before do + account2.enable_features('saml') + account2.save! + saml_settings1 + saml_settings2 + create(:account_user, user: user, account: account) + create(:account_user, user: user, account: account2) + end + + it 'redirects to the first SAML enabled account' do + post '/api/v1/auth/saml_login', params: { email: user.email } + + expect(response).to have_http_status(:temporary_redirect) + returned_account_id = response.location.match(/account_id=(\d+)/)[1].to_i + expect([account.id, account2.id]).to include(returned_account_id) + end + end + end +end