diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index fd3dba87c..900125670 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -19,6 +19,19 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token) end + def sign_in_user_on_mobile + @resource.skip_confirmation! if confirmable_enabled? + + # once the resource is found and verified + # we can just send them to the login page again with the SSO params + # that will log them in + encoded_email = ERB::Util.url_encode(@resource.email) + params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query + + mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp') + redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true + end + def sign_up_user return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed? return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? diff --git a/app/javascript/v3/views/login/Index.vue b/app/javascript/v3/views/login/Index.vue index 028af4f72..04fb939c8 100644 --- a/app/javascript/v3/views/login/Index.vue +++ b/app/javascript/v3/views/login/Index.vue @@ -22,6 +22,8 @@ import MfaVerification from 'dashboard/components/auth/MfaVerification.vue'; const ERROR_MESSAGES = { 'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND', 'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY', + 'saml-authentication-failed': 'LOGIN.SAML.API.ERROR_MESSAGE', + 'saml-not-enabled': 'LOGIN.SAML.API.ERROR_MESSAGE', }; const IMPERSONATION_URL_SEARCH_KEY = 'impersonation'; diff --git a/app/javascript/v3/views/login/Saml.vue b/app/javascript/v3/views/login/Saml.vue index bbb61914d..95749d7e2 100644 --- a/app/javascript/v3/views/login/Saml.vue +++ b/app/javascript/v3/views/login/Saml.vue @@ -15,6 +15,10 @@ const props = defineProps({ type: String, default: '', }, + target: { + type: String, + default: 'web', + }, }); const store = useStore(); @@ -107,6 +111,7 @@ onMounted(async () => { name="authenticity_token" :value="csrfToken" /> + ({ authError: route.query.error, + target: route.query.target, }), }, { diff --git a/enterprise/app/controllers/api/v1/auth_controller.rb b/enterprise/app/controllers/api/v1/auth_controller.rb index 091d4f8f8..b0d8e1366 100644 --- a/enterprise/app/controllers/api/v1/auth_controller.rb +++ b/enterprise/app/controllers/api/v1/auth_controller.rb @@ -5,7 +5,9 @@ class Api::V1::AuthController < Api::BaseController def saml_login return if @account.nil? - saml_initiation_url = "/auth/saml?account_id=#{@account.id}" + relay_state = params[:target] || 'web' + + saml_initiation_url = "/auth/saml?account_id=#{@account.id}&RelayState=#{relay_state}" redirect_to saml_initiation_url, status: :temporary_redirect end @@ -44,7 +46,18 @@ class Api::V1::AuthController < Api::BaseController end def render_saml_error - redirect_to sso_login_page_url(error: 'saml-authentication-failed') + error = 'saml-authentication-failed' + + if mobile_target? + mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp') + redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true + else + redirect_to sso_login_page_url(error: error) + end + end + + def mobile_target? + params[:target]&.casecmp('mobile')&.zero? end def sso_login_page_url(error: nil) diff --git a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb index 973f26650..4856ca443 100644 --- a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb +++ b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb @@ -32,17 +32,40 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController end end + def omniauth_failure + return super unless params[:provider] == 'saml' + + relay_state = saml_relay_state + error = params[:message] || 'authentication-failed' + + if for_mobile?(relay_state) + redirect_to_mobile_error(error, relay_state) + else + redirect_to login_page_url(error: "saml-#{error}") + 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) + relay_state = saml_relay_state + + unless saml_enabled_for_account?(account_id) + return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state) + + return redirect_to login_page_url(error: 'saml-not-enabled') + end @resource = SamlUserBuilder.new(auth_hash, account_id).perform if @resource.persisted? + return sign_in_user_on_mobile if for_mobile?(relay_state) + sign_in_user else + return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state) + redirect_to login_page_url(error: 'saml-authentication-failed') end end @@ -51,6 +74,19 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id') end + def saml_relay_state + session[:saml_relay_state] || request.env['omniauth.params']&.dig('RelayState') + end + + def for_mobile?(relay_state) + relay_state.to_s.casecmp('mobile').zero? + end + + def redirect_to_mobile_error(error) + mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp') + redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true + end + def saml_enabled_for_account?(account_id) return false if account_id.blank? diff --git a/enterprise/config/initializers/omniauth_saml.rb b/enterprise/config/initializers/omniauth_saml.rb index f73e3a109..f39e9d511 100644 --- a/enterprise/config/initializers/omniauth_saml.rb +++ b/enterprise/config/initializers/omniauth_saml.rb @@ -9,18 +9,22 @@ SAML_SETUP_PROC = proc do |env| account_id = request.params['account_id'] || request.session[:saml_account_id] || env['omniauth.params']&.dig('account_id') + relay_state = request.params['RelayState'] || '' if account_id # Store in session and omniauth params for callback request.session[:saml_account_id] = account_id + request.session[:saml_relay_state] = relay_state env['omniauth.params'] ||= {} env['omniauth.params']['account_id'] = account_id + env['omniauth.params']['RelayState'] = relay_state # 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[:idp_sso_service_url_runtime_params] = { RelayState: :RelayState } 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 diff --git a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb index f1e2ef1c7..767011a50 100644 --- a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb @@ -36,6 +36,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed') end + + it 'redirects to mobile deep link with error when target is mobile' do + post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com', target: 'mobile' } + + expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed') + end end context 'when user exists but has no SAML enabled accounts' do @@ -48,6 +54,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed') end + + it 'redirects to mobile deep link with error when target is mobile' do + post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' } + + expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed') + end end context 'when user has account without SAML feature enabled' do @@ -65,6 +77,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed') end + + it 'redirects to mobile deep link with error when target is mobile' do + post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' } + + expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed') + end end context 'when user has valid SAML configuration' do @@ -82,6 +100,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do expect(response.location).to include("/auth/saml?account_id=#{account.id}") end + + it 'redirects to SAML initiation URL with mobile relay state' do + post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' } + + expect(response.location).to include("/auth/saml?account_id=#{account.id}&RelayState=mobile") + end end context 'when user has multiple accounts with SAML' do