Compare commits

...
Author SHA1 Message Date
Shivam Mishra b9d8144709 feat: specs for SLO 2025-09-10 15:24:23 +05:30
Shivam Mishra 63b7b91aad feat: add slo route 2025-09-10 15:21:19 +05:30
Shivam Mishra 690b956f1c fix: SessionManagable not included 2025-09-10 15:20:58 +05:30
Shivam MishraandGitHub 88b1dfb89d Merge branch 'feat/saml-ui' into feat/saml-logout 2025-09-10 13:55:53 +05:30
Shivam MishraandGitHub cc875bf277 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 13:55:48 +05:30
Shivam MishraandGitHub 9c04202871 Merge branch 'develop' into feat/saml-controllers 2025-09-10 13:55:31 +05:30
Shivam Mishra 40830f2707 chore: revert debug change 2025-09-10 13:45:53 +05:30
Shivam Mishra cc83fdcc8e feat: add SLO proc 2025-09-10 13:40:27 +05:30
Shivam MishraandGitHub af1f7cc1d2 Merge branch 'feat/saml-ui' into feat/saml-logout 2025-09-10 13:38:49 +05:30
Shivam Mishra 9ded118059 style: remove let! 2025-09-10 12:31:38 +05:30
Shivam Mishra 1a78b68625 style: Remove redundant sort 2025-09-10 12:30:53 +05:30
Shivam Mishra 43d2b37d64 feat: add specs for passwords controller 2025-09-10 12:29:54 +05:30
Shivam Mishra 5aaf331851 feat: add omniauth tests 2025-09-10 12:26:12 +05:30
Shivam Mishra da61c9855b feat: add test for sessions controller 2025-09-10 11:57:39 +05:30
Shivam MishraandGitHub 334aa09005 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 11:02:43 +05:30
Shivam Mishra fea2a695f1 refactor: proc name 2025-09-10 11:02:31 +05:30
Shivam MishraandGitHub 80018c58b9 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 11:00:25 +05:30
Shivam Mishra c31200b9e5 feat: better intializer loading 2025-09-10 10:59:59 +05:30
Shivam MishraandGitHub 86fa54dd13 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 10:56:22 +05:30
Shivam Mishra 7a9840717e feat: conditionally load saml 2025-09-10 10:56:06 +05:30
Shivam Mishra f1f2b3f302 Revert "feat: move saml init to enterprise"
This reverts commit e0a5a63f2e.
2025-09-10 10:55:00 +05:30
Shivam MishraandGitHub efbf837861 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-10 10:47:16 +05:30
Shivam MishraandGitHub 3a3f5c9469 Merge branch 'develop' into feat/saml-controllers 2025-09-10 10:46:50 +05:30
Shivam Mishra e0a5a63f2e feat: move saml init to enterprise 2025-09-10 10:46:00 +05:30
Shivam Mishra 4f6facb6ac refactor: move saml strategy to a separate proc 2025-09-10 10:26:29 +05:30
Shivam Mishra 2634050f1a refactor: remove redundant validation 2025-09-10 10:16:23 +05:30
Shivam Mishra 0f2b278dcd fix: validate SSO token in SAML user authentication check
Previously, SAML users could bypass password authentication restrictions
by including any sso_auth_token parameter, even invalid ones. Now the
method properly validates SSO tokens against Redis storage before
allowing the bypass, closing the security vulnerability while maintaining
legitimate SSO functionality.
2025-09-10 10:14:01 +05:30
Shivam Mishra b15f408e6a feat: add idp initated logout handler 2025-09-09 15:13:07 +05:30
Shivam Mishra 92a04e4ab4 fix: sp id not saved 2025-09-09 13:42:33 +05:30
Shivam MishraandGitHub cedacac9eb Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-09 13:17:19 +05:30
Shivam MishraandGitHub 85150dd418 Merge branch 'develop' into feat/saml-controllers 2025-09-09 13:16:43 +05:30
Shivam Mishra 56e7166ec7 feat: remove url requirement 2025-09-08 16:39:17 +05:30
Shivam Mishra 1030bf69e1 feat: add certificate validation 2025-09-02 16:52:16 +05:30
Shivam Mishra 4b12f13926 feat: add validations 2025-09-02 16:47:03 +05:30
Shivam Mishra ab64f53e67 feat: rtl fixes 2025-09-02 16:28:07 +05:30
Shivam Mishra 0d54ebf85b feat: handle idp and sp entity ids separately 2025-09-02 15:46:13 +05:30
Shivam Mishra 8859017642 chore: use existing figerprint method 2025-09-02 15:35:42 +05:30
Shivam MishraandGitHub 315d471468 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-02 15:33:11 +05:30
Shivam Mishra 2b08ac4a02 feat: use correct id 2025-09-02 15:32:23 +05:30
Shivam MishraandGitHub d8f306ef5c Merge branch 'feat/sso-models' into feat/saml-controllers 2025-09-02 15:29:41 +05:30
Shivam Mishra 66abb424e4 test: certificate fingerprint 2025-09-02 15:27:45 +05:30
Shivam Mishra ba94ba3bf4 feat: add certificate fingerprint 2025-09-02 15:27:07 +05:30
Shivam Mishra 8b93241ed6 feat: include idp_entity_id and ceriticate details 2025-09-02 15:20:08 +05:30
Shivam Mishra 7cd0863487 feat: update model 2025-09-02 15:13:43 +05:30
Shivam Mishra 468413158f feat: avoid redundant fetch 2025-09-02 14:38:30 +05:30
Shivam MishraandGitHub a5cb283f42 Merge branch 'feat/saml-controllers' into feat/saml-ui 2025-09-01 14:13:04 +05:30
Shivam MishraandGitHub cd75ac262a Merge branch 'feat/sso-models' into feat/saml-controllers 2025-09-01 14:13:02 +05:30
Shivam MishraandGitHub 9081da755d Merge branch 'develop' into feat/sso-models 2025-09-01 14:12:51 +05:30
Shivam Mishra 2156598dde fix: no guard against missing account 2025-09-01 14:00:21 +05:30
Shivam Mishra ac51a2b3d9 fix: info order 2025-09-01 13:34:45 +05:30
Shivam Mishra 3d9cb97bbb fix: save fingerprint from the response 2025-09-01 13:33:38 +05:30
Shivam Mishra 5ba79036ea chore: docs 2025-09-01 13:30:42 +05:30
Shivam Mishra edd78ff4bc fix: remove fingerprint on disable 2025-09-01 13:29:54 +05:30
Shivam Mishra f6b3cb6295 feat: add additional SAML information 2025-09-01 13:28:55 +05:30
Shivam Mishra 5227b4169f feat: include fingerprint and certificate 2025-09-01 13:27:39 +05:30
Shivam Mishra 61eb729a11 feat: add help route 2025-09-01 12:30:37 +05:30
Shivam Mishra 1f41e53ef5 feat: add paywall 2025-09-01 12:27:18 +05:30
Shivam Mishra 73992bef97 feat: include in feature flags 2025-09-01 12:19:58 +05:30
Shivam Mishra 2dbd43217c feat: add SAML settings UI 2025-09-01 12:10:58 +05:30
Shivam Mishra b81683a4de feat: add saml settings API 2025-09-01 12:10:37 +05:30
Shivam MishraandGitHub 71321c2548 Merge branch 'develop' into feat/sso-models 2025-08-28 19:43:05 +05:30
Shivam Mishra b22425d5fb Merge branch 'feat/sso-models' of github.com:chatwoot/chatwoot into feat/saml-controllers 2025-08-28 17:54:15 +05:30
Shivam MishraandGitHub 9b8d6bfc74 Merge branch 'develop' into feat/sso-models 2025-08-28 17:52:15 +05:30
Shivam Mishra d48a7aac64 feat: update gems 2025-08-28 17:25:24 +05:30
Shivam Mishra eca7704bda Merge branch 'feat/sso-models' of github.com:chatwoot/chatwoot into feat/saml-controllers 2025-08-28 16:58:48 +05:30
Shivam Mishra cb887f0dd9 test: fix rubocop issue 2025-08-28 16:54:21 +05:30
Shivam Mishra 3c0f053983 chore: docs 2025-08-28 16:50:51 +05:30
Shivam Mishra 35b09fd8b8 refactor: account_id is not optional 2025-08-28 16:50:40 +05:30
Shivam Mishra cbe5936b7b refactor: builder 2025-08-28 16:47:07 +05:30
Shivam Mishra 184b735846 chore: remove enabled check 2025-08-28 16:39:12 +05:30
Shivam Mishra 594ca1f526 chore: remove attribute map spec 2025-08-28 16:38:13 +05:30
Shivam Mishra 7c78fd3087 test: update specs to handle removed fields 2025-08-28 16:36:59 +05:30
Shivam Mishra f818f462ea chore: remove attribute mapping 2025-08-28 16:33:29 +05:30
Shivam Mishra f17de99d1b Merge branch 'feat/sso-models' of github.com:chatwoot/chatwoot into feat/saml-controllers 2025-08-28 16:31:30 +05:30
Shivam Mishra 94bbfff8fd chore: remove unnecessary change 2025-08-28 16:30:22 +05:30
Shivam Mishra abf07c44c2 chore: revert unrelated change 2025-08-28 16:28:39 +05:30
Shivam Mishra 0c9e4f430a feat: remove enforced_sso and attribute_mappings field 2025-08-28 16:27:44 +05:30
Shivam Mishra 846ea71e56 feat: remove certificate fingerprint field 2025-08-28 16:13:17 +05:30
Shivam Mishra 85fb20013e feat: remove enabled field 2025-08-28 16:09:07 +05:30
Shivam Mishra 04cc25d55e chore: remove debugging files 2025-08-28 16:02:46 +05:30
Shivam Mishra b999fe6439 chore: remove spec 2025-08-28 16:02:01 +05:30
Shivam Mishra 589daac302 chore: revert spec 2025-08-28 16:01:02 +05:30
Shivam Mishra 87171c5b53 feat: remove old controller 2025-08-28 16:00:29 +05:30
Shivam Mishra 40f476315b chore: remove redundant route 2025-08-28 15:58:39 +05:30
Shivam Mishra 2b533aa135 feat: remove saml methods from user 2025-08-28 15:57:35 +05:30
Shivam Mishra 3fb2506aab feat: move SAML builder to enterprise 2025-08-28 15:56:51 +05:30
Shivam Mishra 9598021f87 chore: remove redundant route 2025-08-28 15:50:35 +05:30
Shivam Mishra d7265f21ad feat: remove redundant controller 2025-08-28 15:48:56 +05:30
Shivam Mishra 9f19c41b35 chore: remove whitespace change 2025-08-28 15:47:59 +05:30
Shivam Mishra 995f56f0ad feat: move SAML code to enterprise 2025-08-28 15:44:17 +05:30
Shivam Mishra 34bc1dc4bb chore: revert redundant change 2025-08-28 15:28:35 +05:30
Shivam Mishra 44e997e3bf refactor: simplify auth hash 2025-08-28 15:28:02 +05:30
Shivam Mishra 73e6bc8ecd chore: remove allow_other_host config 2025-08-28 15:26:49 +05:30
Shivam Mishra ea8446ecfe chore: remove redundant files 2025-08-28 15:22:35 +05:30
Shivam Mishra 314ab83040 chore: remove spec 2025-08-28 15:20:54 +05:30
Shivam Mishra 90a38d1065 refactor: merge duplicate tests 2025-08-28 15:15:47 +05:30
Shivam Mishra d5b7068394 refactor: cleaner saml check 2025-08-28 15:06:15 +05:30
Shivam Mishra 379ef876eb feat: add saml enabled check 2025-08-28 15:03:54 +05:30
Shivam Mishra 5fb0c80891 feat: move saml methods to enterprise 2025-08-28 15:03:23 +05:30
Shivam Mishra 5419b949a4 style: rubocop errors 2025-08-28 14:47:55 +05:30
Shivam Mishra f6fe1b130b feat: don't allow password login for SAML user 2025-08-28 14:34:18 +05:30
Shivam Mishra ecc1857f63 feat: don't allow SAML user to reset password 2025-08-28 14:27:05 +05:30
Shivam Mishra 5f0feae688 test: user saml methods 2025-08-28 14:26:47 +05:30
Shivam Mishra 1dd8f73c90 test: saml builder 2025-08-28 14:26:35 +05:30
Shivam Mishra f2eb8c37af feat: allow converting existing users to SAML 2025-08-28 13:54:06 +05:30
Shivam Mishra 9b09ae0e14 feat: add saml error messages 2025-08-28 13:53:47 +05:30
Shivam Mishra 091586177c feat: add saml helpers 2025-08-28 13:52:54 +05:30
Shivam Mishra cac97c543b refactor: clean up callbacks 2025-08-28 13:35:20 +05:30
Shivam Mishra 03dc1b6352 refactor: split success callback 2025-08-28 13:31:03 +05:30
Shivam Mishra 42ca3d97e6 feat: add specs 2025-08-28 13:26:42 +05:30
Shivam Mishra 2b1ded8f4d feat: handle user created but not associated to the account 2025-08-28 13:26:27 +05:30
Shivam Mishra 2eadb61b8e chore: clean debug logs 2025-08-28 12:14:19 +05:30
Shivam Mishra 88115441b9 WIP 2025-08-27 18:40:13 +05:30
Shivam Mishra e17694da73 refactor: use from_email method 2025-08-27 14:28:52 +05:30
Shivam Mishra e8b0b24b36 feat: implement login 2025-08-27 14:27:19 +05:30
Shivam Mishra 4fcee3bbba feat: mock login flow 2025-08-27 14:15:21 +05:30
Shivam Mishra f9d4816db1 feat: kickstart basic controller 2025-08-27 12:27:27 +05:30
Shivam MishraandGitHub 6274f0f803 Merge branch 'develop' into feat/sso-models 2025-08-25 16:45:59 +05:30
Shivam Mishra 572df07365 style: autofix rubocop 2025-08-25 14:40:20 +05:30
Shivam Mishra 5079e0b3b9 feat: move spec to enterprise 2025-08-25 14:39:27 +05:30
Shivam Mishra 92df56f738 fix: saml settings certificate check 2025-08-25 14:29:43 +05:30
Shivam Mishra 7a33065efe fix: certificate 2025-08-25 14:29:30 +05:30
Shivam Mishra 99ab1eadb5 style: fix rubocop 2025-08-25 14:28:58 +05:30
Shivam Mishra 7549a2a1d4 feat: add fingerprint generation 2025-08-25 14:21:57 +05:30
Shivam Mishra 0562647ab1 feat: add specs 2025-08-25 13:57:06 +05:30
Shivam Mishra f5c45c35f2 fix: saml association 2025-08-25 13:56:55 +05:30
Shivam Mishra d06e2cfd98 feat: add saml settings controller 2025-08-25 13:43:43 +05:30
Shivam Mishra e9d852969f chore: update model annotation 2025-08-25 13:43:28 +05:30
Shivam Mishra 71285b368c feat: add saml policy 2025-08-25 13:43:16 +05:30
Shivam Mishra f7323b6dec feat: add jbuilders 2025-08-25 13:43:05 +05:30
Shivam Mishra 94fab7ad1c fix: schema 2025-08-25 13:28:32 +05:30
Shivam Mishra b3fef8f110 feat: feature flag for saml 2025-08-25 13:21:19 +05:30
Shivam Mishra 81cf106578 feat: update models 2025-08-25 13:21:11 +05:30
Shivam Mishra a197f64d2c feat: run migration 2025-08-25 13:09:20 +05:30
Shivam Mishra 67b8674337 feat: add models 2025-08-25 13:08:30 +05:30
Shivam Mishra 46f7390cbe feat: add saml models 2025-08-25 13:08:12 +05:30
34 changed files with 1748 additions and 30 deletions
+2
View File
@@ -81,6 +81,7 @@ gem 'devise_token_auth', '>= 1.2.3'
# authorization # authorization
gem 'jwt' gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
gem 'administrate', '>= 0.20.1' gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3' gem 'administrate-field-active_storage', '>= 1.0.3'
@@ -171,6 +172,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth # need for google auth
gem 'omniauth', '>= 2.1.2' gem 'omniauth', '>= 2.1.2'
gem 'omniauth-saml'
gem 'omniauth-google-oauth2', '>= 1.1.3' gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
+9 -1
View File
@@ -589,8 +589,9 @@ GEM
oj (3.16.10) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.2) omniauth (2.1.3)
hashie (>= 3.4.6) hashie (>= 3.4.6)
logger
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-google-oauth2 (1.1.3) omniauth-google-oauth2 (1.1.3)
@@ -604,6 +605,9 @@ GEM
omniauth-rails_csrf_protection (1.0.2) omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) omniauth (~> 2.0)
omniauth-saml (2.2.4)
omniauth (~> 2.1)
ruby-saml (~> 1.18)
opensearch-ruby (3.4.0) opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3) faraday (>= 1.0, < 3)
multi_json (>= 1.0) multi_json (>= 1.0)
@@ -773,6 +777,9 @@ GEM
faraday (>= 1) faraday (>= 1)
faraday-multipart (>= 1) faraday-multipart (>= 1)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-saml (1.18.1)
nokogiri (>= 1.13.10)
rexml
ruby-vips (2.1.4) ruby-vips (2.1.4)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
@@ -1047,6 +1054,7 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3) omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2 omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
omniauth-saml
opensearch-ruby opensearch-ruby
pg pg
pg_search pg_search
@@ -47,10 +47,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end end
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
# find the user with their email instead of UID and token email = auth_hash.dig('info', 'email')
@resource = resource_class.where( @resource = resource_class.from_email(email)
email: auth_hash['info']['email']
).first
end end
def validate_signup_email_is_business_domain? def validate_signup_email_is_business_domain?
@@ -75,3 +73,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
'user' 'user'
end end
end end
DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController')
@@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
}, status: status }, status: status
end end
end end
DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')
@@ -0,0 +1,26 @@
/* global axios */
import ApiClient from './ApiClient';
class SamlSettingsAPI extends ApiClient {
constructor() {
super('saml_settings', { accountScoped: true });
}
get() {
return axios.get(this.url);
}
create(data) {
return axios.post(this.url, { saml_settings: data });
}
update(data) {
return axios.put(this.url, { saml_settings: data });
}
delete() {
return axios.delete(this.url);
}
}
export default new SamlSettingsAPI();
@@ -494,6 +494,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-clock-alert', icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'), to: accountScopedRoute('sla_list'),
}, },
{
name: 'Settings Security',
label: t('SIDEBAR.SECURITY'),
icon: 'i-lucide-shield',
to: accountScopedRoute('security_settings_index'),
},
{ {
name: 'Settings Billing', name: 'Settings Billing',
label: t('SIDEBAR.BILLING'), label: t('SIDEBAR.BILLING'),
+2
View File
@@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup', WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
CAPTAIN_V2: 'captain_integration_v2', CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
}; };
export const PREMIUM_FEATURES = [ export const PREMIUM_FEATURES = [
@@ -49,4 +50,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.AUDIT_LOGS, FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER, FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2, FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SAML,
]; ];
@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
team_management: 'https://chwt.app/hc/teams', team_management: 'https://chwt.app/hc/teams',
webhook: 'https://chwt.app/hc/webhooks', webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing', billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
}; };
export function getHelpUrlForFeature(featureName) { export function getHelpUrlForFeature(featureName) {
@@ -358,7 +358,8 @@
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.", "INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app." "INFO_SHORT": "Automatically mark offline when you aren't using the app."
}, },
"DOCS": "Read docs" "DOCS": "Read docs",
"SECURITY": "Security"
}, },
"BILLING_SETTINGS": { "BILLING_SETTINGS": {
"TITLE": "Billing", "TITLE": "Billing",
@@ -390,6 +391,77 @@
}, },
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again." "NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
}, },
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "Copied to clipboard",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CREATE_ACCOUNT": { "CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.", "NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account", "NEW_ACCOUNT": "New Account",
@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import SamlSettings from './components/SamlSettings.vue';
import SamlPaywall from './components/SamlPaywall.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { shouldShow, shouldShowPaywall } = usePolicy();
const shouldShowSaml = computed(() =>
shouldShow(
FEATURE_FLAGS.SAML,
['administrator'],
[INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE]
)
);
const showPaywall = computed(() => shouldShowPaywall('saml'));
</script>
<template>
<SettingsLayout
class="max-w-2xl mx-auto"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="$t('SECURITY_SETTINGS.TITLE')"
:description="$t('SECURITY_SETTINGS.DESCRIPTION')"
:link-text="$t('SECURITY_SETTINGS.LINK_TEXT')"
feature-name="saml"
/>
</template>
<template #body>
<SamlPaywall v-if="showPaywall" />
<SamlSettings v-else-if="shouldShowSaml" />
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,48 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const isExpanded = ref(false);
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<template>
<section
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 mb-5 overflow-hidden"
>
<button
type="button"
class="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-n-solid-2 transition-colors"
@click="toggleExpanded"
>
<h4 class="font-medium text-n-slate-12">
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.TITLE') }}
</h4>
<i
class="i-lucide-chevron-down transition-transform duration-200"
:class="{ 'rotate-180': isExpanded }"
/>
</button>
<div
class="transition-[height] duration-200 ease-in-out overflow-hidden"
:class="isExpanded ? 'h-auto' : 'h-0'"
>
<div class="px-4 pb-3">
<p class="text-n-slate-11 mb-2">
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.DESCRIPTION') }}
</p>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<ul class="list-none text-n-slate-12 space-y-1">
<li><code class="px-1 rounded bg-n-slate-3">email</code></li>
<li><code class="px-1 rounded bg-n-slate-3">first_name</code></li>
<li><code class="px-1 rounded bg-n-slate-3">last_name</code></li>
</ul>
</div>
</div>
</section>
</template>
@@ -0,0 +1,102 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import NextButton from 'next/button/Button.vue';
const props = defineProps({
fingerprint: {
type: String,
default: '',
},
spEntityId: {
type: String,
default: '',
},
});
const { t } = useI18n();
const { accountId } = useAccount();
const acsUrl = computed(() => {
const currentHost = window.location.origin;
return `${currentHost}/omniauth/saml/callback?account_id=${accountId.value}`;
});
const allInfoItems = computed(() => [
{
key: 'ACS_URL',
label: t('SECURITY_SETTINGS.SAML.ACS_URL.LABEL'),
value: acsUrl.value,
tooltip: t('SECURITY_SETTINGS.SAML.ACS_URL.TOOLTIP'),
show: true,
},
{
key: 'SP_ENTITY_ID',
label: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.LABEL'),
value: props.spEntityId,
tooltip: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.TOOLTIP'),
show: !!props.spEntityId,
},
{
key: 'FINGERPRINT',
label: t('SECURITY_SETTINGS.SAML.FINGERPRINT.LABEL'),
value: props.fingerprint,
tooltip: t('SECURITY_SETTINGS.SAML.FINGERPRINT.TOOLTIP'),
show: !!props.fingerprint,
},
]);
const visibleInfoItems = computed(() =>
allInfoItems.value.filter(item => item.show)
);
const handleCopy = async text => {
await copyTextToClipboard(text);
useAlert(t('SECURITY_SETTINGS.SAML.COPY_SUCCESS'));
};
</script>
<template>
<div class="space-y-4">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ t('SECURITY_SETTINGS.SAML.INFO_SECTION.TITLE') }}
</h3>
<i
v-tooltip.top="t('SECURITY_SETTINGS.SAML.INFO_SECTION.TOOLTIP')"
class="i-lucide-info text-n-slate-10 w-4 h-4 cursor-help"
/>
</div>
<section
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
>
<div
v-for="item in visibleInfoItems"
:key="item.key"
class="ps-4 pe-1 py-1 flex justify-between items-center"
>
<div class="flex items-center gap-2">
<span class="text-n-slate-11 w-32 flex items-center gap-1">
{{ item.label }}
<i
v-tooltip.top="item.tooltip"
class="i-lucide-info text-n-slate-9 w-3 h-3 cursor-help"
/>
</span>
<span class="flex-1">{{ item.value }}</span>
</div>
<NextButton
type="button"
ghost
sm
slate
icon="i-lucide-copy"
@click="handleCopy(item.value)"
/>
</div>
</section>
</div>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => {
return currentUser.value.type === 'SuperAdmin';
});
const { accountId, isOnChatwootCloud } = useAccount();
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
feature-prefix="SECURITY_SETTINGS.SAML"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@upgrade="openBilling"
/>
</div>
</template>
@@ -0,0 +1,251 @@
<script setup>
import { ref, computed, onMounted, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import samlSettingsAPI from 'dashboard/api/samlSettings';
import SectionLayout from '../../account/components/SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextInput from 'next/input/Input.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'next/button/Button.vue';
import SamlInfoSection from './SamlInfoSection.vue';
import SamlAttributeMap from './SamlAttributeMap.vue';
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const id = ref(null);
const fingerprint = ref('');
const spEntityId = ref('');
const isEnabled = ref(false);
const isSubmitting = ref(false);
const isLoading = ref(true);
const formState = reactive({
ssoUrl: '',
certificate: '',
idpEntityId: '',
});
const validations = {
ssoUrl: { required },
certificate: { required },
idpEntityId: { required },
};
const v$ = useVuelidate(validations, formState);
const hasFeature = computed(() => isCloudFeatureEnabled('saml'));
const ssoUrlError = computed(() =>
v$.value.ssoUrl.$error
? t('SECURITY_SETTINGS.SAML.VALIDATION.SSO_URL_ERROR')
: ''
);
const certificateError = computed(() =>
v$.value.certificate.$error
? t('SECURITY_SETTINGS.SAML.VALIDATION.CERTIFICATE_ERROR')
: ''
);
const idpEntityIdError = computed(() =>
v$.value.idpEntityId.$error
? t('SECURITY_SETTINGS.SAML.VALIDATION.IDP_ENTITY_ID_ERROR')
: ''
);
const loadSamlSettings = async () => {
if (!hasFeature.value) return;
try {
isLoading.value = true;
const response = await samlSettingsAPI.get();
const settings = response.data;
if (settings.sso_url) {
id.value = settings.id;
formState.ssoUrl = settings.sso_url;
formState.certificate = settings.certificate || '';
spEntityId.value = settings.sp_entity_id || '';
formState.idpEntityId = settings.idp_entity_id || '';
fingerprint.value = settings.fingerprint || '';
isEnabled.value = formState.ssoUrl !== '';
}
} catch (error) {
// If no settings exist (404), that's expected - just keep defaults
if (error.response?.status !== 404) {
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR_LOADING'));
}
} finally {
isLoading.value = false;
}
};
const saveSamlSettings = async settings => {
try {
isSubmitting.value = true;
if (isEnabled.value && formState.ssoUrl) {
// Create or update settings based on existing id
let response;
if (id.value) {
response = await samlSettingsAPI.update(settings);
} else {
response = await samlSettingsAPI.create(settings);
}
// Update local state with response data including fingerprint and id
if (response?.data) {
id.value = response.data.id;
fingerprint.value = response.data.fingerprint || '';
spEntityId.value = response.data.sp_entity_id || '';
}
useAlert(t('SECURITY_SETTINGS.SAML.API.SUCCESS'));
} else {
// Disable/delete settings
await samlSettingsAPI.delete();
useAlert(t('SECURITY_SETTINGS.SAML.API.DISABLED'));
}
} catch (error) {
// Handle backend validation errors
if (error.response?.data?.errors) {
const errorMessages = error.response.data.errors;
const firstError = Array.isArray(errorMessages)
? errorMessages[0]
: errorMessages;
useAlert(firstError);
} else {
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR'));
}
throw error;
} finally {
isSubmitting.value = false;
}
};
const handleSubmit = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const settings = {
sso_url: formState.ssoUrl,
certificate: formState.certificate,
idp_entity_id: formState.idpEntityId,
role_mappings: {},
};
await saveSamlSettings(settings);
};
const handleDisable = async () => {
id.value = null;
formState.ssoUrl = '';
formState.certificate = '';
spEntityId.value = '';
formState.idpEntityId = '';
fingerprint.value = '';
// the empty save will delete the SAML settings item
await saveSamlSettings({});
};
const toggleSaml = async () => {
if (!isEnabled.value) {
await handleDisable();
}
};
onMounted(() => {
loadSamlSettings();
});
</script>
<template>
<SectionLayout
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
:hide-content="!hasFeature || !isEnabled || isLoading"
>
<template #headerActions>
<div class="flex justify-end">
<Switch
v-model="isEnabled"
:disabled="isLoading"
@change="toggleSaml"
/>
</div>
</template>
<SamlInfoSection
class="mb-5"
:fingerprint="fingerprint"
:sp-entity-id="spEntityId"
/>
<SamlAttributeMap class="mb-5" />
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
name="ssoUrl"
:label="t('SECURITY_SETTINGS.SAML.SSO_URL.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.SSO_URL.HELP')"
:has-error="v$.ssoUrl.$error"
:error-message="ssoUrlError"
required
>
<TextInput
v-model="formState.ssoUrl"
class="w-full"
type="url"
:placeholder="t('SECURITY_SETTINGS.SAML.SSO_URL.PLACEHOLDER')"
/>
</WithLabel>
<WithLabel
name="idpEntityId"
:label="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.HELP')"
:has-error="v$.idpEntityId.$error"
:error-message="idpEntityIdError"
required
>
<TextInput
v-model="formState.idpEntityId"
class="w-full"
:placeholder="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.PLACEHOLDER')"
/>
</WithLabel>
<WithLabel
name="certificate"
:label="t('SECURITY_SETTINGS.SAML.CERTIFICATE.LABEL')"
:help-message="t('SECURITY_SETTINGS.SAML.CERTIFICATE.HELP')"
:has-error="v$.certificate.$error"
:error-message="certificateError"
required
>
<TextArea
v-model="formState.certificate"
class="w-full"
rows="8"
:placeholder="t('SECURITY_SETTINGS.SAML.CERTIFICATE.PLACEHOLDER')"
/>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:is-loading="isSubmitting"
:label="t('SECURITY_SETTINGS.SAML.UPDATE_BUTTON')"
/>
</div>
</form>
</SectionLayout>
</template>
@@ -0,0 +1,41 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/security'),
meta: {
permissions: ['administrator'],
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
component: SettingsWrapper,
props: {
headerTitle: 'SECURITY_SETTINGS.TITLE',
icon: 'i-lucide-shield',
showNewButton: false,
},
children: [
{
path: '',
name: 'security_settings_index',
component: Index,
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.SAML,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
],
},
],
};
@@ -23,6 +23,7 @@ import sla from './sla/sla.routes';
import teams from './teams/teams.routes'; import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes'; import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes'; import profile from './profile/profile.routes';
import security from './security/security.routes';
export default { export default {
routes: [ routes: [
@@ -61,5 +62,6 @@ export default {
...teams.routes, ...teams.routes,
...customRoles.routes, ...customRoles.routes,
...profile.routes, ...profile.routes,
...security.routes,
], ],
}; };
+78
View File
@@ -0,0 +1,78 @@
# Provides session management functionality for Devise Token Auth tokens.
# Handles logout operations, session limits, and token cleanup.
module SessionManageable
extend ActiveSupport::Concern
# Clears all active sessions for the user.
# @return [void]
def logout_all_sessions!
self.tokens = {}
save!
end
# Logs out a specific session by client ID.
# @param client_id [String] the client identifier to logout
# @return [Boolean] true if session was found and removed
def logout_session!(client_id)
return false unless client_id.present? && tokens.present?
removed = tokens.delete(client_id)
save! if removed
removed.present?
end
# Removes tokens that expired before the given timestamp.
# @param timestamp [Time, Integer] cutoff time for token cleanup
# @return [void]
def reset_tokens_before!(timestamp)
return unless tokens.present?
self.tokens = tokens.select do |_client_id, token_data|
(token_data['expiry'] || 0) >= timestamp.to_i
end
save!
end
# Returns count of non-expired active sessions.
# @return [Integer] number of active sessions
def active_session_count
return 0 unless tokens.present?
current_time = Time.current.to_i
tokens.count { |_client_id, token_data| (token_data['expiry'] || 0) > current_time }
end
# Checks if user has exceeded configured session limit.
# @return [Boolean] true if session limit is exceeded
def session_limit_exceeded?
active_session_count >= session_limit
end
# Returns session information for all active tokens.
# @return [Array<Hash>] array of session info, sorted by expiry (newest first)
def session_info
return [] unless tokens.present?
tokens.map do |client_id, token_data|
{
client_id: client_id,
expiry: Time.zone.at(token_data['expiry'] || 0)
}
end.sort_by { |session| session[:expiry] }.reverse
end
private
# Returns configured session limit from GlobalConfig.
# Defaults to infinity if not configured.
# @return [Integer, Float] session limit or Float::INFINITY
def session_limit
@session_limit ||= GlobalConfig.get(
'USER_SESSION_LIMIT',
'USER_SESSION_LIMIT_PER_USER',
account: Current.account
)&.to_i || Float::INFINITY
end
end
+2 -1
View File
@@ -47,6 +47,7 @@ class User < ApplicationRecord
include Pubsubable include Pubsubable
include Rails.application.routes.url_helpers include Rails.application.routes.url_helpers
include Reportable include Reportable
include SessionManageable
include SsoAuthenticatable include SsoAuthenticatable
include UserAttributeHelpers include UserAttributeHelpers
@@ -58,7 +59,7 @@ class User < ApplicationRecord
:validatable, :validatable,
:confirmable, :confirmable,
:password_has_required_content, :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 # TODO: remove in a future version once online status is moved to account users
# remove the column availability from users # remove the column availability from users
+4
View File
@@ -47,6 +47,10 @@ module Chatwoot
# Add enterprise views to the view paths # Add enterprise views to the view paths
config.paths['app/views'].unshift('enterprise/app/views') config.paths['app/views'].unshift('enterprise/app/views')
# Load enterprise initializers alongside standard initializers
enterprise_initializers = Rails.root.join('enterprise/config/initializers')
Dir[enterprise_initializers.join('**/*.rb')].each { |f| require f } if enterprise_initializers.exist?
# Settings in config/environments/* take precedence over those specified here. # Settings in config/environments/* take precedence over those specified here.
# Application configuration can go into files in config/initializers # Application configuration can go into files in config/initializers
# -- all .rb files in that directory are automatically loaded after loading # -- all .rb files in that directory are automatically loaded after loading
+4
View File
@@ -1,3 +1,7 @@
# OmniAuth configuration
# Sets the full host URL for callbacks and proper redirect handling
OmniAuth.config.full_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
Rails.application.config.middleware.use OmniAuth::Builder do 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 :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), {
provider_ignores_state: true provider_ignores_state: true
+5
View File
@@ -39,6 +39,9 @@ en:
messages: messages:
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions. reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
reset_password_failure: Uh ho! We could not find any user with the specified email. reset_password_failure: Uh ho! We could not find any user with the specified email.
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
saml_not_available: SAML authentication is not available in this installation.
inbox_deletetion_response: Your inbox deletion request will be processed in some time. inbox_deletetion_response: Your inbox deletion request will be processed in some time.
errors: errors:
@@ -100,6 +103,8 @@ en:
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
custom_attribute_definition: custom_attribute_definition:
key_conflict: The provided key is not allowed as it might conflict with default attributes. key_conflict: The provided key is not allowed as it might conflict with default attributes.
account_saml_settings:
invalid_certificate: must be a valid X.509 certificate in PEM format
reports: reports:
period: Reporting period %{since} to %{until} period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone utc_warning: The report generated is in UTC timezone
+3
View File
@@ -8,6 +8,9 @@ Rails.application.routes.draw do
omniauth_callbacks: 'devise_overrides/omniauth_callbacks' omniauth_callbacks: 'devise_overrides/omniauth_callbacks'
}, via: [:get, :post] }, via: [:get, :post]
# SAML SLO endpoint - handled by OmniAuth SAML strategy
post '/auth/saml/slo', to: 'devise_overrides/omniauth_callbacks#passthru'
## renders the frontend paths only if its not an api only server ## 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)) if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false))
root to: 'api#index' root to: 'api#index'
@@ -0,0 +1,101 @@
class SamlUserBuilder
def initialize(auth_hash, account_id)
@auth_hash = auth_hash
@account_id = account_id
@saml_settings = AccountSamlSettings.find_by(account_id: account_id)
end
def perform
@user = find_or_create_user
add_user_to_account if @user.persisted?
@user
end
private
def find_or_create_user
user = User.from_email(auth_attribute('email'))
if user
convert_existing_user_to_saml(user)
return user
end
create_user
end
def convert_existing_user_to_saml(user)
return if user.provider == 'saml'
user.update!(provider: 'saml')
end
def create_user
full_name = [auth_attribute('first_name'), auth_attribute('last_name')].compact.join(' ')
fallback_name = auth_attribute('name') || auth_attribute('email').split('@').first
User.create(
email: auth_attribute('email'),
name: (full_name.presence || fallback_name),
display_name: auth_attribute('first_name'),
provider: 'saml',
uid: uid,
password: SecureRandom.hex(32),
confirmed_at: Time.current
)
end
def add_user_to_account
account = Account.find_by(id: @account_id)
return unless account
# Create account_user if not exists
account_user = AccountUser.find_or_create_by(
user: @user,
account: account
)
# Set default role as agent if not set
account_user.update(role: 'agent') if account_user.role.blank?
# Handle role mappings if configured
apply_role_mappings(account_user, account)
end
def apply_role_mappings(account_user, account)
matching_mapping = find_matching_role_mapping(account)
return unless matching_mapping
if matching_mapping['role']
account_user.update(role: matching_mapping['role'])
elsif matching_mapping['custom_role_id']
account_user.update(custom_role_id: matching_mapping['custom_role_id'])
end
end
def find_matching_role_mapping(_account)
return if @saml_settings&.role_mappings.blank?
saml_groups.each do |group|
mapping = @saml_settings.role_mappings[group]
return mapping if mapping.present?
end
nil
end
def auth_attribute(key, fallback = nil)
@auth_hash.dig('info', key) || fallback
end
def uid
@auth_hash['uid']
end
def saml_groups
# Groups can come from different attributes depending on IdP
@auth_hash.dig('extra', 'raw_info', 'groups') ||
@auth_hash.dig('extra', 'raw_info', 'Group') ||
@auth_hash.dig('extra', 'raw_info', 'memberOf') ||
[]
end
end
@@ -7,11 +7,19 @@ class Api::V1::Accounts::SamlSettingsController < Api::V1::Accounts::BaseControl
def create def create
@saml_settings = Current.account.build_saml_settings(saml_settings_params) @saml_settings = Current.account.build_saml_settings(saml_settings_params)
@saml_settings.save! if @saml_settings.save
render :show
else
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
end
end end
def update def update
@saml_settings.update!(saml_settings_params) if @saml_settings.update(saml_settings_params)
render :show
else
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
end
end end
def destroy def destroy
@@ -0,0 +1,65 @@
module Enterprise::DeviseOverrides::OmniauthCallbacksController
def saml
# Call parent's omniauth_success which handles the auth
omniauth_success
end
def redirect_callbacks
# derive target redirect route from 'resource_class' param, which was set
# before authentication.
devise_mapping = get_devise_mapping
redirect_route = get_redirect_route(devise_mapping)
# preserve omniauth info for success route. ignore 'extra' in twitter
# auth response to avoid CookieOverflow.
session['dta.omniauth.auth'] = request.env['omniauth.auth'].except('extra')
session['dta.omniauth.params'] = request.env['omniauth.params']
# For SAML, use 303 See Other to convert POST to GET and preserve session
if params[:provider] == 'saml'
redirect_to redirect_route, { status: 303 }.merge(redirect_options)
else
super
end
end
def omniauth_success
case auth_hash&.dig('provider')
when 'saml'
handle_saml_auth
else
super
end
end
private
def handle_saml_auth
account_id = extract_saml_account_id
return redirect_to login_page_url(error: 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
if @resource.persisted?
sign_in_user
else
redirect_to login_page_url(error: 'saml-authentication-failed')
end
end
def extract_saml_account_id
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
end
def saml_enabled_for_account?(account_id)
return false unless ChatwootApp.enterprise?
return false if account_id.blank?
account = Account.find_by(id: account_id)
return false if account.nil?
return false unless account.feature_enabled?('saml')
AccountSamlSettings.find_by(account_id: account_id).present?
end
end
@@ -0,0 +1,24 @@
module Enterprise::DeviseOverrides::PasswordsController
def create
if saml_user_attempting_password_reset?
render json: {
success: false,
errors: [I18n.t('messages.reset_password_saml_user')]
}, status: :forbidden
return
end
super
end
private
def saml_user_attempting_password_reset?
return false if params[:email].blank?
user = User.from_email(params[:email])
return false unless user&.provider == 'saml'
true
end
end
@@ -1,4 +1,16 @@
module Enterprise::DeviseOverrides::SessionsController module Enterprise::DeviseOverrides::SessionsController
def create
if saml_user_attempting_password_login?
render json: {
success: false,
errors: [I18n.t('messages.login_saml_user')]
}, status: :unauthorized
return
end
super
end
def render_create_success def render_create_success
create_audit_event('sign_in') create_audit_event('sign_in')
super super
@@ -22,4 +34,17 @@ module Enterprise::DeviseOverrides::SessionsController
) )
end end
end end
private
def saml_user_attempting_password_login?
return false if params[:email].blank?
user = User.from_email(params[:email])
return false unless user&.provider == 'saml'
return false if params[:sso_auth_token].present? && user.valid_sso_auth_token?(params[:sso_auth_token])
true
end
end end
@@ -23,6 +23,7 @@ class AccountSamlSettings < ApplicationRecord
validates :sso_url, presence: true validates :sso_url, presence: true
validates :certificate, presence: true validates :certificate, presence: true
validates :idp_entity_id, presence: true validates :idp_entity_id, presence: true
validate :certificate_must_be_valid_x509
before_validation :set_sp_entity_id, if: :sp_entity_id_needs_generation? before_validation :set_sp_entity_id, if: :sp_entity_id_needs_generation?
@@ -56,4 +57,12 @@ class AccountSamlSettings < ApplicationRecord
def installation_name def installation_name
GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot') GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot')
end end
def certificate_must_be_valid_x509
return if certificate.blank?
OpenSSL::X509::Certificate.new(certificate)
rescue OpenSSL::X509::CertificateError
errors.add(:certificate, I18n.t('errors.account_saml_settings.invalid_certificate'))
end
end end
@@ -0,0 +1,56 @@
# Enterprise Edition SAML SSO Provider
# This initializer adds SAML authentication support for Enterprise customers
# Handle IdP-initiated Single Logout (SLO) session destruction
SAML_SLO_SESSION_DESTROY_PROC = proc do |env, _session|
# Extract user info from SAML logout request
logout_request = env['omniauth.strategy'].response_object
name_id = logout_request.name_id if logout_request.respond_to?(:name_id)
next unless name_id
# Find user by email and verify it's a SAML user
user = User.from_email(name_id)
user&.logout_all_sessions! if user&.provider == 'saml'
end
# 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'] ||
request.session[:saml_account_id] ||
env['omniauth.params']&.dig('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)
if settings
# 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
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'
env['omniauth.strategy'].options[:idp_slo_session_destroy] = SAML_SLO_SESSION_DESTROY_PROC
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
@@ -0,0 +1,214 @@
require 'rails_helper'
RSpec.describe SamlUserBuilder do
let(:email) { 'saml.user@example.com' }
let(:auth_hash) do
{
'provider' => 'saml',
'uid' => 'saml-uid-123',
'info' => {
'email' => email,
'name' => 'SAML User',
'first_name' => 'SAML',
'last_name' => 'User'
},
'extra' => {
'raw_info' => {
'groups' => %w[Administrators Users]
}
}
}
end
let(:account) { create(:account) }
let(:builder) { described_class.new(auth_hash, account.id) }
describe '#perform' do
context 'when user does not exist' do
it 'creates a new user' do
expect { builder.perform }.to change(User, :count).by(1)
end
it 'creates user with correct attributes' do
user = builder.perform
expect(user.email).to eq(email)
expect(user.name).to eq('SAML User')
expect(user.display_name).to eq('SAML')
expect(user.provider).to eq('saml')
expect(user.uid).to eq(email) # User model sets uid to email in before_validation callback
expect(user.confirmed_at).to be_present
end
it 'creates user with a random password' do
user = builder.perform
expect(user.encrypted_password).to be_present
end
it 'adds user to the account' do
user = builder.perform
expect(user.accounts).to include(account)
end
it 'sets default role as agent' do
user = builder.perform
account_user = AccountUser.find_by(user: user, account: account)
expect(account_user.role).to eq('agent')
end
context 'when name is not provided' do
let(:auth_hash) do
{
'provider' => 'saml',
'uid' => 'saml-uid-123',
'info' => {
'email' => email
}
}
end
it 'derives name from email' do
user = builder.perform
expect(user.name).to eq('saml.user')
end
end
end
context 'when user already exists' do
let!(:existing_user) { create(:user, email: email) }
it 'does not create a new user' do
expect { builder.perform }.not_to change(User, :count)
end
it 'returns the existing user' do
user = builder.perform
expect(user).to eq(existing_user)
end
it 'adds existing user to the account if not already added' do
user = builder.perform
expect(user.accounts).to include(account)
end
it 'converts existing user to SAML' do
expect(existing_user.provider).not_to eq('saml')
builder.perform
expect(existing_user.reload.provider).to eq('saml')
end
it 'does not change provider if user is already SAML' do
existing_user.update!(provider: 'saml')
expect { builder.perform }.not_to(change { existing_user.reload.provider })
end
it 'does not duplicate account association' do
existing_user.account_users.create!(account: account, role: 'agent')
expect { builder.perform }.not_to change(AccountUser, :count)
end
end
context 'with role mappings' do
let(:saml_settings) do
create(:account_saml_settings,
account: account,
role_mappings: {
'Administrators' => { 'role' => 'administrator' },
'Agents' => { 'role' => 'agent' }
})
end
before { saml_settings }
it 'applies administrator role based on SAML groups' do
user = builder.perform
account_user = AccountUser.find_by(user: user, account: account)
expect(account_user.role).to eq('administrator')
end
context 'with custom role mapping' do
let!(:custom_role) { create(:custom_role, account: account) }
let(:saml_settings) do
create(:account_saml_settings,
account: account,
role_mappings: {
'Administrators' => { 'custom_role_id' => custom_role.id }
})
end
before { saml_settings }
it 'applies custom role based on SAML groups' do
user = builder.perform
account_user = AccountUser.find_by(user: user, account: account)
expect(account_user.custom_role_id).to eq(custom_role.id)
end
end
context 'when user is not in any mapped groups' do
let(:auth_hash) do
{
'provider' => 'saml',
'uid' => 'saml-uid-123',
'info' => {
'email' => email,
'name' => 'SAML User'
},
'extra' => {
'raw_info' => {
'groups' => ['UnmappedGroup']
}
}
}
end
it 'keeps default agent role' do
user = builder.perform
account_user = AccountUser.find_by(user: user, account: account)
expect(account_user.role).to eq('agent')
end
end
end
context 'with different group attribute names' do
let(:auth_hash) do
{
'provider' => 'saml',
'uid' => 'saml-uid-123',
'info' => {
'email' => email,
'name' => 'SAML User'
},
'extra' => {
'raw_info' => {
'memberOf' => ['CN=Administrators,OU=Groups,DC=example,DC=com']
}
}
}
end
it 'reads groups from memberOf attribute' do
builder_instance = described_class.new(auth_hash, account_id: account.id)
allow(builder_instance).to receive(:saml_groups).and_return(['CN=Administrators,OU=Groups,DC=example,DC=com'])
user = builder_instance.perform
expect(user).to be_persisted
end
end
context 'when there are errors' do
it 'returns unsaved user object when user creation fails' do
allow(User).to receive(:create).and_return(User.new(email: email))
user = builder.perform
expect(user.persisted?).to be false
end
it 'does not create account association for failed user' do
allow(User).to receive(:create).and_return(User.new(email: email))
expect { builder.perform }.not_to change(AccountUser, :count)
end
end
end
end
@@ -0,0 +1,125 @@
require 'rails_helper'
RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
let!(:account) { create(:account) }
let(:saml_settings) { create(:account_saml_settings, account: account) }
def set_saml_config(email = 'test@example.com')
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:saml] = OmniAuth::AuthHash.new(
provider: 'saml',
uid: '123545',
info: {
name: 'Test User',
email: email
}
)
end
before do
allow(ChatwootApp).to receive(:enterprise?).and_return(true)
account.enable_features!('saml')
saml_settings
end
describe '#saml callback' do
it 'creates new user and logs them in' do
with_modified_env FRONTEND_URL: 'http://www.example.com' do
set_saml_config('new_user@example.com')
get "/omniauth/saml/callback?account_id=#{account.id}"
# expect a 302 redirect to auth/saml/callback
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
follow_redirect!
# expect redirect to login with SSO token
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
# verify user was created
user = User.from_email('new_user@example.com')
expect(user).to be_present
expect(user.provider).to eq('saml')
end
end
it 'logs in existing user' do
with_modified_env FRONTEND_URL: 'http://www.example.com' do
create(:user, email: 'existing@example.com', account: account)
set_saml_config('existing@example.com')
get "/omniauth/saml/callback?account_id=#{account.id}"
# expect a 302 redirect to auth/saml/callback
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
end
end
end
describe 'SAML SLO (Single Logout)' do
let(:saml_user) { create(:user, email: 'saml_user@example.com', provider: 'saml', account: account) }
before do
# Create some active sessions for the user
saml_user.tokens = {
'client1' => { 'token' => 'token1', 'expiry' => 1.hour.from_now.to_i },
'client2' => { 'token' => 'token2', 'expiry' => 2.hours.from_now.to_i }
}
saml_user.save!
end
describe 'SAML_SLO_SESSION_DESTROY_PROC' do
it 'destroys all user sessions when receiving IdP-initiated logout request' do
# Create a mock logout request with the user's email as name_id
logout_request = object_double(logout_request, name_id: saml_user.email)
# Create a mock OmniAuth strategy
strategy = object_double(strategy, response_object: logout_request)
# Create the environment hash that would be passed to the proc
env = { 'omniauth.strategy' => strategy }
# Execute the SLO session destroy proc
SAML_SLO_SESSION_DESTROY_PROC.call(env, {})
# Verify all sessions were destroyed
saml_user.reload
expect(saml_user.tokens).to eq({})
end
it 'only destroys sessions for SAML provider users' do
# Create a non-SAML user
email_user = create(:user, email: 'email_user@example.com', provider: 'email', account: account)
email_user.tokens = { 'client1' => { 'token' => 'token1', 'expiry' => 1.hour.from_now.to_i } }
email_user.save!
# Create another SAML user
saml_user2 = create(:user, email: 'another_saml@example.com', provider: 'saml', account: account)
saml_user2.tokens = { 'client2' => { 'token' => 'token2', 'expiry' => 1.hour.from_now.to_i } }
saml_user2.save!
# Create logout request for the second SAML user
logout_request = object_double(logout_request, name_id: 'another_saml@example.com')
strategy = object_double(strategy, response_object: logout_request)
env = { 'omniauth.strategy' => strategy }
# Execute the SLO session destroy proc
SAML_SLO_SESSION_DESTROY_PROC.call(env, {})
# Verify only the targeted SAML user sessions were destroyed
email_user.reload
saml_user.reload
saml_user2.reload
expect(email_user.tokens).not_to be_empty # Non-SAML user unaffected
expect(saml_user.tokens).not_to be_empty # Different SAML user unaffected
expect(saml_user2.tokens).to eq({}) # Target SAML user logged out
end
end
end
end
@@ -0,0 +1,36 @@
require 'rails_helper'
RSpec.describe 'Enterprise Passwords Controller', type: :request do
let!(:account) { create(:account) }
describe 'POST /auth/password' do
context 'with SAML user email' do
let!(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) }
it 'prevents password reset and returns forbidden with custom error message' do
params = { email: saml_user.email, redirect_url: 'http://test.host' }
post user_password_path, params: params, as: :json
expect(response).to have_http_status(:forbidden)
json_response = JSON.parse(response.body)
expect(json_response['success']).to be(false)
expect(json_response['errors']).to include(I18n.t('messages.reset_password_saml_user'))
end
end
context 'with non-SAML user email' do
let!(:regular_user) { create(:user, email: 'regular@example.com', provider: 'email', account: account) }
it 'allows password reset for non-SAML users' do
params = { email: regular_user.email, redirect_url: 'http://test.host' }
post user_password_path, params: params, as: :json
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['message']).to be_present
end
end
end
end
@@ -5,6 +5,40 @@ RSpec.describe 'Enterprise Audit API', type: :request do
let!(:user) { create(:user, password: 'Password1!', account: account) } let!(:user) { create(:user, password: 'Password1!', account: account) }
describe 'POST /sign_in' do describe 'POST /sign_in' do
context 'with SAML user attempting password login' do
let(:saml_settings) { create(:account_saml_settings, account: account) }
let(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) }
before do
saml_settings
saml_user
end
it 'prevents login and returns SAML authentication error' do
params = { email: saml_user.email, password: 'Password1!' }
post new_user_session_url, params: params, as: :json
expect(response).to have_http_status(:unauthorized)
json_response = JSON.parse(response.body)
expect(json_response['success']).to be(false)
expect(json_response['errors']).to include(I18n.t('messages.login_saml_user'))
end
it 'allows login with valid SSO token' do
valid_token = saml_user.generate_sso_auth_token
params = { email: saml_user.email, sso_auth_token: valid_token, password: 'Password1!' }
expect do
post new_user_session_url, params: params, as: :json
end.to change(Enterprise::AuditLog, :count).by(1)
expect(response).to have_http_status(:success)
expect(response.body).to include(saml_user.email)
end
end
context 'with regular user credentials' do
it 'creates a sign_in audit event wwith valid credentials' do it 'creates a sign_in audit event wwith valid credentials' do
params = { email: user.email, password: 'Password1!' } params = { email: user.email, password: 'Password1!' }
@@ -34,6 +68,15 @@ RSpec.describe 'Enterprise Audit API', type: :request do
end end
end end
context 'with blank email' do
it 'skips SAML check and processes normally' do
params = { email: '', password: 'Password1!' }
post new_user_session_url, params: params, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'DELETE /sign_out' do describe 'DELETE /sign_out' do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
it 'signs out the user and creates an audit event' do it 'signs out the user and creates an audit event' do
@@ -0,0 +1,272 @@
require 'rails_helper'
RSpec.describe SessionManageable do
let(:user) { create(:user) }
let(:current_time) { Time.current.to_i }
let(:expired_time) { 1.hour.ago.to_i }
let(:future_time) { 1.hour.from_now.to_i }
before do
# Mock GlobalConfig for session limit
allow(GlobalConfig).to receive(:get).with(
'USER_SESSION_LIMIT',
'USER_SESSION_LIMIT_PER_USER',
account: nil
).and_return('5')
end
describe '#logout_all_sessions!' do
it 'clears all tokens and saves the user' do
user.tokens = {
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
}
expect(user).to receive(:save!)
user.logout_all_sessions!
expect(user.tokens).to eq({})
end
it 'works with empty tokens' do
user.tokens = {}
expect(user).to receive(:save!)
user.logout_all_sessions!
expect(user.tokens).to eq({})
end
it 'works with nil tokens' do
user.tokens = nil
expect(user).to receive(:save!)
user.logout_all_sessions!
expect(user.tokens).to eq({})
end
end
describe '#logout_session!' do
before do
user.tokens = {
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
}
end
it 'removes specific client token and saves' do
expect(user).to receive(:save!)
result = user.logout_session!('client1')
expect(result).to be true
expect(user.tokens).not_to have_key('client1')
expect(user.tokens).to have_key('client2')
end
it 'returns false for non-existent client' do
result = user.logout_session!('non_existent_client')
expect(result).to be false
end
it 'returns false for empty client_id' do
result = user.logout_session!('')
expect(result).to be false
result = user.logout_session!(nil)
expect(result).to be false
end
it 'returns false when no tokens present' do
user.tokens = nil
result = user.logout_session!('client1')
expect(result).to be false
end
end
describe '#reset_tokens_before!' do
before do
user.tokens = {
'expired_client' => { 'token' => 'token1', 'expiry' => expired_time },
'current_client' => { 'token' => 'token2', 'expiry' => future_time },
'edge_case_client' => { 'token' => 'token3', 'expiry' => current_time }
}
end
it 'removes tokens that expired before the given timestamp' do
expect(user).to receive(:save!)
user.reset_tokens_before!(current_time)
expect(user.tokens).not_to have_key('expired_client')
expect(user.tokens).to have_key('current_client')
expect(user.tokens).to have_key('edge_case_client')
end
it 'handles timestamp as Time object' do
timestamp = Time.zone.at(current_time)
expect(user).to receive(:save!)
user.reset_tokens_before!(timestamp)
expect(user.tokens).not_to have_key('expired_client')
expect(user.tokens).to have_key('current_client')
end
it 'does nothing when no tokens present' do
user.tokens = nil
user.reset_tokens_before!(current_time)
# tokens gets initialized to empty hash during the process
expect(user.tokens).to eq({})
end
it 'handles tokens with missing expiry' do
user.tokens = {
'no_expiry_client' => { 'token' => 'token1' },
'zero_expiry_client' => { 'token' => 'token2', 'expiry' => 0 }
}
expect(user).to receive(:save!)
user.reset_tokens_before!(current_time)
expect(user.tokens).to be_empty
end
end
describe '#active_session_count' do
it 'counts only non-expired tokens' do
user.tokens = {
'expired1' => { 'token' => 'token1', 'expiry' => expired_time },
'active1' => { 'token' => 'token2', 'expiry' => future_time },
'active2' => { 'token' => 'token3', 'expiry' => future_time },
'expired2' => { 'token' => 'token4', 'expiry' => expired_time }
}
expect(user.active_session_count).to eq(2)
end
it 'returns 0 when no tokens present' do
user.tokens = nil
expect(user.active_session_count).to eq(0)
user.tokens = {}
expect(user.active_session_count).to eq(0)
end
it 'handles tokens with missing expiry' do
user.tokens = {
'no_expiry' => { 'token' => 'token1' },
'active' => { 'token' => 'token2', 'expiry' => future_time }
}
expect(user.active_session_count).to eq(1)
end
end
describe '#session_limit_exceeded?' do
it 'returns true when active sessions exceed limit' do
# Mock 3 session limit
allow(GlobalConfig).to receive(:get).and_return('3')
user.tokens = {
'active1' => { 'token' => 'token1', 'expiry' => future_time },
'active2' => { 'token' => 'token2', 'expiry' => future_time },
'active3' => { 'token' => 'token3', 'expiry' => future_time },
'active4' => { 'token' => 'token4', 'expiry' => future_time }
}
expect(user.session_limit_exceeded?).to be true
end
it 'returns false when within limit' do
allow(GlobalConfig).to receive(:get).and_return('5')
user.tokens = {
'active1' => { 'token' => 'token1', 'expiry' => future_time },
'active2' => { 'token' => 'token2', 'expiry' => future_time }
}
expect(user.session_limit_exceeded?).to be false
end
it 'handles infinite limit' do
allow(GlobalConfig).to receive(:get).and_return(nil)
user.tokens = {}
(1..100).each do |i|
user.tokens["client#{i}"] = { 'token' => "token#{i}", 'expiry' => future_time }
end
expect(user.session_limit_exceeded?).to be false
end
end
describe '#session_info' do
it 'returns session information sorted by expiry (newest first)' do
earlier_time = 2.hours.from_now.to_i
later_time = 3.hours.from_now.to_i
user.tokens = {
'client1' => { 'token' => 'token1', 'expiry' => earlier_time },
'client2' => { 'token' => 'token2', 'expiry' => later_time }
}
sessions = user.session_info
expect(sessions.size).to eq(2)
expect(sessions[0][:client_id]).to eq('client2')
expect(sessions[0][:expiry]).to eq(Time.zone.at(later_time))
expect(sessions[1][:client_id]).to eq('client1')
expect(sessions[1][:expiry]).to eq(Time.zone.at(earlier_time))
end
it 'returns empty array when no tokens' do
user.tokens = nil
expect(user.session_info).to eq([])
user.tokens = {}
expect(user.session_info).to eq([])
end
it 'handles tokens with missing expiry' do
user.tokens = {
'client1' => { 'token' => 'token1' },
'client2' => { 'token' => 'token2', 'expiry' => future_time }
}
sessions = user.session_info
expect(sessions.size).to eq(2)
expect(sessions[0][:expiry]).to eq(Time.zone.at(future_time))
expect(sessions[1][:expiry]).to eq(Time.zone.at(0))
end
end
describe 'private methods' do
describe '#session_limit' do
it 'returns configured limit from GlobalConfig' do
allow(GlobalConfig).to receive(:get).with(
'USER_SESSION_LIMIT',
'USER_SESSION_LIMIT_PER_USER',
account: nil
).and_return('10')
limit = user.send(:session_limit)
expect(limit).to eq(10)
end
it 'returns infinity when no limit configured' do
allow(GlobalConfig).to receive(:get).and_return(nil)
limit = user.send(:session_limit)
expect(limit).to eq(Float::INFINITY)
end
it 'memoizes the result' do
allow(GlobalConfig).to receive(:get).and_return('5')
# First call
limit1 = user.send(:session_limit)
# Second call should use memoized value
limit2 = user.send(:session_limit)
expect(limit1).to eq(limit2)
expect(GlobalConfig).to have_received(:get).once
end
end
end
end