feat: Add relay state for SAML SSO (#12597)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2025-10-07 20:32:29 +05:30
committed by GitHub
parent 4b2ebb8877
commit 978f4c431a
8 changed files with 101 additions and 3 deletions

View File

@@ -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?

View File

@@ -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';

View File

@@ -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"
/>
<input type="hidden" class="h-0" name="target" :value="target" />
<NextButton
lg
type="submit"

View File

@@ -28,6 +28,7 @@ export default [
meta: { requireEnterprise: true },
props: route => ({
authError: route.query.error,
target: route.query.target,
}),
},
{

View File

@@ -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)

View File

@@ -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?

View File

@@ -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

View File

@@ -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