This commit is contained in:
Shivam Mishra
2025-08-27 18:40:13 +05:30
parent e17694da73
commit 88115441b9
16 changed files with 1164 additions and 105 deletions
+153
View File
@@ -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
@@ -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?
+25 -38
View File
@@ -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
+1 -1
View File
@@ -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
+55
View File
@@ -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
-37
View File
@@ -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
+2 -4
View File
@@ -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))
@@ -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
@@ -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
@@ -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
+5
View File
@@ -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
+270
View File
@@ -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
<?xml version="1.0" encoding="UTF-8"?>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="#{response_id}"
Version="2.0"
IssueInstant="#{issue_instant}"
Destination="http://localhost:3000/omniauth/saml/callback">
<saml:Issuer>#{saml_settings.sso_url}</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
<saml:Assertion ID="#{assertion_id}" Version="2.0" IssueInstant="#{issue_instant}">
<saml:Issuer>#{saml_settings.sso_url}</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">#{user.email}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="#{(Time.now.utc + 300).iso8601}"
Recipient="http://localhost:3000/omniauth/saml/callback"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:AuthnStatement AuthnInstant="#{issue_instant}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="email">
<saml:AttributeValue>#{user.email}</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="name">
<saml:AttributeValue>#{user.name}</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
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"
+195
View File
@@ -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
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="#{response_id}"
Version="2.0"
IssueInstant="#{issue_instant}"
Destination="http://localhost:3000/saml/#{saml_settings.account_id}/callback">
<saml:Issuer>#{saml_settings.sso_url}</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
<saml:Assertion ID="#{assertion_id}" Version="2.0" IssueInstant="#{issue_instant}">
<saml:Issuer>#{saml_settings.sso_url}</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">#{user.email}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="#{(Time.now.utc + 300).iso8601}"
Recipient="http://localhost:3000/saml/#{saml_settings.account_id}/callback"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:AuthnStatement AuthnInstant="#{issue_instant}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress">
<saml:AttributeValue>#{user.email}</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name">
<saml:AttributeValue>#{user.name}</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
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
+71
View File
@@ -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.
+122
View File
@@ -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
+42
View File
@@ -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