refactor(core): Split SSO loader (no-changelog) (#30065)

This commit is contained in:
Irénée 2026-05-11 12:16:02 +02:00 committed by GitHub
parent 96b018d356
commit 26beabb445
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 817 additions and 646 deletions

View File

@ -7,7 +7,7 @@ import type { LogStreamingInstanceSettingsLoader } from '../loaders/log-streamin
import type { McpSettingsLoader } from '../loaders/mcp-settings.loader';
import type { OwnerInstanceSettingsLoader } from '../loaders/owner.instance-settings-loader';
import type { SecurityPolicyInstanceSettingsLoader } from '../loaders/security-policy.instance-settings-loader';
import type { SsoInstanceSettingsLoader } from '../loaders/sso.instance-settings-loader';
import type { SsoInstanceSettingsLoader } from '../loaders/sso/sso.instance-settings-loader';
describe('InstanceSettingsLoaderService', () => {
const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });

View File

@ -1,402 +0,0 @@
import { mock } from 'jest-mock-extended';
import type { Logger } from '@n8n/backend-common';
import type { InstanceSettingsLoaderConfig } from '@n8n/config';
import type { SettingsRepository } from '@n8n/db';
import type { Cipher } from 'n8n-core';
import { SsoInstanceSettingsLoader } from '../loaders/sso.instance-settings-loader';
const mockSetCurrentAuthenticationMethod = jest.fn();
const mockGetCurrentAuthenticationMethod = jest.fn().mockReturnValue('email');
jest.mock('@/sso.ee/sso-helpers', () => ({
setCurrentAuthenticationMethod: (...args: unknown[]) =>
mockSetCurrentAuthenticationMethod(...args),
getCurrentAuthenticationMethod: () => mockGetCurrentAuthenticationMethod(),
}));
describe('SsoInstanceSettingsLoader', () => {
const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
const settingsRepository = mock<SettingsRepository>();
const cipher = mock<Cipher>();
const baseConfig: Partial<InstanceSettingsLoaderConfig> = {
ssoManagedByEnv: false,
oidcClientId: '',
oidcClientSecret: '',
oidcDiscoveryEndpoint: '',
oidcLoginEnabled: false,
oidcPrompt: 'select_account',
oidcAcrValues: '',
samlMetadata: '',
samlMetadataUrl: '',
samlLoginEnabled: false,
ssoUserRoleProvisioning: 'disabled',
};
const validSamlConfig: Partial<InstanceSettingsLoaderConfig> = {
...baseConfig,
ssoManagedByEnv: true,
samlMetadata: '<xml>metadata</xml>',
samlLoginEnabled: true,
};
const validOidcConfig: Partial<InstanceSettingsLoaderConfig> = {
...baseConfig,
ssoManagedByEnv: true,
oidcClientId: 'my-client-id',
oidcClientSecret: 'my-client-secret',
oidcDiscoveryEndpoint: 'https://idp.example.com/.well-known/openid-configuration',
oidcLoginEnabled: true,
};
const createLoader = (configOverrides: Partial<InstanceSettingsLoaderConfig> = {}) => {
const config = { ...baseConfig, ...configOverrides } as InstanceSettingsLoaderConfig;
return new SsoInstanceSettingsLoader(config, settingsRepository, cipher, logger);
};
const getSaveCall = (key: string) =>
settingsRepository.save.mock.calls.find(
(call) => (call[0] as { key: string }).key === key,
)?.[0] as { key: string; value: string } | undefined;
const getUpsertCall = (key: string) =>
settingsRepository.upsert.mock.calls.find(
(call) => (call[0] as { key: string }).key === key,
)?.[0] as { key: string; value: string } | undefined;
beforeEach(() => {
jest.resetAllMocks();
logger.scoped.mockReturnThis();
cipher.encryptV2.mockImplementation(async (v: string) => `encrypted:${v}`);
mockGetCurrentAuthenticationMethod.mockReturnValue('email');
});
describe('ssoManagedByEnv gate', () => {
it('should skip when ssoManagedByEnv is false', async () => {
const loader = createLoader({ ssoManagedByEnv: false });
const result = await loader.run();
expect(result).toBe('skipped');
expect(settingsRepository.save).not.toHaveBeenCalled();
expect(settingsRepository.upsert).not.toHaveBeenCalled();
});
});
describe('mutual exclusion', () => {
it('should throw when both SAML and OIDC login are enabled', async () => {
const loader = createLoader({
...validSamlConfig,
...validOidcConfig,
samlLoginEnabled: true,
oidcLoginEnabled: true,
});
await expect(loader.run()).rejects.toThrow(
'N8N_SSO_SAML_LOGIN_ENABLED and N8N_SSO_OIDC_LOGIN_ENABLED cannot both be true',
);
});
});
describe('SAML config', () => {
it('should throw when neither metadata nor metadataUrl is provided and loginEnabled is true', async () => {
const loader = createLoader({
...validSamlConfig,
samlMetadata: '',
samlMetadataUrl: '',
});
await expect(loader.run()).rejects.toThrow(
'At least one of N8N_SSO_SAML_METADATA or N8N_SSO_SAML_METADATA_URL is required',
);
});
it('should save SAML preferences when valid config with metadata is provided', async () => {
const loader = createLoader(validSamlConfig);
const result = await loader.run();
expect(result).toBe('created');
const saved = getSaveCall('features.saml');
expect(saved).toBeDefined();
expect(JSON.parse(saved!.value)).toEqual({
metadata: '<xml>metadata</xml>',
loginEnabled: true,
});
});
it('should save SAML preferences when valid config with metadataUrl is provided', async () => {
const loader = createLoader({
...validSamlConfig,
samlMetadata: '',
samlMetadataUrl: 'https://idp.example.com/metadata',
});
await loader.run();
const saved = JSON.parse(getSaveCall('features.saml')!.value);
expect(saved.metadataUrl).toBe('https://idp.example.com/metadata');
expect(saved.metadata).toBeUndefined();
});
it('should set authentication method to saml when SAML login is enabled', async () => {
const loader = createLoader(validSamlConfig);
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('saml');
});
});
describe('OIDC config', () => {
it('should throw when clientId is missing and loginEnabled is true', async () => {
const loader = createLoader({ ...validOidcConfig, oidcClientId: '' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_OIDC_CLIENT_ID is required');
});
it('should throw when clientSecret is missing and loginEnabled is true', async () => {
const loader = createLoader({ ...validOidcConfig, oidcClientSecret: '' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_OIDC_CLIENT_SECRET is required');
});
it('should throw when discoveryEndpoint is not a valid URL and loginEnabled is true', async () => {
const loader = createLoader({ ...validOidcConfig, oidcDiscoveryEndpoint: 'not-a-url' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_OIDC_DISCOVERY_ENDPOINT');
});
it('should throw when oidcPrompt has an invalid value', async () => {
const loader = createLoader({ ...validOidcConfig, oidcPrompt: 'invalid' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_OIDC_PROMPT');
});
it('should upsert OIDC preferences when valid config is provided', async () => {
const loader = createLoader(validOidcConfig);
const result = await loader.run();
expect(result).toBe('created');
const upserted = getUpsertCall('features.oidc');
expect(upserted).toBeDefined();
const parsed = JSON.parse(upserted!.value);
expect(parsed.clientId).toBe('my-client-id');
expect(parsed.clientSecret).toBe('encrypted:my-client-secret');
expect(parsed.loginEnabled).toBe(true);
});
it('should handle messy ACR values with extra commas and whitespace', async () => {
const loader = createLoader({ ...validOidcConfig, oidcAcrValues: ',mfa,, phrh ,,' });
await loader.run();
const parsed = JSON.parse(getUpsertCall('features.oidc')!.value);
expect(parsed.authenticationContextClassReference).toEqual(['mfa', 'phrh']);
});
it('should set authentication method to oidc when OIDC login is enabled', async () => {
const loader = createLoader(validOidcConfig);
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('oidc');
});
});
describe('provisioning', () => {
it('should throw when ssoUserRoleProvisioning has an invalid value with SAML enabled', async () => {
const loader = createLoader({ ...validSamlConfig, ssoUserRoleProvisioning: 'invalid' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_USER_ROLE_PROVISIONING must be one of');
});
it('should throw when ssoUserRoleProvisioning has an invalid value with OIDC enabled', async () => {
const loader = createLoader({ ...validOidcConfig, ssoUserRoleProvisioning: 'invalid' });
await expect(loader.run()).rejects.toThrow('N8N_SSO_USER_ROLE_PROVISIONING must be one of');
});
it('should write disabled provisioning config when SAML is enabled', async () => {
const loader = createLoader(validSamlConfig);
await loader.run();
expect(settingsRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
key: 'features.provisioning',
value: JSON.stringify({
scopesProvisionInstanceRole: false,
scopesProvisionProjectRoles: false,
scopesUseExpressionMapping: false,
}),
}),
{ conflictPaths: ['key'] },
);
});
it('should write instance_role provisioning config', async () => {
const loader = createLoader({ ...validSamlConfig, ssoUserRoleProvisioning: 'instance_role' });
await loader.run();
expect(settingsRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
key: 'features.provisioning',
value: JSON.stringify({
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: false,
scopesUseExpressionMapping: false,
}),
}),
{ conflictPaths: ['key'] },
);
});
it('should write instance_and_project_roles provisioning config', async () => {
const loader = createLoader({
...validOidcConfig,
ssoUserRoleProvisioning: 'instance_and_project_roles',
});
await loader.run();
expect(settingsRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
key: 'features.provisioning',
value: JSON.stringify({
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: true,
scopesUseExpressionMapping: false,
}),
}),
{ conflictPaths: ['key'] },
);
});
it('should not write provisioning config when neither protocol is enabled', async () => {
const loader = createLoader({ ssoManagedByEnv: true });
await loader.run();
expect(
settingsRepository.upsert.mock.calls.find(
(call) => (call[0] as { key: string }).key === 'features.provisioning',
),
).toBeUndefined();
});
});
describe('disabled config', () => {
it('should write loginEnabled=false for both protocols when no env vars are set', async () => {
const loader = createLoader({ ssoManagedByEnv: true });
const result = await loader.run();
expect(result).toBe('created');
expect(getSaveCall('features.saml')?.value).toBe(JSON.stringify({ loginEnabled: false }));
expect(getUpsertCall('features.oidc')?.value).toBe(JSON.stringify({ loginEnabled: false }));
});
it('should ignore SAML env vars and write loginEnabled=false when SAML is not enabled', async () => {
const loader = createLoader({
...validSamlConfig,
samlLoginEnabled: false,
});
const result = await loader.run();
expect(result).toBe('created');
expect(getSaveCall('features.saml')?.value).toBe(JSON.stringify({ loginEnabled: false }));
});
it('should ignore OIDC env vars and write loginEnabled=false when OIDC is not enabled', async () => {
const loader = createLoader({
...validOidcConfig,
oidcLoginEnabled: false,
});
const result = await loader.run();
expect(result).toBe('created');
expect(getUpsertCall('features.oidc')?.value).toBe(JSON.stringify({ loginEnabled: false }));
});
it('should ignore invalid OIDC env vars and write loginEnabled=false when OIDC is not enabled', async () => {
const loader = createLoader({
ssoManagedByEnv: true,
oidcClientId: 'some-id',
oidcDiscoveryEndpoint: 'not-a-url',
});
const result = await loader.run();
expect(result).toBe('created');
expect(getUpsertCall('features.oidc')?.value).toBe(JSON.stringify({ loginEnabled: false }));
});
});
describe('auth method sync', () => {
it('should reset auth method to email when current is saml and neither protocol is enabled', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('saml');
const loader = createLoader({ ssoManagedByEnv: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('email');
});
it('should reset auth method to email when current is oidc and neither protocol is enabled', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('oidc');
const loader = createLoader({ ssoManagedByEnv: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('email');
});
it('should not change auth method when current is email and neither protocol is enabled', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('email');
const loader = createLoader({ ssoManagedByEnv: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).not.toHaveBeenCalled();
});
it('should set auth method to saml (not email) when switching from oidc to saml', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('oidc');
const loader = createLoader(validSamlConfig);
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('saml');
expect(mockSetCurrentAuthenticationMethod).not.toHaveBeenCalledWith('email');
});
it('should set auth method to oidc (not email) when switching from saml to oidc', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('saml');
const loader = createLoader(validOidcConfig);
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('oidc');
expect(mockSetCurrentAuthenticationMethod).not.toHaveBeenCalledWith('email');
});
});
describe('cross-protocol state', () => {
it('should write OIDC loginEnabled=false when SAML is enabled and OIDC has no env vars', async () => {
const loader = createLoader(validSamlConfig);
await loader.run();
expect(getUpsertCall('features.oidc')?.value).toBe(JSON.stringify({ loginEnabled: false }));
});
it('should write SAML loginEnabled=false when OIDC is enabled and SAML has no env vars', async () => {
const loader = createLoader(validOidcConfig);
await loader.run();
expect(getSaveCall('features.saml')?.value).toBe(JSON.stringify({ loginEnabled: false }));
});
});
});

View File

@ -0,0 +1,123 @@
import type { Logger } from '@n8n/backend-common';
import type { InstanceSettingsLoaderConfig } from '@n8n/config';
import type { SettingsRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { Cipher } from 'n8n-core';
import { OidcInstanceSettingsLoader } from '../../loaders/sso/oidc.instance-settings-loader';
describe('OidcInstanceSettingsLoader', () => {
const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
const settingsRepository = mock<SettingsRepository>();
const cipher = mock<Cipher>();
const baseConfig: Partial<InstanceSettingsLoaderConfig> = {
oidcClientId: '',
oidcClientSecret: '',
oidcDiscoveryEndpoint: '',
oidcLoginEnabled: false,
oidcPrompt: 'select_account',
oidcAcrValues: '',
};
const validConfig: Partial<InstanceSettingsLoaderConfig> = {
...baseConfig,
oidcClientId: 'my-client-id',
oidcClientSecret: 'my-client-secret',
oidcDiscoveryEndpoint: 'https://idp.example.com/.well-known/openid-configuration',
oidcLoginEnabled: true,
};
const createLoader = (configOverrides: Partial<InstanceSettingsLoaderConfig> = {}) => {
const config = { ...baseConfig, ...configOverrides } as InstanceSettingsLoaderConfig;
return new OidcInstanceSettingsLoader(config, settingsRepository, cipher, logger);
};
const getUpsertedValue = () =>
JSON.parse((settingsRepository.upsert.mock.calls[0][0] as { value: string }).value);
beforeEach(() => {
jest.resetAllMocks();
logger.scoped.mockReturnThis();
cipher.encryptV2.mockImplementation(async (v: string) => `encrypted:${v}`);
});
describe('when OIDC login is enabled', () => {
it('should throw when clientId is missing', async () => {
const loader = createLoader({ ...validConfig, oidcClientId: '' });
await expect(loader.apply()).rejects.toThrow('N8N_SSO_OIDC_CLIENT_ID is required');
});
it('should throw when clientSecret is missing', async () => {
const loader = createLoader({ ...validConfig, oidcClientSecret: '' });
await expect(loader.apply()).rejects.toThrow('N8N_SSO_OIDC_CLIENT_SECRET is required');
});
it('should throw when discoveryEndpoint is not a valid URL', async () => {
const loader = createLoader({ ...validConfig, oidcDiscoveryEndpoint: 'not-a-url' });
await expect(loader.apply()).rejects.toThrow('N8N_SSO_OIDC_DISCOVERY_ENDPOINT');
});
it('should throw when oidcPrompt has an invalid value', async () => {
const loader = createLoader({ ...validConfig, oidcPrompt: 'invalid' });
await expect(loader.apply()).rejects.toThrow('N8N_SSO_OIDC_PROMPT');
});
it('should upsert preferences with encrypted clientSecret when valid config is provided', async () => {
const loader = createLoader(validConfig);
await loader.apply();
const parsed = getUpsertedValue();
expect(parsed.clientId).toBe('my-client-id');
expect(parsed.clientSecret).toBe('encrypted:my-client-secret');
expect(parsed.loginEnabled).toBe(true);
});
it('should handle messy ACR values with extra commas and whitespace', async () => {
const loader = createLoader({ ...validConfig, oidcAcrValues: ',mfa,, phrh ,,' });
await loader.apply();
const parsed = getUpsertedValue();
expect(parsed.authenticationContextClassReference).toEqual(['mfa', 'phrh']);
});
});
describe('when OIDC login is disabled', () => {
it('should upsert loginEnabled=false', async () => {
const loader = createLoader({ oidcLoginEnabled: false });
await loader.apply();
expect(settingsRepository.upsert).toHaveBeenCalledWith(
{
key: 'features.oidc',
value: JSON.stringify({ loginEnabled: false }),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
});
it('should ignore OIDC env vars and upsert loginEnabled=false', async () => {
const loader = createLoader({ ...validConfig, oidcLoginEnabled: false });
await loader.apply();
expect(getUpsertedValue()).toEqual({ loginEnabled: false });
});
it('should ignore invalid OIDC env vars and upsert loginEnabled=false', async () => {
const loader = createLoader({
oidcClientId: 'some-id',
oidcDiscoveryEndpoint: 'not-a-url',
oidcLoginEnabled: false,
});
await loader.apply();
expect(getUpsertedValue()).toEqual({ loginEnabled: false });
});
});
});

View File

@ -0,0 +1,87 @@
import type { Logger } from '@n8n/backend-common';
import type { InstanceSettingsLoaderConfig } from '@n8n/config';
import type { SettingsRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import { ProvisioningInstanceSettingsLoader } from '../../loaders/sso/provisioning.instance-settings-loader';
describe('ProvisioningInstanceSettingsLoader', () => {
const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
const settingsRepository = mock<SettingsRepository>();
const createLoader = (configOverrides: Partial<InstanceSettingsLoaderConfig> = {}) => {
const config = {
ssoUserRoleProvisioning: 'disabled',
...configOverrides,
} as InstanceSettingsLoaderConfig;
return new ProvisioningInstanceSettingsLoader(config, settingsRepository, logger);
};
beforeEach(() => {
jest.resetAllMocks();
logger.scoped.mockReturnThis();
});
it('should throw when ssoUserRoleProvisioning has an invalid value', async () => {
const loader = createLoader({ ssoUserRoleProvisioning: 'invalid' });
await expect(loader.apply()).rejects.toThrow('N8N_SSO_USER_ROLE_PROVISIONING must be one of');
});
it('should write disabled provisioning config', async () => {
const loader = createLoader({ ssoUserRoleProvisioning: 'disabled' });
await loader.apply();
expect(settingsRepository.upsert).toHaveBeenCalledWith(
{
key: 'features.provisioning',
value: JSON.stringify({
scopesProvisionInstanceRole: false,
scopesProvisionProjectRoles: false,
scopesUseExpressionMapping: false,
}),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
});
it('should write instance_role provisioning config', async () => {
const loader = createLoader({ ssoUserRoleProvisioning: 'instance_role' });
await loader.apply();
expect(settingsRepository.upsert).toHaveBeenCalledWith(
{
key: 'features.provisioning',
value: JSON.stringify({
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: false,
scopesUseExpressionMapping: false,
}),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
});
it('should write instance_and_project_roles provisioning config', async () => {
const loader = createLoader({ ssoUserRoleProvisioning: 'instance_and_project_roles' });
await loader.apply();
expect(settingsRepository.upsert).toHaveBeenCalledWith(
{
key: 'features.provisioning',
value: JSON.stringify({
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: true,
scopesUseExpressionMapping: false,
}),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
});
});

View File

@ -0,0 +1,96 @@
import type { Logger } from '@n8n/backend-common';
import type { InstanceSettingsLoaderConfig } from '@n8n/config';
import type { SettingsRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import { SamlInstanceSettingsLoader } from '../../loaders/sso/saml.instance-settings-loader';
describe('SamlInstanceSettingsLoader', () => {
const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
const settingsRepository = mock<SettingsRepository>();
const baseConfig: Partial<InstanceSettingsLoaderConfig> = {
samlMetadata: '',
samlMetadataUrl: '',
samlLoginEnabled: false,
};
const createLoader = (configOverrides: Partial<InstanceSettingsLoaderConfig> = {}) => {
const config = { ...baseConfig, ...configOverrides } as InstanceSettingsLoaderConfig;
return new SamlInstanceSettingsLoader(config, settingsRepository, logger);
};
beforeEach(() => {
jest.resetAllMocks();
logger.scoped.mockReturnThis();
});
describe('when SAML login is enabled', () => {
it('should throw when neither metadata nor metadataUrl is provided', async () => {
const loader = createLoader({ samlLoginEnabled: true });
await expect(loader.apply()).rejects.toThrow(
'At least one of N8N_SSO_SAML_METADATA or N8N_SSO_SAML_METADATA_URL is required',
);
});
it('should save preferences when valid config with metadata is provided', async () => {
const loader = createLoader({
samlLoginEnabled: true,
samlMetadata: '<xml>metadata</xml>',
});
await loader.apply();
expect(settingsRepository.save).toHaveBeenCalledWith({
key: 'features.saml',
value: JSON.stringify({ metadata: '<xml>metadata</xml>', loginEnabled: true }),
loadOnStartup: true,
});
});
it('should save preferences when valid config with metadataUrl is provided', async () => {
const loader = createLoader({
samlLoginEnabled: true,
samlMetadataUrl: 'https://idp.example.com/metadata',
});
await loader.apply();
const saved = JSON.parse(
(settingsRepository.save.mock.calls[0][0] as { value: string }).value,
);
expect(saved.metadataUrl).toBe('https://idp.example.com/metadata');
expect(saved.metadata).toBeUndefined();
});
});
describe('when SAML login is disabled', () => {
it('should write loginEnabled=false', async () => {
const loader = createLoader({ samlLoginEnabled: false });
await loader.apply();
expect(settingsRepository.save).toHaveBeenCalledWith({
key: 'features.saml',
value: JSON.stringify({ loginEnabled: false }),
loadOnStartup: true,
});
});
it('should ignore SAML env vars and write loginEnabled=false', async () => {
const loader = createLoader({
samlLoginEnabled: false,
samlMetadata: '<xml>metadata</xml>',
});
await loader.apply();
expect(settingsRepository.save).toHaveBeenCalledWith({
key: 'features.saml',
value: JSON.stringify({ loginEnabled: false }),
loadOnStartup: true,
});
});
});
});

View File

@ -0,0 +1,199 @@
import type { Logger } from '@n8n/backend-common';
import type { InstanceSettingsLoaderConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import type { OidcInstanceSettingsLoader } from '../../loaders/sso/oidc.instance-settings-loader';
import type { ProvisioningInstanceSettingsLoader } from '../../loaders/sso/provisioning.instance-settings-loader';
import type { SamlInstanceSettingsLoader } from '../../loaders/sso/saml.instance-settings-loader';
import { SsoInstanceSettingsLoader } from '../../loaders/sso/sso.instance-settings-loader';
const mockSetCurrentAuthenticationMethod = jest.fn();
const mockGetCurrentAuthenticationMethod = jest.fn().mockReturnValue('email');
jest.mock('@/sso.ee/sso-helpers', () => ({
setCurrentAuthenticationMethod: (...args: unknown[]) =>
mockSetCurrentAuthenticationMethod(...args),
getCurrentAuthenticationMethod: () => mockGetCurrentAuthenticationMethod(),
}));
describe('SsoInstanceSettingsLoader', () => {
const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
const samlLoader = mock<SamlInstanceSettingsLoader>();
const oidcLoader = mock<OidcInstanceSettingsLoader>();
const provisioningLoader = mock<ProvisioningInstanceSettingsLoader>();
const baseConfig: Partial<InstanceSettingsLoaderConfig> = {
ssoManagedByEnv: false,
samlLoginEnabled: false,
oidcLoginEnabled: false,
};
const createLoader = (configOverrides: Partial<InstanceSettingsLoaderConfig> = {}) => {
const config = { ...baseConfig, ...configOverrides } as InstanceSettingsLoaderConfig;
return new SsoInstanceSettingsLoader(
config,
samlLoader,
oidcLoader,
provisioningLoader,
logger,
);
};
beforeEach(() => {
jest.resetAllMocks();
logger.scoped.mockReturnThis();
mockGetCurrentAuthenticationMethod.mockReturnValue('email');
});
describe('ssoManagedByEnv gate', () => {
it('should skip when ssoManagedByEnv is false', async () => {
const loader = createLoader({ ssoManagedByEnv: false });
const result = await loader.run();
expect(result).toBe('skipped');
expect(samlLoader.apply).not.toHaveBeenCalled();
expect(oidcLoader.apply).not.toHaveBeenCalled();
expect(provisioningLoader.apply).not.toHaveBeenCalled();
expect(mockSetCurrentAuthenticationMethod).not.toHaveBeenCalled();
});
});
describe('mutual exclusion', () => {
it('should throw when both SAML and OIDC login are enabled', async () => {
const loader = createLoader({
ssoManagedByEnv: true,
samlLoginEnabled: true,
oidcLoginEnabled: true,
});
await expect(loader.run()).rejects.toThrow(
'N8N_SSO_SAML_LOGIN_ENABLED and N8N_SSO_OIDC_LOGIN_ENABLED cannot both be true',
);
expect(samlLoader.apply).not.toHaveBeenCalled();
expect(oidcLoader.apply).not.toHaveBeenCalled();
expect(provisioningLoader.apply).not.toHaveBeenCalled();
});
});
describe('ordering', () => {
it('should call saml, oidc, provisioning, then sync when SAML is enabled', async () => {
const callOrder: string[] = [];
samlLoader.apply.mockImplementation(async () => {
callOrder.push('saml');
});
oidcLoader.apply.mockImplementation(async () => {
callOrder.push('oidc');
});
provisioningLoader.apply.mockImplementation(async () => {
callOrder.push('provisioning');
});
mockSetCurrentAuthenticationMethod.mockImplementation(() => {
callOrder.push('sync');
});
const loader = createLoader({ ssoManagedByEnv: true, samlLoginEnabled: true });
await loader.run();
expect(callOrder).toEqual(['saml', 'oidc', 'provisioning', 'sync']);
});
it('should call saml, oidc, provisioning, then sync when OIDC is enabled', async () => {
const callOrder: string[] = [];
samlLoader.apply.mockImplementation(async () => {
callOrder.push('saml');
});
oidcLoader.apply.mockImplementation(async () => {
callOrder.push('oidc');
});
provisioningLoader.apply.mockImplementation(async () => {
callOrder.push('provisioning');
});
mockSetCurrentAuthenticationMethod.mockImplementation(() => {
callOrder.push('sync');
});
const loader = createLoader({ ssoManagedByEnv: true, oidcLoginEnabled: true });
await loader.run();
expect(callOrder).toEqual(['saml', 'oidc', 'provisioning', 'sync']);
});
it('should not call provisioning when neither SAML nor OIDC is enabled', async () => {
const loader = createLoader({ ssoManagedByEnv: true });
const result = await loader.run();
expect(result).toBe('created');
expect(provisioningLoader.apply).not.toHaveBeenCalled();
expect(samlLoader.apply).toHaveBeenCalled();
expect(oidcLoader.apply).toHaveBeenCalled();
});
});
describe('auth method sync', () => {
it('should set authentication method to saml when SAML login is enabled', async () => {
const loader = createLoader({ ssoManagedByEnv: true, samlLoginEnabled: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('saml');
});
it('should set authentication method to oidc when OIDC login is enabled', async () => {
const loader = createLoader({ ssoManagedByEnv: true, oidcLoginEnabled: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('oidc');
});
it('should reset auth method to email when current is saml and neither protocol is enabled', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('saml');
const loader = createLoader({ ssoManagedByEnv: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('email');
});
it('should reset auth method to email when current is oidc and neither protocol is enabled', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('oidc');
const loader = createLoader({ ssoManagedByEnv: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('email');
});
it('should not change auth method when current is email and neither protocol is enabled', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('email');
const loader = createLoader({ ssoManagedByEnv: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).not.toHaveBeenCalled();
});
it('should set auth method to saml (not email) when switching from oidc to saml', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('oidc');
const loader = createLoader({ ssoManagedByEnv: true, samlLoginEnabled: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('saml');
expect(mockSetCurrentAuthenticationMethod).not.toHaveBeenCalledWith('email');
});
it('should set auth method to oidc (not email) when switching from saml to oidc', async () => {
mockGetCurrentAuthenticationMethod.mockReturnValue('saml');
const loader = createLoader({ ssoManagedByEnv: true, oidcLoginEnabled: true });
await loader.run();
expect(mockSetCurrentAuthenticationMethod).toHaveBeenCalledWith('oidc');
expect(mockSetCurrentAuthenticationMethod).not.toHaveBeenCalledWith('email');
});
});
});

View File

@ -6,7 +6,7 @@ import { LogStreamingInstanceSettingsLoader } from './loaders/log-streaming.inst
import { McpSettingsLoader } from './loaders/mcp-settings.loader';
import { OwnerInstanceSettingsLoader } from './loaders/owner.instance-settings-loader';
import { SecurityPolicyInstanceSettingsLoader } from './loaders/security-policy.instance-settings-loader';
import { SsoInstanceSettingsLoader } from './loaders/sso.instance-settings-loader';
import { SsoInstanceSettingsLoader } from './loaders/sso/sso.instance-settings-loader';
type LoaderResult = 'created' | 'skipped';

View File

@ -1,238 +0,0 @@
import {
OIDC_PROMPT_VALUES,
type ProvisioningMode,
type ProvisioningModeFlags,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { SettingsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { Cipher } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import { z } from 'zod';
import { PROVISIONING_PREFERENCES_DB_KEY } from '@/modules/provisioning.ee/constants';
import { OIDC_PREFERENCES_DB_KEY } from '@/modules/sso-oidc/constants';
import { SAML_PREFERENCES_DB_KEY } from '@/modules/sso-saml/constants';
import {
getCurrentAuthenticationMethod,
setCurrentAuthenticationMethod,
} from '@/sso.ee/sso-helpers';
const ENV_PROVISIONING_MODES = [
'disabled',
'instance_role',
'instance_and_project_roles',
] as const satisfies readonly ProvisioningMode[];
const provisioningSchema = z
.object({
ssoUserRoleProvisioning: z.enum(ENV_PROVISIONING_MODES, {
errorMap: () => ({
message: `N8N_SSO_USER_ROLE_PROVISIONING must be one of: ${ENV_PROVISIONING_MODES.join(', ')}`,
}),
}),
})
.transform(
(input): ProvisioningModeFlags => ({
scopesProvisionInstanceRole:
input.ssoUserRoleProvisioning === 'instance_role' ||
input.ssoUserRoleProvisioning === 'instance_and_project_roles',
scopesProvisionProjectRoles: input.ssoUserRoleProvisioning === 'instance_and_project_roles',
scopesUseExpressionMapping: false,
}),
);
const samlEnvSchema = z
.object({
samlMetadata: z.string(),
samlMetadataUrl: z.string(),
samlLoginEnabled: z.boolean(),
})
.refine((data) => data.samlMetadata || data.samlMetadataUrl, {
message:
'At least one of N8N_SSO_SAML_METADATA or N8N_SSO_SAML_METADATA_URL is required when configuring SAML via environment variables',
})
.transform(({ samlMetadata, samlMetadataUrl, samlLoginEnabled }) => ({
...(samlMetadata ? { metadata: samlMetadata } : {}),
...(samlMetadataUrl ? { metadataUrl: samlMetadataUrl } : {}),
loginEnabled: samlLoginEnabled,
}));
const oidcEnvSchema = z
.object({
oidcClientId: z
.string()
.min(1, 'N8N_SSO_OIDC_CLIENT_ID is required when configuring OIDC via environment variables'),
oidcClientSecret: z
.string()
.min(
1,
'N8N_SSO_OIDC_CLIENT_SECRET is required when configuring OIDC via environment variables',
),
oidcDiscoveryEndpoint: z.string().url('N8N_SSO_OIDC_DISCOVERY_ENDPOINT must be a valid URL'),
oidcLoginEnabled: z.boolean(),
oidcPrompt: z.enum(OIDC_PROMPT_VALUES, {
errorMap: () => ({
message: `N8N_SSO_OIDC_PROMPT must be one of: ${OIDC_PROMPT_VALUES.join(', ')}`,
}),
}),
oidcAcrValues: z.string(),
})
.transform((input) => ({
clientId: input.oidcClientId,
clientSecret: input.oidcClientSecret,
discoveryEndpoint: input.oidcDiscoveryEndpoint,
loginEnabled: input.oidcLoginEnabled,
prompt: input.oidcPrompt,
authenticationContextClassReference: input.oidcAcrValues
? input.oidcAcrValues
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: [],
}));
@Service()
export class SsoInstanceSettingsLoader {
constructor(
private readonly config: InstanceSettingsLoaderConfig,
private readonly settingsRepository: SettingsRepository,
private readonly cipher: Cipher,
private logger: Logger,
) {
this.logger = this.logger.scoped('instance-settings-loader');
}
async run(): Promise<'created' | 'skipped'> {
if (!this.config.ssoManagedByEnv) {
this.logger.debug('ssoManagedByEnv is disabled — skipping SSO config');
return 'skipped';
}
const { samlLoginEnabled, oidcLoginEnabled } = this.config;
if (samlLoginEnabled && oidcLoginEnabled) {
throw new OperationalError(
'N8N_SSO_SAML_LOGIN_ENABLED and N8N_SSO_OIDC_LOGIN_ENABLED cannot both be true. Only one SSO protocol can be enabled at a time.',
);
}
if (samlLoginEnabled || oidcLoginEnabled) {
await this.writeProvisioning();
}
await this.applySamlConfig();
await this.applyOidcConfig();
await this.syncAuthMethod();
return 'created';
}
private async applySamlConfig(): Promise<void> {
if (!this.config.samlLoginEnabled) {
await this.writeSamlLoginDisabled();
return;
}
this.logger.info('SAML login is enabled — applying SAML SSO env vars');
const parsed = samlEnvSchema.safeParse(this.config);
if (!parsed.success) {
throw new OperationalError(parsed.error.issues[0].message);
}
await this.writeSamlPreferences(parsed.data);
}
private async applyOidcConfig(): Promise<void> {
if (!this.config.oidcLoginEnabled) {
await this.writeOidcLoginDisabled();
return;
}
this.logger.info('OIDC login is enabled — applying OIDC SSO env vars');
const parsed = oidcEnvSchema.safeParse(this.config);
if (!parsed.success) {
throw new OperationalError(parsed.error.issues[0].message);
}
await this.writeOidcPreferences(parsed.data);
}
private async syncAuthMethod(): Promise<void> {
const { samlLoginEnabled, oidcLoginEnabled } = this.config;
if (samlLoginEnabled) {
await setCurrentAuthenticationMethod('saml');
return;
}
if (oidcLoginEnabled) {
await setCurrentAuthenticationMethod('oidc');
return;
}
const current = getCurrentAuthenticationMethod();
if (current === 'saml' || current === 'oidc') {
await setCurrentAuthenticationMethod('email');
}
}
private async writeSamlPreferences(preferences: Record<string, unknown>): Promise<void> {
await this.settingsRepository.save({
key: SAML_PREFERENCES_DB_KEY,
value: JSON.stringify(preferences),
loadOnStartup: true,
});
}
private async writeSamlLoginDisabled(): Promise<void> {
await this.settingsRepository.save({
key: SAML_PREFERENCES_DB_KEY,
value: JSON.stringify({ loginEnabled: false }),
loadOnStartup: true,
});
}
private async writeOidcPreferences(preferences: {
clientSecret: string;
[key: string]: unknown;
}): Promise<void> {
await this.settingsRepository.upsert(
{
key: OIDC_PREFERENCES_DB_KEY,
value: JSON.stringify({
...preferences,
clientSecret: await this.cipher.encryptV2(preferences.clientSecret),
}),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
}
private async writeOidcLoginDisabled(): Promise<void> {
await this.settingsRepository.upsert(
{
key: OIDC_PREFERENCES_DB_KEY,
value: JSON.stringify({ loginEnabled: false }),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
}
private async writeProvisioning(): Promise<void> {
const parsed = provisioningSchema.safeParse(this.config);
if (!parsed.success) {
throw new OperationalError(parsed.error.issues[0].message);
}
await this.settingsRepository.upsert(
{
key: PROVISIONING_PREFERENCES_DB_KEY,
value: JSON.stringify(parsed.data),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
}
}

View File

@ -0,0 +1,99 @@
import { OIDC_PROMPT_VALUES } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { SettingsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { Cipher } from 'n8n-core';
import { z } from 'zod';
import { OIDC_PREFERENCES_DB_KEY } from '@/modules/sso-oidc/constants';
import { InstanceBootstrappingError } from '../../instance-bootstrapping.error';
const oidcEnvSchema = z
.object({
oidcClientId: z
.string()
.min(1, 'N8N_SSO_OIDC_CLIENT_ID is required when configuring OIDC via environment variables'),
oidcClientSecret: z
.string()
.min(
1,
'N8N_SSO_OIDC_CLIENT_SECRET is required when configuring OIDC via environment variables',
),
oidcDiscoveryEndpoint: z.string().url('N8N_SSO_OIDC_DISCOVERY_ENDPOINT must be a valid URL'),
oidcLoginEnabled: z.boolean(),
oidcPrompt: z.enum(OIDC_PROMPT_VALUES, {
errorMap: () => ({
message: `N8N_SSO_OIDC_PROMPT must be one of: ${OIDC_PROMPT_VALUES.join(', ')}`,
}),
}),
oidcAcrValues: z.string(),
})
.transform((input) => ({
clientId: input.oidcClientId,
clientSecret: input.oidcClientSecret,
discoveryEndpoint: input.oidcDiscoveryEndpoint,
loginEnabled: input.oidcLoginEnabled,
prompt: input.oidcPrompt,
authenticationContextClassReference: input.oidcAcrValues
? input.oidcAcrValues
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: [],
}));
@Service()
export class OidcInstanceSettingsLoader {
constructor(
private readonly config: InstanceSettingsLoaderConfig,
private readonly settingsRepository: SettingsRepository,
private readonly cipher: Cipher,
private logger: Logger,
) {
this.logger = this.logger.scoped('instance-settings-loader');
}
async apply(): Promise<void> {
if (!this.config.oidcLoginEnabled) {
await this.writeLoginDisabled();
return;
}
this.logger.info('OIDC login is enabled — applying OIDC SSO env vars');
const parsed = oidcEnvSchema.safeParse(this.config);
if (!parsed.success) {
throw new InstanceBootstrappingError(parsed.error.issues[0].message);
}
await this.writePreferences(parsed.data);
}
private async writePreferences(preferences: {
clientSecret: string;
[key: string]: unknown;
}): Promise<void> {
await this.settingsRepository.upsert(
{
key: OIDC_PREFERENCES_DB_KEY,
value: JSON.stringify({
...preferences,
clientSecret: await this.cipher.encryptV2(preferences.clientSecret),
}),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
}
private async writeLoginDisabled(): Promise<void> {
await this.settingsRepository.upsert(
{
key: OIDC_PREFERENCES_DB_KEY,
value: JSON.stringify({ loginEnabled: false }),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
}
}

View File

@ -0,0 +1,61 @@
import { type ProvisioningMode, type ProvisioningModeFlags } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { SettingsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { z } from 'zod';
import { PROVISIONING_PREFERENCES_DB_KEY } from '@/modules/provisioning.ee/constants';
import { InstanceBootstrappingError } from '../../instance-bootstrapping.error';
const ENV_PROVISIONING_MODES = [
'disabled',
'instance_role',
'instance_and_project_roles',
] as const satisfies readonly ProvisioningMode[];
const provisioningSchema = z
.object({
ssoUserRoleProvisioning: z.enum(ENV_PROVISIONING_MODES, {
errorMap: () => ({
message: `N8N_SSO_USER_ROLE_PROVISIONING must be one of: ${ENV_PROVISIONING_MODES.join(', ')}`,
}),
}),
})
.transform(
(input): ProvisioningModeFlags => ({
scopesProvisionInstanceRole:
input.ssoUserRoleProvisioning === 'instance_role' ||
input.ssoUserRoleProvisioning === 'instance_and_project_roles',
scopesProvisionProjectRoles: input.ssoUserRoleProvisioning === 'instance_and_project_roles',
scopesUseExpressionMapping: false,
}),
);
@Service()
export class ProvisioningInstanceSettingsLoader {
constructor(
private readonly config: InstanceSettingsLoaderConfig,
private readonly settingsRepository: SettingsRepository,
private logger: Logger,
) {
this.logger = this.logger.scoped('instance-settings-loader');
}
async apply(): Promise<void> {
const parsed = provisioningSchema.safeParse(this.config);
if (!parsed.success) {
throw new InstanceBootstrappingError(parsed.error.issues[0].message);
}
await this.settingsRepository.upsert(
{
key: PROVISIONING_PREFERENCES_DB_KEY,
value: JSON.stringify(parsed.data),
loadOnStartup: true,
},
{ conflictPaths: ['key'] },
);
}
}

View File

@ -0,0 +1,66 @@
import { Logger } from '@n8n/backend-common';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { SettingsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { z } from 'zod';
import { SAML_PREFERENCES_DB_KEY } from '@/modules/sso-saml/constants';
import { InstanceBootstrappingError } from '../../instance-bootstrapping.error';
const samlEnvSchema = z
.object({
samlMetadata: z.string(),
samlMetadataUrl: z.string(),
samlLoginEnabled: z.boolean(),
})
.refine((data) => data.samlMetadata || data.samlMetadataUrl, {
message:
'At least one of N8N_SSO_SAML_METADATA or N8N_SSO_SAML_METADATA_URL is required when configuring SAML via environment variables',
})
.transform(({ samlMetadata, samlMetadataUrl, samlLoginEnabled }) => ({
...(samlMetadata ? { metadata: samlMetadata } : {}),
...(samlMetadataUrl ? { metadataUrl: samlMetadataUrl } : {}),
loginEnabled: samlLoginEnabled,
}));
@Service()
export class SamlInstanceSettingsLoader {
constructor(
private readonly config: InstanceSettingsLoaderConfig,
private readonly settingsRepository: SettingsRepository,
private logger: Logger,
) {
this.logger = this.logger.scoped('instance-settings-loader');
}
async apply(): Promise<void> {
if (!this.config.samlLoginEnabled) {
await this.writeLoginDisabled();
return;
}
this.logger.info('SAML login is enabled — applying SAML SSO env vars');
const parsed = samlEnvSchema.safeParse(this.config);
if (!parsed.success) {
throw new InstanceBootstrappingError(parsed.error.issues[0].message);
}
await this.writePreferences(parsed.data);
}
private async writePreferences(preferences: Record<string, unknown>): Promise<void> {
await this.settingsRepository.save({
key: SAML_PREFERENCES_DB_KEY,
value: JSON.stringify(preferences),
loadOnStartup: true,
});
}
private async writeLoginDisabled(): Promise<void> {
await this.settingsRepository.save({
key: SAML_PREFERENCES_DB_KEY,
value: JSON.stringify({ loginEnabled: false }),
loadOnStartup: true,
});
}
}

View File

@ -0,0 +1,80 @@
import { Logger } from '@n8n/backend-common';
import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import {
getCurrentAuthenticationMethod,
setCurrentAuthenticationMethod,
} from '@/sso.ee/sso-helpers';
import { OidcInstanceSettingsLoader } from './oidc.instance-settings-loader';
import { ProvisioningInstanceSettingsLoader } from './provisioning.instance-settings-loader';
import { SamlInstanceSettingsLoader } from './saml.instance-settings-loader';
import { InstanceBootstrappingError } from '../../instance-bootstrapping.error';
@Service()
export class SsoInstanceSettingsLoader {
constructor(
private readonly config: InstanceSettingsLoaderConfig,
private readonly samlLoader: SamlInstanceSettingsLoader,
private readonly oidcLoader: OidcInstanceSettingsLoader,
private readonly provisioningLoader: ProvisioningInstanceSettingsLoader,
private logger: Logger,
) {
this.logger = this.logger.scoped('instance-settings-loader');
}
async run(): Promise<'created' | 'skipped'> {
if (!this.config.ssoManagedByEnv) {
this.logger.debug('ssoManagedByEnv is disabled — skipping SSO config');
return 'skipped';
}
const { samlLoginEnabled, oidcLoginEnabled } = this.config;
if (samlLoginEnabled && oidcLoginEnabled) {
throw new InstanceBootstrappingError(
'N8N_SSO_SAML_LOGIN_ENABLED and N8N_SSO_OIDC_LOGIN_ENABLED cannot both be true. Only one SSO protocol can be enabled at a time.',
);
}
await this.samlLoader.apply();
await this.oidcLoader.apply();
if (samlLoginEnabled || oidcLoginEnabled) {
await this.provisioningLoader.apply();
}
await this.syncAuthMethod();
return 'created';
}
private async syncAuthMethod(): Promise<void> {
const { samlLoginEnabled, oidcLoginEnabled } = this.config;
if (samlLoginEnabled) {
await setCurrentAuthenticationMethod('saml');
this.logger.debug(
'Switching authentication method to SAML. Current authentication method: saml',
);
return;
}
if (oidcLoginEnabled) {
await setCurrentAuthenticationMethod('oidc');
this.logger.debug(
'Switching authentication method to OIDC. Current authentication method: oidc',
);
return;
}
const current = getCurrentAuthenticationMethod();
if (current === 'saml' || current === 'oidc') {
await setCurrentAuthenticationMethod('email');
this.logger.debug(
`Switching authentication method to email because SAML or OIDC is disabled. Current authentication method: ${current}`,
);
}
}
}

View File

@ -3,7 +3,7 @@ import { GlobalConfig } from '@n8n/config';
import { SettingsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { SsoInstanceSettingsLoader } from '@/instance-settings-loader/loaders/sso.instance-settings-loader';
import { SsoInstanceSettingsLoader } from '@/instance-settings-loader/loaders/sso/sso.instance-settings-loader';
import { PROVISIONING_PREFERENCES_DB_KEY } from '@/modules/provisioning.ee/constants';
import { OIDC_PREFERENCES_DB_KEY } from '@/modules/sso-oidc/constants';
import { OidcService } from '@/modules/sso-oidc/oidc.service.ee';
@ -17,7 +17,7 @@ afterAll(async () => {
await testDb.terminate();
});
describe('SsoInstanceSettingsLoader → OidcService roundtrip', () => {
describe('SsoInstanceSettingsLoader → OIDC', () => {
let originalConfig: Record<string, unknown>;
beforeEach(() => {

View File

@ -3,7 +3,7 @@ import { GlobalConfig } from '@n8n/config';
import { SettingsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { SsoInstanceSettingsLoader } from '@/instance-settings-loader/loaders/sso.instance-settings-loader';
import { SsoInstanceSettingsLoader } from '@/instance-settings-loader/loaders/sso/sso.instance-settings-loader';
import { PROVISIONING_PREFERENCES_DB_KEY } from '@/modules/provisioning.ee/constants';
import { OIDC_PREFERENCES_DB_KEY } from '@/modules/sso-oidc/constants';
import { SAML_PREFERENCES_DB_KEY } from '@/modules/sso-saml/constants';
@ -17,7 +17,7 @@ afterAll(async () => {
await testDb.terminate();
});
describe('SsoInstanceSettingsLoader → SamlService roundtrip', () => {
describe('SsoInstanceSettingsLoader → SAML', () => {
let originalConfig: Record<string, unknown>;
beforeEach(() => {