mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat(core): Configure OIDC settings via env vars (#28185)
Signed-off-by: James Gee <1285296+geemanjs@users.noreply.github.com> Co-authored-by: Irénée <irenee.ajeneza@n8n.io> Co-authored-by: Ali Elkhateeb <ali.elkhateeb@n8n.io>
This commit is contained in:
parent
e849041c11
commit
36261fbe7a
24 changed files with 428 additions and 16 deletions
|
|
@ -163,7 +163,7 @@ export {
|
|||
type RoleProjectMembersResponse,
|
||||
} from './roles/role-project-members-response.dto';
|
||||
|
||||
export { OidcConfigDto } from './oidc/config.dto';
|
||||
export { OidcConfigDto, OIDC_PROMPT_VALUES } from './oidc/config.dto';
|
||||
export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto';
|
||||
|
||||
export { CreateDataTableDto } from './data-table/create-data-table.dto';
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ import { z } from 'zod';
|
|||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export const OIDC_PROMPT_VALUES = ['none', 'login', 'consent', 'select_account', 'create'] as const;
|
||||
|
||||
export class OidcConfigDto extends Z.class({
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
discoveryEndpoint: z.string().url(),
|
||||
loginEnabled: z.boolean().optional().default(false),
|
||||
prompt: z
|
||||
.enum(['none', 'login', 'consent', 'select_account', 'create'])
|
||||
.optional()
|
||||
.default('select_account'),
|
||||
prompt: z.enum(OIDC_PROMPT_VALUES).optional().default('select_account'),
|
||||
authenticationContextClassReference: z.array(z.string()).default([]),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export interface FrontendSettings {
|
|||
defaultLocale: string;
|
||||
userManagement: IUserManagementSettings;
|
||||
sso: {
|
||||
managedByEnv: boolean;
|
||||
saml: {
|
||||
loginLabel: string;
|
||||
loginEnabled: boolean;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,38 @@ export class InstanceSettingsLoaderConfig {
|
|||
@Env('N8N_INSTANCE_OWNER_PASSWORD_HASH')
|
||||
ownerPasswordHash: string = '';
|
||||
|
||||
// --- SSO ---
|
||||
|
||||
/** When true, SSO connection config is read from env vars on every startup and the UI is locked. */
|
||||
@Env('N8N_SSO_MANAGED_BY_ENV')
|
||||
ssoManagedByEnv: boolean = false;
|
||||
|
||||
/** User role provisioning mode: disabled, instance_role, or instance_and_project_roles. */
|
||||
@Env('N8N_SSO_USER_ROLE_PROVISIONING')
|
||||
ssoUserRoleProvisioning: string = 'disabled';
|
||||
|
||||
// --- OIDC ---
|
||||
|
||||
@Env('N8N_SSO_OIDC_CLIENT_ID')
|
||||
oidcClientId: string = '';
|
||||
|
||||
@Env('N8N_SSO_OIDC_CLIENT_SECRET')
|
||||
oidcClientSecret: string = '';
|
||||
|
||||
@Env('N8N_SSO_OIDC_DISCOVERY_ENDPOINT')
|
||||
oidcDiscoveryEndpoint: string = '';
|
||||
|
||||
@Env('N8N_SSO_OIDC_LOGIN_ENABLED')
|
||||
oidcLoginEnabled: boolean = false;
|
||||
|
||||
/** Values can be found in packages/@n8n/api-types/src/dto/oidc/config.dto.ts */
|
||||
@Env('N8N_SSO_OIDC_PROMPT')
|
||||
oidcPrompt: string = 'select_account';
|
||||
|
||||
/** Comma-separated ACR values */
|
||||
@Env('N8N_SSO_OIDC_ACR_VALUES')
|
||||
oidcAcrValues: string = '';
|
||||
|
||||
/**
|
||||
* When true, security policy settings are managed via environment variables.
|
||||
* On every startup the security policy will be overridden by env vars.
|
||||
|
|
|
|||
|
|
@ -503,6 +503,14 @@ describe('GlobalConfig', () => {
|
|||
ownerFirstName: 'Instance',
|
||||
ownerLastName: 'Owner',
|
||||
ownerPasswordHash: '',
|
||||
ssoManagedByEnv: false,
|
||||
oidcClientId: '',
|
||||
oidcClientSecret: '',
|
||||
oidcDiscoveryEndpoint: '',
|
||||
oidcLoginEnabled: false,
|
||||
oidcPrompt: 'select_account',
|
||||
oidcAcrValues: '',
|
||||
ssoUserRoleProvisioning: 'disabled',
|
||||
securityPolicyManagedByEnv: false,
|
||||
mfaEnforcedEnabled: false,
|
||||
personalSpacePublishingEnabled: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
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 { OidcInstanceSettingsLoader } from '../loaders/oidc.instance-settings-loader';
|
||||
|
||||
describe('OidcInstanceSettingsLoader', () => {
|
||||
const logger = mock<Logger>({ scoped: jest.fn().mockReturnThis() });
|
||||
const settingsRepository = mock<SettingsRepository>();
|
||||
const cipher = mock<Cipher>();
|
||||
|
||||
const validConfig: Partial<InstanceSettingsLoaderConfig> = {
|
||||
ssoManagedByEnv: true,
|
||||
oidcClientId: 'my-client-id',
|
||||
oidcClientSecret: 'my-client-secret',
|
||||
oidcDiscoveryEndpoint: 'https://idp.example.com/.well-known/openid-configuration',
|
||||
oidcLoginEnabled: false,
|
||||
oidcPrompt: 'select_account',
|
||||
oidcAcrValues: '',
|
||||
ssoUserRoleProvisioning: 'disabled',
|
||||
};
|
||||
|
||||
const createLoader = (configOverrides: Partial<InstanceSettingsLoaderConfig> = {}) => {
|
||||
const config = {
|
||||
ssoManagedByEnv: false,
|
||||
oidcClientId: '',
|
||||
oidcClientSecret: '',
|
||||
oidcDiscoveryEndpoint: '',
|
||||
oidcLoginEnabled: false,
|
||||
oidcPrompt: 'select_account',
|
||||
oidcAcrValues: '',
|
||||
ssoUserRoleProvisioning: 'disabled',
|
||||
...configOverrides,
|
||||
} as InstanceSettingsLoaderConfig;
|
||||
|
||||
return new OidcInstanceSettingsLoader(config, settingsRepository, cipher, logger);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
logger.scoped.mockReturnThis();
|
||||
cipher.encrypt.mockReturnValue('encrypted-secret');
|
||||
});
|
||||
|
||||
it('should skip when ssoManagedByEnv is false', async () => {
|
||||
const loader = createLoader({ ssoManagedByEnv: false });
|
||||
|
||||
const result = await loader.run();
|
||||
|
||||
expect(result).toBe('skipped');
|
||||
expect(settingsRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when clientId is missing', async () => {
|
||||
const loader = createLoader({ ...validConfig, oidcClientId: '' });
|
||||
await expect(loader.run()).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.run()).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.run()).rejects.toThrow('N8N_SSO_OIDC_DISCOVERY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('should throw when oidcPrompt is an invalid value', async () => {
|
||||
const loader = createLoader({ ...validConfig, oidcPrompt: 'invalid' });
|
||||
await expect(loader.run()).rejects.toThrow('N8N_SSO_OIDC_PROMPT');
|
||||
});
|
||||
|
||||
it('should throw when ssoUserRoleProvisioning is an invalid value', async () => {
|
||||
const loader = createLoader({ ...validConfig, ssoUserRoleProvisioning: 'invalid' });
|
||||
await expect(loader.run()).rejects.toThrow('N8N_SSO_USER_ROLE_PROVISIONING must be one of');
|
||||
});
|
||||
|
||||
it('should handle messy ACR values with extra commas and whitespace', async () => {
|
||||
const loader = createLoader({ ...validConfig, oidcAcrValues: ',mfa,, phrh ,,' });
|
||||
|
||||
await loader.run();
|
||||
|
||||
const savedValue = JSON.parse(
|
||||
(settingsRepository.upsert.mock.calls[0][0] as { value: string }).value,
|
||||
);
|
||||
expect(savedValue.authenticationContextClassReference).toEqual(['mfa', 'phrh']);
|
||||
});
|
||||
|
||||
describe('isConfiguredByEnv', () => {
|
||||
it('should return false when ssoManagedByEnv is false', () => {
|
||||
const loader = createLoader({ ssoManagedByEnv: false });
|
||||
expect(loader.isConfiguredByEnv()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when ssoManagedByEnv is true', () => {
|
||||
const loader = createLoader({ ssoManagedByEnv: true });
|
||||
expect(loader.isConfiguredByEnv()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Error thrown during instance bootstrapping when environment variable
|
||||
* configuration is invalid. These errors indicate a misconfiguration
|
||||
* that must be fixed before the instance can start.
|
||||
*/
|
||||
export class InstanceBootstrappingError extends UnexpectedError {}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { OidcInstanceSettingsLoader } from './loaders/oidc.instance-settings-loader';
|
||||
import { OwnerInstanceSettingsLoader } from './loaders/owner.instance-settings-loader';
|
||||
import { SecurityPolicyInstanceSettingsLoader } from './loaders/security-policy.instance-settings-loader';
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ export class InstanceSettingsLoaderService {
|
|||
constructor(
|
||||
private logger: Logger,
|
||||
private readonly ownerLoader: OwnerInstanceSettingsLoader,
|
||||
private readonly oidcLoader: OidcInstanceSettingsLoader,
|
||||
private readonly securityPolicyLoader: SecurityPolicyInstanceSettingsLoader,
|
||||
) {
|
||||
this.logger = this.logger.scoped('instance-settings-loader');
|
||||
|
|
@ -18,6 +20,7 @@ export class InstanceSettingsLoaderService {
|
|||
|
||||
async init(): Promise<void> {
|
||||
await this.run('owner', async () => await this.ownerLoader.run());
|
||||
await this.run('oidc', async () => await this.oidcLoader.run());
|
||||
await this.run('security-policy', async () => await this.securityPolicyLoader.run());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
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 { InstanceBootstrappingError } from '../instance-bootstrapping.error';
|
||||
|
||||
import { OIDC_PREFERENCES_DB_KEY } from '@/modules/sso-oidc/constants';
|
||||
import { PROVISIONING_PREFERENCES_DB_KEY } from '@/modules/provisioning.ee/constants';
|
||||
|
||||
const PROVISIONING_MODES = ['disabled', 'instance_role', 'instance_and_project_roles'] as const;
|
||||
|
||||
const ssoEnvSchema = 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(),
|
||||
ssoUserRoleProvisioning: z.enum(PROVISIONING_MODES, {
|
||||
errorMap: () => ({
|
||||
message: `N8N_SSO_USER_ROLE_PROVISIONING must be one of: ${PROVISIONING_MODES.join(', ')}`,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.transform((input) => ({
|
||||
oidc: {
|
||||
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)
|
||||
: [],
|
||||
},
|
||||
provisioning: {
|
||||
scopesProvisionInstanceRole:
|
||||
input.ssoUserRoleProvisioning === 'instance_role' ||
|
||||
input.ssoUserRoleProvisioning === 'instance_and_project_roles',
|
||||
scopesProvisionProjectRoles: input.ssoUserRoleProvisioning === 'instance_and_project_roles',
|
||||
},
|
||||
}));
|
||||
|
||||
@Service()
|
||||
export class OidcInstanceSettingsLoader {
|
||||
constructor(
|
||||
private readonly instanceSettingsLoaderConfig: InstanceSettingsLoaderConfig,
|
||||
private readonly settingsRepository: SettingsRepository,
|
||||
private readonly cipher: Cipher,
|
||||
private logger: Logger,
|
||||
) {
|
||||
this.logger = this.logger.scoped('instance-settings-loader');
|
||||
}
|
||||
|
||||
isConfiguredByEnv(): boolean {
|
||||
return this.instanceSettingsLoaderConfig.ssoManagedByEnv;
|
||||
}
|
||||
|
||||
async run(): Promise<'created' | 'skipped'> {
|
||||
const { ssoManagedByEnv, oidcClientId, oidcClientSecret, oidcDiscoveryEndpoint } =
|
||||
this.instanceSettingsLoaderConfig;
|
||||
|
||||
if (!ssoManagedByEnv) {
|
||||
if (oidcClientId || oidcClientSecret || oidcDiscoveryEndpoint) {
|
||||
this.logger.warn(
|
||||
'N8N_SSO_OIDC_* env vars are set but N8N_SSO_MANAGED_BY_ENV is not enabled — ignoring SSO env vars',
|
||||
);
|
||||
}
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
this.logger.info('N8N_SSO_MANAGED_BY_ENV is enabled — applying OIDC SSO env vars');
|
||||
|
||||
const result = ssoEnvSchema.safeParse(this.instanceSettingsLoaderConfig);
|
||||
|
||||
if (!result.success) {
|
||||
throw new InstanceBootstrappingError(result.error.issues[0].message);
|
||||
}
|
||||
|
||||
const { oidc, provisioning } = result.data;
|
||||
|
||||
await this.settingsRepository.upsert(
|
||||
{
|
||||
key: OIDC_PREFERENCES_DB_KEY,
|
||||
value: JSON.stringify({
|
||||
...oidc,
|
||||
clientSecret: this.cipher.encrypt(oidc.clientSecret),
|
||||
}),
|
||||
loadOnStartup: true,
|
||||
},
|
||||
{ conflictPaths: ['key'] },
|
||||
);
|
||||
|
||||
await this.settingsRepository.upsert(
|
||||
{
|
||||
key: PROVISIONING_PREFERENCES_DB_KEY,
|
||||
value: JSON.stringify(provisioning),
|
||||
loadOnStartup: true,
|
||||
},
|
||||
{ conflictPaths: ['key'] },
|
||||
);
|
||||
|
||||
this.logger.debug('OIDC configuration applied from environment variables');
|
||||
|
||||
return 'created';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { InstanceSettingsLoaderConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { InstanceBootstrappingError } from '../instance-bootstrapping.error';
|
||||
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
|
||||
/** Bcrypt hash format: $2b$NN$<53 base64 chars> (60 chars total) */
|
||||
|
|
@ -63,7 +64,7 @@ export class OwnerInstanceSettingsLoader {
|
|||
const result = ownerEnvSchema.safeParse(this.instanceSettingsLoaderConfig);
|
||||
|
||||
if (!result.success) {
|
||||
throw new OperationalError(result.error.issues[0].message);
|
||||
throw new InstanceBootstrappingError(result.error.issues[0].message);
|
||||
}
|
||||
|
||||
await this.ownershipService.setupOwner(result.data.payload, result.data.options);
|
||||
|
|
|
|||
|
|
@ -3,14 +3,20 @@ import { mock } from 'jest-mock-extended';
|
|||
|
||||
import { ProvisioningController } from '../provisioning.controller.ee';
|
||||
import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
import type { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.instance-settings-loader';
|
||||
import { type Response } from 'express';
|
||||
import { type AuthenticatedRequest } from '@n8n/db';
|
||||
import { type ProvisioningConfigDto } from '@n8n/api-types';
|
||||
|
||||
const provisioningService = mock<ProvisioningService>();
|
||||
const licenseState = mock<LicenseState>();
|
||||
const oidcSettingsLoader = mock<OidcInstanceSettingsLoader>();
|
||||
|
||||
const controller = new ProvisioningController(provisioningService, licenseState);
|
||||
const controller = new ProvisioningController(
|
||||
provisioningService,
|
||||
licenseState,
|
||||
oidcSettingsLoader,
|
||||
);
|
||||
|
||||
describe('ProvisioningController', () => {
|
||||
beforeEach(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Get, GlobalScope, Patch, RestController } from '@n8n/decorators';
|
||||
import { LicenseState } from '@n8n/backend-common';
|
||||
|
||||
import { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.instance-settings-loader';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
import { ProvisioningService } from './provisioning.service.ee';
|
||||
import { Response } from 'express';
|
||||
|
||||
|
|
@ -9,6 +13,7 @@ export class ProvisioningController {
|
|||
constructor(
|
||||
private readonly provisioningService: ProvisioningService,
|
||||
private readonly licenseState: LicenseState,
|
||||
private readonly oidcSettingsLoader: OidcInstanceSettingsLoader,
|
||||
) {}
|
||||
|
||||
@Get('/config')
|
||||
|
|
@ -28,6 +33,12 @@ export class ProvisioningController {
|
|||
return res.status(403).json({ message: 'Provisioning is not licensed' });
|
||||
}
|
||||
|
||||
if (this.oidcSettingsLoader.isConfiguredByEnv()) {
|
||||
throw new BadRequestError(
|
||||
'Provisioning configuration is managed via environment variables and cannot be modified through the UI',
|
||||
);
|
||||
}
|
||||
|
||||
return await this.provisioningService.patchConfig(req.body);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { mock } from 'jest-mock-extended';
|
|||
|
||||
import type { AuthService } from '@/auth/auth.service';
|
||||
import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants';
|
||||
import type { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.instance-settings-loader';
|
||||
import type { AuthlessRequest } from '@/requests';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
|
||||
|
|
@ -18,7 +19,15 @@ const oidcService = mock<OidcService>();
|
|||
const urlService = mock<UrlService>();
|
||||
const globalConfig = mock<GlobalConfig>();
|
||||
const logger = mock<Logger>();
|
||||
const controller = new OidcController(oidcService, authService, urlService, globalConfig, logger);
|
||||
const oidcSettingsLoader = mock<OidcInstanceSettingsLoader>();
|
||||
const controller = new OidcController(
|
||||
oidcService,
|
||||
authService,
|
||||
urlService,
|
||||
globalConfig,
|
||||
logger,
|
||||
oidcSettingsLoader,
|
||||
);
|
||||
|
||||
const user = mock<User>({
|
||||
id: '456',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Request, Response } from 'express';
|
|||
import { AuthService } from '@/auth/auth.service';
|
||||
import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.instance-settings-loader';
|
||||
import { AuthlessRequest } from '@/requests';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ export class OidcController {
|
|||
private readonly urlService: UrlService,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly logger: Logger,
|
||||
private readonly oidcSettingsLoader: OidcInstanceSettingsLoader,
|
||||
) {}
|
||||
|
||||
@Get('/config')
|
||||
|
|
@ -45,6 +47,11 @@ export class OidcController {
|
|||
_res: Response,
|
||||
@Body payload: OidcConfigDto,
|
||||
) {
|
||||
if (this.oidcSettingsLoader.isConfiguredByEnv()) {
|
||||
throw new BadRequestError(
|
||||
'OIDC configuration is managed via environment variables and cannot be modified through the UI',
|
||||
);
|
||||
}
|
||||
await this.oidcService.updateConfig(payload);
|
||||
const config = this.oidcService.getRedactedConfig();
|
||||
return config;
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ export class FrontendService {
|
|||
authenticationMethod: getCurrentAuthenticationMethod(),
|
||||
},
|
||||
sso: {
|
||||
managedByEnv: this.globalConfig.instanceSettingsLoader.ssoManagedByEnv,
|
||||
saml: {
|
||||
loginEnabled: false,
|
||||
loginLabel: '',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { SettingsRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { OidcInstanceSettingsLoader } from '@/instance-settings-loader/loaders/oidc.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';
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('OidcInstanceSettingsLoader → OidcService roundtrip', () => {
|
||||
let originalConfig: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
const loader = globalConfig.instanceSettingsLoader;
|
||||
originalConfig = { ...loader };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore original config
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
Object.assign(globalConfig.instanceSettingsLoader, originalConfig);
|
||||
|
||||
// Clean up DB rows
|
||||
const settingsRepository = Container.get(SettingsRepository);
|
||||
await settingsRepository.delete({ key: OIDC_PREFERENCES_DB_KEY });
|
||||
await settingsRepository.delete({ key: PROVISIONING_PREFERENCES_DB_KEY });
|
||||
});
|
||||
|
||||
it('should write config that OidcService reads back with correct values', async () => {
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
Object.assign(globalConfig.instanceSettingsLoader, {
|
||||
ssoManagedByEnv: true,
|
||||
oidcClientId: 'my-client-id',
|
||||
oidcClientSecret: 'my-actual-secret',
|
||||
oidcDiscoveryEndpoint: 'https://idp.example.com/.well-known/openid-configuration',
|
||||
oidcLoginEnabled: true,
|
||||
oidcPrompt: 'consent',
|
||||
oidcAcrValues: 'mfa, phrh',
|
||||
ssoUserRoleProvisioning: 'instance_and_project_roles',
|
||||
});
|
||||
|
||||
const loader = Container.get(OidcInstanceSettingsLoader);
|
||||
await loader.run();
|
||||
|
||||
const oidcService = Container.get(OidcService);
|
||||
const config = await oidcService.loadConfig(true);
|
||||
|
||||
expect(config.clientId).toBe('my-client-id');
|
||||
expect(config.clientSecret).toBe('my-actual-secret');
|
||||
expect(config.discoveryEndpoint.toString()).toBe(
|
||||
'https://idp.example.com/.well-known/openid-configuration',
|
||||
);
|
||||
expect(config.loginEnabled).toBe(true);
|
||||
expect(config.prompt).toBe('consent');
|
||||
expect(config.authenticationContextClassReference).toEqual(['mfa', 'phrh']);
|
||||
});
|
||||
});
|
||||
|
|
@ -4392,6 +4392,7 @@
|
|||
"settings.sso.settings.oidc.prompt.consent": "Consent (Ask the user to consent)",
|
||||
"settings.sso.settings.oidc.prompt.select_account": "Select Account (Allow the user to select an account)",
|
||||
"settings.sso.settings.oidc.prompt.create": "Create (Ask the OP to show the registration page first)",
|
||||
"settings.sso.settings.oidc.overrideBanner": "OIDC connection is configured via environment variables. To modify, update the environment variables and restart n8n.",
|
||||
"settings.sso.settings.userRoleProvisioning.label": "User role provisioning",
|
||||
"settings.sso.settings.userRoleProvisioning.help": "Manage instance and project roles from your SSO provider.",
|
||||
"settings.sso.settings.userRoleProvisioning.help.linkText": "Link to docs",
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export const defaultSettings: FrontendSettings = {
|
|||
saveManualExecutions: false,
|
||||
saveExecutionProgress: false,
|
||||
sso: {
|
||||
managedByEnv: false,
|
||||
ldap: { loginEnabled: false, loginLabel: '' },
|
||||
saml: { loginEnabled: false, loginLabel: '' },
|
||||
oidc: { loginEnabled: false, loginUrl: '', callbackUrl: '' },
|
||||
|
|
|
|||
|
|
@ -140,14 +140,15 @@ describe('Init', () => {
|
|||
const oidc = { loginEnabled: false, loginUrl: '', callbackUrl: '' };
|
||||
|
||||
settingsStore.userManagement.authenticationMethod = UserManagementAuthenticationMethod.Saml;
|
||||
settingsStore.settings.sso = { saml, ldap, oidc };
|
||||
settingsStore.settings.sso = { managedByEnv: false, saml, ldap, oidc };
|
||||
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml] = true;
|
||||
|
||||
await initializeCore();
|
||||
|
||||
expect(ssoStore.initialize).toHaveBeenCalledWith({
|
||||
authenticationMethod: UserManagementAuthenticationMethod.Saml,
|
||||
config: { saml, ldap, oidc },
|
||||
managedByEnv: false,
|
||||
config: { managedByEnv: false, saml, ldap, oidc },
|
||||
features: {
|
||||
saml: true,
|
||||
ldap: false,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export async function initializeCore() {
|
|||
ssoStore.initialize({
|
||||
authenticationMethod: settingsStore.userManagement
|
||||
.authenticationMethod as UserManagementAuthenticationMethod,
|
||||
managedByEnv: settingsStore.settings.sso.managedByEnv,
|
||||
config: settingsStore.settings.sso,
|
||||
features: {
|
||||
saml: settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml],
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const message = useMessage();
|
|||
|
||||
const savingForm = ref<boolean>(false);
|
||||
const roleMappingRuleEditorRef = ref<InstanceType<typeof RoleMappingRuleEditor> | null>(null);
|
||||
const isOverrideActive = computed(() => ssoStore.ssoManagedByEnv);
|
||||
|
||||
const discoveryEndpoint = ref('');
|
||||
const clientId = ref('');
|
||||
|
|
@ -217,7 +218,9 @@ const onTest = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const hasUnsavedChanges = computed(() => !cannotSaveOidcSettings.value && !savingForm.value);
|
||||
const hasUnsavedChanges = computed(
|
||||
() => !cannotSaveOidcSettings.value && !savingForm.value && !isOverrideActive.value,
|
||||
);
|
||||
|
||||
defineExpose({ hasUnsavedChanges, onSave: onOidcSettingsSave });
|
||||
|
||||
|
|
@ -242,6 +245,7 @@ onMounted(async () => {
|
|||
<label>Discovery Endpoint</label>
|
||||
<N8nInput
|
||||
:model-value="discoveryEndpoint"
|
||||
:disabled="isOverrideActive"
|
||||
type="text"
|
||||
data-test-id="oidc-discovery-endpoint"
|
||||
placeholder="https://accounts.google.com/.well-known/openid-configuration"
|
||||
|
|
@ -253,6 +257,7 @@ onMounted(async () => {
|
|||
<label>Client ID</label>
|
||||
<N8nInput
|
||||
:model-value="clientId"
|
||||
:disabled="isOverrideActive"
|
||||
type="text"
|
||||
data-test-id="oidc-client-id"
|
||||
@update:model-value="(v: string) => (clientId = v)"
|
||||
|
|
@ -265,6 +270,7 @@ onMounted(async () => {
|
|||
<label>Client Secret</label>
|
||||
<N8nInput
|
||||
:model-value="clientSecret"
|
||||
:disabled="isOverrideActive"
|
||||
type="password"
|
||||
data-test-id="oidc-client-secret"
|
||||
@update:model-value="(v: string) => (clientSecret = v)"
|
||||
|
|
@ -278,6 +284,7 @@ onMounted(async () => {
|
|||
<label>Prompt</label>
|
||||
<N8nSelect
|
||||
:model-value="prompt"
|
||||
:disabled="isOverrideActive"
|
||||
data-test-id="oidc-prompt"
|
||||
@update:model-value="handlePromptChange"
|
||||
>
|
||||
|
|
@ -298,6 +305,7 @@ onMounted(async () => {
|
|||
v-model:mapping-method="mappingMethod"
|
||||
v-model:legacy-value="userRoleProvisioning"
|
||||
auth-protocol="oidc"
|
||||
:disabled="isOverrideActive"
|
||||
/>
|
||||
<RoleMappingRuleEditor
|
||||
v-if="mappingMethod === 'rules_in_n8n'"
|
||||
|
|
@ -316,6 +324,7 @@ onMounted(async () => {
|
|||
<N8nInput
|
||||
:model-value="authenticationContextClassReference"
|
||||
type="textarea"
|
||||
:disabled="isOverrideActive"
|
||||
data-test-id="oidc-authentication-context-class-reference"
|
||||
placeholder="mfa, phrh, pwd"
|
||||
@update:model-value="(v: string) => (authenticationContextClassReference = v)"
|
||||
|
|
@ -337,6 +346,7 @@ onMounted(async () => {
|
|||
:model-value="ssoStore.isOidcLoginEnabled ? 'enabled' : 'disabled'"
|
||||
size="medium"
|
||||
data-test-id="sso-oidc-toggle"
|
||||
:disabled="isOverrideActive"
|
||||
@update:model-value="ssoStore.isOidcLoginEnabled = $event === 'enabled'"
|
||||
>
|
||||
<template #prefix>
|
||||
|
|
@ -351,6 +361,7 @@ onMounted(async () => {
|
|||
|
||||
<div :class="$style.buttons">
|
||||
<N8nButton
|
||||
v-if="!isOverrideActive"
|
||||
data-test-id="sso-oidc-save"
|
||||
size="large"
|
||||
:loading="savingForm"
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ const legacyValue = defineModel<UserRoleProvisioningSetting>('legacyValue', {
|
|||
default: 'disabled',
|
||||
});
|
||||
|
||||
const { authProtocol } = defineProps<{
|
||||
const { authProtocol, disabled = false } = defineProps<{
|
||||
authProtocol: SupportedProtocolType;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
|
@ -107,7 +108,7 @@ const legacyOptions: Array<{ label: string; value: UserRoleProvisioningSetting }
|
|||
<div :class="shared.settingsItemControl">
|
||||
<N8nSelect
|
||||
:model-value="legacyValue"
|
||||
:disabled="!canManage"
|
||||
:disabled="disabled || !canManage"
|
||||
data-test-id="oidc-user-role-provisioning"
|
||||
@update:model-value="legacyValue = $event as UserRoleProvisioningSetting"
|
||||
>
|
||||
|
|
@ -133,7 +134,7 @@ const legacyOptions: Array<{ label: string; value: UserRoleProvisioningSetting }
|
|||
<N8nSelect
|
||||
v-model="roleAssignment"
|
||||
size="medium"
|
||||
:disabled="!canManage"
|
||||
:disabled="disabled || !canManage"
|
||||
data-test-id="role-assignment-select"
|
||||
>
|
||||
<N8nOption
|
||||
|
|
@ -160,7 +161,7 @@ const legacyOptions: Array<{ label: string; value: UserRoleProvisioningSetting }
|
|||
<N8nSelect
|
||||
v-model="mappingMethod"
|
||||
size="medium"
|
||||
:disabled="!canManage"
|
||||
:disabled="disabled || !canManage"
|
||||
data-test-id="role-mapping-method-select"
|
||||
>
|
||||
<N8nOption
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export const useSSOStore = defineStore('sso', () => {
|
|||
|
||||
const initialize = (options: {
|
||||
authenticationMethod: UserManagementAuthenticationMethod;
|
||||
managedByEnv?: boolean;
|
||||
config: {
|
||||
ldap?: Pick<LdapConfig, 'loginLabel' | 'loginEnabled'>;
|
||||
saml?: Pick<SamlPreferences, 'loginLabel' | 'loginEnabled'>;
|
||||
|
|
@ -52,6 +53,7 @@ export const useSSOStore = defineStore('sso', () => {
|
|||
};
|
||||
}) => {
|
||||
authenticationMethod.value = options.authenticationMethod;
|
||||
ssoManagedByEnv.value = options.managedByEnv ?? false;
|
||||
|
||||
isEnterpriseLdapEnabled.value = options.features.ldap;
|
||||
if (options.config.ldap) {
|
||||
|
|
@ -132,6 +134,8 @@ export const useSSOStore = defineStore('sso', () => {
|
|||
|
||||
const isEnterpriseOidcEnabled = ref(false);
|
||||
|
||||
const ssoManagedByEnv = ref(false);
|
||||
|
||||
const getOidcConfig = async () => {
|
||||
const config = await ssoApi.getOidcConfig(rootStore.restApiContext);
|
||||
oidcConfig.value = config;
|
||||
|
|
@ -224,6 +228,7 @@ export const useSSOStore = defineStore('sso', () => {
|
|||
|
||||
oidc,
|
||||
oidcConfig,
|
||||
ssoManagedByEnv,
|
||||
isOidcLoginEnabled,
|
||||
isEnterpriseOidcEnabled,
|
||||
isDefaultAuthenticationOidc,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ElDialog } from 'element-plus';
|
|||
import {
|
||||
N8nActionBox,
|
||||
N8nButton,
|
||||
N8nCallout,
|
||||
N8nHeading,
|
||||
N8nOption,
|
||||
N8nSelect,
|
||||
|
|
@ -111,6 +112,13 @@ onMounted(() => {
|
|||
{{ i18n.baseText('settings.sso.info.link') }}
|
||||
</a>
|
||||
</p>
|
||||
<N8nCallout
|
||||
v-if="ssoStore.ssoManagedByEnv"
|
||||
theme="warning"
|
||||
style="margin-bottom: var(--spacing--lg)"
|
||||
>
|
||||
{{ i18n.baseText('settings.sso.settings.oidc.overrideBanner') }}
|
||||
</N8nCallout>
|
||||
<!-- Protocol selector — rendered independently outside form v-ifs for E2E timing -->
|
||||
<div v-if="hasAnySsoEnabled" :class="[shared.card, $style.protocolCard]">
|
||||
<div data-test-id="sso-auth-protocol-select" :class="shared.settingsItem">
|
||||
|
|
@ -122,6 +130,7 @@ onMounted(() => {
|
|||
<N8nSelect
|
||||
filterable
|
||||
size="medium"
|
||||
:disabled="ssoStore.ssoManagedByEnv"
|
||||
:model-value="authProtocol"
|
||||
:placeholder="i18n.baseText('parameterInput.select')"
|
||||
@update:model-value="onAuthProtocolUpdated"
|
||||
|
|
|
|||
Loading…
Reference in a new issue