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.'); } }