diff --git a/packages/@n8n/config/src/configs/sso.config.ts b/packages/@n8n/config/src/configs/sso.config.ts new file mode 100644 index 0000000000..919d04cb30 --- /dev/null +++ b/packages/@n8n/config/src/configs/sso.config.ts @@ -0,0 +1,48 @@ +import { Config, Env, Nested } from '../decorators'; + +@Config +class SamlConfig { + /** Whether to enable SAML SSO. */ + @Env('N8N_SSO_SAML_LOGIN_ENABLED') + loginEnabled: boolean = false; + + @Env('N8N_SSO_SAML_LOGIN_LABEL') + loginLabel: string = ''; +} + +@Config +class OidcConfig { + /** Whether to enable OIDC SSO. */ + @Env('N8N_SSO_OIDC_LOGIN_ENABLED') + loginEnabled: boolean = false; +} + +@Config +class LdapConfig { + /** Whether to enable LDAP SSO. */ + @Env('N8N_SSO_LDAP_LOGIN_ENABLED') + loginEnabled: boolean = false; + + @Env('N8N_SSO_LDAP_LOGIN_LABEL') + loginLabel: string = ''; +} + +@Config +export class SsoConfig { + /** Whether to create users when they log in via SSO. */ + @Env('N8N_SSO_JUST_IN_TIME_PROVISIONING') + justInTimeProvisioning: boolean = true; + + /** Whether to redirect users from the login dialog to initialize SSO flow. */ + @Env('N8N_SSO_REDIRECT_LOGIN_TO_SSO') + redirectLoginToSso: boolean = true; + + @Nested + saml: SamlConfig; + + @Nested + oidc: OidcConfig; + + @Nested + ldap: LdapConfig; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index 862bfa0753..9d4ce0114f 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -25,6 +25,7 @@ import { TaskRunnersConfig } from './configs/runners.config'; import { ScalingModeConfig } from './configs/scaling-mode.config'; import { SecurityConfig } from './configs/security.config'; import { SentryConfig } from './configs/sentry.config'; +import { SsoConfig } from './configs/sso.config'; import { TagsConfig } from './configs/tags.config'; import { TemplatesConfig } from './configs/templates.config'; import { UserManagementConfig } from './configs/user-management.config'; @@ -167,6 +168,9 @@ export class GlobalConfig { @Nested personalization: PersonalizationConfig; + @Nested + sso: SsoConfig; + /** Default locale for the UI. */ @Env('N8N_DEFAULT_LOCALE') defaultLocale: string = 'en'; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 731fdfb85d..6d7b569dc7 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -331,6 +331,21 @@ describe('GlobalConfig', () => { enabled: true, pruneTime: -1, }, + sso: { + justInTimeProvisioning: true, + redirectLoginToSso: true, + saml: { + loginEnabled: false, + loginLabel: '', + }, + oidc: { + loginEnabled: false, + }, + ldap: { + loginEnabled: false, + loginLabel: '', + }, + }, }; it('should use all default values when no env variables are defined', () => { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index d72bdbb2ac..d7bfad28d3 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -146,47 +146,6 @@ export const schema = { env: 'EXTERNAL_FRONTEND_HOOKS_URLS', }, - sso: { - justInTimeProvisioning: { - format: Boolean, - default: true, - doc: 'Whether to automatically create users when they login via SSO.', - }, - redirectLoginToSso: { - format: Boolean, - default: true, - doc: 'Whether to automatically redirect users from login dialog to initialize SSO flow.', - }, - saml: { - loginEnabled: { - format: Boolean, - default: false, - doc: 'Whether to enable SAML SSO.', - }, - loginLabel: { - format: String, - default: '', - }, - }, - oidc: { - loginEnabled: { - format: Boolean, - default: false, - doc: 'Whether to enable OIDC SSO.', - }, - }, - ldap: { - loginEnabled: { - format: Boolean, - default: false, - }, - loginLabel: { - format: String, - default: '', - }, - }, - }, - redis: { prefix: { doc: 'Prefix for all n8n related keys', diff --git a/packages/cli/src/ldap.ee/__tests__/ldap.service.test.ts b/packages/cli/src/ldap.ee/__tests__/ldap.service.test.ts index a923e10f2b..3fdd01dd61 100644 --- a/packages/cli/src/ldap.ee/__tests__/ldap.service.test.ts +++ b/packages/cli/src/ldap.ee/__tests__/ldap.service.test.ts @@ -1,8 +1,9 @@ -import { mockLogger } from '@n8n/backend-test-utils'; -import { mockInstance } from '@n8n/backend-test-utils'; +import { mockLogger, mockInstance } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; import { LDAP_FEATURE_NAME, type LdapConfig } from '@n8n/constants'; import type { Settings } from '@n8n/db'; import { AuthIdentityRepository, SettingsRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; import { QueryFailedError } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import { Client } from 'ldapts'; @@ -12,7 +13,7 @@ import { randomString } from 'n8n-workflow'; import config from '@/config'; import type { EventService } from '@/events/event.service'; -import { BINARY_AD_ATTRIBUTES, LDAP_LOGIN_ENABLED, LDAP_LOGIN_LABEL } from '../constants'; +import { BINARY_AD_ATTRIBUTES } from '../constants'; import { getLdapIds, createFilter, @@ -51,6 +52,15 @@ jest.mock('n8n-workflow', () => ({ randomString: jest.fn(), })); +mockInstance(GlobalConfig, { + sso: { + ldap: { + loginEnabled: true, + loginLabel: 'fakeLoginLabel', + }, + }, +}); + describe('LdapService', () => { const ldapConfig: LdapConfig = { loginEnabled: true, @@ -74,7 +84,7 @@ describe('LdapService', () => { searchTimeout: 6, }; - let settingsRepository = mockInstance(SettingsRepository); + const settingsRepository = mockInstance(SettingsRepository); beforeAll(() => { // Need fake timers to avoid setInterval @@ -116,13 +126,12 @@ describe('LdapService', () => { await ldapService.init(); - expect(configSetSpy).toHaveBeenNthCalledWith(1, LDAP_LOGIN_ENABLED, ldapConfig.loginEnabled); expect(configSetSpy).toHaveBeenNthCalledWith( - 2, + 1, 'userManagement.authenticationMethod', 'ldap', ); - expect(configSetSpy).toHaveBeenNthCalledWith(3, LDAP_LOGIN_LABEL, ldapConfig.loginLabel); + expect(Container.get(GlobalConfig).sso.ldap.loginLabel).toBe(ldapConfig.loginLabel); }); it('should set expected configuration variables from LDAP config if LDAP is disabled', async () => { @@ -134,13 +143,12 @@ describe('LdapService', () => { await ldapService.init(); - expect(configSetSpy).toHaveBeenNthCalledWith(1, LDAP_LOGIN_ENABLED, givenConfig.loginEnabled); expect(configSetSpy).toHaveBeenNthCalledWith( - 2, + 1, 'userManagement.authenticationMethod', 'email', ); - expect(configSetSpy).toHaveBeenNthCalledWith(3, LDAP_LOGIN_LABEL, givenConfig.loginLabel); + expect(Container.get(GlobalConfig).sso.ldap.loginLabel).toBe(ldapConfig.loginLabel); }); it('should show logger warning if authentication method is not ldap or email', async () => { @@ -356,17 +364,14 @@ describe('LdapService', () => { it('should update the LDAP login label in the config', async () => { mockSettingsRespositoryFindOneByOrFail(ldapConfig); - mockInstance(AuthIdentityRepository, { find: jest.fn().mockResolvedValue([{ user: { id: 'userId' } }]), delete: jest.fn(), }); - const cipherMock = mock({ encrypt: jest.fn().mockReturnValue('encryptedPassword'), }); - const configSetSpy = jest.spyOn(config, 'set'); - + const globalConfig = Container.get(GlobalConfig); config.set('userManagement.authenticationMethod', 'email'); const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); @@ -377,8 +382,7 @@ describe('LdapService', () => { loginLabel: 'newLoginLabel', }; await ldapService.updateConfig(newConfig); - - expect(configSetSpy).toHaveBeenNthCalledWith(4, LDAP_LOGIN_LABEL, newConfig.loginLabel); + expect(globalConfig.sso.ldap.loginLabel).toBe(newConfig.loginLabel); }); }); diff --git a/packages/cli/src/ldap.ee/helpers.ee.ts b/packages/cli/src/ldap.ee/helpers.ee.ts index 0416535eee..a58b5ae03d 100644 --- a/packages/cli/src/ldap.ee/helpers.ee.ts +++ b/packages/cli/src/ldap.ee/helpers.ee.ts @@ -1,3 +1,4 @@ +import { GlobalConfig } from '@n8n/config'; import type { LdapConfig, ConnectionSecurity } from '@n8n/constants'; import type { AuthProviderSyncHistory } from '@n8n/db'; import { @@ -13,15 +14,9 @@ import type { Entry as LdapUser } from 'ldapts'; import { Filter } from 'ldapts/filters/Filter'; import { randomString } from 'n8n-workflow'; -import config from '@/config'; import { License } from '@/license'; -import { - BINARY_AD_ATTRIBUTES, - LDAP_CONFIG_SCHEMA, - LDAP_LOGIN_ENABLED, - LDAP_LOGIN_LABEL, -} from './constants'; +import { BINARY_AD_ATTRIBUTES, LDAP_CONFIG_SCHEMA } from './constants'; /** * Check whether the LDAP feature is disabled in the instance @@ -33,12 +28,12 @@ export const isLdapEnabled = () => { /** * Retrieve the LDAP login label from the configuration object */ -export const getLdapLoginLabel = (): string => config.getEnv(LDAP_LOGIN_LABEL); +export const getLdapLoginLabel = (): string => Container.get(GlobalConfig).sso.ldap.loginLabel; /** * Retrieve the LDAP login enabled from the configuration object */ -export const isLdapLoginEnabled = (): boolean => config.getEnv(LDAP_LOGIN_ENABLED); +export const isLdapLoginEnabled = (): boolean => Container.get(GlobalConfig).sso.ldap.loginEnabled; /** * Validate the structure of the LDAP configuration schema diff --git a/packages/cli/src/ldap.ee/ldap.service.ee.ts b/packages/cli/src/ldap.ee/ldap.service.ee.ts index 38edfb1032..da494aa093 100644 --- a/packages/cli/src/ldap.ee/ldap.service.ee.ts +++ b/packages/cli/src/ldap.ee/ldap.service.ee.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; import type { LdapConfig } from '@n8n/constants'; import { LDAP_FEATURE_NAME } from '@n8n/constants'; import { SettingsRepository } from '@n8n/db'; @@ -12,7 +13,6 @@ import { Cipher } from 'n8n-core'; import { jsonParse, UnexpectedError } from 'n8n-workflow'; import type { ConnectionOptions } from 'tls'; -import config from '@/config'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; @@ -45,7 +45,7 @@ import { setCurrentAuthenticationMethod, } from '@/sso.ee/sso-helpers'; -import { BINARY_AD_ATTRIBUTES, LDAP_LOGIN_ENABLED, LDAP_LOGIN_LABEL } from './constants'; +import { BINARY_AD_ATTRIBUTES } from './constants'; @Service() export class LdapService { @@ -141,7 +141,7 @@ export class LdapService { /** Take the LDAP configuration and set login enabled and login label to the config object */ private async setGlobalLdapConfigVariables(ldapConfig: LdapConfig): Promise { await this.setLdapLoginEnabled(ldapConfig.loginEnabled); - config.set(LDAP_LOGIN_LABEL, ldapConfig.loginLabel); + Container.get(GlobalConfig).sso.ldap.loginLabel = ldapConfig.loginLabel; } /** Set the LDAP login enabled to the configuration object */ @@ -153,7 +153,7 @@ export class LdapService { ); } - config.set(LDAP_LOGIN_ENABLED, enabled); + Container.get(GlobalConfig).sso.ldap.loginEnabled = enabled; const targetAuthenticationMethod = !enabled && currentAuthenticationMethod === 'ldap' ? 'email' : currentAuthenticationMethod; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index d3d2557724..dcc78600a4 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -343,20 +343,20 @@ export class FrontendService { if (this.license.isLdapEnabled()) { Object.assign(this.settings.sso.ldap, { loginLabel: getLdapLoginLabel(), - loginEnabled: config.getEnv('sso.ldap.loginEnabled'), + loginEnabled: this.globalConfig.sso.ldap.loginEnabled, }); } if (this.license.isSamlEnabled()) { Object.assign(this.settings.sso.saml, { loginLabel: getSamlLoginLabel(), - loginEnabled: config.getEnv('sso.saml.loginEnabled'), + loginEnabled: this.globalConfig.sso.saml.loginEnabled, }); } if (this.licenseState.isOidcLicensed()) { Object.assign(this.settings.sso.oidc, { - loginEnabled: config.getEnv('sso.oidc.loginEnabled'), + loginEnabled: this.globalConfig.sso.oidc.loginEnabled, }); } diff --git a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts index fa66d44f1b..5e843f9eec 100644 --- a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts +++ b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts @@ -8,22 +8,17 @@ import { type User, UserRepository, } from '@n8n/db'; -import { Service } from '@n8n/di'; +import { Container, Service } from '@n8n/di'; import { Cipher } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import * as client from 'openid-client'; -import config from '@/config'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { UrlService } from '@/services/url.service'; -import { - OIDC_CLIENT_SECRET_REDACTED_VALUE, - OIDC_LOGIN_ENABLED, - OIDC_PREFERENCES_DB_KEY, -} from './constants'; +import { OIDC_CLIENT_SECRET_REDACTED_VALUE, OIDC_PREFERENCES_DB_KEY } from './constants'; import { getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, @@ -235,7 +230,7 @@ export class OidcService { const targetAuthenticationMethod = !enabled && currentAuthenticationMethod === 'oidc' ? 'email' : currentAuthenticationMethod; - config.set(OIDC_LOGIN_ENABLED, enabled); + Container.get(GlobalConfig).sso.oidc.loginEnabled = enabled; await setCurrentAuthenticationMethod(enabled ? 'oidc' : targetAuthenticationMethod); } diff --git a/packages/cli/src/sso.ee/saml/saml-helpers.ts b/packages/cli/src/sso.ee/saml/saml-helpers.ts index 3e2330f01f..cebbac7ea4 100644 --- a/packages/cli/src/sso.ee/saml/saml-helpers.ts +++ b/packages/cli/src/sso.ee/saml/saml-helpers.ts @@ -1,17 +1,16 @@ import type { SamlAcsDto, SamlPreferences } from '@n8n/api-types'; +import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; import { AuthIdentity, AuthIdentityRepository, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { randomString } from 'n8n-workflow'; import type { FlowResult } from 'samlify/types/src/flow'; -import config from '@/config'; import { AuthError } from '@/errors/response-errors/auth.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { License } from '@/license'; import { PasswordUtility } from '@/services/password.utility'; -import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee'; import type { SamlAttributeMapping, SamlUserAttributes } from './types'; import { @@ -25,11 +24,11 @@ import { * Check whether the SAML feature is licensed and enabled in the instance */ export function isSamlLoginEnabled(): boolean { - return config.getEnv(SAML_LOGIN_ENABLED); + return Container.get(GlobalConfig).sso.saml.loginEnabled; } export function getSamlLoginLabel(): string { - return config.getEnv(SAML_LOGIN_LABEL); + return Container.get(GlobalConfig).sso.saml.loginLabel; } // can only toggle between email and saml, not directly to e.g. ldap @@ -44,12 +43,12 @@ export async function setSamlLoginEnabled(enabled: boolean): Promise { const targetAuthenticationMethod = !enabled && currentAuthenticationMethod === 'saml' ? 'email' : currentAuthenticationMethod; - config.set(SAML_LOGIN_ENABLED, enabled); + Container.get(GlobalConfig).sso.saml.loginEnabled = enabled; await setCurrentAuthenticationMethod(enabled ? 'saml' : targetAuthenticationMethod); } export function setSamlLoginLabel(label: string): void { - config.set(SAML_LOGIN_LABEL, label); + Container.get(GlobalConfig).sso.saml.loginLabel = label; } export function isSamlLicensed(): boolean { diff --git a/packages/cli/src/sso.ee/sso-helpers.ts b/packages/cli/src/sso.ee/sso-helpers.ts index e7fd6d8120..d59cb0892f 100644 --- a/packages/cli/src/sso.ee/sso-helpers.ts +++ b/packages/cli/src/sso.ee/sso-helpers.ts @@ -1,3 +1,4 @@ +import { GlobalConfig } from '@n8n/config'; import { SettingsRepository, type AuthProviderType } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -44,9 +45,9 @@ export function isEmailCurrentAuthenticationMethod(): boolean { } export function isSsoJustInTimeProvisioningEnabled(): boolean { - return config.getEnv('sso.justInTimeProvisioning'); + return Container.get(GlobalConfig).sso.justInTimeProvisioning; } export function doRedirectUsersFromLoginToSsoFlow(): boolean { - return config.getEnv('sso.redirectLoginToSso'); + return Container.get(GlobalConfig).sso.redirectLoginToSso; } diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index ad5230183f..a486bd6fec 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -18,7 +18,10 @@ const testServer = utils.setupTestServer({ endpointGroups: ['me'] }); beforeEach(async () => { await testDb.truncate(['User']); - mockInstance(GlobalConfig, { publicApi: { disabled: false } }); + mockInstance(GlobalConfig, { + publicApi: { disabled: false }, + sso: { saml: { loginEnabled: true } }, + }); }); describe('Owner shell', () => { diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index 00345af21e..219cb19f4a 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -1,5 +1,7 @@ import { randomEmail, randomName, randomValidPassword } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; +import { Container } from '@n8n/di'; import { setSamlLoginEnabled } from '@/sso.ee/saml/saml-helpers'; import { @@ -33,6 +35,7 @@ beforeAll(async () => { someUser = await createUser({ password: memberPassword }); authOwnerAgent = testServer.authAgentFor(owner); authMemberAgent = testServer.authAgentFor(someUser); + Container.get(GlobalConfig).sso.saml.loginEnabled = true; }); beforeEach(async () => await enableSaml(false));