From 88115441b924eb336ad6a2163ced94733f99e50d Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 27 Aug 2025 18:40:13 +0530 Subject: [PATCH] WIP --- SAML_TESTING_GUIDE.md | 153 ++++++++++ .../omniauth_callbacks_controller.rb | 82 +++++- app/controllers/omniauth_controller.rb | 63 ++-- app/models/user.rb | 2 +- config/initializers/omniauth.rb | 55 ++++ config/initializers/omniauth_saml.rb | 37 --- config/routes.rb | 6 +- .../v1/accounts/saml/callbacks_controller.rb | 3 +- .../controllers/omniauth_controller_spec.rb | 39 ++- .../omniauth_controller_unit_spec.rb | 124 ++++++++ start_server_with_logs.sh | 5 + test_saml_devise_flow.rb | 270 ++++++++++++++++++ test_saml_login.rb | 195 +++++++++++++ test_saml_oauth_flow.md | 71 +++++ test_with_mocksaml.md | 122 ++++++++ update_mocksaml_certificate.rb | 42 +++ 16 files changed, 1164 insertions(+), 105 deletions(-) create mode 100644 SAML_TESTING_GUIDE.md delete mode 100644 config/initializers/omniauth_saml.rb create mode 100644 spec/enterprise/controllers/omniauth_controller_unit_spec.rb create mode 100755 start_server_with_logs.sh create mode 100644 test_saml_devise_flow.rb create mode 100644 test_saml_login.rb create mode 100644 test_saml_oauth_flow.md create mode 100644 test_with_mocksaml.md create mode 100644 update_mocksaml_certificate.rb diff --git a/SAML_TESTING_GUIDE.md b/SAML_TESTING_GUIDE.md new file mode 100644 index 000000000..2dd2e2e2e --- /dev/null +++ b/SAML_TESTING_GUIDE.md @@ -0,0 +1,153 @@ +# SAML Testing Guide with MockSAML.com + +## Prerequisites +- Chatwoot running locally on `http://localhost:3000` +- MockSAML.com account (free tier works) +- A user account in Chatwoot that you'll use for testing + +## Step 1: Set Up MockSAML + +1. Go to https://mocksaml.com and sign in +2. Click "Create New Configuration" +3. Fill in the configuration: + - **Configuration Name**: Chatwoot Test + - **Service Provider Entity ID**: `http://localhost:3000/saml/1` (or use your custom entity ID from SAML settings) + - **Assertion Consumer Service URL**: `http://localhost:3000/auth/saml/1/callback` + - **Authentication Method**: Email/Password + - **Default RelayState**: `/app/dashboard` (optional) + +4. In the "Attributes" section, add: + - **Email Address**: + - Name: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` + - Value: Use the email of an existing user in your Chatwoot database + - **Name**: + - Name: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` + - Value: The user's full name + +5. Save the configuration and note down: + - The SSO URL (will be like `https://mocksaml.com/api/saml/sso/{config-id}`) + - Download the certificate from the "Metadata" section + +## Step 2: Update Chatwoot SAML Settings + +### Using Rails Console: +```ruby +rails console + +# Find your account +account = Account.find(1) + +# Get or create SAML settings +saml_settings = account.account_saml_settings || account.create_account_saml_settings + +# Update with MockSAML values +saml_settings.update!( + enabled: true, + sso_url: 'https://mocksaml.com/api/saml/sso/YOUR_CONFIG_ID', # Replace with your MockSAML SSO URL + certificate: %{-----BEGIN CERTIFICATE----- +PASTE_THE_CERTIFICATE_FROM_MOCKSAML_HERE +-----END CERTIFICATE-----}, + sp_entity_id: 'http://localhost:3000/saml/1', # Must match what you set in MockSAML + enforced_sso: false +) + +# Verify settings +puts saml_settings.certificate_fingerprint +``` + +### Using API: +```bash +# Update SAML settings via API +curl -X PUT http://localhost:3000/api/v1/accounts/1/saml_settings \ + -H "Content-Type: application/json" \ + -H "api_access_token: YOUR_API_TOKEN" \ + -d '{ + "enabled": true, + "sso_url": "https://mocksaml.com/api/saml/sso/YOUR_CONFIG_ID", + "certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + "sp_entity_id": "http://localhost:3000/saml/1" + }' +``` + +## Step 3: Test the SAML Flow + +### Method 1: Direct Browser Test +1. Open your browser +2. Navigate to: `http://localhost:3000/auth/saml/1` +3. You should be redirected to MockSAML login page +4. Enter the test credentials you configured in MockSAML +5. After successful authentication, you should be redirected to: + - `http://localhost:3000/app/login?email=USER_EMAIL&sso_auth_token=TOKEN` +6. The frontend should automatically complete the login using the SSO token + +### Method 2: API Test +```bash +# 1. Get SAML configuration +curl http://localhost:3000/api/v1/accounts/1/saml/sso + +# 2. Initiate SAML flow (in browser) +# Visit: http://localhost:3000/auth/saml/1 +``` + +## Step 4: Verify the Login + +1. Check browser developer tools: + - Network tab should show the SAML flow + - After redirect, check for `/auth/sign_in` API call with SSO token + +2. Check Rails logs: + ```bash + tail -f log/development.log | grep -E "OmniauthController|SAML|SSO" + ``` + +3. Verify in Rails console: + ```ruby + # Check if SSO token was created + Redis::Alfred.keys("chatwoot:user:*:sso_auth_token:*") + + # Check user's last sign in + User.find_by(email: 'your-test-email@example.com').current_sign_in_at + ``` + +## Troubleshooting + +### Common Issues: + +1. **"SAML not enabled for this account"** + - Ensure `enabled: true` in SAML settings + - Verify account ID matches + +2. **Redirect to /auth/sign_in without SSO token** + - Check if SAML response validation failed + - Verify certificate and fingerprint match + - Check Rails logs for OmniAuth errors + +3. **"User not found" error** + - Ensure the email in MockSAML matches an existing Chatwoot user + - Check if `User.find_by(email: 'test@example.com')` returns a user + +4. **Certificate validation errors** + - Make sure you copied the entire certificate including BEGIN/END lines + - Certificate should not have any extra whitespace + +### Debug Commands: + +```bash +# Check current SAML settings +rails runner "pp AccountSamlSettings.find_by(account_id: 1)" + +# Test metadata endpoint +curl http://localhost:3000/auth/saml/1/metadata + +# Check OmniAuth configuration +rails runner "pp Rails.application.config.omniauth" +``` + +## Next Steps + +Once basic login works: +1. Test with multiple users +2. Test logout flow (if implementing SLO) +3. Test with enforced SSO enabled +4. Implement user provisioning for new users +5. Add role mapping from SAML attributes \ No newline at end of file diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index db312e94f..1a9da1553 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -1,7 +1,66 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController include EmailHelper + + # Handle the redirect from OmniAuth for SAML + def redirect_callbacks + Rails.logger.debug "[redirect_callbacks] Provider: #{params[:provider]}" + Rails.logger.debug "[redirect_callbacks] Params: #{params.inspect}" + + # For SAML, we need to preserve the account_id through the redirect + if params[:provider] == 'saml' && params[:account_id] + session[:saml_account_id] = params[:account_id] + end + + # Redirect to the auth callback path, preserving the method and body + redirect_to "/auth/#{params[:provider]}/callback", status: 307 + end + # SAML-specific callback to handle the provider + def saml + Rails.logger.debug "[SAML Callback] Called saml method" + Rails.logger.debug "[SAML Callback] Auth hash: #{request.env['omniauth.auth'].inspect}" + Rails.logger.debug "[SAML Callback] Params: #{params.inspect}" + + # Set auth hash for parent class if needed + self.auth_hash = request.env['omniauth.auth'] + + # Call the generic success handler + omniauth_success + end + def omniauth_success + Rails.logger.debug "[OmniAuth Callback] Starting omniauth_success" + Rails.logger.debug "[OmniAuth Callback] request.env keys: #{request.env.keys.select { |k| k.to_s.include?('omniauth') }}" + + auth_hash = request.env['omniauth.auth'] || auth_hash # auth_hash is from parent class + Rails.logger.debug "[OmniAuth Callback] Auth hash from env: #{request.env['omniauth.auth'].inspect}" + Rails.logger.debug "[OmniAuth Callback] Auth hash from method: #{auth_hash.inspect if defined?(auth_hash)}" + + if auth_hash + Rails.logger.debug "[OmniAuth Callback] Provider: #{auth_hash['provider']}" + Rails.logger.debug "[OmniAuth Callback] Info: #{auth_hash['info'].inspect}" + Rails.logger.debug "[OmniAuth Callback] UID: #{auth_hash['uid']}" + Rails.logger.debug "[OmniAuth Callback] Extra: #{auth_hash['extra'].inspect if auth_hash['extra']}" + else + Rails.logger.debug "[OmniAuth Callback] No auth hash found!" + end + + Rails.logger.debug "[OmniAuth Callback] Session SAML account: #{session[:saml_account_id]}" + Rails.logger.debug "[OmniAuth Callback] Params: #{params.inspect}" + Rails.logger.debug "[OmniAuth Callback] OmniAuth params: #{request.env['omniauth.params']}" + + # For SAML, check if account has SAML enabled + if auth_hash && auth_hash['provider'] == 'saml' + account_id = params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id') + if account_id + saml_settings = AccountSamlSettings.find_by(account_id: account_id, enabled: true) + unless saml_settings + Rails.logger.error "[OmniAuth Callback] SAML not enabled for account #{account_id}" + return redirect_to login_page_url(error: 'saml-not-enabled') + end + end + end + get_resource_from_auth_hash @resource.present? ? sign_in_user : sign_up_user @@ -47,10 +106,25 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName - # find the user with their email instead of UID and token - @resource = resource_class.where( - email: auth_hash['info']['email'] - ).first + Rails.logger.debug "[get_resource_from_auth_hash] auth_hash: #{auth_hash.inspect}" + Rails.logger.debug "[get_resource_from_auth_hash] auth_hash class: #{auth_hash.class}" + + # Check if auth_hash is available from parent class method + if auth_hash.nil? && defined?(super) + Rails.logger.debug "[get_resource_from_auth_hash] Calling parent auth_hash method" + auth_hash + end + + if auth_hash + Rails.logger.debug "[get_resource_from_auth_hash] Looking for user with email: #{auth_hash['info']['email']}" + # find the user with their email instead of UID and token + @resource = resource_class.where( + email: auth_hash['info']['email'] + ).first + Rails.logger.debug "[get_resource_from_auth_hash] Found resource: #{@resource.inspect}" + else + Rails.logger.error "[get_resource_from_auth_hash] No auth_hash available!" + end end def validate_signup_email_is_business_domain? diff --git a/app/controllers/omniauth_controller.rb b/app/controllers/omniauth_controller.rb index 34f7347c7..c793668ce 100644 --- a/app/controllers/omniauth_controller.rb +++ b/app/controllers/omniauth_controller.rb @@ -1,56 +1,43 @@ -class OmniauthController < ApplicationController - skip_before_action :set_current_user - +class OmniauthController < DeviseOverrides::OmniauthCallbacksController + # We inherit from Devise's OmniAuth controller to get proper request handling + 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'] + # OmniAuth populates auth_hash from request.env['omniauth.auth'] + return handle_auth_failure unless auth_hash.present? + account_id = params[:account_id] - + # Check if SAML is enabled for this account saml_settings = AccountSamlSettings.find_by(account_id: account_id, enabled: true) - return render json: { error: 'SAML not enabled for this account' }, status: :unauthorized unless saml_settings + unless saml_settings + return redirect_to login_page_url(error: 'saml-not-enabled') + end - if auth.present? - # Find existing user by email - email = auth['info']['email'] - user = User.from_email(email) + # Find existing user by email + email = auth_hash['info']['email'] + @resource = User.find_by(email: email) - if user - # Create authentication token (DeviseTokenAuth way) - @resource = user - @token = @resource.create_token - @resource.save! - - # Sign in the user - sign_in(:user, @resource, store: false, bypass: false) - - # Update sign in tracking - @resource.update_tracked_fields!(request) - - # Render success response using Chatwoot's auth template - render partial: 'devise/auth', formats: [:json], locals: { resource: @resource } - else - render json: { - error: 'User not found', - message: "No user exists with email: #{email}" - }, status: :not_found - end + if @resource + # Use the parent class method for SSO token flow + sign_in_user else - render json: { - error: 'SAML authentication failed', - message: request.env['omniauth.error'] || 'Unknown error' - }, status: :unauthorized + redirect_to login_page_url(error: 'no-account-found') end end def failure - render json: { - error: 'SAML authentication failed', - message: params[:message] || request.env['omniauth.error'] || 'Unknown error' - }, status: :unauthorized + redirect_to login_page_url(error: params[:message] || 'saml-auth-failed') + end + + private + + def handle_auth_failure + error_message = request.env['omniauth.error'] || 'Unknown error' + redirect_to login_page_url(error: 'saml-auth-failed') end end diff --git a/app/models/user.rb b/app/models/user.rb index d1907362a..e0440a67c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -58,7 +58,7 @@ class User < ApplicationRecord :validatable, :confirmable, :password_has_required_content, - :omniauthable, omniauth_providers: [:google_oauth2] + :omniauthable, omniauth_providers: [:google_oauth2, :saml] # TODO: remove in a future version once online status is moved to account users # remove the column availability from users diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index d92d9b040..77031b8e8 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -1,5 +1,60 @@ +# Enable OmniAuth debug logging +OmniAuth.config.logger = Rails.logger +OmniAuth.config.logger.level = Logger::DEBUG +OmniAuth.config.full_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + Rails.application.config.middleware.use OmniAuth::Builder do provider :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), { provider_ignores_state: true } + + # SAML provider with setup phase for multi-tenant configuration + provider :saml, + setup: lambda { |env| + request = ActionDispatch::Request.new(env) + + Rails.logger.debug "[SAML Setup] Request path: #{request.path}" + Rails.logger.debug "[SAML Setup] Request params: #{request.params.inspect}" + Rails.logger.debug "[SAML Setup] Session ID: #{request.session.id}" + + # Extract account_id from various sources + account_id = request.params['account_id'] || + request.session[:saml_account_id] || + env['omniauth.params']&.dig('account_id') + + Rails.logger.debug "[SAML Setup] Account ID: #{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, enabled: true) + + if settings + Rails.logger.debug "[SAML Setup] Found settings for account #{account_id}" + Rails.logger.debug "[SAML Setup] SSO URL: #{settings.sso_url}" + Rails.logger.debug "[SAML Setup] SP Entity ID: #{settings.sp_entity_id_or_default}" + + # 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_or_default + 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' + + Rails.logger.debug "[SAML Setup] Strategy configured successfully" + else + Rails.logger.warn "[SAML Setup] No SAML settings found for account #{account_id}" + # Set a dummy certificate to avoid the error + env['omniauth.strategy'].options[:idp_cert] = "DUMMY" + end + else + Rails.logger.warn "[SAML Setup] No account_id found in params or session" + # Set a dummy certificate to avoid the error + env['omniauth.strategy'].options[:idp_cert] = "DUMMY" + end + } end diff --git a/config/initializers/omniauth_saml.rb b/config/initializers/omniauth_saml.rb deleted file mode 100644 index e73047417..000000000 --- a/config/initializers/omniauth_saml.rb +++ /dev/null @@ -1,37 +0,0 @@ -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 7dee658fa..e0775055b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,10 +8,8 @@ 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 + # SAML routes - bypass the redirect_callbacks to handle POST directly + match '/omniauth/saml/callback', to: 'devise_overrides/omniauth_callbacks#saml', via: [:get, :post], constraints: { provider: 'saml' } ## 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)) diff --git a/enterprise/app/controllers/api/v1/accounts/saml/callbacks_controller.rb b/enterprise/app/controllers/api/v1/accounts/saml/callbacks_controller.rb index 8bcdec1bd..183daf64e 100644 --- a/enterprise/app/controllers/api/v1/accounts/saml/callbacks_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/saml/callbacks_controller.rb @@ -44,6 +44,7 @@ class Api::V1::Accounts::Saml::CallbacksController < Api::V1::Accounts::BaseCont end def acs_url - api_v1_account_saml_callback_url(@current_account) + # Use the new SAML callback URL + "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/saml/#{@current_account.id}/callback" end end diff --git a/spec/enterprise/controllers/omniauth_controller_spec.rb b/spec/enterprise/controllers/omniauth_controller_spec.rb index bf43ea415..8c187344f 100644 --- a/spec/enterprise/controllers/omniauth_controller_spec.rb +++ b/spec/enterprise/controllers/omniauth_controller_spec.rb @@ -29,36 +29,35 @@ RSpec.describe 'SAML Authentication API', type: :request do existing_user end - it 'successfully logs in the user' do + it 'redirects to frontend login with SSO token' do post "/auth/saml/#{account.id}/callback", env: { 'omniauth.auth' => auth_hash }, as: :json - expect(response).to have_http_status(:ok) - json_response = JSON.parse(response.body) - expect(json_response['data']).to be_present - expect(json_response['data']['email']).to eq('john.doe@example.com') - expect(json_response['data']['id']).to eq(existing_user.id) + expect(response).to have_http_status(:found) # 302 redirect + expect(response.location).to include('/app/login') + expect(response.location).to include('email=john.doe%40example.com') + expect(response.location).to include('sso_auth_token=') end - it 'creates authentication tokens for the user' do + it 'generates SSO auth token for the user' do + expect(existing_user).to receive(:generate_sso_auth_token).and_call_original + + post "/auth/saml/#{account.id}/callback", + env: { 'omniauth.auth' => auth_hash }, + as: :json + + # Verify token was created in Redis + expect(response).to have_http_status(:found) + end + + it 'does not update sign in tracking directly' do + # Sign in tracking should happen when user completes login via frontend expect do post "/auth/saml/#{account.id}/callback", env: { 'omniauth.auth' => auth_hash }, as: :json - end.to change { existing_user.reload.tokens.count }.by(1) - end - - it 'updates user sign in tracking' do - existing_user - - expect do - post "/auth/saml/#{account.id}/callback", - env: { 'omniauth.auth' => auth_hash }, - as: :json - end.to change { existing_user.reload.sign_in_count }.by(1) - - expect(existing_user.reload.current_sign_in_at).to be_present + end.not_to change { existing_user.reload.sign_in_count } end end diff --git a/spec/enterprise/controllers/omniauth_controller_unit_spec.rb b/spec/enterprise/controllers/omniauth_controller_unit_spec.rb new file mode 100644 index 000000000..5c37cf7d0 --- /dev/null +++ b/spec/enterprise/controllers/omniauth_controller_unit_spec.rb @@ -0,0 +1,124 @@ +require 'rails_helper' + +RSpec.describe OmniauthController, type: :controller do + let(:account) { create(:account) } + let!(:saml_settings) do + create(:account_saml_settings, + :enabled, + account: account, + sso_url: 'https://mocksaml.com/sso') + end + let(:existing_user) do + create(:user, email: 'john.doe@example.com', name: 'John Doe') + end + + describe 'GET #callback' do + context 'with existing user' do + let(:auth_hash) do + OmniAuth::AuthHash.new({ + provider: 'saml', + uid: 'john.doe@example.com', + info: { + email: 'john.doe@example.com', + name: 'John Doe', + first_name: 'John', + last_name: 'Doe' + } + }) + end + + before do + existing_user + # Mock the OmniAuth auth hash in the request environment + request.env['omniauth.auth'] = auth_hash + end + + it 'redirects to frontend login with SSO token' do + get :callback, params: { account_id: account.id } + + expect(response).to have_http_status(:found) + expect(response.location).to include('/app/login') + expect(response.location).to include('email=john.doe%40example.com') + expect(response.location).to include('sso_auth_token=') + end + + it 'generates a valid SSO auth token' do + allow(SecureRandom).to receive(:hex).and_return('test_token_12345') + + get :callback, params: { account_id: account.id } + + # Verify token was stored in Redis + token_key = "chatwoot:user:#{existing_user.id}:sso_auth_token:test_token_12345" + expect(::Redis::Alfred.get(token_key)).to eq('true') + end + end + + context 'when user does not exist' do + let(:auth_hash) do + OmniAuth::AuthHash.new({ + provider: 'saml', + uid: 'newuser@example.com', + info: { + email: 'newuser@example.com', + name: 'New User' + } + }) + end + + before do + request.env['omniauth.auth'] = auth_hash + end + + it 'returns user not found error' do + get :callback, params: { account_id: account.id } + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('User not found') + expect(json_response['message']).to include('newuser@example.com') + end + end + + context 'when SAML is not enabled for account' do + before do + saml_settings.update!(enabled: false) + request.env['omniauth.auth'] = { info: { email: 'test@example.com' } } + end + + it 'returns unauthorized error' do + get :callback, params: { account_id: account.id } + + expect(response).to have_http_status(:unauthorized) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('SAML not enabled for this account') + end + end + + context 'when authentication fails' do + before do + request.env['omniauth.auth'] = nil + request.env['omniauth.error'] = 'Invalid SAML Response' + end + + it 'returns authentication failed error' do + get :callback, params: { account_id: account.id } + + expect(response).to have_http_status(:unauthorized) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('SAML authentication failed') + expect(json_response['message']).to eq('Invalid SAML Response') + end + end + end + + describe 'GET #failure' do + it 'returns authentication failure message' do + get :failure, params: { message: 'SAML IdP error occurred' } + + expect(response).to have_http_status(:unauthorized) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('SAML authentication failed') + expect(json_response['message']).to eq('SAML IdP error occurred') + end + end +end \ No newline at end of file diff --git a/start_server_with_logs.sh b/start_server_with_logs.sh new file mode 100755 index 000000000..0ef26ad8f --- /dev/null +++ b/start_server_with_logs.sh @@ -0,0 +1,5 @@ +#!/bin/bash +echo "Starting Rails server with debug logging..." +echo "Watch for [SAML Setup] and [OmniAuth Callback] messages in the logs" +echo "" +bundle exec rails server \ No newline at end of file diff --git a/test_saml_devise_flow.rb b/test_saml_devise_flow.rb new file mode 100644 index 000000000..99c8738ca --- /dev/null +++ b/test_saml_devise_flow.rb @@ -0,0 +1,270 @@ +#!/usr/bin/env ruby +# Run with: rails runner test_saml_devise_flow.rb + +require 'net/http' +require 'uri' +require 'json' +require 'base64' +require 'time' + +# Enable OmniAuth debug logging +OmniAuth.config.logger = Rails.logger +OmniAuth.config.logger.level = Logger::DEBUG + +puts "=== SAML Authentication Flow Test ===" +puts "" + +# 1. Find SAML settings +account_id = 1 +saml_settings = AccountSamlSettings.find_by(account_id: account_id, enabled: true) + +unless saml_settings + puts "❌ No SAML settings found for account #{account_id}" + exit 1 +end + +puts "✅ Found SAML settings for account #{account_id}" +puts " SSO URL: #{saml_settings.sso_url}" +puts " SP Entity ID: #{saml_settings.sp_entity_id_or_default}" +puts "" + +# 2. Find a test user +user = User.first +unless user + puts "❌ No user found to test with" + exit 1 +end + +puts "✅ Testing with user: #{user.email}" +puts "" + +# 3. Test the initial SAML request endpoint +puts "=== Testing SAML Initiation ===" +saml_init_url = "http://localhost:3000/auth/saml?account_id=#{account_id}" +puts "GET #{saml_init_url}" + +uri = URI(saml_init_url) +http = Net::HTTP.new(uri.host, uri.port) +http.use_ssl = false +http.set_debug_output($stdout) if ENV['DEBUG'] + +# Enable cookie handling +http.start do |http_session| + request = Net::HTTP::Get.new(uri) + request['Accept'] = 'text/html' + + response = http_session.request(request) + puts "Response Status: #{response.code}" + + # Store cookies + cookies = response.get_fields('set-cookie') + cookie_hash = {} + if cookies + cookies.each do |cookie| + cookie_hash[cookie.split(';')[0].split('=')[0]] = cookie.split(';')[0] + end + end + + if response.code == '307' + puts "✅ Got temporary redirect to: #{response['location']}" + + # Follow the redirect + redirect_uri = URI(response['location']) + redirect_request = Net::HTTP::Get.new(redirect_uri) + redirect_request['Cookie'] = cookie_hash.values.join('; ') if cookie_hash.any? + + redirect_response = http_session.request(redirect_request) + puts "Redirect Response Status: #{redirect_response.code}" + + if redirect_response.code == '302' || redirect_response.code == '303' + puts "✅ Redirected to IdP: #{redirect_response['location']}" + + # Check if it's redirecting to the IdP + if redirect_response['location']&.include?(saml_settings.sso_url) + puts "✅ Correctly redirecting to IdP" + else + puts "❌ Not redirecting to expected IdP URL" + end + end + elsif response.code == '302' || response.code == '303' + puts "✅ Redirected to: #{response['location']}" + + # Check if it's redirecting to the IdP + if response['location']&.include?(saml_settings.sso_url) + puts "✅ Correctly redirecting to IdP" + else + puts "❌ Not redirecting to expected IdP URL" + end + else + puts "❌ Expected redirect but got #{response.code}" + puts "Response body: #{response.body[0..500]}" + end +end + +puts "" + +# 4. Simulate SAML callback +puts "=== Simulating SAML Callback ===" + +# Generate a mock SAML response +def generate_saml_response(saml_settings, user) + issue_instant = Time.now.utc.iso8601 + response_id = "_#{SecureRandom.hex(21)}" + assertion_id = "_#{SecureRandom.hex(21)}" + + # Create a simple unsigned SAML response for testing + saml_response = <<-XML + + + #{saml_settings.sso_url} + + + + + #{saml_settings.sso_url} + + #{user.email} + + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + #{user.email} + + + #{user.name} + + + + +XML + + Base64.strict_encode64(saml_response) +end + +callback_url = "http://localhost:3000/omniauth/saml/callback" +puts "POST #{callback_url}" + +saml_response = generate_saml_response(saml_settings, user) + +uri = URI(callback_url) +http = Net::HTTP.new(uri.host, uri.port) + +# We need to maintain session cookies +cookie_jar = {} + +# First, get a session by visiting the auth endpoint +session_uri = URI(saml_init_url) +session_http = Net::HTTP.new(session_uri.host, session_uri.port) +session_request = Net::HTTP::Get.new(session_uri) + +session_response = session_http.request(session_request) +if session_response['set-cookie'] + cookie_jar['Cookie'] = session_response['set-cookie'].split(';')[0] + puts "✅ Got session cookie" +end + +# Now send the callback with the session +request = Net::HTTP::Post.new(uri) +request['Content-Type'] = 'application/x-www-form-urlencoded' +request['Cookie'] = cookie_jar['Cookie'] if cookie_jar['Cookie'] + +callback_params = { + 'SAMLResponse' => saml_response, + 'RelayState' => '' +} + +request.body = URI.encode_www_form(callback_params) + +puts "Sending SAML response for user: #{user.email}" +response = http.request(request) + +puts "Response Status: #{response.code}" + +if response.code == '302' || response.code == '303' + redirect_location = response['location'] + puts "✅ Redirected to: #{redirect_location}" + + # Parse redirect URL to check for SSO token + if redirect_location + redirect_uri = URI(redirect_location) + if redirect_uri.path == '/app/login' + params = URI.decode_www_form(redirect_uri.query || '') + params_hash = params.to_h + + puts "" + puts "=== Login Redirect Parameters ===" + puts "Email: #{params_hash['email']}" + puts "SSO Token: #{params_hash['sso_auth_token']}" + + if params_hash['sso_auth_token'] + puts "" + puts "✅ Successfully generated SSO auth token!" + + # Optional: Test the SSO token login + puts "" + puts "=== Testing SSO Token Login ===" + login_url = "http://localhost:3000/auth/sign_in" + + login_params = { + email: params_hash['email'], + sso_auth_token: params_hash['sso_auth_token'] + } + + uri = URI(login_url) + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Post.new(uri) + request['Content-Type'] = 'application/json' + request.body = login_params.to_json + + login_response = http.request(request) + puts "Login Response Status: #{login_response.code}" + + if login_response.code == '200' + puts "✅ SSO token login successful!" + + # Check for auth headers + if login_response['access-token'] + puts "" + puts "Authentication Headers:" + puts " access-token: #{login_response['access-token']}" + puts " client: #{login_response['client']}" + puts " uid: #{login_response['uid']}" + end + else + puts "❌ SSO token login failed" + puts "Response: #{login_response.body}" + end + elsif params_hash['error'] + puts "❌ Error: #{params_hash['error']}" + end + else + puts "❌ Unexpected redirect path: #{redirect_uri.path}" + end + end +else + puts "❌ Expected redirect but got #{response.code}" + puts "Response body: #{response.body[0..1000]}" + + # Check if it's an error page + if response.body.include?('error') || response.body.include?('Error') + puts "" + puts "Possible error in response. Check Rails logs for details." + end +end + +puts "" +puts "=== Test Complete ===" +puts "Check your Rails server logs for detailed OmniAuth debug information" \ No newline at end of file diff --git a/test_saml_login.rb b/test_saml_login.rb new file mode 100644 index 000000000..948bab903 --- /dev/null +++ b/test_saml_login.rb @@ -0,0 +1,195 @@ +#!/usr/bin/env ruby +# Run with: rails runner test_saml_login.rb + +require 'net/http' +require 'uri' +require 'json' +require 'openssl' +require 'base64' +require 'time' + +# Find SAML settings for account 1 +saml_settings = AccountSamlSettings.find_by(id: 1) + +unless saml_settings + puts "No SAML settings found with ID 1" + exit 1 +end + +puts "Found SAML settings for account #{saml_settings.account_id}:" +puts " SSO URL: #{saml_settings.sso_url}" +puts " SP Entity ID: #{saml_settings.sp_entity_id_or_default}" +puts " Certificate Fingerprint: #{saml_settings.certificate_fingerprint}" +puts "" + +# Find a user to login as +user = User.first +unless user + puts "No user found to test with" + exit 1 +end + +puts "Testing with user: #{user.email}" +puts "" + +# Generate a proper SAML response +def generate_saml_response(saml_settings, user) + # Create a basic SAML response XML + issue_instant = Time.now.utc.iso8601 + response_id = "_#{SecureRandom.hex(21)}" + assertion_id = "_#{SecureRandom.hex(21)}" + + saml_response = <<-XML + + #{saml_settings.sso_url} + + + + + #{saml_settings.sso_url} + + #{user.email} + + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + #{user.email} + + + #{user.name} + + + + + XML + + Base64.encode64(saml_response) +end + +# First, test the SSO endpoint +sso_url = "http://localhost:3000/api/v1/accounts/#{saml_settings.account_id}/saml/sso" +puts "Testing SSO endpoint: #{sso_url}" + +uri = URI(sso_url) +http = Net::HTTP.new(uri.host, uri.port) +request = Net::HTTP::Get.new(uri) +request['Content-Type'] = 'application/json' + +response = http.request(request) +puts "SSO Response Status: #{response.code}" +puts "SSO Response Body: #{response.body}" +puts "" + +# Now test the callback with a proper SAML response +callback_url = "http://localhost:3000/saml/#{saml_settings.account_id}/callback" +puts "Testing callback endpoint: #{callback_url}" + +# Generate SAML response +saml_response = generate_saml_response(saml_settings, user) + +# Create a form-encoded body (SAML callbacks are typically POST with form data) +callback_params = { + 'SAMLResponse' => saml_response, + 'RelayState' => '/app/dashboard' +} + +uri = URI(callback_url) +http = Net::HTTP.new(uri.host, uri.port) +request = Net::HTTP::Post.new(uri) +request['Content-Type'] = 'application/x-www-form-urlencoded' +request.body = URI.encode_www_form(callback_params) + +puts "Sending POST to callback with SAML response for user: #{user.email}" +response = http.request(request) +puts "Callback Response Status: #{response.code}" +puts "Callback Response Headers:" +response.each_header do |key, value| + puts " #{key}: #{value}" if key.downcase.include?('location') || key.downcase.include?('content-type') +end + +if response.code == '302' + puts "\nRedirect location: #{response['location']}" + + # Parse the redirect URL to extract SSO token + if response['location'] + redirect_uri = URI(response['location']) + params = URI.decode_www_form(redirect_uri.query || '') + params_hash = params.to_h + + puts "\nRedirect parameters:" + puts " Email: #{params_hash['email']}" + puts " SSO Auth Token: #{params_hash['sso_auth_token']}" + + # Test if we can use this SSO token to login + if params_hash['sso_auth_token'] + puts "\n\nTesting SSO token login:" + login_url = "http://localhost:3000/auth/sign_in" + + login_params = { + email: params_hash['email'], + sso_auth_token: params_hash['sso_auth_token'] + } + + uri = URI(login_url) + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Post.new(uri) + request['Content-Type'] = 'application/json' + request.body = login_params.to_json + + login_response = http.request(request) + puts "Login Response Status: #{login_response.code}" + + if login_response.body && !login_response.body.empty? + begin + json = JSON.parse(login_response.body) + puts "Login Response:" + puts JSON.pretty_generate(json) + + # Check for auth headers + puts "\nAuthentication Headers:" + puts " access-token: #{login_response['access-token']}" if login_response['access-token'] + puts " client: #{login_response['client']}" if login_response['client'] + puts " uid: #{login_response['uid']}" if login_response['uid'] + rescue + puts "Login Response Body: #{login_response.body}" + end + end + end + end +elsif response.body && !response.body.empty? + puts "\nCallback Response Body:" + begin + json = JSON.parse(response.body) + puts JSON.pretty_generate(json) + rescue + puts response.body + end +end + +# Test direct OmniAuth request +puts "\n\nTesting OmniAuth request endpoint:" +omniauth_url = "http://localhost:3000/saml/#{saml_settings.account_id}" +puts "GET #{omniauth_url}" + +uri = URI(omniauth_url) +http = Net::HTTP.new(uri.host, uri.port) +request = Net::HTTP::Get.new(uri) + +response = http.request(request) +puts "Response Status: #{response.code}" +if response.code == '302' + puts "Redirected to: #{response['location']}" +end \ No newline at end of file diff --git a/test_saml_oauth_flow.md b/test_saml_oauth_flow.md new file mode 100644 index 000000000..5e0524e77 --- /dev/null +++ b/test_saml_oauth_flow.md @@ -0,0 +1,71 @@ +# Testing SAML Authentication Flow + +## 1. Start the SAML Authentication Flow + +Visit this URL in your browser to initiate SAML authentication: + +``` +http://localhost:3000/auth/saml?account_id=1 +``` + +This should: +1. Trigger the OmniAuth SAML provider +2. Run the setup phase to load account-specific SAML settings +3. Redirect you to MockSAML.com with the correct parameters + +## 2. Complete Authentication at MockSAML + +At MockSAML.com: +1. Enter any email address (use an existing Chatwoot user's email) +2. Complete the authentication +3. MockSAML will POST back to: `http://localhost:3000/omniauth/saml/callback` + +## 3. Expected Flow + +After successful SAML authentication: +1. The `devise_overrides/omniauth_callbacks#omniauth_success` method will be called +2. It will find the user by email +3. Generate an SSO auth token +4. Redirect to the frontend login page with the token: `http://localhost:3000/app/login?email=user@example.com&sso_auth_token=xxx` + +## 4. Check Server Logs + +Monitor the Rails server logs for: +- "Processing by DeviseOverrides::OmniauthCallbacksController#omniauth_success" +- Any SAML-related errors or warnings + +## 5. Debugging + +If you encounter issues: + +1. **Check if SAML settings are loaded:** + ```ruby + rails console + AccountSamlSettings.find_by(account_id: 1, enabled: true) + ``` + +2. **Test the setup phase manually:** + ```ruby + # In rails console + settings = AccountSamlSettings.find_by(account_id: 1) + puts "SSO URL: #{settings.sso_url}" + puts "SP Entity ID: #{settings.sp_entity_id_or_default}" + puts "Certificate present: #{settings.certificate.present?}" + ``` + +3. **Enable OmniAuth debug logging:** + Add this to an initializer: + ```ruby + OmniAuth.config.logger = Rails.logger + OmniAuth.config.logger.level = Logger::DEBUG + ``` + +## 6. Alternative Testing with cURL + +You can also test the initial redirect: + +```bash +curl -v "http://localhost:3000/auth/saml?account_id=1" +``` + +This should return a 302 redirect to MockSAML.com. \ No newline at end of file diff --git a/test_with_mocksaml.md b/test_with_mocksaml.md new file mode 100644 index 000000000..857acebd3 --- /dev/null +++ b/test_with_mocksaml.md @@ -0,0 +1,122 @@ +# Testing SAML with MockSAML.com + +## 1. Verify Your SAML Settings + +First, check your current SAML configuration: + +```bash +rails console +``` + +```ruby +settings = AccountSamlSettings.find_by(account_id: 1) +puts "SSO URL: #{settings.sso_url}" +puts "SP Entity ID: #{settings.sp_entity_id_or_default}" +puts "ACS URL: #{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=1" +``` + +## 2. Update MockSAML Configuration + +Go to MockSAML.com and ensure these settings match: +- **SP Entity ID**: Should match `settings.sp_entity_id_or_default` +- **ACS URL**: `http://localhost:3000/omniauth/saml/callback?account_id=1` +- **Name ID Format**: Email Address + +## 3. Start the Authentication Flow + +### Option A: Browser Test +1. Open your browser +2. Navigate to: `http://localhost:3000/auth/saml?account_id=1` +3. You should be redirected to MockSAML.com +4. Enter any email (use an existing Chatwoot user's email like `john@acme.inc`) +5. Complete authentication at MockSAML +6. You should be redirected back to Chatwoot login page with SSO token + +### Option B: Direct Link Test +1. Get the SAML initiation URL: + ``` + http://localhost:3000/auth/saml?account_id=1 + ``` + +2. Click or paste this in your browser + +## 4. What to Expect + +### Success Flow: +1. Browser redirects to MockSAML.com +2. You authenticate at MockSAML +3. MockSAML POST backs to `/omniauth/saml/callback?account_id=1` +4. Chatwoot processes the SAML response +5. You're redirected to: `http://localhost:3000/app/login?email=USER_EMAIL&sso_auth_token=TOKEN` + +### If Using ngrok or Public URL: +If you're testing with a public URL (ngrok), update the FRONTEND_URL: +```bash +export FRONTEND_URL=https://your-ngrok-url.ngrok.io +``` + +## 5. Debugging Tips + +### Watch Server Logs: +Look for these log messages: +- `[SAML Setup] Request path:` +- `[SAML Setup] Account ID:` +- `[SAML Setup] Found settings for account` +- `[OmniAuth Callback] Provider: saml` + +### Common Issues: + +1. **"No fingerprint or certificate on settings"** + - The account_id isn't being passed properly + - Check logs for `[SAML Setup] Account ID:` + +2. **Redirect to /auth/sign_in instead of /app/login** + - The SAML response wasn't processed correctly + - Check if the user exists with that email + +3. **404 on callback** + - The route isn't configured properly + - Run `rails routes | grep saml` to verify + +### Manual SAML Response Test: +If you want to test just the callback with a SAML response from MockSAML: + +1. Use browser developer tools to intercept the POST to `/omniauth/saml/callback` +2. Copy the SAMLResponse parameter +3. Use the test script to send it: + +```ruby +# save_as: test_saml_callback.rb +require 'net/http' +require 'uri' + +saml_response = "PASTE_YOUR_SAML_RESPONSE_HERE" +callback_url = "http://localhost:3000/omniauth/saml/callback?account_id=1" + +uri = URI(callback_url) +http = Net::HTTP.new(uri.host, uri.port) +request = Net::HTTP::Post.new(uri) +request['Content-Type'] = 'application/x-www-form-urlencoded' +request.body = URI.encode_www_form({ + 'SAMLResponse' => saml_response, + 'RelayState' => '' +}) + +response = http.request(request) +puts "Status: #{response.code}" +puts "Location: #{response['location']}" if response['location'] +``` + +## 6. Test Different Scenarios + +### Existing User Login: +- Use email of existing user (e.g., `john@acme.inc`) +- Should redirect with SSO token + +### New User (if enabled): +- Use new email address +- Should redirect to password setup page (if account signup is enabled) + +### SAML Disabled: +- Disable SAML in settings and try again +- Should show error message \ No newline at end of file diff --git a/update_mocksaml_certificate.rb b/update_mocksaml_certificate.rb new file mode 100644 index 000000000..990b424e0 --- /dev/null +++ b/update_mocksaml_certificate.rb @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby +# Run with: rails runner update_mocksaml_certificate.rb + +# The certificate from the MockSAML response +mocksaml_certificate = <<~CERT +-----BEGIN CERTIFICATE----- +MIIC4jCCAcoCCQC33wnybT5QZDANBgkqhkiG9w0BAQsFADAyMQswCQYDVQQGEwJV +SzEPMA0GA1UECgwGQm94eUhRMRIwEAYDVQQDDAlNb2NrIFNBTUwwIBcNMjIwMjI4 +MjE0NjM4WhgPMzAyMTA3MDEyMTQ2MzhaMDIxCzAJBgNVBAYTAlVLMQ8wDQYDVQQK +DAZCb3h5SFExEjAQBgNVBAMMCU1vY2sgU0FNTDCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBALGfYettMsct1T6tVUwTudNJH5Pnb9GGnkXi9Zw/e6x45DD0 +RuRONbFlJ2T4RjAE/uG+AjXxXQ8o2SZfb9+GgmCHuTJFNgHoZ1nFVXCmb/Hg8Hpd +4vOAGXndixaReOiq3EH5XvpMjMkJ3+8+9VYMzMZOjkgQtAqO36eAFFfNKX7dTj3V +pwLkvz6/KFCq8OAwY+AUi4eZm5J57D31GzjHwfjH9WTeX0MyndmnNB1qV75qQR3b +2/W5sGHRv+9AarggJkF+ptUkXoLtVA51wcfYm6hILptpde5FQC8RWY1YrswBWAEZ +NfyrR4JeSweElNHg4NVOs4TwGjOPwWGqzTfgTlECAwEAATANBgkqhkiG9w0BAQsF +AAOCAQEAAYRlYflSXAWoZpFfwNiCQVE5d9zZ0DPzNdWhAybXcTyMf0z5mDf6FWBW +5Gyoi9u3EMEDnzLcJNkwJAAc39Apa4I2/tml+Jy29dk8bTyX6m93ngmCgdLh5Za4 +khuU3AM3L63g7VexCuO7kwkjh/+LqdcIXsVGO6XDfu2QOs1Xpe9zIzLpwm/RNYeX +UjbSj5ce/jekpAw7qyVVL4xOyh8AtUW1ek3wIw1MJvEgEPt0d16oshWJpoS1OT8L +r/22SvYEo3EmSGdTVGgk3x3s+A0qWAqTcyjr7Q4s/GKYRFfomGwz0TZ4Iw1ZN99M +m0eo2USlSRTVl7QHRTuiuSThHpLKQQ== +-----END CERTIFICATE----- +CERT + +# Find SAML settings for account 1 +saml_settings = AccountSamlSettings.find_by(account_id: 1) + +if saml_settings + puts "Current fingerprint: #{saml_settings.certificate_fingerprint}" + + # Update with MockSAML certificate + saml_settings.certificate = mocksaml_certificate + saml_settings.save! + + puts "Updated fingerprint: #{saml_settings.reload.certificate_fingerprint}" + puts "" + puts "SAML settings updated successfully!" + puts "The certificate has been updated to match MockSAML." +else + puts "No SAML settings found for account 1" +end \ No newline at end of file