mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
refactor(core): Split SSO loader (no-changelog) (#30065)
This commit is contained in:
parent
96b018d356
commit
26beabb445
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'] },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user