feat: SAML authentication controllers [CW-2958] (#12319)

This commit is contained in:
Shivam Mishra
2025-09-10 20:02:27 +05:30
committed by GitHub
parent 257df30589
commit 79b93bed77
18 changed files with 653 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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