mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
feat: Add relay state for SAML SSO (#12597)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -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)
|
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
|
||||||
end
|
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
|
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: 'no-account-found') unless account_signup_allowed?
|
||||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
|
|||||||
const ERROR_MESSAGES = {
|
const ERROR_MESSAGES = {
|
||||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||||
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
'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';
|
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
target: {
|
||||||
|
type: String,
|
||||||
|
default: 'web',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -107,6 +111,7 @@ onMounted(async () => {
|
|||||||
name="authenticity_token"
|
name="authenticity_token"
|
||||||
:value="csrfToken"
|
:value="csrfToken"
|
||||||
/>
|
/>
|
||||||
|
<input type="hidden" class="h-0" name="target" :value="target" />
|
||||||
<NextButton
|
<NextButton
|
||||||
lg
|
lg
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default [
|
|||||||
meta: { requireEnterprise: true },
|
meta: { requireEnterprise: true },
|
||||||
props: route => ({
|
props: route => ({
|
||||||
authError: route.query.error,
|
authError: route.query.error,
|
||||||
|
target: route.query.target,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ class Api::V1::AuthController < Api::BaseController
|
|||||||
def saml_login
|
def saml_login
|
||||||
return if @account.nil?
|
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
|
redirect_to saml_initiation_url, status: :temporary_redirect
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -44,7 +46,18 @@ class Api::V1::AuthController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_saml_error
|
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
|
end
|
||||||
|
|
||||||
def sso_login_page_url(error: nil)
|
def sso_login_page_url(error: nil)
|
||||||
|
|||||||
@@ -32,17 +32,40 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
|||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
def handle_saml_auth
|
def handle_saml_auth
|
||||||
account_id = extract_saml_account_id
|
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
|
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
|
||||||
|
|
||||||
if @resource.persisted?
|
if @resource.persisted?
|
||||||
|
return sign_in_user_on_mobile if for_mobile?(relay_state)
|
||||||
|
|
||||||
sign_in_user
|
sign_in_user
|
||||||
else
|
else
|
||||||
|
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
|
||||||
|
|
||||||
redirect_to login_page_url(error: 'saml-authentication-failed')
|
redirect_to login_page_url(error: 'saml-authentication-failed')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -51,6 +74,19 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
|||||||
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
|
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
|
||||||
end
|
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)
|
def saml_enabled_for_account?(account_id)
|
||||||
return false if account_id.blank?
|
return false if account_id.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -9,18 +9,22 @@ SAML_SETUP_PROC = proc do |env|
|
|||||||
account_id = request.params['account_id'] ||
|
account_id = request.params['account_id'] ||
|
||||||
request.session[:saml_account_id] ||
|
request.session[:saml_account_id] ||
|
||||||
env['omniauth.params']&.dig('account_id')
|
env['omniauth.params']&.dig('account_id')
|
||||||
|
relay_state = request.params['RelayState'] || ''
|
||||||
|
|
||||||
if account_id
|
if account_id
|
||||||
# Store in session and omniauth params for callback
|
# Store in session and omniauth params for callback
|
||||||
request.session[:saml_account_id] = account_id
|
request.session[:saml_account_id] = account_id
|
||||||
|
request.session[:saml_relay_state] = relay_state
|
||||||
env['omniauth.params'] ||= {}
|
env['omniauth.params'] ||= {}
|
||||||
env['omniauth.params']['account_id'] = account_id
|
env['omniauth.params']['account_id'] = account_id
|
||||||
|
env['omniauth.params']['RelayState'] = relay_state
|
||||||
|
|
||||||
# Find SAML settings for this account
|
# Find SAML settings for this account
|
||||||
settings = AccountSamlSettings.find_by(account_id: account_id)
|
settings = AccountSamlSettings.find_by(account_id: account_id)
|
||||||
|
|
||||||
if settings
|
if settings
|
||||||
# Configure the strategy options dynamically
|
# 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[: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[:sp_entity_id] = settings.sp_entity_id
|
||||||
env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id
|
env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id
|
||||||
|
|||||||
@@ -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')
|
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
context 'when user exists but has no SAML enabled accounts' do
|
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')
|
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
context 'when user has account without SAML feature enabled' do
|
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')
|
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
context 'when user has valid SAML configuration' do
|
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}")
|
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
context 'when user has multiple accounts with SAML' do
|
context 'when user has multiple accounts with SAML' do
|
||||||
|
|||||||
Reference in New Issue
Block a user