diff --git a/packages/cli/src/instance-settings-loader/__tests__/instance-settings-loader.service.test.ts b/packages/cli/src/instance-settings-loader/__tests__/instance-settings-loader.service.test.ts index d1dd15d783a..6bc22ec7624 100644 --- a/packages/cli/src/instance-settings-loader/__tests__/instance-settings-loader.service.test.ts +++ b/packages/cli/src/instance-settings-loader/__tests__/instance-settings-loader.service.test.ts @@ -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({ scoped: jest.fn().mockReturnThis() }); diff --git a/packages/cli/src/instance-settings-loader/__tests__/sso.instance-settings-loader.test.ts b/packages/cli/src/instance-settings-loader/__tests__/sso.instance-settings-loader.test.ts deleted file mode 100644 index 671dbb2ce27..00000000000 --- a/packages/cli/src/instance-settings-loader/__tests__/sso.instance-settings-loader.test.ts +++ /dev/null @@ -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({ scoped: jest.fn().mockReturnThis() }); - const settingsRepository = mock(); - const cipher = mock(); - - const baseConfig: Partial = { - ssoManagedByEnv: false, - oidcClientId: '', - oidcClientSecret: '', - oidcDiscoveryEndpoint: '', - oidcLoginEnabled: false, - oidcPrompt: 'select_account', - oidcAcrValues: '', - samlMetadata: '', - samlMetadataUrl: '', - samlLoginEnabled: false, - ssoUserRoleProvisioning: 'disabled', - }; - - const validSamlConfig: Partial = { - ...baseConfig, - ssoManagedByEnv: true, - samlMetadata: 'metadata', - samlLoginEnabled: true, - }; - - const validOidcConfig: Partial = { - ...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 = {}) => { - 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: 'metadata', - 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 })); - }); - }); -}); diff --git a/packages/cli/src/instance-settings-loader/__tests__/sso/oidc.instance-settings-loader.test.ts b/packages/cli/src/instance-settings-loader/__tests__/sso/oidc.instance-settings-loader.test.ts new file mode 100644 index 00000000000..f63c6de8d2b --- /dev/null +++ b/packages/cli/src/instance-settings-loader/__tests__/sso/oidc.instance-settings-loader.test.ts @@ -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({ scoped: jest.fn().mockReturnThis() }); + const settingsRepository = mock(); + const cipher = mock(); + + const baseConfig: Partial = { + oidcClientId: '', + oidcClientSecret: '', + oidcDiscoveryEndpoint: '', + oidcLoginEnabled: false, + oidcPrompt: 'select_account', + oidcAcrValues: '', + }; + + const validConfig: Partial = { + ...baseConfig, + oidcClientId: 'my-client-id', + oidcClientSecret: 'my-client-secret', + oidcDiscoveryEndpoint: 'https://idp.example.com/.well-known/openid-configuration', + oidcLoginEnabled: true, + }; + + const createLoader = (configOverrides: Partial = {}) => { + 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 }); + }); + }); +}); diff --git a/packages/cli/src/instance-settings-loader/__tests__/sso/provisioning.instance-settings-loader.test.ts b/packages/cli/src/instance-settings-loader/__tests__/sso/provisioning.instance-settings-loader.test.ts new file mode 100644 index 00000000000..143550d41de --- /dev/null +++ b/packages/cli/src/instance-settings-loader/__tests__/sso/provisioning.instance-settings-loader.test.ts @@ -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({ scoped: jest.fn().mockReturnThis() }); + const settingsRepository = mock(); + + const createLoader = (configOverrides: Partial = {}) => { + 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'] }, + ); + }); +}); diff --git a/packages/cli/src/instance-settings-loader/__tests__/sso/saml.instance-settings-loader.test.ts b/packages/cli/src/instance-settings-loader/__tests__/sso/saml.instance-settings-loader.test.ts new file mode 100644 index 00000000000..f1c26e18ef9 --- /dev/null +++ b/packages/cli/src/instance-settings-loader/__tests__/sso/saml.instance-settings-loader.test.ts @@ -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({ scoped: jest.fn().mockReturnThis() }); + const settingsRepository = mock(); + + const baseConfig: Partial = { + samlMetadata: '', + samlMetadataUrl: '', + samlLoginEnabled: false, + }; + + const createLoader = (configOverrides: Partial = {}) => { + 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: 'metadata', + }); + + await loader.apply(); + + expect(settingsRepository.save).toHaveBeenCalledWith({ + key: 'features.saml', + value: JSON.stringify({ metadata: 'metadata', 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: 'metadata', + }); + + await loader.apply(); + + expect(settingsRepository.save).toHaveBeenCalledWith({ + key: 'features.saml', + value: JSON.stringify({ loginEnabled: false }), + loadOnStartup: true, + }); + }); + }); +}); diff --git a/packages/cli/src/instance-settings-loader/__tests__/sso/sso.instance-settings-loader.test.ts b/packages/cli/src/instance-settings-loader/__tests__/sso/sso.instance-settings-loader.test.ts new file mode 100644 index 00000000000..a7999c80773 --- /dev/null +++ b/packages/cli/src/instance-settings-loader/__tests__/sso/sso.instance-settings-loader.test.ts @@ -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({ scoped: jest.fn().mockReturnThis() }); + const samlLoader = mock(); + const oidcLoader = mock(); + const provisioningLoader = mock(); + + const baseConfig: Partial = { + ssoManagedByEnv: false, + samlLoginEnabled: false, + oidcLoginEnabled: false, + }; + + const createLoader = (configOverrides: Partial = {}) => { + 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'); + }); + }); +}); diff --git a/packages/cli/src/instance-settings-loader/instance-settings-loader.service.ts b/packages/cli/src/instance-settings-loader/instance-settings-loader.service.ts index d590e2a7dc5..ba1f79ca7c5 100644 --- a/packages/cli/src/instance-settings-loader/instance-settings-loader.service.ts +++ b/packages/cli/src/instance-settings-loader/instance-settings-loader.service.ts @@ -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'; diff --git a/packages/cli/src/instance-settings-loader/loaders/sso.instance-settings-loader.ts b/packages/cli/src/instance-settings-loader/loaders/sso.instance-settings-loader.ts deleted file mode 100644 index 427b0fa959b..00000000000 --- a/packages/cli/src/instance-settings-loader/loaders/sso.instance-settings-loader.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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): Promise { - await this.settingsRepository.save({ - key: SAML_PREFERENCES_DB_KEY, - value: JSON.stringify(preferences), - loadOnStartup: true, - }); - } - - private async writeSamlLoginDisabled(): Promise { - 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 { - 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 { - await this.settingsRepository.upsert( - { - key: OIDC_PREFERENCES_DB_KEY, - value: JSON.stringify({ loginEnabled: false }), - loadOnStartup: true, - }, - { conflictPaths: ['key'] }, - ); - } - - private async writeProvisioning(): Promise { - 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'] }, - ); - } -} diff --git a/packages/cli/src/instance-settings-loader/loaders/sso/oidc.instance-settings-loader.ts b/packages/cli/src/instance-settings-loader/loaders/sso/oidc.instance-settings-loader.ts new file mode 100644 index 00000000000..0e7fc83dc92 --- /dev/null +++ b/packages/cli/src/instance-settings-loader/loaders/sso/oidc.instance-settings-loader.ts @@ -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 { + 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 { + 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 { + await this.settingsRepository.upsert( + { + key: OIDC_PREFERENCES_DB_KEY, + value: JSON.stringify({ loginEnabled: false }), + loadOnStartup: true, + }, + { conflictPaths: ['key'] }, + ); + } +} diff --git a/packages/cli/src/instance-settings-loader/loaders/sso/provisioning.instance-settings-loader.ts b/packages/cli/src/instance-settings-loader/loaders/sso/provisioning.instance-settings-loader.ts new file mode 100644 index 00000000000..3bf369c0051 --- /dev/null +++ b/packages/cli/src/instance-settings-loader/loaders/sso/provisioning.instance-settings-loader.ts @@ -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 { + 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'] }, + ); + } +} diff --git a/packages/cli/src/instance-settings-loader/loaders/sso/saml.instance-settings-loader.ts b/packages/cli/src/instance-settings-loader/loaders/sso/saml.instance-settings-loader.ts new file mode 100644 index 00000000000..886b47dac61 --- /dev/null +++ b/packages/cli/src/instance-settings-loader/loaders/sso/saml.instance-settings-loader.ts @@ -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 { + 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): Promise { + await this.settingsRepository.save({ + key: SAML_PREFERENCES_DB_KEY, + value: JSON.stringify(preferences), + loadOnStartup: true, + }); + } + + private async writeLoginDisabled(): Promise { + await this.settingsRepository.save({ + key: SAML_PREFERENCES_DB_KEY, + value: JSON.stringify({ loginEnabled: false }), + loadOnStartup: true, + }); + } +} diff --git a/packages/cli/src/instance-settings-loader/loaders/sso/sso.instance-settings-loader.ts b/packages/cli/src/instance-settings-loader/loaders/sso/sso.instance-settings-loader.ts new file mode 100644 index 00000000000..6b23218ed1f --- /dev/null +++ b/packages/cli/src/instance-settings-loader/loaders/sso/sso.instance-settings-loader.ts @@ -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 { + 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}`, + ); + } + } +} diff --git a/packages/cli/test/integration/oidc/oidc.instance-settings-loader.test.ts b/packages/cli/test/integration/oidc/oidc.instance-settings-loader.test.ts index a5b417f3e93..c7c21183410 100644 --- a/packages/cli/test/integration/oidc/oidc.instance-settings-loader.test.ts +++ b/packages/cli/test/integration/oidc/oidc.instance-settings-loader.test.ts @@ -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; beforeEach(() => { diff --git a/packages/cli/test/integration/saml/saml.instance-settings-loader.test.ts b/packages/cli/test/integration/saml/saml.instance-settings-loader.test.ts index 038c4be1b57..65fc39f8dde 100644 --- a/packages/cli/test/integration/saml/saml.instance-settings-loader.test.ts +++ b/packages/cli/test/integration/saml/saml.instance-settings-loader.test.ts @@ -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; beforeEach(() => {