WIP
This commit is contained in:
@@ -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?
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
Executable
+5
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user