refactor(core): Port SSO config (#17044)

This commit is contained in:
Iván Ovejero
2025-07-07 16:59:35 +02:00
committed by GitHub
parent 044b0bb330
commit 982a7a11f5
13 changed files with 116 additions and 90 deletions

View File

@@ -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;
}

View File

@@ -25,6 +25,7 @@ import { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config'; import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SecurityConfig } from './configs/security.config'; import { SecurityConfig } from './configs/security.config';
import { SentryConfig } from './configs/sentry.config'; import { SentryConfig } from './configs/sentry.config';
import { SsoConfig } from './configs/sso.config';
import { TagsConfig } from './configs/tags.config'; import { TagsConfig } from './configs/tags.config';
import { TemplatesConfig } from './configs/templates.config'; import { TemplatesConfig } from './configs/templates.config';
import { UserManagementConfig } from './configs/user-management.config'; import { UserManagementConfig } from './configs/user-management.config';
@@ -167,6 +168,9 @@ export class GlobalConfig {
@Nested @Nested
personalization: PersonalizationConfig; personalization: PersonalizationConfig;
@Nested
sso: SsoConfig;
/** Default locale for the UI. */ /** Default locale for the UI. */
@Env('N8N_DEFAULT_LOCALE') @Env('N8N_DEFAULT_LOCALE')
defaultLocale: string = 'en'; defaultLocale: string = 'en';

View File

@@ -331,6 +331,21 @@ describe('GlobalConfig', () => {
enabled: true, enabled: true,
pruneTime: -1, 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', () => { it('should use all default values when no env variables are defined', () => {

View File

@@ -146,47 +146,6 @@ export const schema = {
env: 'EXTERNAL_FRONTEND_HOOKS_URLS', 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: { redis: {
prefix: { prefix: {
doc: 'Prefix for all n8n related keys', doc: 'Prefix for all n8n related keys',

View File

@@ -1,8 +1,9 @@
import { mockLogger } from '@n8n/backend-test-utils'; import { mockLogger, mockInstance } from '@n8n/backend-test-utils';
import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config';
import { LDAP_FEATURE_NAME, type LdapConfig } from '@n8n/constants'; import { LDAP_FEATURE_NAME, type LdapConfig } from '@n8n/constants';
import type { Settings } from '@n8n/db'; import type { Settings } from '@n8n/db';
import { AuthIdentityRepository, SettingsRepository } from '@n8n/db'; import { AuthIdentityRepository, SettingsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { QueryFailedError } from '@n8n/typeorm'; import { QueryFailedError } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { Client } from 'ldapts'; import { Client } from 'ldapts';
@@ -12,7 +13,7 @@ import { randomString } from 'n8n-workflow';
import config from '@/config'; import config from '@/config';
import type { EventService } from '@/events/event.service'; 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 { import {
getLdapIds, getLdapIds,
createFilter, createFilter,
@@ -51,6 +52,15 @@ jest.mock('n8n-workflow', () => ({
randomString: jest.fn(), randomString: jest.fn(),
})); }));
mockInstance(GlobalConfig, {
sso: {
ldap: {
loginEnabled: true,
loginLabel: 'fakeLoginLabel',
},
},
});
describe('LdapService', () => { describe('LdapService', () => {
const ldapConfig: LdapConfig = { const ldapConfig: LdapConfig = {
loginEnabled: true, loginEnabled: true,
@@ -74,7 +84,7 @@ describe('LdapService', () => {
searchTimeout: 6, searchTimeout: 6,
}; };
let settingsRepository = mockInstance(SettingsRepository); const settingsRepository = mockInstance(SettingsRepository);
beforeAll(() => { beforeAll(() => {
// Need fake timers to avoid setInterval // Need fake timers to avoid setInterval
@@ -116,13 +126,12 @@ describe('LdapService', () => {
await ldapService.init(); await ldapService.init();
expect(configSetSpy).toHaveBeenNthCalledWith(1, LDAP_LOGIN_ENABLED, ldapConfig.loginEnabled);
expect(configSetSpy).toHaveBeenNthCalledWith( expect(configSetSpy).toHaveBeenNthCalledWith(
2, 1,
'userManagement.authenticationMethod', 'userManagement.authenticationMethod',
'ldap', '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 () => { it('should set expected configuration variables from LDAP config if LDAP is disabled', async () => {
@@ -134,13 +143,12 @@ describe('LdapService', () => {
await ldapService.init(); await ldapService.init();
expect(configSetSpy).toHaveBeenNthCalledWith(1, LDAP_LOGIN_ENABLED, givenConfig.loginEnabled);
expect(configSetSpy).toHaveBeenNthCalledWith( expect(configSetSpy).toHaveBeenNthCalledWith(
2, 1,
'userManagement.authenticationMethod', 'userManagement.authenticationMethod',
'email', '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 () => { 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 () => { it('should update the LDAP login label in the config', async () => {
mockSettingsRespositoryFindOneByOrFail(ldapConfig); mockSettingsRespositoryFindOneByOrFail(ldapConfig);
mockInstance(AuthIdentityRepository, { mockInstance(AuthIdentityRepository, {
find: jest.fn().mockResolvedValue([{ user: { id: 'userId' } }]), find: jest.fn().mockResolvedValue([{ user: { id: 'userId' } }]),
delete: jest.fn(), delete: jest.fn(),
}); });
const cipherMock = mock<Cipher>({ const cipherMock = mock<Cipher>({
encrypt: jest.fn().mockReturnValue('encryptedPassword'), encrypt: jest.fn().mockReturnValue('encryptedPassword'),
}); });
const configSetSpy = jest.spyOn(config, 'set'); const globalConfig = Container.get(GlobalConfig);
config.set('userManagement.authenticationMethod', 'email'); config.set('userManagement.authenticationMethod', 'email');
const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock()); const ldapService = new LdapService(mockLogger(), settingsRepository, cipherMock, mock());
@@ -377,8 +382,7 @@ describe('LdapService', () => {
loginLabel: 'newLoginLabel', loginLabel: 'newLoginLabel',
}; };
await ldapService.updateConfig(newConfig); await ldapService.updateConfig(newConfig);
expect(globalConfig.sso.ldap.loginLabel).toBe(newConfig.loginLabel);
expect(configSetSpy).toHaveBeenNthCalledWith(4, LDAP_LOGIN_LABEL, newConfig.loginLabel);
}); });
}); });

View File

@@ -1,3 +1,4 @@
import { GlobalConfig } from '@n8n/config';
import type { LdapConfig, ConnectionSecurity } from '@n8n/constants'; import type { LdapConfig, ConnectionSecurity } from '@n8n/constants';
import type { AuthProviderSyncHistory } from '@n8n/db'; import type { AuthProviderSyncHistory } from '@n8n/db';
import { import {
@@ -13,15 +14,9 @@ import type { Entry as LdapUser } from 'ldapts';
import { Filter } from 'ldapts/filters/Filter'; import { Filter } from 'ldapts/filters/Filter';
import { randomString } from 'n8n-workflow'; import { randomString } from 'n8n-workflow';
import config from '@/config';
import { License } from '@/license'; import { License } from '@/license';
import { import { BINARY_AD_ATTRIBUTES, LDAP_CONFIG_SCHEMA } from './constants';
BINARY_AD_ATTRIBUTES,
LDAP_CONFIG_SCHEMA,
LDAP_LOGIN_ENABLED,
LDAP_LOGIN_LABEL,
} from './constants';
/** /**
* Check whether the LDAP feature is disabled in the instance * 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 * 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 * 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 * Validate the structure of the LDAP configuration schema

View File

@@ -1,4 +1,5 @@
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { LdapConfig } from '@n8n/constants'; import type { LdapConfig } from '@n8n/constants';
import { LDAP_FEATURE_NAME } from '@n8n/constants'; import { LDAP_FEATURE_NAME } from '@n8n/constants';
import { SettingsRepository } from '@n8n/db'; import { SettingsRepository } from '@n8n/db';
@@ -12,7 +13,6 @@ import { Cipher } from 'n8n-core';
import { jsonParse, UnexpectedError } from 'n8n-workflow'; import { jsonParse, UnexpectedError } from 'n8n-workflow';
import type { ConnectionOptions } from 'tls'; import type { ConnectionOptions } from 'tls';
import config from '@/config';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
@@ -45,7 +45,7 @@ import {
setCurrentAuthenticationMethod, setCurrentAuthenticationMethod,
} from '@/sso.ee/sso-helpers'; } from '@/sso.ee/sso-helpers';
import { BINARY_AD_ATTRIBUTES, LDAP_LOGIN_ENABLED, LDAP_LOGIN_LABEL } from './constants'; import { BINARY_AD_ATTRIBUTES } from './constants';
@Service() @Service()
export class LdapService { 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 */ /** Take the LDAP configuration and set login enabled and login label to the config object */
private async setGlobalLdapConfigVariables(ldapConfig: LdapConfig): Promise<void> { private async setGlobalLdapConfigVariables(ldapConfig: LdapConfig): Promise<void> {
await this.setLdapLoginEnabled(ldapConfig.loginEnabled); 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 */ /** 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 = const targetAuthenticationMethod =
!enabled && currentAuthenticationMethod === 'ldap' ? 'email' : currentAuthenticationMethod; !enabled && currentAuthenticationMethod === 'ldap' ? 'email' : currentAuthenticationMethod;

View File

@@ -343,20 +343,20 @@ export class FrontendService {
if (this.license.isLdapEnabled()) { if (this.license.isLdapEnabled()) {
Object.assign(this.settings.sso.ldap, { Object.assign(this.settings.sso.ldap, {
loginLabel: getLdapLoginLabel(), loginLabel: getLdapLoginLabel(),
loginEnabled: config.getEnv('sso.ldap.loginEnabled'), loginEnabled: this.globalConfig.sso.ldap.loginEnabled,
}); });
} }
if (this.license.isSamlEnabled()) { if (this.license.isSamlEnabled()) {
Object.assign(this.settings.sso.saml, { Object.assign(this.settings.sso.saml, {
loginLabel: getSamlLoginLabel(), loginLabel: getSamlLoginLabel(),
loginEnabled: config.getEnv('sso.saml.loginEnabled'), loginEnabled: this.globalConfig.sso.saml.loginEnabled,
}); });
} }
if (this.licenseState.isOidcLicensed()) { if (this.licenseState.isOidcLicensed()) {
Object.assign(this.settings.sso.oidc, { Object.assign(this.settings.sso.oidc, {
loginEnabled: config.getEnv('sso.oidc.loginEnabled'), loginEnabled: this.globalConfig.sso.oidc.loginEnabled,
}); });
} }

View File

@@ -8,22 +8,17 @@ import {
type User, type User,
UserRepository, UserRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Container, Service } from '@n8n/di';
import { Cipher } from 'n8n-core'; import { Cipher } from 'n8n-core';
import { jsonParse } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow';
import * as client from 'openid-client'; import * as client from 'openid-client';
import config from '@/config';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
import { import { OIDC_CLIENT_SECRET_REDACTED_VALUE, OIDC_PREFERENCES_DB_KEY } from './constants';
OIDC_CLIENT_SECRET_REDACTED_VALUE,
OIDC_LOGIN_ENABLED,
OIDC_PREFERENCES_DB_KEY,
} from './constants';
import { import {
getCurrentAuthenticationMethod, getCurrentAuthenticationMethod,
isEmailCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod,
@@ -235,7 +230,7 @@ export class OidcService {
const targetAuthenticationMethod = const targetAuthenticationMethod =
!enabled && currentAuthenticationMethod === 'oidc' ? 'email' : currentAuthenticationMethod; !enabled && currentAuthenticationMethod === 'oidc' ? 'email' : currentAuthenticationMethod;
config.set(OIDC_LOGIN_ENABLED, enabled); Container.get(GlobalConfig).sso.oidc.loginEnabled = enabled;
await setCurrentAuthenticationMethod(enabled ? 'oidc' : targetAuthenticationMethod); await setCurrentAuthenticationMethod(enabled ? 'oidc' : targetAuthenticationMethod);
} }

View File

@@ -1,17 +1,16 @@
import type { SamlAcsDto, SamlPreferences } from '@n8n/api-types'; import type { SamlAcsDto, SamlPreferences } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import { AuthIdentity, AuthIdentityRepository, UserRepository } from '@n8n/db'; import { AuthIdentity, AuthIdentityRepository, UserRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { randomString } from 'n8n-workflow'; import { randomString } from 'n8n-workflow';
import type { FlowResult } from 'samlify/types/src/flow'; import type { FlowResult } from 'samlify/types/src/flow';
import config from '@/config';
import { AuthError } from '@/errors/response-errors/auth.error'; import { AuthError } from '@/errors/response-errors/auth.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { License } from '@/license'; import { License } from '@/license';
import { PasswordUtility } from '@/services/password.utility'; import { PasswordUtility } from '@/services/password.utility';
import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants';
import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee'; import { getServiceProviderConfigTestReturnUrl } from './service-provider.ee';
import type { SamlAttributeMapping, SamlUserAttributes } from './types'; import type { SamlAttributeMapping, SamlUserAttributes } from './types';
import { import {
@@ -25,11 +24,11 @@ import {
* Check whether the SAML feature is licensed and enabled in the instance * Check whether the SAML feature is licensed and enabled in the instance
*/ */
export function isSamlLoginEnabled(): boolean { export function isSamlLoginEnabled(): boolean {
return config.getEnv(SAML_LOGIN_ENABLED); return Container.get(GlobalConfig).sso.saml.loginEnabled;
} }
export function getSamlLoginLabel(): string { 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 // can only toggle between email and saml, not directly to e.g. ldap
@@ -44,12 +43,12 @@ export async function setSamlLoginEnabled(enabled: boolean): Promise<void> {
const targetAuthenticationMethod = const targetAuthenticationMethod =
!enabled && currentAuthenticationMethod === 'saml' ? 'email' : currentAuthenticationMethod; !enabled && currentAuthenticationMethod === 'saml' ? 'email' : currentAuthenticationMethod;
config.set(SAML_LOGIN_ENABLED, enabled); Container.get(GlobalConfig).sso.saml.loginEnabled = enabled;
await setCurrentAuthenticationMethod(enabled ? 'saml' : targetAuthenticationMethod); await setCurrentAuthenticationMethod(enabled ? 'saml' : targetAuthenticationMethod);
} }
export function setSamlLoginLabel(label: string): void { export function setSamlLoginLabel(label: string): void {
config.set(SAML_LOGIN_LABEL, label); Container.get(GlobalConfig).sso.saml.loginLabel = label;
} }
export function isSamlLicensed(): boolean { export function isSamlLicensed(): boolean {

View File

@@ -1,3 +1,4 @@
import { GlobalConfig } from '@n8n/config';
import { SettingsRepository, type AuthProviderType } from '@n8n/db'; import { SettingsRepository, type AuthProviderType } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
@@ -44,9 +45,9 @@ export function isEmailCurrentAuthenticationMethod(): boolean {
} }
export function isSsoJustInTimeProvisioningEnabled(): boolean { export function isSsoJustInTimeProvisioningEnabled(): boolean {
return config.getEnv('sso.justInTimeProvisioning'); return Container.get(GlobalConfig).sso.justInTimeProvisioning;
} }
export function doRedirectUsersFromLoginToSsoFlow(): boolean { export function doRedirectUsersFromLoginToSsoFlow(): boolean {
return config.getEnv('sso.redirectLoginToSso'); return Container.get(GlobalConfig).sso.redirectLoginToSso;
} }

View File

@@ -18,7 +18,10 @@ const testServer = utils.setupTestServer({ endpointGroups: ['me'] });
beforeEach(async () => { beforeEach(async () => {
await testDb.truncate(['User']); await testDb.truncate(['User']);
mockInstance(GlobalConfig, { publicApi: { disabled: false } }); mockInstance(GlobalConfig, {
publicApi: { disabled: false },
sso: { saml: { loginEnabled: true } },
});
}); });
describe('Owner shell', () => { describe('Owner shell', () => {

View File

@@ -1,5 +1,7 @@
import { randomEmail, randomName, randomValidPassword } from '@n8n/backend-test-utils'; import { randomEmail, randomName, randomValidPassword } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { setSamlLoginEnabled } from '@/sso.ee/saml/saml-helpers'; import { setSamlLoginEnabled } from '@/sso.ee/saml/saml-helpers';
import { import {
@@ -33,6 +35,7 @@ beforeAll(async () => {
someUser = await createUser({ password: memberPassword }); someUser = await createUser({ password: memberPassword });
authOwnerAgent = testServer.authAgentFor(owner); authOwnerAgent = testServer.authAgentFor(owner);
authMemberAgent = testServer.authAgentFor(someUser); authMemberAgent = testServer.authAgentFor(someUser);
Container.get(GlobalConfig).sso.saml.loginEnabled = true;
}); });
beforeEach(async () => await enableSaml(false)); beforeEach(async () => await enableSaml(false));