From f9d4816db11aefd1d430ddf9ba3f5ac9235a3482 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 27 Aug 2025 12:27:27 +0530 Subject: [PATCH] feat: kickstart basic controller --- Gemfile | 3 + Gemfile.lock | 10 ++ app/controllers/omniauth_controller.rb | 46 +++++++ config/initializers/omniauth_saml.rb | 37 +++++ config/routes.rb | 10 ++ .../v1/accounts/saml/callbacks_controller.rb | 49 +++++++ .../saml/callbacks_controller_spec.rb | 130 ++++++++++++++++++ 7 files changed, 285 insertions(+) create mode 100644 app/controllers/omniauth_controller.rb create mode 100644 config/initializers/omniauth_saml.rb create mode 100644 enterprise/app/controllers/api/v1/accounts/saml/callbacks_controller.rb create mode 100644 spec/enterprise/controllers/api/v1/accounts/saml/callbacks_controller_spec.rb diff --git a/Gemfile b/Gemfile index 615267a77..51a12ee2a 100644 --- a/Gemfile +++ b/Gemfile @@ -77,6 +77,9 @@ gem 'devise_token_auth', '>= 1.2.3' # authorization gem 'jwt' gem 'pundit' +# SAML authentication +gem 'omniauth-saml', '~> 2.1' +gem 'omniauth-multi-provider' # super admin gem 'administrate', '>= 0.20.1' gem 'administrate-field-active_storage', '>= 1.0.3' diff --git a/Gemfile.lock b/Gemfile.lock index 8fa38a31e..50a195a7d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -595,12 +595,17 @@ GEM oauth2 (~> 2.0) omniauth (~> 2.0) omniauth-oauth2 (~> 1.8) + omniauth-multi-provider (0.4.0) + omniauth omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) + omniauth-saml (2.2.4) + omniauth (~> 2.1) + ruby-saml (~> 1.18) openssl (3.2.0) orm_adapter (0.5.0) os (1.1.4) @@ -767,6 +772,9 @@ GEM faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) + ruby-saml (1.18.1) + nokogiri (>= 1.13.10) + rexml ruby-vips (2.1.4) ffi (~> 1.12) ruby2_keywords (0.0.5) @@ -1034,8 +1042,10 @@ DEPENDENCIES newrelic_rpm omniauth (>= 2.1.2) omniauth-google-oauth2 (>= 1.1.3) + omniauth-multi-provider omniauth-oauth2 omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) + omniauth-saml (~> 2.1) pg pg_search pgvector diff --git a/app/controllers/omniauth_controller.rb b/app/controllers/omniauth_controller.rb new file mode 100644 index 000000000..9f90bc09f --- /dev/null +++ b/app/controllers/omniauth_controller.rb @@ -0,0 +1,46 @@ +class OmniauthController < ApplicationController + skip_before_action :verify_authenticity_token + skip_before_action :authenticate_user! + skip_before_action :set_current_user + skip_before_action :check_authorization + + def request + # This will be handled by OmniAuth middleware + # The request will be redirected to the IdP + end + + def callback + auth = request.env['omniauth.auth'] + account_id = params[:account_id] + + if auth.present? + render json: { + message: 'SAML authentication successful', + account_id: account_id, + provider: auth.provider, + uid: auth.uid, + info: { + email: auth.info.email, + name: auth.info.name, + first_name: auth.info.first_name, + last_name: auth.info.last_name + }, + extra: { + raw_info: auth.extra.raw_info + } + } + else + render json: { + error: 'SAML authentication failed', + message: request.env['omniauth.error'] || 'Unknown error' + }, status: :unauthorized + end + end + + def failure + render json: { + error: 'SAML authentication failed', + message: params[:message] || request.env['omniauth.error'] || 'Unknown error' + }, status: :unauthorized + end +end diff --git a/config/initializers/omniauth_saml.rb b/config/initializers/omniauth_saml.rb new file mode 100644 index 000000000..e73047417 --- /dev/null +++ b/config/initializers/omniauth_saml.rb @@ -0,0 +1,37 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + if defined?(OmniAuth::MultiProvider) + OmniAuth::MultiProvider.register( + self, + provider_name: :saml, + identity_provider_id_regex: /\d+/, + path_prefix: '/auth/saml' + ) do |account_id, rack_env| + # Find the account's SAML settings + saml_settings = AccountSamlSettings.find_by(account_id: account_id, enabled: true) + + if saml_settings + # Store the account in the rack environment for later use + rack_env['chatwoot.account_id'] = account_id + + # Return the SAML provider options with correct option names + { + assertion_consumer_service_url: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/auth/saml/#{account_id}/callback", + sp_entity_id: saml_settings.sp_entity_id_or_default, + idp_sso_service_url: saml_settings.sso_url, + idp_cert_fingerprint: saml_settings.certificate_fingerprint, + idp_cert: saml_settings.certificate, + name_identifier_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + attribute_statements: saml_settings.attribute_mappings || { + email: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'], + name: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'], + first_name: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'], + last_name: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'] + } + } + end + end + end +end + +OmniAuth.config.allowed_request_methods = [:post, :get] +OmniAuth.config.silence_get_warning = true diff --git a/config/routes.rb b/config/routes.rb index b524a2ac6..7dee658fa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,11 @@ Rails.application.routes.draw do omniauth_callbacks: 'devise_overrides/omniauth_callbacks' }, via: [:get, :post] + # OmniAuth SAML routes + match '/auth/saml/:account_id', to: 'omniauth#request', via: [:get, :post], as: :saml_auth + match '/auth/saml/:account_id/callback', to: 'omniauth#callback', via: [:get, :post], as: :saml_callback + match '/auth/failure', to: 'omniauth#failure', via: [:get, :post], as: :saml_failure + ## renders the frontend paths only if its not an api only server if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) root to: 'api#index' @@ -70,6 +75,11 @@ Rails.application.routes.draw do resources :documents, only: [:index, :show, :create, :destroy] end resource :saml_settings, only: [:show, :create, :update, :destroy] + + namespace :saml do + get 'sso', to: 'callbacks#sso' + post 'callback', to: 'callbacks#create' + end resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do delete :avatar, on: :member post :reset_access_token, on: :member diff --git a/enterprise/app/controllers/api/v1/accounts/saml/callbacks_controller.rb b/enterprise/app/controllers/api/v1/accounts/saml/callbacks_controller.rb new file mode 100644 index 000000000..8bcdec1bd --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/saml/callbacks_controller.rb @@ -0,0 +1,49 @@ +class Api::V1::Accounts::Saml::CallbacksController < Api::V1::Accounts::BaseController + skip_before_action :authenticate_user! + + def create + @saml_settings = AccountSamlSettings.find_by(account_id: @current_account.id, enabled: true) + + return render_saml_not_enabled unless @saml_settings + + render json: { + saml_settings: { + sp_entity_id: @saml_settings.sp_entity_id_or_default, + enabled: @saml_settings.enabled, + sso_url: @saml_settings.sso_url, + certificate_fingerprint: @saml_settings.certificate_fingerprint, + enforced_sso: @saml_settings.enforced_sso, + attribute_mappings: @saml_settings.attribute_mappings, + role_mappings: @saml_settings.role_mappings + }, + received_data: { + saml_response: params[:SAMLResponse], + relay_state: params[:RelayState] + }, + message: 'SAML callback received successfully' + } + end + + def sso + @saml_settings = AccountSamlSettings.find_by(account_id: @current_account.id, enabled: true) + + return render_saml_not_enabled unless @saml_settings + + render json: { + sso_url: @saml_settings.sso_url, + sp_entity_id: @saml_settings.sp_entity_id_or_default, + acs_url: acs_url, + certificate_fingerprint: @saml_settings.certificate_fingerprint + } + end + + private + + def render_saml_not_enabled + render json: { error: 'SAML not enabled for this account' }, status: :not_found + end + + def acs_url + api_v1_account_saml_callback_url(@current_account) + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts/saml/callbacks_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/saml/callbacks_controller_spec.rb new file mode 100644 index 000000000..40455212d --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/saml/callbacks_controller_spec.rb @@ -0,0 +1,130 @@ +require 'rails_helper' + +RSpec.describe Api::V1::Accounts::Saml::CallbacksController, type: :controller do + let(:account) { create(:account) } + let(:saml_settings) { create(:account_saml_settings, :enabled, account: account) } + + describe 'POST #create' do + before do + allow(AccountSamlSettings).to receive(:find_by).and_return(saml_settings) + end + + context 'when SAML response is received' do + let(:saml_response) { Base64.encode64('dummy') } + let(:relay_state) { "/app/accounts/#{account.id}/dashboard" } + + it 'receives the SAML response' do + post :create, params: { + account_id: account.id, + SAMLResponse: saml_response, + RelayState: relay_state + } + + expect(response).to have_http_status(:ok) + end + + it 'loads the correct account SAML settings' do + expect(AccountSamlSettings).to receive(:find_by).with(account_id: account.id, enabled: true) + + post :create, params: { + account_id: account.id, + SAMLResponse: saml_response, + RelayState: relay_state + } + end + + it 'returns SAML settings in response' do + post :create, params: { + account_id: account.id, + SAMLResponse: saml_response, + RelayState: relay_state + } + + json_response = JSON.parse(response.body) + expect(json_response['saml_settings']).to include( + 'sp_entity_id' => saml_settings.sp_entity_id_or_default, + 'enabled' => true, + 'sso_url' => saml_settings.sso_url, + 'certificate_fingerprint' => saml_settings.certificate_fingerprint, + 'enforced_sso' => saml_settings.enforced_sso, + 'attribute_mappings' => saml_settings.attribute_mappings, + 'role_mappings' => saml_settings.role_mappings + ) + end + + it 'includes received SAML data in response' do + post :create, params: { + account_id: account.id, + SAMLResponse: saml_response, + RelayState: relay_state + } + + json_response = JSON.parse(response.body) + expect(json_response['received_data']).to include( + 'saml_response' => saml_response, + 'relay_state' => relay_state + ) + end + end + + context 'when SAML is not enabled for account' do + before do + allow(AccountSamlSettings).to receive(:find_by).and_return(nil) + end + + it 'returns not found error' do + post :create, params: { + account_id: account.id, + SAMLResponse: 'dummy' + } + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('SAML not enabled for this account') + end + end + + context 'when account does not exist' do + it 'returns not found error' do + post :create, params: { + account_id: 0, + SAMLResponse: 'dummy' + } + + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'GET #sso' do + before do + allow(AccountSamlSettings).to receive(:find_by).and_return(saml_settings) + end + + context 'when initiating SSO' do + it 'returns SSO configuration' do + get :sso, params: { account_id: account.id } + + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response).to include( + 'sso_url' => saml_settings.sso_url, + 'sp_entity_id' => saml_settings.sp_entity_id_or_default, + 'acs_url' => "http://test.host/api/v1/accounts/#{account.id}/saml/callback" + ) + end + end + + context 'when SAML is not enabled' do + before do + allow(AccountSamlSettings).to receive(:find_by).and_return(nil) + end + + it 'returns not found error' do + get :sso, params: { account_id: account.id } + + expect(response).to have_http_status(:not_found) + end + end + end +end