diff --git a/cypress-tests/cypress/commands/commands.js b/cypress-tests/cypress/commands/commands.js
index fab8dc7649..d6a57f9021 100644
--- a/cypress-tests/cypress/commands/commands.js
+++ b/cypress-tests/cypress/commands/commands.js
@@ -266,6 +266,10 @@ Cypress.Commands.add(
.and(assertion, value, ...arg);
}
);
+Cypress.Commands.add("scrollToElement", (selector) => {
+ cy.get(selector).scrollIntoView()
+ .should("be.visible");
+});
Cypress.Commands.add("openInCurrentTab", (selector) => {
cy.get(selector).first().invoke("removeAttr", "target").click({ force: true });
diff --git a/cypress-tests/cypress/commands/platform/platformApiCommands.js b/cypress-tests/cypress/commands/platform/platformApiCommands.js
index c9542b241c..0bec38c6b1 100644
--- a/cypress-tests/cypress/commands/platform/platformApiCommands.js
+++ b/cypress-tests/cypress/commands/platform/platformApiCommands.js
@@ -749,6 +749,19 @@ Cypress.Commands.add(
});
}
);
+Cypress.Commands.add(
+ "apiUpdateAutoSSO",
+ (state, scope = "instance", returnCached = false) => {
+ cy.getAuthHeaders(returnCached).then((headers) => {
+ cy.request({
+ method: "PATCH",
+ url: `${Cypress.env("server_host")}/api/login-configs/${scope}-general`,
+ headers: headers,
+ body: { automaticSsoLogin: state },
+ });
+ });
+ }
+);
Cypress.Commands.add(
"apiFullUserOnboarding",
@@ -782,7 +795,7 @@ Cypress.Commands.add(
performOnboarding(userEmail, userPassword, organizationToken);
}
- function performOnboarding (email, password, orgToken) {
+ function performOnboarding(email, password, orgToken) {
cy.task("dbConnection", {
dbconfig: Cypress.env("app_db"),
sql: `
diff --git a/cypress-tests/cypress/constants/selectors/manageSSO.js b/cypress-tests/cypress/constants/selectors/manageSSO.js
index 45a8337f16..fd2cd671bc 100644
--- a/cypress-tests/cypress/constants/selectors/manageSSO.js
+++ b/cypress-tests/cypress/constants/selectors/manageSSO.js
@@ -2,16 +2,14 @@ export const ssoSelector = {
pagetitle: "[data-cy=manage-sso-page-title]",
instanceLoginPage: {
cardTitle: "[data-cy=card-title]",
- allowedDomainLabel: "[data-cy=allowed-domain-label]",
- allowedDomainInput: "[data-cy=allowed-domains]",
- allowedDomainHelperText: '[data-cy="allowed-allowedDomains-helper-text"]',
+ configurationLabel: "[data-cy=configuration-label]",
+ domainConstraintsLabel: "[data-cy=domain-constraints-label]",
+ domainConstraintsHelperText: '[data-cy="domain-constraints-helper-text"]',
superAdminUrlLabel: '[data-cy="workspace-login-url-label"]',
superAdminUrl: '[data-cy="workspace-login-url"]',
superAdminUrlHelperText: '[data-cy="workspace-login-help-text"]',
enableSignupLabel: '[data-cy="enable-sign-up-label"]',
enableSignupHelperText: "[data-cy=enable-sign-up-helper-text]",
- passwordLoginLabel: '[data-cy="label-password-login"]',
- passwordDisableHelperText: '[data-cy="disable-password-helper-text"]',
workspaceConfigurationLabel: '[data-cy="enable-workspace-configuration-label"]',
workspaceConfigurationHelperText: '[data-cy="enable-workspace-configuration-helper-text"]',
autoSSOLabel: '[data-cy="auto-sso-label"]',
@@ -20,6 +18,9 @@ export const ssoSelector = {
customLogoutUrlLabel: '[data-cy="custom-logout-url-label"]',
customLogoutUrlPlaceholder: '[data-cy="custom-logout-url-input"]',
customLogoutUrlHelperText: '[data-cy="custom-logout-url-helper-text"]',
+ loginMethodsLabel: '[data-cy="login-methods-label"]',
+ passwordLoginLabel: '[data-cy="label-password-login"]',
+ passwordDisableHelperText: '[data-cy="disable-password-helper-text"]',
ssoHeader: '[data-cy="sso-header"]',
googleLabel: '[data-cy="google-label"]',
githubLabel: '[data-cy="github-label"]',
@@ -27,18 +28,19 @@ export const ssoSelector = {
},
workspaceLoginPage: {
cardTitle: "[data-cy=card-title]",
- allowedDomainLabel: "[data-cy=allowed-domain-label]",
- allowedDomainInput: "[data-cy=allowed-domains]",
- allowedDomainHelperText: '[data-cy="allowed-domain-helper-text"]',
+ configurationLabel: "[data-cy=configuration-label]",
+ domainConstraintsLabel: "[data-cy=domain-constraints-label]",
+ domainConstraintsHelperText: '[data-cy="domain-constraints-helper-text"]',
workspaceLoginUrlLabel: '[data-cy="workspace-login-url-label"]',
workspaceLoginUrl: '[data-cy="workspace-login-url"]',
workspaceLoginUrlHelperText: '[data-cy="workspace-login-help-text"]',
enableSignupLabel: '[data-cy="enable-sign-up-label"]',
enableSignupHelperText: "[data-cy=enable-sign-up-helper-text]",
- passwordLoginLabel: '[data-cy="label-password-login"]',
- passwordDisableHelperText: '[data-cy="disable-password-helper-text"]',
autoSSOLabel: '[data-cy="auto-sso-label"]',
autoSSOHelperText: '[data-cy="auto-sso-helper-text"]',
+ loginMethodsLabel: '[data-cy="login-methods-label"]',
+ passwordLoginLabel: '[data-cy="label-password-login"]',
+ passwordDisableHelperText: '[data-cy="disable-password-helper-text"]',
ssoHeader: '[data-cy="sso-header"]',
instanceSsoCard: '[data-cy="instance-sso-card"]',
instanceSsoHelperText: '[data-cy="instance-sso-helper-text"]',
@@ -108,7 +110,16 @@ export const ssoSelector = {
allowDefaultSSOToggle:
'[style="padding-left: 0px; margin-bottom: 1px;"] > .switch > .slider',
defaultSSOImage: '[data-cy="default-sso-status-image"]',
- allowedDomainInput: '[data-cy="allowed-domains"]',
+ passwordLoginDropdown: '[data-cy="password-login-dropdown"]',
+ passwordLoginDropdownLabel: '[data-cy="password-login-dropdown-label"]',
+ passwordAllowedDomainsLabel: '[data-cy="password-allowed-domains-label"]',
+ passwordAllowedDomainsInput: '[data-cy="password-allowed-domains-input"]',
+ passwordRestrictedDomainsLabel: '[data-cy="password-restricted-domains-label"]',
+ passwordRestrictedDomainsInput: '[data-cy="password-restricted-domains-input"]',
+ ssoLoginDropdown: '[data-cy="sso-login-dropdown"]',
+ ssoLoginDropdownLabel: '[data-cy="sso-login-dropdown-label"]',
+ ssoAllowedDomainsLabel: '[data-cy="sso-allowed-domains-label"]',
+ ssoAllowedDomainsInput: '[data-cy="sso-allowed-domains-input"]',
workspaceLoginUrl: '[data-cy="workspace-login-url"]',
cancelButton: "[data-cy=cancel-button]",
saveButton: "[data-cy=save-button]",
diff --git a/cypress-tests/cypress/constants/texts/manageSSO.js b/cypress-tests/cypress/constants/texts/manageSSO.js
index 877e6a6124..d505487a25 100644
--- a/cypress-tests/cypress/constants/texts/manageSSO.js
+++ b/cypress-tests/cypress/constants/texts/manageSSO.js
@@ -3,9 +3,9 @@ export const ssoText = {
instanceLoginPage: {
cardTitle: "Instance login",
- allowedDomainLabel: "Allowed domains",
- allowedDomainInput: "",
- allowedDomainHelperText: "Support multiple domains. Enter allowed domains names separated by comma. example: tooljet.com,tooljet.io,yourorganization.com",
+ configurationLabel: "CONFIGURATION",
+ domainConstraintsLabel: "Domain constraints",
+ domainConstraintsHelperText: "Support multiple domains. Enter domain names separated by comma. Example: tooljet.com, tooljet.io",
superAdminUrlLabel: "Super admin login URL",
superAdminUrl: Cypress.env('proxy') === true
? `${Cypress.config("server_host")}/login/super-admin`
@@ -13,8 +13,6 @@ export const ssoText = {
superAdminUrlHelperText: "Use this URL for super admin to login via password",
enableSignupLabel: "Enable Signup",
enableSignupHelperText: "Users will be able to sign up without being invited",
- passwordLoginLabel: "Password login",
- passwordDisableHelperText: 'Disable password login only if your SSO is configured otherwise you will get locked out',
workspaceConfigurationLabel: "Enable workspace configuration",
workspaceConfigurationHelperText: "Allow workspace admin to configure their workspace’s login differently",
autoSSOLabel: "Automatic SSO login",
@@ -23,6 +21,9 @@ export const ssoText = {
customLogoutUrlLabel: "Custom logout URL",
customLogoutUrlPlaceholder: "",
customLogoutUrlHelperText: "Set a personalized logout URL for users logging out of this instance.",
+ loginMethodsLabel: "LOGIN METHODS",
+ passwordLoginLabel: "Password login",
+ passwordDisableHelperText: 'Disable password login only if your SSO is configured otherwise you will get locked out',
ssoHeader: "SSO",
googleLabel: "Google",
githubLabel: "GitHub",
@@ -30,9 +31,9 @@ export const ssoText = {
},
workspaceLoginPage: {
cardTitle: "Workspace login",
- allowedDomainLabel: "Allowed domains",
- allowedDomainInput: "",
- allowedDomainHelperText: "Support multiple domains. Enter domain names separated by comma. example: tooljet.com,tooljet.io,yourorganization.com",
+ configurationLabel: "CONFIGURATION",
+ domainConstraintsLabel: "Domain constraints",
+ domainConstraintsHelperText: "Support multiple domains. Enter domain names separated by comma. Example: tooljet.com, tooljet.io",
workspaceLoginUrlLabel: "Login URL",
workspaceLoginUrl: Cypress.env('proxy') === true
? `${Cypress.config("server_host")}/login/workspace`
@@ -40,10 +41,11 @@ export const ssoText = {
workspaceLoginUrlHelperText: "Use this URL to login directly to this workspace",
enableSignupLabel: "Enable signup",
enableSignupHelperText: "Users will be able to sign up without being invited",
- passwordLoginLabel: "Password login",
- passwordDisableHelperText: 'Disable password login only if your SSO is configured otherwise you will get locked out',
autoSSOLabel: "Automatic SSO login",
autoSSOHelperText: "This will simulate the configured SSO login, bypassing the login screen in ToolJet",
+ loginMethodsLabel: "LOGIN METHODS",
+ passwordLoginLabel: "Password login",
+ passwordDisableHelperText: 'Disable password login only if your SSO is configured otherwise you will get locked out',
ssoHeader: "SSO",
instanceSsoCard: "Instance SSO (3)",
instanceSsoHelperText: "Display instance SSO for workspace URL login",
@@ -105,6 +107,11 @@ export const ssoText = {
cancelButton: "Cancel",
saveButton: "Save changes",
allowedDomain: "tooljet.io,gmail.com",
+ passwordLoginDropdownLabel: "Password login",
+ passwordAllowedDomainsLabel: "Allowed domains",
+ passwordRestrictedDomainsLabel: "Restricted domains",
+ ssoLoginDropdownLabel: "SSO login",
+ ssoAllowedDomainsLabel: "Allowed domains",
passwordDisableWarning: "Please ensure SSO is configured successfully before disabling password login or else you will get locked out. Are you sure you want to continue?",
superAdminInfoText: `Super admin can still access their account via ${
Cypress.env('proxy') === true
diff --git a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/workspace/manageSSO.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/workspace/manageSSO.cy.js
index 98fb22c4a6..d5be5afa39 100644
--- a/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/workspace/manageSSO.cy.js
+++ b/cypress-tests/cypress/e2e/happyPath/platform/ceTestcases/workspace/manageSSO.cy.js
@@ -40,6 +40,7 @@ describe("Manage SSO for multi workspace", () => {
});
it("Should verify workspace settings page elements and their functionality", () => {
+ cy.apiUpdateAutoSSO(false, "organization");
SSO.setSignupStatus(false);
SSO.defaultSSO(true);
common.navigateToManageSSO();
diff --git a/cypress-tests/cypress/support/utils/manageSSO.js b/cypress-tests/cypress/support/utils/manageSSO.js
index 21578eef95..a0c2872cf7 100644
--- a/cypress-tests/cypress/support/utils/manageSSO.js
+++ b/cypress-tests/cypress/support/utils/manageSSO.js
@@ -11,10 +11,41 @@ import { commonText } from "Texts/common";
import { ssoText } from "Texts/manageSSO";
export const verifyLoginSettings = (pageName) => {
- cy.get(ssoSelector.enableSignUpToggle).should("be.visible");
- cy.get(ssoSelector.allowedDomainInput).should("be.visible");
- cy.get(ssoSelector.workspaceLoginUrl).should("be.visible");
- cy.get(commonSelectors.copyIcon).should("be.visible");
+ //Verify Password and SSO Domain section
+ cy.get(ssoSelector.passwordLoginDropdown).verifyVisibleElement("have.text", ssoText.passwordLoginDropdownLabel).click();
+ cy.get(ssoSelector.passwordAllowedDomainsLabel).should("be.visible");
+ cy.scrollToElement(ssoSelector.passwordAllowedDomainsInput).should("be.enabled");
+ cy.clearAndType(ssoSelector.passwordAllowedDomainsInput, "allow.com");
+ cy.scrollToElement(ssoSelector.passwordRestrictedDomainsLabel);
+ cy.scrollToElement(ssoSelector.passwordRestrictedDomainsInput).should("be.enabled");
+ cy.clearAndType(ssoSelector.passwordRestrictedDomainsInput, "restrict.com");
+ cy.get(ssoSelector.ssoLoginDropdown).verifyVisibleElement("have.text", ssoText.ssoLoginDropdownLabel).click();
+ cy.scrollToElement(ssoSelector.ssoAllowedDomainsLabel);
+ cy.scrollToElement(ssoSelector.ssoAllowedDomainsInput).should("be.enabled");
+ cy.clearAndType(ssoSelector.ssoAllowedDomainsInput, "allow.com");
+ cy.get(ssoSelector.saveButton).verifyVisibleElement("have.text", ssoText.saveButton).click();
+ cy.verifyToastMessage(
+ commonSelectors.toastMessage,
+ ssoText[`${pageName}SsoToast`]
+ );
+
+ [
+ ssoSelector.passwordAllowedDomainsInput,
+ ssoSelector.passwordRestrictedDomainsInput,
+ ssoSelector.ssoAllowedDomainsInput
+ ].forEach(selector => {
+ cy.clearAndType(selector, `{selectall}{backspace}`);
+ });
+ cy.scrollToElement(ssoSelector.saveButton).click();
+ cy.verifyToastMessage(
+ commonSelectors.toastMessage,
+ ssoText[`${pageName}SsoToast`]
+ );
+
+ cy.scrollToElement(ssoSelector.ssoLoginDropdown).click();
+ cy.scrollToElement(ssoSelector.passwordLoginDropdown).click();
+ cy.scrollToElement(ssoSelector.workspaceLoginUrl);
+ cy.scrollToElement(commonSelectors.copyIcon);
cy.get(ssoSelector.cancelButton).verifyVisibleElement(
"have.text",
@@ -24,8 +55,7 @@ export const verifyLoginSettings = (pageName) => {
"have.text",
ssoText.saveButton
);
-
- cy.get(ssoSelector.passwordEnableToggle).should("be.visible");
+ cy.scrollToElement(ssoSelector.passwordEnableToggle);
//Configure sign up toggle
cy.get(ssoSelector.enableSignUpToggle).check();
@@ -39,13 +69,6 @@ export const verifyLoginSettings = (pageName) => {
cy.get(ssoSelector.saveButton).click();
cy.get(ssoSelector.enableSignUpToggle).should("not.be.checked");
- cy.clearAndType(ssoSelector.allowedDomainInput, ssoText.allowedDomain);
- cy.get(ssoSelector.saveButton).click();
- cy.verifyToastMessage(
- commonSelectors.toastMessage,
- ssoText[`${pageName}SsoToast`]
- );
-
cy.get(ssoSelector.passwordEnableToggle).uncheck();
cy.get(commonSelectors.modalComponent).should("be.visible");
cy.get(ssoSelector.disablePasswordLoginTitle).verifyVisibleElement(
diff --git a/frontend/src/modules/WorkspaceSettings/components/BaseWorkspaceSettingsPage/BaseWorkspaceSettingsPage.jsx b/frontend/src/modules/WorkspaceSettings/components/BaseWorkspaceSettingsPage/BaseWorkspaceSettingsPage.jsx
index 6f7e1ffaca..2f4f4be1aa 100644
--- a/frontend/src/modules/WorkspaceSettings/components/BaseWorkspaceSettingsPage/BaseWorkspaceSettingsPage.jsx
+++ b/frontend/src/modules/WorkspaceSettings/components/BaseWorkspaceSettingsPage/BaseWorkspaceSettingsPage.jsx
@@ -111,7 +111,7 @@ export default function WorkspaceSettingsPage({ extraLinks, ...props }) {
-
+
diff --git a/server/data-migrations/1765958549099-AddGranularDomainSettings.ts b/server/data-migrations/1765958549099-AddGranularDomainSettings.ts
new file mode 100644
index 0000000000..1fb50fa5e9
--- /dev/null
+++ b/server/data-migrations/1765958549099-AddGranularDomainSettings.ts
@@ -0,0 +1,49 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+import {
+ INSTANCE_CONFIGS_DATA_TYPES,
+ INSTANCE_SETTINGS_TYPE,
+} from '@modules/instance-settings/constants';
+import { INSTANCE_SYSTEM_SETTINGS } from '@modules/instance-settings/constants';
+
+export class AddGranularDomainSettings1765958549099 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise
{
+ await queryRunner.query(
+ `
+ INSERT INTO instance_settings (key, label, data_type, value, type)
+ VALUES
+ (
+ 'PASSWORD_ALLOWED_DOMAINS',
+ 'Password Allowed Domains',
+ '${INSTANCE_CONFIGS_DATA_TYPES.TEXT}',
+ COALESCE(
+ (
+ SELECT value
+ FROM instance_settings
+ WHERE key = '${INSTANCE_SYSTEM_SETTINGS.ALLOWED_DOMAINS}'
+ LIMIT 1
+ ),
+ ''
+ ),
+ '${INSTANCE_SETTINGS_TYPE.SYSTEM}'
+ ),
+ (
+ 'PASSWORD_RESTRICTED_DOMAINS',
+ 'Password Restricted Domains',
+ '${INSTANCE_CONFIGS_DATA_TYPES.TEXT}',
+ '',
+ '${INSTANCE_SETTINGS_TYPE.SYSTEM}'
+ )
+ ON CONFLICT (key) DO NOTHING;
+ `,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `
+ DELETE FROM instance_settings
+ WHERE key IN ('PASSWORD_ALLOWED_DOMAINS', 'PASSWORD_RESTRICTED_DOMAINS');
+ `,
+ );
+ }
+}
diff --git a/server/migrations/1766224841342-AddPasswordDomainColumnsToOrganizations.ts b/server/migrations/1766224841342-AddPasswordDomainColumnsToOrganizations.ts
new file mode 100644
index 0000000000..3a9fb3ef4d
--- /dev/null
+++ b/server/migrations/1766224841342-AddPasswordDomainColumnsToOrganizations.ts
@@ -0,0 +1,29 @@
+import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
+
+export class AddPasswordDomainColumnsToOrganizations1766224841342 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.addColumns('organizations', [
+ new TableColumn({
+ name: 'password_allowed_domains',
+ type: 'varchar',
+ isNullable: true,
+ }),
+ new TableColumn({
+ name: 'password_restricted_domains',
+ type: 'varchar',
+ isNullable: true,
+ }),
+ ]);
+
+ // Prefill password_allowed_domains with existing domain values
+ await queryRunner.query(
+ `UPDATE organizations SET password_allowed_domains = domain WHERE domain IS NOT NULL AND domain != ''`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropColumn('organizations', 'password_allowed_domains');
+ await queryRunner.dropColumn('organizations', 'password_restricted_domains');
+ }
+}
+
diff --git a/server/src/entities/organization.entity.ts b/server/src/entities/organization.entity.ts
index 5ed48f4fbc..2630836039 100644
--- a/server/src/entities/organization.entity.ts
+++ b/server/src/entities/organization.entity.ts
@@ -37,6 +37,12 @@ export class Organization extends BaseEntity {
@Column({ name: 'domain' })
domain: string;
+ @Column({ name: 'password_allowed_domains', nullable: true })
+ passwordAllowedDomains: string;
+
+ @Column({ name: 'password_restricted_domains', nullable: true })
+ passwordRestrictedDomains: string;
+
@Column({ name: 'is_default', default: false })
isDefault: boolean;
diff --git a/server/src/helpers/utils.helper.ts b/server/src/helpers/utils.helper.ts
index ebcabb06d0..311e5543bb 100644
--- a/server/src/helpers/utils.helper.ts
+++ b/server/src/helpers/utils.helper.ts
@@ -9,6 +9,7 @@ import { getEnvVars } from 'scripts/database-config-utils';
import { decamelizeKeys } from 'humps';
import * as semver from 'semver';
import { BadRequestException } from '@nestjs/common';
+import { INSTANCE_SYSTEM_SETTINGS } from '@modules/instance-settings/constants';
const PASSWORD_REGEX =
/^(?=.{12,24}$)[A-Za-z0-9!@#\$%\^&\*\(\)_+\-=\{\}\[\]:;\"',\.\?\/\\\|]+$/;
@@ -465,6 +466,125 @@ export const isValidDomain = (email: string, restrictedDomain: string): boolean
return true;
};
+/**
+ * Validates email domain for SSO sign-in with organization/instance hierarchy.
+ * Organization domain settings override instance settings.
+ * This feature is only available in EE edition. For CE edition, always returns true.
+ * @param email - User email address
+ * @param orgDomain - Organization domain setting (backward compatible with old 'domain' field)
+ * @param instanceAllowedDomains - Instance level SSO allowed domains
+ * @returns true if domain is valid, false otherwise
+ */
+export const isValidSSODomain = (
+ email: string,
+ orgDomain: string | null | undefined,
+ instanceAllowedDomains: string | null | undefined
+): boolean => {
+ // Skip validation for CE edition, validate for EE edition
+ if (getTooljetEdition() === 'ce') {
+ return true;
+ }
+
+ if (!email) {
+ return false;
+ }
+
+ // Use organization domain if present (overrides instance), otherwise use instance setting
+ const allowedDomains = orgDomain || instanceAllowedDomains;
+
+ // If no domain restriction, allow all
+ if (!allowedDomains || !allowedDomains.trim()) {
+ return true;
+ }
+
+ const domain = email.substring(email.lastIndexOf('@') + 1);
+ if (!domain) {
+ return false;
+ }
+
+ // Check if domain is in allowed list
+ const allowedDomainList = allowedDomains
+ .split(',')
+ .map((e) => e && e.trim())
+ .filter((e) => !!e);
+
+ return allowedDomainList.includes(domain);
+};
+
+/**
+ * Validates email domain for password sign-in with organization/instance hierarchy.
+ * Organization domain settings override instance settings.
+ * Supports both allowed domains (whitelist) and restricted domains (blacklist).
+ * This feature is only available in EE edition. For CE edition, always returns true.
+ * @param email - User email address
+ * @param orgAllowedDomains - Organization level password allowed domains
+ * @param orgRestrictedDomains - Organization level password restricted domains
+ * @param instanceAllowedDomains - Instance level password allowed domains
+ * @param instanceRestrictedDomains - Instance level password restricted domains
+ * @returns true if domain is valid, false otherwise
+ */
+export const isValidPasswordDomain = (
+ email: string,
+ orgAllowedDomains: string | null | undefined,
+ orgRestrictedDomains: string | null | undefined,
+ instanceAllowedDomains: string | null | undefined,
+ instanceRestrictedDomains: string | null | undefined
+): boolean => {
+ // Skip validation for CE edition, validate for EE edition
+ if (getTooljetEdition() === 'ce') {
+ return true;
+ }
+
+ if (!email) {
+ return false;
+ }
+
+ const domain = email.substring(email.lastIndexOf('@') + 1);
+ if (!domain) {
+ return false;
+ }
+
+ // Use organization settings if present (overrides instance), otherwise use instance settings
+ const allowedDomains = orgAllowedDomains || instanceAllowedDomains;
+
+ // Check restricted domains from both org and instance (blacklist takes precedence)
+ // If domain is restricted in either org or instance, deny access
+ if (orgRestrictedDomains && orgRestrictedDomains.trim()) {
+ const orgRestrictedDomainList = orgRestrictedDomains
+ .split(',')
+ .map((e) => e && e.trim())
+ .filter((e) => !!e);
+
+ if (orgRestrictedDomainList.includes(domain)) {
+ return false;
+ }
+ }
+
+ if (instanceRestrictedDomains && instanceRestrictedDomains.trim()) {
+ const instanceRestrictedDomainList = instanceRestrictedDomains
+ .split(',')
+ .map((e) => e && e.trim())
+ .filter((e) => !!e);
+
+ if (instanceRestrictedDomainList.includes(domain)) {
+ return false;
+ }
+ }
+
+ // Check allowed domains (whitelist)
+ if (allowedDomains && allowedDomains.trim()) {
+ const allowedDomainList = allowedDomains
+ .split(',')
+ .map((e) => e && e.trim())
+ .filter((e) => !!e);
+
+ return allowedDomainList.includes(domain);
+ }
+
+ // If no restrictions configured, allow all
+ return true;
+};
+
export const isHttpsEnabled = () => {
return !!process.env.TOOLJET_HOST?.startsWith('https');
};
@@ -567,3 +687,83 @@ export function throwIfSignalAborted(signal: AbortSignal, timeout: number) {
throw new QueryError(`Defined query timeout of ${timeout}ms exceeded when running query.`, 'Query timed out', {});
}
}
+
+/**
+ * Validates email domain for SSO sign-in with organization/instance hierarchy.
+ * Fetches instance settings internally. Organization domain settings override instance settings.
+ * This feature is only available in EE edition. For CE edition, always returns true.
+ * @param email - User email address
+ * @param orgDomain - Organization domain setting (backward compatible with old 'domain' field)
+ * @param instanceSettingsUtilService - Instance settings util service to fetch settings
+ * @param isInstanceSSO - Whether this is instance-level SSO (domain already contains instance value)
+ * @returns Promise - true if domain is valid, false otherwise
+ */
+export async function validateSSODomain(
+ email: string,
+ orgDomain: string | null | undefined,
+ instanceSettingsUtilService: any,
+ isInstanceSSO: boolean = false
+): Promise {
+ // Skip validation for CE edition, validate for EE edition
+ if (getTooljetEdition() === 'ce') {
+ return true;
+ }
+
+ if (!email) {
+ return false;
+ }
+
+ // For instance SSO, orgDomain already contains instance value, so use it directly
+ if (isInstanceSSO) {
+ return isValidSSODomain(email, null, orgDomain);
+ }
+
+ // Fetch instance settings
+ const instanceSettings = await instanceSettingsUtilService.getSettings([
+ INSTANCE_SYSTEM_SETTINGS.ALLOWED_DOMAINS,
+ ]);
+ const instanceAllowedDomains = instanceSettings?.ALLOWED_DOMAINS;
+
+ return isValidSSODomain(email, orgDomain, instanceAllowedDomains);
+}
+
+/**
+ * Validates email domain for password sign-in with organization/instance hierarchy.
+ * Fetches instance settings internally. Organization domain settings override instance settings.
+ * Supports both allowed domains (whitelist) and restricted domains (blacklist).
+ * This feature is only available in EE edition. For CE edition, always returns true.
+ * @param email - User email address
+ * @param orgAllowedDomains - Organization level password allowed domains
+ * @param orgRestrictedDomains - Organization level password restricted domains
+ * @param instanceSettingsUtilService - Instance settings util service to fetch settings
+ * @returns Promise - true if domain is valid, false otherwise
+ */
+export async function validatePasswordDomain(
+ email: string,
+ orgAllowedDomains: string | null | undefined,
+ orgRestrictedDomains: string | null | undefined,
+ instanceSettingsUtilService: any
+): Promise {
+ // Skip validation for CE edition, validate for EE edition
+ if (getTooljetEdition() === 'ce') {
+ return true;
+ }
+
+ if (!email) {
+ return false;
+ }
+
+ // Fetch instance settings
+ const instanceSettings = await instanceSettingsUtilService.getSettings([
+ INSTANCE_SYSTEM_SETTINGS.PASSWORD_ALLOWED_DOMAINS,
+ INSTANCE_SYSTEM_SETTINGS.PASSWORD_RESTRICTED_DOMAINS,
+ ]);
+
+ return isValidPasswordDomain(
+ email,
+ orgAllowedDomains,
+ orgRestrictedDomains,
+ instanceSettings?.PASSWORD_ALLOWED_DOMAINS,
+ instanceSettings?.PASSWORD_RESTRICTED_DOMAINS
+ );
+}
diff --git a/server/src/modules/auth/service.ts b/server/src/modules/auth/service.ts
index 16b0d8879c..a07493b97a 100644
--- a/server/src/modules/auth/service.ts
+++ b/server/src/modules/auth/service.ts
@@ -4,7 +4,11 @@ import { Organization } from 'src/entities/organization.entity';
import { SSOConfigs } from 'src/entities/sso_config.entity';
import { EntityManager } from 'typeorm';
import { WORKSPACE_USER_STATUS } from '@modules/users/constants/lifecycle';
-import { isSuperAdmin, generateNextNameAndSlug } from 'src/helpers/utils.helper';
+import {
+ isSuperAdmin,
+ generateNextNameAndSlug,
+ validatePasswordDomain,
+} from 'src/helpers/utils.helper';
import { dbTransactionWrap } from 'src/helpers/database.helper';
import { InstanceSettingsUtilService } from '@modules/instance-settings/util.service';
import { Response } from 'express';
@@ -96,6 +100,20 @@ export class AuthService implements IAuthService {
if (organization) user.organizationId = organization.id;
/* CASE: No active workspace. But one workspace with invited status. waiting for activation */
if (isInviteRedirect && !organization) user.organizationId = invitingOrganizationId ?? '';
+
+ // Validate password domain for global login (org overrides instance)
+ if (organization && !isSuperAdmin(user)) {
+ if (
+ !(await validatePasswordDomain(
+ email,
+ organization.passwordAllowedDomains,
+ organization.passwordRestrictedDomains,
+ this.instanceSettingsUtilService
+ ))
+ ) {
+ throw new UnauthorizedException(`This login method is not available for your domain. Please contact admin or try another method.`);
+ }
+ }
} else {
// organization specific login
// No need to validate user status, validateUser() already covers it
@@ -109,6 +127,20 @@ export class AuthService implements IAuthService {
// no configurations in organization side or Form login disabled for the organization
throw new UnauthorizedException('Password login is disabled for the organization');
}
+
+ // Validate password domain with org/instance hierarchy (org overrides instance)
+ if (!isSuperAdmin(user)) {
+ if (
+ !(await validatePasswordDomain(
+ email,
+ organization.passwordAllowedDomains,
+ organization.passwordRestrictedDomains,
+ this.instanceSettingsUtilService
+ ))
+ ) {
+ throw new UnauthorizedException(`This login method is not available for your domain. Please contact admin or try another method.`);
+ }
+ }
}
const shouldUpdateDefaultOrgId =
diff --git a/server/src/modules/configs/service.ts b/server/src/modules/configs/service.ts
index 88fb47676a..46bafcbde6 100644
--- a/server/src/modules/configs/service.ts
+++ b/server/src/modules/configs/service.ts
@@ -56,6 +56,8 @@ export class ConfigService implements IConfigService {
INSTANCE_USER_SETTINGS.ALLOW_PERSONAL_WORKSPACE,
INSTANCE_USER_SETTINGS.ENABLE_MULTIPLAYER_EDITING,
INSTANCE_USER_SETTINGS.ENABLE_COMMENTS,
+ INSTANCE_SYSTEM_SETTINGS.PASSWORD_ALLOWED_DOMAINS,
+ INSTANCE_SYSTEM_SETTINGS.PASSWORD_RESTRICTED_DOMAINS,
INSTANCE_SYSTEM_SETTINGS.ALLOWED_DOMAINS,
INSTANCE_SYSTEM_SETTINGS.ENABLE_SIGNUP,
INSTANCE_SYSTEM_SETTINGS.ENABLE_WORKSPACE_LOGIN_CONFIGURATION,
diff --git a/server/src/modules/instance-settings/constants/index.ts b/server/src/modules/instance-settings/constants/index.ts
index f144d0b1ef..1547bb6051 100644
--- a/server/src/modules/instance-settings/constants/index.ts
+++ b/server/src/modules/instance-settings/constants/index.ts
@@ -12,6 +12,8 @@ export enum INSTANCE_SETTINGS_TYPE {
export enum INSTANCE_SYSTEM_SETTINGS {
ALLOWED_DOMAINS = 'ALLOWED_DOMAINS',
+ PASSWORD_ALLOWED_DOMAINS = 'PASSWORD_ALLOWED_DOMAINS',
+ PASSWORD_RESTRICTED_DOMAINS = 'PASSWORD_RESTRICTED_DOMAINS',
ENABLE_SIGNUP = 'ENABLE_SIGNUP',
ENABLE_WORKSPACE_LOGIN_CONFIGURATION = 'ENABLE_WORKSPACE_LOGIN_CONFIGURATION',
AUTOMATIC_SSO_LOGIN = 'AUTOMATIC_SSO_LOGIN',
diff --git a/server/src/modules/login-configs/dto/index.ts b/server/src/modules/login-configs/dto/index.ts
index d2a8838dc3..d4044d52e3 100644
--- a/server/src/modules/login-configs/dto/index.ts
+++ b/server/src/modules/login-configs/dto/index.ts
@@ -9,6 +9,18 @@ export class OrganizationConfigsUpdateDto {
@MaxLength(250, { message: 'Domain cannot be longer than 250 characters' })
domain?: string;
+ @IsOptional()
+ @IsString()
+ @Transform(({ value }) => sanitizeInput(value))
+ @MaxLength(250, { message: 'Password allowed domains cannot be longer than 250 characters' })
+ passwordAllowedDomains?: string;
+
+ @IsOptional()
+ @IsString()
+ @Transform(({ value }) => sanitizeInput(value))
+ @MaxLength(250, { message: 'Password restricted domains cannot be longer than 250 characters' })
+ passwordRestrictedDomains?: string;
+
@IsOptional()
@IsBoolean()
enableSignUp?: boolean;
@@ -28,6 +40,18 @@ export class InstanceConfigsUpdateDto {
@Transform(({ value }) => sanitizeInput(value))
@MaxLength(250, { message: 'Domains cannot be longer than 250 characters' })
allowedDomains?: string;
+
+ @IsOptional()
+ @IsString()
+ @Transform(({ value }) => sanitizeInput(value))
+ @MaxLength(250, { message: 'Allowed domains cannot be longer than 250 characters' })
+ passwordAllowedDomains?: string;
+
+ @IsOptional()
+ @IsString()
+ @Transform(({ value }) => sanitizeInput(value))
+ @MaxLength(250, { message: 'Restricted domains cannot be longer than 250 characters' })
+ passwordRestrictedDomains?: string;
@IsOptional()
@IsBoolean()
diff --git a/server/src/modules/login-configs/service.ts b/server/src/modules/login-configs/service.ts
index c9db2b32ad..2ac03ef162 100644
--- a/server/src/modules/login-configs/service.ts
+++ b/server/src/modules/login-configs/service.ts
@@ -145,10 +145,12 @@ export class LoginConfigsService implements ILoginConfigsService {
async updateGeneralOrganizationConfigs(user: User, params: OrganizationConfigsUpdateDto) {
const organizationId = user.organizationId;
- const { domain, enableSignUp, inheritSSO, automaticSsoLogin } = params;
+ const { domain, passwordAllowedDomains, passwordRestrictedDomains, enableSignUp, inheritSSO, automaticSsoLogin } = params;
const updatableParams = {
domain,
+ passwordAllowedDomains,
+ passwordRestrictedDomains,
enableSignUp,
inheritSSO,
automaticSsoLogin,
diff --git a/server/src/modules/onboarding/service.ts b/server/src/modules/onboarding/service.ts
index c64afcd52f..f62a7658c7 100644
--- a/server/src/modules/onboarding/service.ts
+++ b/server/src/modules/onboarding/service.ts
@@ -32,6 +32,7 @@ import {
isValidDomain,
generateWorkspaceSlug,
validatePasswordServer,
+ validatePasswordDomain,
} from 'src/helpers/utils.helper';
import { dbTransactionWrap } from 'src/helpers/database.helper';
import { Response } from 'express';
@@ -99,12 +100,21 @@ export class OnboardingService implements IOnboardingService {
throw new NotFoundException('Could not found organization details. Please verify the orgnization id');
}
/* Check if the workspace allows user signup or not */
- const { enableSignUp, domain } = signingUpOrganization;
+ const { enableSignUp, passwordAllowedDomains, passwordRestrictedDomains } = signingUpOrganization;
if (!enableSignUp) {
throw new ForbiddenException('Workspace signup has been disabled. Please contact the workspace admin.');
}
- if (!isValidDomain(email, domain)) {
- throw new ForbiddenException('You cannot sign up using the email address - Domain verification failed.');
+ if (
+ !(await validatePasswordDomain(email, passwordAllowedDomains, passwordRestrictedDomains, this.instanceSettingsUtilService))
+ ) {
+ throw new ForbiddenException('This login method is not available for your domain. Please contact admin or try another method.');
+ }
+ } else {
+ // No organization provided - validate against instance-level settings
+ if (
+ !(await validatePasswordDomain(email, undefined, undefined, this.instanceSettingsUtilService))
+ ) {
+ throw new ForbiddenException('This login method is not available for your domain. Please contact admin or try another method.');
}
}