mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
This PR makes SAML login independent of Rails session cookies ## Problem The normal SAML login flow should be straightforward: - User opens Chatwoot. - Chatwoot creates `_chatwoot_session`. - User starts SSO. - Chatwoot redirects the browser to the SAML provider. - The provider authenticates the user. - The provider sends the browser back to Chatwoot's ACS URL. - Chatwoot reads the SAML response, finds or creates the user, and logs them in. The fragile step is the ACS callback. Most SSO flows return to the app through browser redirects where cookies usually pass through as expected. **ADFS commonly returns the SAML response with a cross-site POST**. With Chatwoot's session cookie using `SameSite=Lax`, browsers may not send `_chatwoot_session` on that POST. SAML validation itself does not need the old Rails session cookie. The problem was our callback handoff after validation. DeviseTokenAuth stores the verified OmniAuth payload in Rails session, then redirects to a second callback route. If the browser does not preserve that session, Chatwoot has already received a valid SAML response but can no longer finish login. ## Solution This PR removes the session-backed handoff for SAML only: - The SAML callback completes login in the same request where OmniAuth validates the SAML response. - Chatwoot reads the verified auth payload directly from `request.env['omniauth.auth']`. - Account context and RelayState come from callback params or OmniAuth env data, not Rails session. - Other OmniAuth providers continue using the existing DeviseTokenAuth flow. - Mobile SAML still works when the IdP returns `RelayState=mobile`; the callback redirects to the mobile deep link with the generated SSO token. The previous SAML override used `303 See Other` to avoid replaying the SAML POST into the second callback route. This change keeps that intent, but removes the second callback route for SAML entirely. ## Screen recording ### SP Initiated https://github.com/user-attachments/assets/b0735e93-3864-4cc3-b6fc-419fff4b549e ### IDP Initiated https://github.com/user-attachments/assets/3ded0246-933c-4c85-9b7c-fa15fdc34883 ## Testing Manual validation: - Complete a SAML login. - In the browser network trace, find the IdP POST to `/omniauth/saml/callback?account_id=<account-id>`. - Confirm it redirects directly to `/app/login?...sso_auth_token=...` for web login. - For mobile, confirm `RelayState=mobile` redirects to the configured mobile deep link. - Confirm there is no intermediate `/auth/saml/callback` request. Testing with mocksaml.com: - Configure Chatwoot with a public `FRONTEND_URL`. - Set the mocksaml ACS URL to: ```text https://<chatwoot-host>/omniauth/saml/callback?account_id=<account-id> ``` - Set the mocksaml audience/SP entity ID to the value shown in Chatwoot SAML settings, usually: ```text https://<chatwoot-host>/saml/sp/<account-id> ``` - Use an email returned by mocksaml that exists in the SAML-enabled account. - Start login from Chatwoot's SSO login page. - Confirm the callback redirects directly to the app login URL with an SSO token. --------- Co-authored-by: Sojan Jose <sojan@pepalo.com>
46 lines
2.0 KiB
Ruby
46 lines
2.0 KiB
Ruby
# Enterprise Edition SAML SSO Provider
|
|
# This initializer adds SAML authentication support for Enterprise customers
|
|
|
|
# SAML setup proc for multi-tenant configuration
|
|
SAML_SETUP_PROC = proc do |env|
|
|
request = ActionDispatch::Request.new(env)
|
|
|
|
# Extract account_id from various sources
|
|
account_id = request.params['account_id'] ||
|
|
env['omniauth.params']&.dig('account_id')
|
|
relay_state = request.params['RelayState'] || ''
|
|
|
|
if account_id
|
|
# Keep SAML request context in OmniAuth env so the callback can be processed
|
|
# without depending on the Rails session cookie.
|
|
env['omniauth.params'] ||= {}
|
|
env['omniauth.params']['account_id'] = account_id
|
|
env['omniauth.params']['RelayState'] = relay_state
|
|
|
|
# Find SAML settings for this account
|
|
settings = AccountSamlSettings.find_by(account_id: account_id)
|
|
|
|
if settings
|
|
# Configure the strategy options dynamically
|
|
env['omniauth.strategy'].options[:idp_sso_service_url_runtime_params] = { RelayState: :RelayState }
|
|
env['omniauth.strategy'].options[:assertion_consumer_service_url] = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}"
|
|
env['omniauth.strategy'].options[:sp_entity_id] = settings.sp_entity_id
|
|
env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id
|
|
env['omniauth.strategy'].options[:idp_sso_service_url] = settings.sso_url
|
|
env['omniauth.strategy'].options[:idp_cert] = settings.certificate
|
|
env['omniauth.strategy'].options[:name_identifier_format] = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
|
|
else
|
|
# Set a dummy certificate to avoid the error
|
|
env['omniauth.strategy'].options[:idp_cert] = 'DUMMY'
|
|
end
|
|
else
|
|
# Set a dummy certificate to avoid the error
|
|
env['omniauth.strategy'].options[:idp_cert] = 'DUMMY'
|
|
end
|
|
end
|
|
|
|
Rails.application.config.middleware.use OmniAuth::Builder do
|
|
# SAML provider with setup phase for multi-tenant configuration
|
|
provider :saml, setup: SAML_SETUP_PROC
|
|
end
|